refactor(app): split layer tree rows

This commit is contained in:
Danila Poyarkov 2026-06-30 19:27:38 +03:00
parent 669d987abf
commit b7facfdf93
12 changed files with 418 additions and 254 deletions

View file

@ -134,7 +134,7 @@ export {
GradientEditorStop
} from '#vue/primitives/GradientEditor'
export { LayerTreeRoot, LayerTreeItem, useLayerTree } from '#vue/primitives/LayerTree'
export type { LayerTreeContext, LayerNode } from '#vue/primitives/LayerTree'
export type { LayerDragInstruction, LayerTreeContext, LayerNode } from '#vue/primitives/LayerTree'
export { LayoutControlsRoot, useLayoutControlsContext } from '#vue/primitives/LayoutControls'
export type { LayoutControlsContext } from '#vue/primitives/LayoutControls'
export { AppearanceControlsRoot } from '#vue/primitives/AppearanceControls'

View file

@ -1,4 +1,8 @@
export { default as LayerTreeRoot } from '#vue/primitives/LayerTree/LayerTreeRoot.vue'
export { default as LayerTreeItem } from '#vue/primitives/LayerTree/LayerTreeItem.vue'
export { useLayerTree } from '#vue/primitives/LayerTree/context'
export type { LayerTreeContext, LayerNode } from '#vue/primitives/LayerTree/context'
export type {
LayerDragInstruction,
LayerTreeContext,
LayerNode
} from '#vue/primitives/LayerTree/context'

View file

