refactor(app): split layer tree rows
This commit is contained in:
parent
669d987abf
commit
b7facfdf93
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
156
src/components/LayerTree/LayerTree.vue
Normal file
156
src/components/LayerTree/LayerTree.vue
Normal 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>
|
||||
64
src/components/LayerTree/LayerTreeActions.vue
Normal file
64
src/components/LayerTree/LayerTreeActions.vue
Normal 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>
|
||||
23
src/components/LayerTree/LayerTreeDisclosure.vue
Normal file
23
src/components/LayerTree/LayerTreeDisclosure.vue
Normal 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>
|
||||
34
src/components/LayerTree/LayerTreeDropIndicator.vue
Normal file
34
src/components/LayerTree/LayerTreeDropIndicator.vue
Normal 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>
|
||||
68
src/components/LayerTree/LayerTreeNodeRow.vue
Normal file
68
src/components/LayerTree/LayerTreeNodeRow.vue
Normal 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>
|
||||
43
src/components/LayerTree/LayerTreeRenameRow.vue
Normal file
43
src/components/LayerTree/LayerTreeRenameRow.vue
Normal 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>
|
||||
22
src/components/LayerTree/types.ts
Normal file
22
src/components/LayerTree/types.ts
Normal 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>
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue