Make items in the outline menu collapsable (#367)

Co-authored-by: Clement Roche <rchclement@gmail.com>
Co-authored-by: Cole Lawrence <cole@colelawrence.com>
Co-authored-by: Aria <aria.minaei@gmail.com>
This commit is contained in:
Clément Roche 2023-01-02 19:43:19 +01:00 committed by GitHub
parent a727ee277b
commit 6558181dbb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 252 additions and 118 deletions

View file

@ -1,6 +1,7 @@
import type {VoidFn} from '@theatre/shared/utils/types' import type {VoidFn} from '@theatre/shared/utils/types'
import React from 'react' import React from 'react'
import styled, {css} from 'styled-components' import styled, {css} from 'styled-components'
import noop from '@theatre/shared/utils/noop'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import {ChevronDown, Package} from '@theatre/studio/uiComponents/icons' import {ChevronDown, Package} from '@theatre/studio/uiComponents/icons'
@ -62,15 +63,6 @@ const Header = styled(BaseHeader)`
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08);
} }
// Hit zone
&:before {
position: absolute;
inset: -1px 0;
display: block;
content: ' ';
z-index: 5;
}
@supports not (backdrop-filter: blur()) { @supports not (backdrop-filter: blur()) {
background: rgba(40, 43, 47, 0.95); background: rgba(40, 43, 47, 0.95);
} }
@ -105,16 +97,24 @@ const Head_IconContainer = styled.div`
opacity: 0.99; opacity: 0.99;
` `
const Head_Icon_WithDescendants = styled.span<{isOpen: boolean}>` const Head_Icon_WithDescendants = styled.span`
font-size: 9px; font-size: 9px;
position: relative; position: relative;
display: block; display: block;
${Container}.collapsed & {
transform: rotate(-90deg);
}
` `
const ChildrenContainer = styled.ul` const ChildrenContainer = styled.ul`
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
${Container}.collapsed & {
display: none;
}
` `
type SelectionStatus = type SelectionStatus =
@ -129,7 +129,18 @@ const BaseItem: React.FC<{
depth: number depth: number
selectionStatus: SelectionStatus selectionStatus: SelectionStatus
labelDecoration?: React.ReactNode labelDecoration?: React.ReactNode
}> = ({label, children, depth, select, selectionStatus, labelDecoration}) => { collapsed?: boolean
setIsCollapsed?: (v: boolean) => void
}> = ({
label,
children,
depth,
select,
selectionStatus,
labelDecoration,
collapsed = false,
setIsCollapsed,
}) => {
const canContainChildren = children !== undefined const canContainChildren = children !== undefined
return ( return (
@ -138,11 +149,18 @@ const BaseItem: React.FC<{
/* @ts-ignore */ /* @ts-ignore */
{'--depth': depth} {'--depth': depth}
} }
className={collapsed ? 'collapsed' : ''}
> >
<Header className={selectionStatus} onClick={select}> <Header className={selectionStatus} onClick={select ?? noop} data-header>
<Head_IconContainer> <Head_IconContainer>
{canContainChildren ? ( {canContainChildren ? (
<Head_Icon_WithDescendants isOpen={true}> <Head_Icon_WithDescendants
onClick={(evt) => {
evt.stopPropagation()
setIsCollapsed?.(!collapsed)
select?.()
}}
>
<ChevronDown /> <ChevronDown />
</Head_Icon_WithDescendants> </Head_Icon_WithDescendants>
) : ( ) : (

View file

@ -1,49 +0,0 @@
import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
import {getObjectNamespacePath} from './getObjectNamespacePath'
/** See {@link addToNamespace} for adding to the namespace, easily. */
export type NamespacedObjects = Map<
string,
{
object?: SheetObject
nested?: NamespacedObjects
}
>
export function addToNamespace(
mutObjects: NamespacedObjects,
object: SheetObject,
) {
_addToNamespace(mutObjects, getObjectNamespacePath(object), object)
}
function _addToNamespace(
mutObjects: NamespacedObjects,
path: string[],
object: SheetObject,
) {
console.assert(path.length > 0, 'expecting path to not be empty')
const [next, ...rest] = path
let existing = mutObjects.get(next)
if (!existing) {
existing = {
nested: undefined,
object: undefined,
}
mutObjects.set(next, existing)
}
if (rest.length === 0) {
console.assert(
!existing.object,
'expect not to have existing object with same name',
{existing, object},
)
existing.object = object
} else {
if (!existing.nested) {
existing.nested = new Map()
}
_addToNamespace(existing.nested, rest, object)
}
}

View file

@ -6,8 +6,7 @@ import styled from 'styled-components'
import {ObjectItem} from './ObjectItem' import {ObjectItem} from './ObjectItem'
import type SheetObject from '@theatre/core/sheetObjects/SheetObject' import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
import BaseItem from '@theatre/studio/panels/OutlinePanel/BaseItem' import BaseItem from '@theatre/studio/panels/OutlinePanel/BaseItem'
import type {NamespacedObjects} from './NamespacedObjects' import {useCollapseStateInOutlinePanel} from '@theatre/studio/panels/OutlinePanel/outlinePanelUtils'
import {addToNamespace} from './NamespacedObjects'
export const Li = styled.li<{isSelected: boolean}>` export const Li = styled.li<{isSelected: boolean}>`
color: ${(props) => (props.isSelected ? 'white' : 'hsl(1, 1%, 80%)')}; color: ${(props) => (props.isSelected ? 'white' : 'hsl(1, 1%, 80%)')};
@ -28,23 +27,65 @@ const ObjectsList: React.FC<{
addToNamespace(rootObject, object) addToNamespace(rootObject, object)
}) })
return <NamespaceTree namespace={rootObject} visualIndentation={depth} /> return (
<NamespaceTree
namespace={rootObject}
visualIndentation={depth}
path={[]}
sheet={sheet}
/>
)
}, [sheet, depth]) }, [sheet, depth])
} }
function NamespaceTree(props: { function NamespaceTree(props: {
namespace: NamespacedObjects namespace: NamespacedObjects
visualIndentation: number visualIndentation: number
path: string[]
sheet: Sheet
}) { }) {
return ( return (
<> <>
{[...props.namespace.entries()].map(([label, {object, nested}]) => { {[...props.namespace.entries()].map(([label, {object, nested}]) => {
return (
<Namespace
key={label}
label={label}
object={object}
nested={nested}
visualIndentation={props.visualIndentation}
path={props.path}
sheet={props.sheet}
/>
)
})}
</>
)
}
function Namespace(props: {
nested?: NamespacedObjects
label: string
object?: SheetObject
visualIndentation: number
path: string[]
sheet: Sheet
}) {
const {nested, label, object, sheet} = props
const {collapsed, setCollapsed} = useCollapseStateInOutlinePanel({
type: 'namespace',
sheet,
path: props.path,
})
const nestedChildrenElt = nested && ( const nestedChildrenElt = nested && (
<NamespaceTree <NamespaceTree
namespace={nested} namespace={nested}
path={[...props.path, label]}
// Question: will there be key conflict if two components have the same labels? // Question: will there be key conflict if two components have the same labels?
key={'namespaceTree(' + label + ')'} key={'namespaceTree(' + label + ')'}
visualIndentation={props.visualIndentation + 1} visualIndentation={props.visualIndentation + 1}
sheet={sheet}
/> />
) )
const sameNameElt = object && ( const sameNameElt = object && (
@ -69,13 +110,72 @@ function NamespaceTree(props: {
key={`baseItem(${label})`} key={`baseItem(${label})`}
depth={props.visualIndentation} depth={props.visualIndentation}
children={nestedChildrenElt} children={nestedChildrenElt}
collapsed={collapsed}
setIsCollapsed={setCollapsed}
/> />
)} )}
</React.Fragment> </React.Fragment>
) )
})}
</>
)
} }
export default ObjectsList export default ObjectsList
/** See {@link addToNamespace} for adding to the namespace, easily. */
type NamespacedObjects = Map<
string,
{
object?: SheetObject
nested?: NamespacedObjects
path: string[]
}
>
function addToNamespace(
mutObjects: NamespacedObjects,
object: SheetObject,
path = getObjectNamespacePath(object),
) {
const [next, ...rest] = path
let existing = mutObjects.get(next)
if (!existing) {
existing = {
nested: undefined,
object: undefined,
path: [...path],
}
mutObjects.set(next, existing)
}
if (rest.length === 0) {
console.assert(
!existing.object,
'expect not to have existing object with same name',
{existing, object},
)
existing.object = object
} else {
if (!existing.nested) {
existing.nested = new Map()
}
addToNamespace(existing.nested, object, rest)
}
}
function getObjectNamespacePath(object: SheetObject): string[] {
let existing = OBJECT_SPLITS_MEMO.get(object)
if (!existing) {
existing = object.address.objectKey.split(
RE_SPLIT_BY_SLASH_WITHOUT_WHITESPACE,
)
console.assert(existing.length > 0, 'expected not empty')
OBJECT_SPLITS_MEMO.set(object, existing)
}
return existing
}
/**
* Relying on the fact we try to "sanitize paths" earlier.
* Go look for `sanifySlashedPath` in a `utils/slashedPaths.ts`.
*/
const RE_SPLIT_BY_SLASH_WITHOUT_WHITESPACE = /\s*\/\s*/g
const OBJECT_SPLITS_MEMO = new WeakMap<SheetObject, string[]>()

View file

@ -1,19 +0,0 @@
import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
export function getObjectNamespacePath(object: SheetObject): string[] {
let existing = OBJECT_SPLITS_MEMO.get(object)
if (!existing) {
existing = object.address.objectKey.split(
RE_SPLIT_BY_SLASH_WITHOUT_WHITESPACE,
)
console.assert(existing.length > 0, 'expected not empty')
OBJECT_SPLITS_MEMO.set(object, existing)
}
return existing
}
/**
* Relying on the fact we try to "sanitize paths" earlier.
* Go look for `sanifySlashedPath` in a `utils/slashedPaths.ts`.
*/
const RE_SPLIT_BY_SLASH_WITHOUT_WHITESPACE = /\s*\/\s*/g
const OBJECT_SPLITS_MEMO = new WeakMap<SheetObject, string[]>()

View file

@ -7,6 +7,7 @@ import {usePrism} from '@theatre/react'
import {getOutlineSelection} from '@theatre/studio/selectors' import {getOutlineSelection} from '@theatre/studio/selectors'
import {val} from '@theatre/dataverse' import {val} from '@theatre/dataverse'
import styled from 'styled-components' import styled from 'styled-components'
import {useCollapseStateInOutlinePanel} from '@theatre/studio/panels/OutlinePanel/outlinePanelUtils'
const ConflictNotice = styled.div` const ConflictNotice = styled.div`
color: #ff6363; color: #ff6363;
@ -38,10 +39,14 @@ const ProjectListItem: React.FC<{
}) })
}, [project]) }, [project])
const {collapsed, setCollapsed} = useCollapseStateInOutlinePanel(project)
return ( return (
<BaseItem <BaseItem
depth={depth} depth={depth}
label={project.address.projectId} label={project.address.projectId}
setIsCollapsed={setCollapsed}
collapsed={collapsed}
labelDecoration={ labelDecoration={
hasConflict ? <ConflictNotice>Has Conflicts</ConflictNotice> : null hasConflict ? <ConflictNotice>Has Conflicts</ConflictNotice> : null
} }
@ -56,7 +61,7 @@ const ProjectListItem: React.FC<{
: 'not-selected' : 'not-selected'
} }
select={select} select={select}
></BaseItem> />
) )
} }

View file

@ -6,6 +6,7 @@ import styled from 'styled-components'
import ObjectsList from '@theatre/studio/panels/OutlinePanel/ObjectsList/ObjectsList' import ObjectsList from '@theatre/studio/panels/OutlinePanel/ObjectsList/ObjectsList'
import BaseItem from '@theatre/studio/panels/OutlinePanel/BaseItem' import BaseItem from '@theatre/studio/panels/OutlinePanel/BaseItem'
import type Sheet from '@theatre/core/sheets/Sheet' import type Sheet from '@theatre/core/sheets/Sheet'
import {useCollapseStateInOutlinePanel} from '@theatre/studio/panels/OutlinePanel/outlinePanelUtils'
const Head = styled.div` const Head = styled.div`
display: flex; display: flex;
@ -21,6 +22,8 @@ export const SheetInstanceItem: React.FC<{
depth: number depth: number
sheet: Sheet sheet: Sheet
}> = ({sheet, depth}) => { }> = ({sheet, depth}) => {
const {collapsed, setCollapsed} = useCollapseStateInOutlinePanel(sheet)
const setSelectedSheet = useCallback(() => { const setSelectedSheet = useCallback(() => {
getStudio()!.transaction(({stateEditors}) => { getStudio()!.transaction(({stateEditors}) => {
stateEditors.studio.historic.panels.outline.selection.set([sheet]) stateEditors.studio.historic.panels.outline.selection.set([sheet])
@ -34,6 +37,8 @@ export const SheetInstanceItem: React.FC<{
<BaseItem <BaseItem
depth={depth} depth={depth}
select={setSelectedSheet} select={setSelectedSheet}
setIsCollapsed={setCollapsed}
collapsed={collapsed}
selectionStatus={ selectionStatus={
selection.some((s) => s === sheet) selection.some((s) => s === sheet)
? 'selected' ? 'selected'
@ -58,5 +63,5 @@ export const SheetInstanceItem: React.FC<{
</Body> </Body>
</BaseItem> </BaseItem>
) )
}, [depth]) }, [depth, collapsed])
} }

View file

@ -0,0 +1,45 @@
import type Project from '@theatre/core/projects/Project'
import {useCallback} from 'react'
import getStudio from '@theatre/studio/getStudio'
import {useVal} from '@theatre/react'
import type Sheet from '@theatre/core/sheets/Sheet'
export function useCollapseStateInOutlinePanel(
item: Project | Sheet | {type: 'namespace'; sheet: Sheet; path: string[]},
): {
collapsed: boolean
setCollapsed: (collapsed: boolean) => void
} {
const itemKey =
item.type === 'namespace'
? `namespace:${item.sheet.address.sheetId}:${item.path.join('/')}`
: item.type === 'Theatre_Project'
? 'project'
: item.type === 'Theatre_Sheet'
? `sheetInstance:${item.address.sheetId}:${item.address.sheetInstanceId}`
: 'unknown'
const projectId =
item.type === 'namespace'
? item.sheet.address.projectId
: item.address.projectId
const isCollapsed =
useVal(
getStudio().atomP.ahistoric.projects.stateByProjectId[projectId]
.collapsedItemsInOutline[itemKey],
) ?? false
const setCollapsed = useCallback(
(isCollapsed: boolean) => {
getStudio().transaction(({stateEditors}) => {
stateEditors.studio.ahistoric.projects.stateByProjectId.collapsedItemsInOutline.set(
{projectId, isCollapsed, itemKey: itemKey},
)
})
},
[itemKey],
)
return {collapsed: isCollapsed, setCollapsed}
}

View file

@ -455,6 +455,7 @@ namespace stateEditors {
} }
} }
} }
export namespace projects { export namespace projects {
export namespace stateByProjectId { export namespace stateByProjectId {
export function _ensure(p: ProjectAddress) { export function _ensure(p: ProjectAddress) {
@ -468,6 +469,33 @@ namespace stateEditors {
return s.projects.stateByProjectId[p.projectId]! return s.projects.stateByProjectId[p.projectId]!
} }
export namespace collapsedItemsInOutline {
export function _ensure(p: ProjectAddress) {
const projectState =
stateEditors.studio.ahistoric.projects.stateByProjectId._ensure(
p,
)
if (!projectState.collapsedItemsInOutline) {
projectState.collapsedItemsInOutline = {}
}
return projectState.collapsedItemsInOutline!
}
export function set(
p: ProjectAddress & {isCollapsed: boolean; itemKey: string},
) {
const collapsedItemsInOutline =
stateEditors.studio.ahistoric.projects.stateByProjectId.collapsedItemsInOutline._ensure(
p,
)
if (p.isCollapsed) {
collapsedItemsInOutline[p.itemKey] = true
} else {
delete collapsedItemsInOutline[p.itemKey]
}
}
}
export namespace stateBySheetId { export namespace stateBySheetId {
export function _ensure(p: WithoutSheetInstance<SheetAddress>) { export function _ensure(p: WithoutSheetInstance<SheetAddress>) {
const projectState = const projectState =

View file

@ -45,6 +45,7 @@ export type StudioAhistoricState = {
stateByProjectId: StrictRecord< stateByProjectId: StrictRecord<
ProjectId, ProjectId,
{ {
collapsedItemsInOutline?: StrictRecord<string, boolean>
stateBySheetId: StrictRecord< stateBySheetId: StrictRecord<
SheetId, SheetId,
{ {