@ -1,250 +0,0 @@
<script setup lang="ts">
import { useAttrs, watch } from 'vue'
import { templateRef } from '@vueuse/core'
import {
TreeItem,
TreeVirtualizer,
ContextMenuRoot,
ContextMenuTrigger,
ContextMenuPortal
} from 'reka-ui'
import { LayerTreeRoot, LayerTreeItem, useI18n, useInlineRename } from '@open-pencil/vue'
import type { LayerNode } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { nodeIcon, COMPONENT_TYPES } from '@/app/editor/icons'
import CanvasMenu from './CanvasMenu.vue'
import Tip from './ui/Tip.vue'
defineOptions({ inheritAttrs: false })
const INDENT = 16
const attrs = useAttrs()
const store = useEditorStore()
const renameInput = templateRef<HTMLInputElement>('renameInput')
const rename = useInlineRename((id, name) => store.renameNode(id, name))
const { menu: t } = useI18n()
watch(renameInput, (input) => {
if (input) void rename.focusInput(input)
})
function onLayerRightClick(e: MouseEvent) {
const row = (e.target as HTMLElement).closest<HTMLElement>('[data-node-id]')
if (!row?.dataset.nodeId) return
if (!store.state.selectedIds.has(row.dataset.nodeId)) store.select([row.dataset.nodeId])
}
function isAdditiveSelect(e: CustomEvent): boolean {
const mouseEvent = e.detail?.originalEvent as MouseEvent | undefined
return !!(mouseEvent?.shiftKey || mouseEvent?.metaKey || mouseEvent?.ctrlKey)
}
function onTreeSelect(e: CustomEvent, id: string, select: (id: string, additive: boolean) => void) {
e.preventDefault()
select(id, isAdditiveSelect(e))
}
function isLayerNode(value: unknown): value is LayerNode {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'type' in value &&
'layoutMode' in value &&
'visible' in value &&
'locked' in value &&
typeof value.id === 'string' &&
typeof value.name === 'string' &&
typeof value.type === 'string' &&
typeof value.layoutMode === 'string' &&
typeof value.visible === 'boolean' &&
typeof value.locked === 'boolean'
)
}
function toLayerNode(value: unknown): LayerNode {
if (isLayerNode(value)) return value
throw new Error('[open-pencil] Invalid layer tree item')
}
function layerTextContent(value: unknown): string {
return toLayerNode(value).name
}
</script>
<template>
<LayerTreeRoot
v-slot="{ actions: rootActions, draggingId, instruction, instructionTargetId }"
:indent-per-level="INDENT"
>
<ContextMenuRoot :modal="false">
<div v-bind="attrs" class="relative min-h-0 flex-1 overflow-hidden">
<ContextMenuTrigger as-child @contextmenu="onLayerRightClick">
<div data-test-id="layers-scroll" class="scrollbar-thin h-full overflow-y-auto px-1">
<TreeVirtualizer v-slot="{ item }" :estimate-size="24" :text-content="layerTextContent">
<TreeItem
v-slot="{ isExpanded }"
v-bind="item.bind"
as-child
@select="(e: CustomEvent) => onTreeSelect(e, toLayerNode(item.value).id, rootActions.select)"
@toggle="
(e: CustomEvent) => {
if (e.detail.originalEvent?.type === 'click') e.preventDefault()
}
"
>
<LayerTreeItem
v-slot="{ node, isSelected, padLeft, actions }"
:node="toLayerNode(item.value)"
:level="item.level"
:has-children="item.hasChildren"
>
<!-- Rename mode -->
<div
v-if="rename.editingId.value === node.id"
class="flex w-full items-center gap-1 py-1"
:style="{ paddingLeft: padLeft }"
>
<span
v-if="item.hasChildren"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface"
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
@click.stop="actions.toggleExpand"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component :is="nodeIcon(node)" class="size-3 shrink-0 opacity-70" />
<input
ref="renameInput"
data-layer-edit
data-test-id="layers-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="node.name"
@blur="rename.commit(node.id, $event)"
@keydown.stop="rename.onKeydown"
/>
</div>
<!-- Normal row -->
<button
v-else
data-test-id="layers-item"
class="group/row relative flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 pr-1 text-left text-xs"
:class="[
isSelected
? 'bg-accent text-white'
: 'bg-transparent text-surface hover:bg-hover',
draggingId === node.id ? 'opacity-30' : '',
instructionTargetId === node.id && instruction?.type === 'make-child'
? 'bg-accent/15 text-surface outline-2 outline-accent outline-offset-[-2px]'
: '',
!node.visible ? 'opacity-50' : ''
]"
:style="{ paddingLeft: padLeft }"
@dblclick="rename.start(node.id, node.name)"
>
<span
v-if="item.hasChildren"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface"
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
@click.stop="actions.toggleExpand"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component
:is="nodeIcon(node)"
class="size-3 shrink-0"
:class="
COMPONENT_TYPES.has(node.type) ? 'text-component opacity-100' : 'opacity-70'
"
/>
<span class="min-w-0 flex-1 truncate">{{ node.name }}</span>
<span
class="flex shrink-0 items-center gap-0.5"
:class="
!node.locked && node.visible ? 'opacity-0 group-hover/row:opacity-100' : ''
"
>
<Tip :label="node.locked ? t.unlock : t.lock">
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop
@click.stop="actions.toggleLock"
>
<icon-lucide-lock
v-if="node.locked"
class="size-3"
:class="isSelected ? 'text-white' : 'text-surface'"
/>
<icon-lucide-unlock
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="isSelected ? 'text-white/80' : 'text-surface/70'"
/>
</span>
</Tip>
<Tip :label="node.visible ? t.hide : t.show">
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop
@click.stop="actions.toggleVisibility"
>
<icon-lucide-eye-off
v-if="!node.visible"
class="size-3"
:class="isSelected ? 'text-white' : 'text-surface'"
/>
<icon-lucide-eye
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="isSelected ? 'text-white/80' : 'text-surface/70'"
/>
</span>
</Tip>
</span>
<div
v-if="instructionTargetId === node.id && instruction?.type === 'make-child'"
class="pointer-events-none absolute inset-y-1 rounded border border-accent bg-accent/10"
:style="{
left: `${item.level * INDENT}px`,
right: '4px'
}"
/>
<!-- DnD reorder indicator -->
<div
v-if="
instructionTargetId === node.id &&
instruction &&
instruction.type !== 'make-child'
"
class="pointer-events-none absolute h-0.5 bg-accent"
:class="{
'bottom-0': instruction.type === 'reorder-below',
'top-0': instruction.type === 'reorder-above'
}"
:style="{
left: `${(item.level - 1) * INDENT}px`,
width: `calc(100% - ${(item.level - 1) * INDENT}px)`
}"
/>
</button>
</LayerTreeItem>
</TreeItem>
</TreeVirtualizer>
</div>
</ContextMenuTrigger>
</div>
<ContextMenuPortal>
<CanvasMenu />
</ContextMenuPortal>
</ContextMenuRoot>
</LayerTreeRoot>
</template>

View file

@ -0,0 +1,156 @@
<script setup lang="ts">
import { useAttrs } from 'vue'
import {
TreeItem,
TreeVirtualizer,
ContextMenuRoot,
ContextMenuTrigger,
ContextMenuPortal
} from 'reka-ui'
import { LayerTreeRoot, LayerTreeItem, useInlineRename } from '@open-pencil/vue'
import type { LayerDragInstruction, LayerNode } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import CanvasMenu from '../CanvasMenu.vue'
import LayerTreeNodeRow from './LayerTreeNodeRow.vue'
import LayerTreeRenameRow from './LayerTreeRenameRow.vue'
interface LayerTreeRootActions {
select: (id: string, additive: boolean) => void
toggleExpand: (id: string) => void
}
interface LayerTreeSlotScope {
actions: LayerTreeRootActions
draggingId: string | null
instruction: LayerDragInstruction | null
instructionTargetId: string | null
}
defineOptions({ inheritAttrs: false })
const INDENT = 16
const attrs = useAttrs()
const store = useEditorStore()
const rename = useInlineRename((id, name) => store.renameNode(id, name))
const renameControls = {
commit: rename.commit,
onKeydown: rename.onKeydown,
focusInput: rename.focusInput
}
function onLayerRightClick(e: MouseEvent) {
const row = (e.target as HTMLElement).closest<HTMLElement>('[data-node-id]')
if (!row?.dataset.nodeId) return
if (!store.state.selectedIds.has(row.dataset.nodeId)) store.select([row.dataset.nodeId])
}
function isAdditiveSelect(e: CustomEvent): boolean {
const mouseEvent = e.detail?.originalEvent as MouseEvent | undefined
return !!(mouseEvent?.shiftKey || mouseEvent?.metaKey || mouseEvent?.ctrlKey)
}
function onTreeSelect(e: CustomEvent, id: string, select: (id: string, additive: boolean) => void) {
e.preventDefault()
select(id, isAdditiveSelect(e))
}
function isLayerNode(value: unknown): value is LayerNode {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'type' in value &&
'layoutMode' in value &&
'visible' in value &&
'locked' in value &&
typeof value.id === 'string' &&
typeof value.name === 'string' &&
typeof value.type === 'string' &&
typeof value.layoutMode === 'string' &&
typeof value.visible === 'boolean' &&
typeof value.locked === 'boolean'
)
}
function toLayerNode(value: unknown): LayerNode {
if (isLayerNode(value)) return value
throw new Error('[open-pencil] Invalid layer tree item')
}
function layerTextContent(value: unknown): string {
return toLayerNode(value).name
}
function chrome(scope: Omit<LayerTreeSlotScope, 'actions'>) {
return {
draggingId: scope.draggingId,
instruction: scope.instruction,
instructionTargetId: scope.instructionTargetId,
indent: INDENT
}
}
</script>
<template>
<LayerTreeRoot v-slot="scope" :indent-per-level="INDENT">
<ContextMenuRoot :modal="false">
<div v-bind="attrs" class="relative min-h-0 flex-1 overflow-hidden">
<ContextMenuTrigger as-child @contextmenu="onLayerRightClick">
<div data-test-id="layers-scroll" class="scrollbar-thin h-full overflow-y-auto px-1">
<TreeVirtualizer v-slot="{ item }" :estimate-size="24" :text-content="layerTextContent">
<TreeItem
v-slot="{ isExpanded }"
v-bind="item.bind"
as-child
@select="
(e: CustomEvent) =>
onTreeSelect(e, toLayerNode(item.value).id, scope.actions.select)
"
@toggle="
(e: CustomEvent) => {
if (e.detail.originalEvent?.type === 'click') e.preventDefault()
}
"
>
<LayerTreeItem
v-slot="{ node, isSelected, padLeft, actions }"
:node="toLayerNode(item.value)"
:level="item.level"
:has-children="item.hasChildren"
>
<LayerTreeRenameRow
v-if="rename.editingId.value === node.id"
:node="node"
:has-children="item.hasChildren"
:pad-left="padLeft"
:expanded="isExpanded"
:actions="actions"
:rename-controls="renameControls"
/>
<LayerTreeNodeRow
v-else
:node="node"
:level="item.level"
:has-children="item.hasChildren"
:selected="isSelected"
:pad-left="padLeft"
:expanded="isExpanded"
:actions="actions"
:chrome="chrome(scope)"
@rename-start="rename.start"
/>
</LayerTreeItem>
</TreeItem>
</TreeVirtualizer>
</div>
</ContextMenuTrigger>
</div>
<ContextMenuPortal>
<CanvasMenu />
</ContextMenuPortal>
</ContextMenuRoot>
</LayerTreeRoot>
</template>

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
import { useI18n } from '@open-pencil/vue'
import Tip from '../ui/Tip.vue'
import type { LayerNode } from '@open-pencil/vue'
const { node, selected } = defineProps<{
node: LayerNode
selected: boolean
}>()
const emit = defineEmits<{
toggleLock: []
toggleVisibility: []
}>()
const { menu: t } = useI18n()
</script>
<template>
<span
class="flex shrink-0 items-center gap-0.5"
:class="!node.locked && node.visible ? 'opacity-0 group-hover/row:opacity-100' : ''"
>
<Tip :label="node.locked ? t.unlock : t.lock">
<button
type="button"
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop
@click.stop="emit('toggleLock')"
>
<icon-lucide-lock
v-if="node.locked"
class="size-3"
:class="selected ? 'text-white' : 'text-surface'"
/>
<icon-lucide-unlock
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="selected ? 'text-white/80' : 'text-surface/70'"
/>
</button>
</Tip>
<Tip :label="node.visible ? t.hide : t.show">
<button
type="button"
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop
@click.stop="emit('toggleVisibility')"
>
<icon-lucide-eye-off
v-if="!node.visible"
class="size-3"
:class="selected ? 'text-white' : 'text-surface'"
/>
<icon-lucide-eye
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="selected ? 'text-white/80' : 'text-surface/70'"
/>
</button>
</Tip>
</span>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
const { expanded, visible } = defineProps<{
expanded: boolean
visible: boolean
}>()
const emit = defineEmits<{
toggle: []
}>()
</script>
<template>
<button
v-if="visible"
type="button"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface"
:class="expanded ? 'rotate-90' : 'rotate-0'"
@click.stop="emit('toggle')"
>
<icon-lucide-chevron-right class="size-3" />
</button>
<span v-else class="w-4 shrink-0" />
</template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import type { LayerDragInstruction } from '@open-pencil/vue'
const { active, instruction, level, indent } = defineProps<{
active: boolean
instruction: LayerDragInstruction | null
level: number
indent: number
}>()
</script>
<template>
<div
v-if="active && instruction?.type === 'make-child'"
class="pointer-events-none absolute inset-y-1 rounded border border-accent bg-accent/10"
:style="{
left: `${level * indent}px`,
right: '4px'
}"
/>
<div
v-else-if="active && instruction"
class="pointer-events-none absolute h-0.5 bg-accent"
:class="{
'bottom-0': instruction.type === 'reorder-below',
'top-0': instruction.type === 'reorder-above'
}"
:style="{
left: `${(level - 1) * indent}px`,
width: `calc(100% - ${(level - 1) * indent}px)`
}"
/>
</template>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { COMPONENT_TYPES, nodeIcon } from '@/app/editor/icons'
import LayerTreeActions from './LayerTreeActions.vue'
import LayerTreeDisclosure from './LayerTreeDisclosure.vue'
import LayerTreeDropIndicator from './LayerTreeDropIndicator.vue'
import type { LayerNode } from '@open-pencil/vue'
import type { LayerTreeChrome, LayerTreeItemActions } from './types'
const { node, level, hasChildren, selected, padLeft, expanded, actions, chrome } = defineProps<{
node: LayerNode
level: number
hasChildren: boolean
selected: boolean
padLeft: string
expanded: boolean
actions: LayerTreeItemActions
chrome: LayerTreeChrome
}>()
const emit = defineEmits<{
renameStart: [id: string, name: string]
}>()
</script>
<template>
<div
data-test-id="layers-item"
class="group/row relative flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 pr-1 text-left text-xs"
:class="[
selected ? 'bg-accent text-white' : 'bg-transparent text-surface hover:bg-hover',
chrome.draggingId === node.id ? 'opacity-30' : '',
chrome.instructionTargetId === node.id && chrome.instruction?.type === 'make-child'
? 'bg-accent/15 text-surface outline-2 outline-accent outline-offset-[-2px]'
: '',
!node.visible ? 'opacity-50' : ''
]"
:style="{ paddingLeft: padLeft }"
@dblclick="emit('renameStart', node.id, node.name)"
>
<LayerTreeDisclosure
:expanded="expanded"
:visible="hasChildren"
@toggle="actions.toggleExpand"
/>
<component
:is="nodeIcon(node)"
class="size-3 shrink-0"
:class="COMPONENT_TYPES.has(node.type) ? 'text-component opacity-100' : 'opacity-70'"
/>
<span class="min-w-0 flex-1 truncate">{{ node.name }}</span>
<LayerTreeActions
:node="node"
:selected="selected"
@toggle-lock="actions.toggleLock"
@toggle-visibility="actions.toggleVisibility"
/>
<LayerTreeDropIndicator
:active="chrome.instructionTargetId === node.id"
:instruction="chrome.instruction"
:level="level"
:indent="chrome.indent"
/>
</div>
</template>

View file

@ -0,0 +1,43 @@
<script setup lang="ts">
import { useTemplateRef, watch } from 'vue'
import { nodeIcon } from '@/app/editor/icons'
import LayerTreeDisclosure from './LayerTreeDisclosure.vue'
import type { LayerNode } from '@open-pencil/vue'
import type { LayerRenameControls, LayerTreeItemActions } from './types'
const { renameControls } = defineProps<{
node: LayerNode
hasChildren: boolean
padLeft: string
expanded: boolean
actions: LayerTreeItemActions
renameControls: LayerRenameControls
}>()
const renameInput = useTemplateRef<HTMLInputElement>('renameInput')
watch(renameInput, (input) => {
if (input) void renameControls.focusInput(input)
})
</script>
<template>
<div class="flex w-full items-center gap-1 py-1" :style="{ paddingLeft: padLeft }">
<LayerTreeDisclosure
:expanded="expanded"
:visible="hasChildren"
@toggle="actions.toggleExpand"
/>
<component :is="nodeIcon(node)" class="size-3 shrink-0 opacity-70" />
<input
ref="renameInput"
data-layer-edit
data-test-id="layers-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="node.name"
@blur="renameControls.commit(node.id, $event)"
@keydown.stop="renameControls.onKeydown"
/>
</div>
</template>

View file

@ -0,0 +1,22 @@
import type { LayerDragInstruction } from '@open-pencil/vue'
export interface LayerTreeItemActions {
select: (additive: boolean) => void
toggleExpand: () => void
toggleVisibility: () => void
toggleLock: () => void
rename: (name: string) => void
}
export interface LayerTreeChrome {
draggingId: string | null
instruction: LayerDragInstruction | null
instructionTargetId: string | null
indent: number
}
export interface LayerRenameControls {
commit: (id: string, event: Event) => void
onKeydown: (event: KeyboardEvent) => void
focusInput: (input: HTMLInputElement) => Promise<void>
}

View file

@ -6,7 +6,7 @@ import { useI18n } from '@open-pencil/vue'
import AppMenu from './AppMenu.vue'
import AssetsPanel from './AssetsPanel.vue'
import LayerTree from './LayerTree.vue'
import LayerTree from './LayerTree/LayerTree.vue'
import PagesPanel from './PagesPanel.vue'
const { menu, panels } = useI18n()

View file

@ -8,7 +8,7 @@ import { computed, ref } from 'vue'
import ChatPanel from './ChatPanel.vue'
import CodePanel from './CodePanel.vue'
import DesignPanel from './DesignPanel.vue'
import LayerTree from './LayerTree.vue'
import LayerTree from './LayerTree/LayerTree.vue'
import PagesPanel from './PagesPanel.vue'
import {
DRAWER_SPRING_DAMPING,