feat(editor): scale and theme Layers panel

- Add indexed visible rows, virtual scroll-to-selection, and range selection

- Move Layer Tree state styling into typed Tailwind Variants slots

- Enforce parsed Vue state-class theming and cover 5,000-node documents
This commit is contained in:
Danila Poyarkov 2026-07-17 17:23:16 +03:00
parent 9919d8710a
commit 7bfebce0bf
24 changed files with 1185 additions and 217 deletions

View file

@ -13,6 +13,7 @@
- Add saved per-node export settings for repeat exports.
- Add Design panel controls for layer blend modes and alpha, vector, and luminance masks.
- Refine Design panel foundations with 26px controls, consistently aligned action rails, shared Tailwind themes, and Storybook component states.
- Scale the Layers panel to 5,000-node documents with virtualized rows, indexed updates, scroll-to-selection, range selection, and focus-aware themed states.
- Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`.
- Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API.
- Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions.

View file

@ -161,8 +161,24 @@ export type {
GradientEditorStopSlotProps,
GradientEditorStopSlots
} from '#vue/primitives/GradientEditor'
export { LayerTreeRoot, LayerTreeItem, useLayerTree } from '#vue/primitives/LayerTree'
export type { LayerDragInstruction, LayerTreeContext, LayerNode } from '#vue/primitives/LayerTree'
export {
buildLayerTreeModel,
indexLayerNodes,
layerSelectionForTarget,
LayerTreeItem,
LayerTreeRoot,
patchLayerNode,
useLayerTree,
visibleLayerRows
} from '#vue/primitives/LayerTree'
export type {
LayerDragInstruction,
LayerNode,
LayerRow,
LayerSelectionMode,
LayerTreeContext,
LayerTreeVirtualizer
} from '#vue/primitives/LayerTree'
export { LayoutControlsRoot, useLayoutControlsContext } from '#vue/primitives/LayoutControls'
export type {
LayoutControlsContext,

View file

@ -74,6 +74,7 @@ defineExpose({ rowEl })
:has-children="hasChildren"
:is-selected="isSelected"
:is-dragging="isDragging"
:focused="ctx.focused.value"
:pad-left="padLeft"
:actions="actions"
/>

View file

@ -1,13 +1,25 @@
<script setup lang="ts">
import { computed, nextTick, onScopeDispose, ref, watch } from 'vue'
import { computed, nextTick, onScopeDispose, ref } from 'vue'
import { TreeRoot } from 'reka-ui'
import type { SceneNode } from '@open-pencil/scene-graph'
import { useEditor } from '#vue/editor/context'
import { provideLayerTree } from '#vue/primitives/LayerTree/context'
import {
buildLayerTreeModel,
indexLayerNodes,
layerSelectionForTarget,
patchLayerNode,
visibleLayerRows
} from '#vue/primitives/LayerTree/model'
import { useLayerDrag } from '#vue/primitives/LayerTree/useLayerDrag'
import type { LayerNode } from '#vue/primitives/LayerTree/context'
import type { SceneNode } from '@open-pencil/scene-graph'
import type {
LayerNode,
LayerSelectionMode,
LayerTreeVirtualizer
} from '#vue/primitives/LayerTree/context'
const { indentPerLevel = 16 } = defineProps<{
indentPerLevel?: number
@ -22,6 +34,16 @@ const emit = defineEmits<{
}>()
const editor = useEditor()
const items = ref<LayerNode[]>([])
const expanded = ref<string[]>([])
const treeVersion = ref(0)
const selectedIds = computed(() => editor.state.selectedIds)
const focused = ref(false)
const visibleRows = computed(() => visibleLayerRows(items.value, new Set(expanded.value)))
let nodesById = new Map<string, LayerNode>()
let virtualizer: LayerTreeVirtualizer | null = null
let selectionAnchorId: string | null = null
let applyingSelection = false
function expandNode(id: string) {
if (!expanded.value.includes(id)) expanded.value = [...expanded.value, id]
@ -33,77 +55,73 @@ const { draggingId, instruction, instructionTargetId, setupItem } = useLayerDrag
expandNode
)
function nodeToLayerNode(node: SceneNode): LayerNode {
return {
id: node.id,
name: node.name,
type: node.type,
layoutMode: node.layoutMode,
visible: node.visible,
locked: node.locked
}
}
function buildTree(parentId: string): LayerNode[] {
const parent = editor.graph.getNode(parentId)
if (!parent) return []
return parent.childIds
.map((cid) => editor.graph.getNode(cid))
.filter((n): n is NonNullable<typeof n> => !!n)
.map((node) => ({
...nodeToLayerNode(node),
children: node.childIds.length > 0 ? buildTree(node.id) : undefined
}))
}
const items = ref(buildTree(editor.state.currentPageId))
const treeVersion = ref(0)
const expanded = ref<string[]>([])
const selectedIds = computed(() => editor.state.selectedIds)
function rebuildTree() {
items.value = buildTree(editor.state.currentPageId)
const model = buildLayerTreeModel(editor.graph, editor.state.currentPageId)
items.value = model.items
nodesById = indexLayerNodes(items.value)
expanded.value = expanded.value.filter((id) => nodesById.has(id))
treeVersion.value++
}
function replaceLayerNode(nodes: LayerNode[], replacement: LayerNode): LayerNode[] | null {
let changed = false
const next = nodes.map((node) => {
if (node.id === replacement.id) {
changed = true
return { ...replacement, children: node.children }
}
if (!node.children) return node
const children = replaceLayerNode(node.children, replacement)
if (!children) return node
changed = true
return { ...node, children }
})
return changed ? next : null
}
rebuildTree()
function patchLayerNode(id: string, changes: Partial<SceneNode>) {
const PATCHABLE_NODE_KEYS = new Set<keyof SceneNode>([
'name',
'type',
'layoutMode',
'visible',
'locked'
])
function patchTreeNode(id: string, changes: Partial<SceneNode>) {
if ('childIds' in changes || 'parentId' in changes) {
rebuildTree()
return
}
if (
!(
'name' in changes ||
'type' in changes ||
'layoutMode' in changes ||
'visible' in changes ||
'locked' in changes
)
) {
if (!(Object.keys(changes) as (keyof SceneNode)[]).some((key) => PATCHABLE_NODE_KEYS.has(key))) {
return
}
const target = nodesById.get(id)
const source = editor.graph.getNode(id)
if (target && source) patchLayerNode(target, source)
}
const node = editor.graph.getNode(id)
if (!node) return
const next = replaceLayerNode(items.value, nodeToLayerNode(node))
if (next) items.value = next
const rowRefs = new Map<string, HTMLElement>()
function setRowRef(id: string, el: HTMLElement | null) {
if (el) rowRefs.set(id, el)
else rowRefs.delete(id)
}
function expandSelectionAncestors(ids: readonly string[]) {
const next = new Set(expanded.value)
for (const id of ids) {
let node = editor.graph.getNode(id)
while (node?.parentId && node.parentId !== editor.state.currentPageId) {
next.add(node.parentId)
node = editor.graph.getNode(node.parentId)
}
}
if (next.size !== expanded.value.length) expanded.value = [...next]
}
function scrollToNode(id: string) {
void nextTick(() => {
const index = visibleRows.value.findIndex((row) => row.node.id === id)
if (index !== -1 && virtualizer) {
virtualizer.scrollToIndex(index, { align: 'auto' })
return
}
rowRefs.get(id)?.scrollIntoView({ block: 'nearest' })
})
}
function onSelectionChanged(ids: string[]) {
expandSelectionAncestors(ids)
if (applyingSelection) return
const visibleIds = new Set(visibleRows.value.map((row) => row.node.id))
selectionAnchorId = ids.find((id) => visibleIds.has(id)) ?? null
if (selectionAnchorId) scrollToNode(selectionAnchorId)
}
const unsubscribe = [
@ -113,39 +131,14 @@ const unsubscribe = [
editor.onEditorEvent('node:deleted', rebuildTree),
editor.onEditorEvent('node:reparented', rebuildTree),
editor.onEditorEvent('node:reordered', rebuildTree),
editor.onEditorEvent('node:updated', patchLayerNode)
editor.onEditorEvent('node:updated', patchTreeNode),
editor.onEditorEvent('selection:changed', onSelectionChanged)
]
onScopeDispose(() => {
for (const stop of unsubscribe) stop()
})
const rowRefs = new Map<string, HTMLElement>()
function setRowRef(id: string, el: HTMLElement | null) {
if (el) rowRefs.set(id, el)
else rowRefs.delete(id)
}
watch(
() => editor.state.selectedIds,
(ids) => {
const toExpand = new Set(expanded.value)
for (const id of ids) {
let node = editor.graph.getNode(id)
while (node?.parentId && node.parentId !== editor.state.currentPageId) {
toExpand.add(node.parentId)
node = editor.graph.getNode(node.parentId)
}
}
if (toExpand.size > expanded.value.length) expanded.value = [...toExpand]
nextTick(() => {
const first = [...ids][0]
if (first) rowRefs.get(first)?.scrollIntoView({ block: 'nearest' })
})
}
)
function syncCanvasScope(nodeId: string) {
const node = editor.graph.getNode(nodeId)
if (!node) return
@ -161,20 +154,32 @@ function syncCanvasScope(nodeId: string) {
editor.state.enteredContainerId = null
}
function select(id: string, additive: boolean) {
emit('select', id, additive)
if (additive) {
editor.select([id], true)
} else {
editor.select([id])
syncCanvasScope(id)
function select(id: string, selection: boolean | LayerSelectionMode) {
const mode = typeof selection === 'boolean' ? { additive: selection, range: false } : selection
emit('select', id, mode.additive)
const visibleIds = visibleRows.value.map((row) => row.node.id)
const next = layerSelectionForTarget(
visibleIds,
editor.state.selectedIds,
selectionAnchorId,
id,
mode
)
if (!mode.range) selectionAnchorId = id
applyingSelection = true
try {
editor.select([...next])
} finally {
applyingSelection = false
}
if (!mode.additive && !mode.range) syncCanvasScope(id)
scrollToNode(id)
}
function toggleExpand(id: string) {
emit('toggleExpand', id)
const idx = expanded.value.indexOf(id)
if (idx !== -1) expanded.value = expanded.value.filter((e) => e !== id)
const index = expanded.value.indexOf(id)
if (index !== -1) expanded.value = expanded.value.filter((expandedId) => expandedId !== id)
else expandNode(id)
}
@ -186,17 +191,27 @@ function getChildren(node: LayerNode) {
return node.children
}
function setVirtualizer(next: LayerTreeVirtualizer) {
virtualizer = next
}
const actions = {
select,
toggleExpand
toggleExpand,
setFocused: (value: boolean) => {
focused.value = value
},
setVirtualizer
}
provideLayerTree({
editor,
items,
expanded,
visibleRows,
treeVersion,
selectedIds,
focused,
indentPerLevel,
draggingId,
instruction,
@ -204,6 +219,8 @@ provideLayerTree({
setupDrag: setupItem,
select,
toggleExpand,
setFocused: actions.setFocused,
setVirtualizer,
toggleVisibility: (id: string) => {
emit('toggleVisibility', id)
editor.toggleNodeVisibility(id)
@ -223,9 +240,9 @@ provideLayerTree({
<template>
<TreeRoot
v-slot="{ flattenItems }"
v-model:expanded="expanded"
as="div"
class="flex min-h-0 flex-1 flex-col overflow-hidden"
v-model:expanded="expanded"
:items="items"
:get-key="getKey"
:get-children="getChildren"
@ -233,9 +250,11 @@ provideLayerTree({
<slot
:items="items"
:flatten-items="flattenItems"
:visible-rows="visibleRows"
:expanded="expanded"
:tree-version="treeVersion"
:selected-ids="selectedIds"
:focused="focused"
:dragging-id="draggingId"
:instruction="instruction"
:instruction-target-id="instructionTargetId"

View file

@ -12,6 +12,21 @@ export interface LayerNode {
children?: LayerNode[]
}
export interface LayerRow {
node: LayerNode
level: number
hasChildren: boolean
}
export interface LayerSelectionMode {
additive: boolean
range: boolean
}
export interface LayerTreeVirtualizer {
scrollToIndex: (index: number, options?: { align?: 'auto' | 'center' | 'end' | 'start' }) => void
}
export interface LayerDragInstruction {
type: 'reorder-above' | 'reorder-below' | 'make-child'
}
@ -20,8 +35,10 @@ export interface LayerTreeContext {
editor: Editor
items: Ref<LayerNode[]>
expanded: Ref<string[]>
visibleRows: ComputedRef<LayerRow[]>
treeVersion: Ref<number>
selectedIds: ComputedRef<Set<string>>
focused: Ref<boolean>
indentPerLevel: number
draggingId: Ref<string | null>
instruction: Ref<LayerDragInstruction | null>
@ -30,8 +47,10 @@ export interface LayerTreeContext {
el: Ref<HTMLElement | null>,
item: () => { id: string; level: number; hasChildren: boolean; parentId: string | null }
) => void
select: (id: string, additive: boolean) => void
select: (id: string, selection: boolean | LayerSelectionMode) => void
toggleExpand: (id: string) => void
setFocused: (focused: boolean) => void
setVirtualizer: (virtualizer: LayerTreeVirtualizer) => void
toggleVisibility: (id: string) => void
toggleLock: (id: string) => void
rename: (id: string, name: string) => void

View file

@ -3,6 +3,16 @@ export { default as LayerTreeItem } from '#vue/primitives/LayerTree/LayerTreeIte
export { useLayerTree } from '#vue/primitives/LayerTree/context'
export type {
LayerDragInstruction,
LayerNode,
LayerRow,
LayerSelectionMode,
LayerTreeContext,
LayerNode
LayerTreeVirtualizer
} from '#vue/primitives/LayerTree/context'
export {
buildLayerTreeModel,
indexLayerNodes,
layerSelectionForTarget,
patchLayerNode,
visibleLayerRows
} from '#vue/primitives/LayerTree/model'

View file

@ -0,0 +1,112 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { LayerNode, LayerRow, LayerSelectionMode } from '#vue/primitives/LayerTree/context'
function nodeToLayerNode(node: SceneNode): LayerNode {
return {
id: node.id,
name: node.name,
type: node.type,
layoutMode: node.layoutMode,
visible: node.visible,
locked: node.locked
}
}
export interface LayerTreeModel {
items: LayerNode[]
byId: Map<string, LayerNode>
}
export function buildLayerTreeModel(graph: SceneGraph, parentId: string): LayerTreeModel {
const byId = new Map<string, LayerNode>()
const buildChildren = (id: string): LayerNode[] => {
const parent = graph.getNode(id)
if (!parent) return []
const children: LayerNode[] = []
for (const childId of parent.childIds) {
const sceneNode = graph.getNode(childId)
if (!sceneNode) continue
const node = nodeToLayerNode(sceneNode)
byId.set(node.id, node)
if (sceneNode.childIds.length > 0) node.children = buildChildren(node.id)
children.push(node)
}
return children
}
return { items: buildChildren(parentId), byId }
}
export function indexLayerNodes(items: readonly LayerNode[]): Map<string, LayerNode> {
const byId = new Map<string, LayerNode>()
const visit = (nodes: readonly LayerNode[]) => {
for (const node of nodes) {
byId.set(node.id, node)
if (node.children) visit(node.children)
}
}
visit(items)
return byId
}
export function patchLayerNode(target: LayerNode, source: SceneNode): boolean {
const changed =
target.name !== source.name ||
target.type !== source.type ||
target.layoutMode !== source.layoutMode ||
target.visible !== source.visible ||
target.locked !== source.locked
if (!changed) return false
target.name = source.name
target.type = source.type
target.layoutMode = source.layoutMode
target.visible = source.visible
target.locked = source.locked
return true
}
export function visibleLayerRows(
items: readonly LayerNode[],
expandedIds: ReadonlySet<string>
): LayerRow[] {
const rows: LayerRow[] = []
const append = (nodes: readonly LayerNode[], level: number) => {
for (const node of nodes) {
const hasChildren = (node.children?.length ?? 0) > 0
rows.push({ node, level, hasChildren })
if (hasChildren && expandedIds.has(node.id)) append(node.children ?? [], level + 1)
}
}
append(items, 1)
return rows
}
export function layerSelectionForTarget(
visibleIds: readonly string[],
currentIds: ReadonlySet<string>,
anchorId: string | null,
targetId: string,
mode: LayerSelectionMode
): Set<string> {
if (!mode.range || !anchorId) {
if (!mode.additive) return new Set([targetId])
const next = new Set(currentIds)
if (next.has(targetId)) next.delete(targetId)
else next.add(targetId)
return next
}
const anchorIndex = visibleIds.indexOf(anchorId)
const targetIndex = visibleIds.indexOf(targetId)
if (anchorIndex === -1 || targetIndex === -1) return new Set([targetId])
const start = Math.min(anchorIndex, targetIndex)
const end = Math.max(anchorIndex, targetIndex)
const next = mode.additive ? new Set(currentIds) : new Set<string>()
for (let index = start; index <= end; index++) {
const id = visibleIds[index]
if (id) next.add(id)
}
return next
}

View file

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, within } from 'storybook/test'
import LayerTreeThemeDemo from './demo/LayerTreeThemeDemo.vue'
const meta = {
title: 'Design System/Editor/Layer Tree',
component: LayerTreeThemeDemo,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Layer Tree theme states for selection focus, visibility, locking, dragging, drop instructions, and rename.'
}
}
}
} satisfies Meta<typeof LayerTreeThemeDemo>
export default meta
type Story = StoryObj<typeof meta>
export const StateMatrix: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByLabelText('Selected focused').firstElementChild).toHaveAttribute(
'data-focused'
)
await expect(canvas.getByLabelText('Selected unfocused').firstElementChild).toHaveAttribute(
'data-selected'
)
await expect(canvas.getByLabelText('Hidden').firstElementChild).toHaveAttribute('data-hidden')
await expect(canvas.getByLabelText('Dragging').firstElementChild).toHaveAttribute(
'data-dragging'
)
await expect(canvas.getByLabelText('Child drop').firstElementChild).toHaveAttribute(
'data-drop-position',
'child'
)
}
}

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { useAttrs } from 'vue'
import { tv } from 'tailwind-variants'
import {
TreeItem,
TreeVirtualizer,
@ -9,15 +10,27 @@ import {
} from 'reka-ui'
import { LayerTreeRoot, LayerTreeItem, useInlineRename } from '@open-pencil/vue'
import type { LayerDragInstruction, LayerNode } from '@open-pencil/vue'
import type {
LayerDragInstruction,
LayerNode,
LayerSelectionMode,
LayerTreeVirtualizer
} from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import CanvasMenu from '../canvas/CanvasMenu.vue'
import LayerTreeNodeRow from './LayerTreeNodeRow.vue'
import LayerTreeRenameRow from './LayerTreeRenameRow.vue'
import { provideLayerTreeUI } from './ui'
import layerTreeTheme from '@/theme/layer-tree'
import type { LayerTreeUI } from './ui'
interface LayerTreeRootActions {
select: (id: string, additive: boolean) => void
select: (id: string, selection: boolean | LayerSelectionMode) => void
toggleExpand: (id: string) => void
setFocused: (focused: boolean) => void
setVirtualizer: (virtualizer: LayerTreeVirtualizer) => void
}
interface LayerTreeSlotScope {
@ -25,12 +38,17 @@ interface LayerTreeSlotScope {
draggingId: string | null
instruction: LayerDragInstruction | null
instructionTargetId: string | null
focused: boolean
}
defineOptions({ inheritAttrs: false })
const { ui } = defineProps<{ ui?: LayerTreeUI }>()
const INDENT = 16
const attrs = useAttrs()
const styles = tv(layerTreeTheme)()
provideLayerTreeUI(() => ui)
const store = useEditorStore()
const rename = useInlineRename((id, name) => store.renameNode(id, name))
const renameControls = {
@ -45,14 +63,21 @@ function onLayerRightClick(e: MouseEvent) {
if (!store.state.selectedIds.has(row.dataset.nodeId)) store.select([row.dataset.nodeId])
}
function isAdditiveSelect(e: CustomEvent): boolean {
function layerSelectionMode(e: CustomEvent): LayerSelectionMode {
const mouseEvent = e.detail?.originalEvent as MouseEvent | undefined
return !!(mouseEvent?.shiftKey || mouseEvent?.metaKey || mouseEvent?.ctrlKey)
return {
additive: !!(mouseEvent?.metaKey || mouseEvent?.ctrlKey),
range: !!mouseEvent?.shiftKey
}
}
function onTreeSelect(e: CustomEvent, id: string, select: (id: string, additive: boolean) => void) {
function onTreeSelect(
e: CustomEvent,
id: string,
select: (id: string, selection: boolean | LayerSelectionMode) => void
) {
e.preventDefault()
select(id, isAdditiveSelect(e))
select(id, layerSelectionMode(e))
}
function isLayerNode(value: unknown): value is LayerNode {
@ -88,18 +113,53 @@ function chrome(scope: Omit<LayerTreeSlotScope, 'actions'>) {
draggingId: scope.draggingId,
instruction: scope.instruction,
instructionTargetId: scope.instructionTargetId,
focused: scope.focused,
indent: INDENT
}
}
function registerVirtualizer(
actions: LayerTreeRootActions,
virtualizer: LayerTreeVirtualizer
): boolean {
actions.setVirtualizer(virtualizer)
return true
}
function onFocusOut(event: FocusEvent, actions: LayerTreeRootActions) {
const next = event.relatedTarget
if (
next instanceof Node &&
event.currentTarget instanceof Node &&
event.currentTarget.contains(next)
) {
return
}
actions.setFocused(false)
}
</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">
<div
v-bind="attrs"
class="relative min-h-0 flex-1 overflow-hidden"
@focusin="scope.actions.setFocused(true)"
@focusout="onFocusOut($event, scope.actions)"
>
<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">
<div
data-test-id="layers-scroll"
data-slot="viewport"
:class="styles.viewport({ class: ui?.viewport })"
>
<TreeVirtualizer
v-slot="{ item, virtualizer }"
:estimate-size="24"
:text-content="layerTextContent"
>
<template v-if="registerVirtualizer(scope.actions, virtualizer)">
<TreeItem
v-slot="{ isExpanded }"
v-bind="item.bind"
@ -144,6 +204,7 @@ function chrome(scope: Omit<LayerTreeSlotScope, 'actions'>) {
/>
</LayerTreeItem>
</TreeItem>
</template>
</TreeVirtualizer>
</div>
</ContextMenuTrigger>

View file

@ -1,6 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import { useI18n } from '@open-pencil/vue'
import Tip from '../ui/Tip.vue'
import { useLayerTreeUI } from './ui'
import layerTreeTheme from '@/theme/layer-tree'
import type { LayerNode } from '@open-pencil/vue'
@ -15,48 +22,57 @@ const emit = defineEmits<{
}>()
const { menu: t } = useI18n()
const ui = useLayerTreeUI()
const layerTree = tv(layerTreeTheme)
const styles = computed(() => layerTree({ actionsVisible: node.locked || !node.visible }))
const lockStyles = computed(() => layerTree({ actionActive: node.locked }))
const visibilityStyles = computed(() => layerTree({ actionActive: !node.visible }))
</script>
<template>
<span
class="flex shrink-0 items-center gap-0.5"
:class="!node.locked && node.visible ? 'opacity-0 group-hover/row:opacity-100' : ''"
data-slot="actions"
:data-selected="selected || undefined"
:data-persistent="node.locked || !node.visible || undefined"
:class="styles.actions({ class: ui?.actions })"
>
<Tip :label="node.locked ? t.unlock : t.lock">
<button
type="button"
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
data-slot="action"
:class="styles.action({ class: ui?.action })"
@pointerdown.stop
@click.stop="emit('toggleLock')"
>
<icon-lucide-lock
v-if="node.locked"
class="size-3"
:class="selected ? 'text-white' : 'text-surface'"
data-slot="action-icon"
:class="lockStyles.actionIcon({ class: ui?.actionIcon })"
/>
<icon-lucide-unlock
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="selected ? 'text-white/80' : 'text-surface/70'"
data-slot="action-icon"
:class="lockStyles.actionIcon({ class: ui?.actionIcon })"
/>
</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"
data-slot="action"
:class="styles.action({ class: ui?.action })"
@pointerdown.stop
@click.stop="emit('toggleVisibility')"
>
<icon-lucide-eye-off
v-if="!node.visible"
class="size-3"
:class="selected ? 'text-white' : 'text-surface'"
data-slot="action-icon"
:class="visibilityStyles.actionIcon({ class: ui?.actionIcon })"
/>
<icon-lucide-eye
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="selected ? 'text-white/80' : 'text-surface/70'"
data-slot="action-icon"
:class="visibilityStyles.actionIcon({ class: ui?.actionIcon })"
/>
</button>
</Tip>

View file

@ -1,4 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import { useLayerTreeUI } from './ui'
import layerTreeTheme from '@/theme/layer-tree'
const { expanded, visible } = defineProps<{
expanded: boolean
visible: boolean
@ -7,17 +14,26 @@ const { expanded, visible } = defineProps<{
const emit = defineEmits<{
toggle: []
}>()
const ui = useLayerTreeUI()
const layerTree = tv(layerTreeTheme)
const styles = computed(() => layerTree({ expanded }))
</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'"
data-slot="disclosure"
:data-expanded="expanded || undefined"
:class="styles.disclosure({ class: ui?.disclosure })"
@click.stop="emit('toggle')"
>
<icon-lucide-chevron-right class="size-3" />
</button>
<span v-else class="w-4 shrink-0" />
<span
v-else
data-slot="disclosure-placeholder"
:class="styles.disclosurePlaceholder({ class: ui?.disclosurePlaceholder })"
/>
</template>

View file

@ -1,4 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import { useLayerTreeUI } from './ui'
import layerTreeTheme from '@/theme/layer-tree'
import type { LayerDragInstruction } from '@open-pencil/vue'
const { active, instruction, level, indent } = defineProps<{
@ -7,28 +14,28 @@ const { active, instruction, level, indent } = defineProps<{
level: number
indent: number
}>()
const position = computed(() => {
if (!instruction) return null
if (instruction.type === 'make-child') return 'child' as const
return instruction.type === 'reorder-above' ? ('above' as const) : ('below' as const)
})
const indicatorStyle = computed(() => {
if (position.value === 'child') return { left: `${level * indent}px`, right: '4px' }
const offset = (level - 1) * indent
return { left: `${offset}px`, width: `calc(100% - ${offset}px)` }
})
const ui = useLayerTreeUI()
const layerTree = tv(layerTreeTheme)
const styles = computed(() => layerTree({ dropPosition: position.value ?? undefined }))
</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)`
}"
v-if="active && position"
data-slot="drop-indicator"
:data-drop-position="position"
:class="styles.dropIndicator({ class: ui?.dropIndicator })"
:style="indicatorStyle"
/>
</template>

View file

@ -1,8 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import { COMPONENT_TYPES, nodeIcon } from '@/app/editor/icons'
import LayerTreeActions from './LayerTreeActions.vue'
import LayerTreeDisclosure from './LayerTreeDisclosure.vue'
import LayerTreeDropIndicator from './LayerTreeDropIndicator.vue'
import { useLayerTreeUI } from './ui'
import layerTreeTheme from '@/theme/layer-tree'
import type { LayerNode } from '@open-pencil/vue'
import type { LayerTreeChrome, LayerTreeItemActions } from './types'
@ -21,20 +27,36 @@ const { node, level, hasChildren, selected, padLeft, expanded, actions, chrome }
const emit = defineEmits<{
renameStart: [id: string, name: string]
}>()
const ui = useLayerTreeUI()
const layerTree = tv(layerTreeTheme)
const styles = computed(() =>
layerTree({
selected,
focused: chrome.focused,
dragging: chrome.draggingId === node.id,
visible: node.visible,
component: COMPONENT_TYPES.has(node.type),
childDropTarget:
chrome.instructionTargetId === node.id && chrome.instruction?.type === 'make-child'
})
)
</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' : '',
data-slot="row"
:data-selected="selected || undefined"
:data-focused="chrome.focused || undefined"
:data-dragging="chrome.draggingId === node.id || undefined"
:data-hidden="!node.visible || undefined"
:data-drop-position="
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' : ''
]"
? 'child'
: undefined
"
:class="styles.row({ class: ui?.row })"
:style="{ paddingLeft: padLeft }"
@dblclick="emit('renameStart', node.id, node.name)"
>
@ -44,12 +66,8 @@ const emit = defineEmits<{
@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>
<component :is="nodeIcon(node)" data-slot="icon" :class="styles.icon({ class: ui?.icon })" />
<span data-slot="label" :class="styles.label({ class: ui?.label })">{{ node.name }}</span>
<LayerTreeActions
:node="node"

View file

@ -1,12 +1,16 @@
<script setup lang="ts">
import { useTemplateRef, watch } from 'vue'
import { computed, useTemplateRef, watch } from 'vue'
import { tv } from 'tailwind-variants'
import { nodeIcon } from '@/app/editor/icons'
import LayerTreeDisclosure from './LayerTreeDisclosure.vue'
import { useLayerTreeUI } from './ui'
import layerTreeTheme from '@/theme/layer-tree'
import type { LayerNode } from '@open-pencil/vue'
import type { LayerRenameControls, LayerTreeItemActions } from './types'
const { renameControls } = defineProps<{
const { renameControls, expanded } = defineProps<{
node: LayerNode
hasChildren: boolean
padLeft: string
@ -16,6 +20,9 @@ const { renameControls } = defineProps<{
}>()
const renameInput = useTemplateRef<HTMLInputElement>('renameInput')
const ui = useLayerTreeUI()
const layerTree = tv(layerTreeTheme)
const styles = computed(() => layerTree({ expanded }))
watch(renameInput, (input) => {
if (input) void renameControls.focusInput(input)
@ -23,18 +30,27 @@ watch(renameInput, (input) => {
</script>
<template>
<div class="flex w-full items-center gap-1 py-1" :style="{ paddingLeft: padLeft }">
<div
data-slot="rename-row"
:class="styles.renameRow({ class: ui?.renameRow })"
:style="{ paddingLeft: padLeft }"
>
<LayerTreeDisclosure
:expanded="expanded"
:visible="hasChildren"
@toggle="actions.toggleExpand"
/>
<component :is="nodeIcon(node)" class="size-3 shrink-0 opacity-70" />
<component
:is="nodeIcon(node)"
data-slot="rename-icon"
:class="styles.renameIcon({ class: ui?.renameIcon })"
/>
<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"
data-slot="rename-input"
:class="styles.renameInput({ class: ui?.renameInput })"
:value="node.name"
@blur="renameControls.commit(node.id, $event)"
@keydown.stop="renameControls.onKeydown"

View file

@ -0,0 +1,141 @@
<script setup lang="ts">
import LayerTreeNodeRow from '../LayerTreeNodeRow.vue'
import LayerTreeRenameRow from '../LayerTreeRenameRow.vue'
import { provideLayerTreeUI } from '../ui'
import type { LayerNode } from '@open-pencil/vue'
import type { LayerRenameControls, LayerTreeChrome, LayerTreeItemActions } from '../types'
provideLayerTreeUI(() => undefined)
function noop() {
return undefined
}
const actions: LayerTreeItemActions = {
select: noop,
toggleExpand: noop,
toggleVisibility: noop,
toggleLock: noop,
rename: noop
}
const renameControls: LayerRenameControls = {
commit: noop,
onKeydown: noop,
focusInput: async (input) => {
input.focus()
}
}
function node(id: string, overrides: Partial<LayerNode> = {}): LayerNode {
return {
id,
name: id,
type: 'RECTANGLE',
layoutMode: 'NONE',
visible: true,
locked: false,
...overrides
}
}
function chrome(overrides: Partial<LayerTreeChrome> = {}): LayerTreeChrome {
return {
draggingId: null,
instruction: null,
instructionTargetId: null,
focused: false,
indent: 16,
...overrides
}
}
const states = [
{ label: 'Normal', node: node('Normal'), selected: false, chrome: chrome() },
{
label: 'Selected focused',
node: node('Selected focused'),
selected: true,
chrome: chrome({ focused: true })
},
{
label: 'Selected unfocused',
node: node('Selected unfocused'),
selected: true,
chrome: chrome()
},
{ label: 'Hidden', node: node('Hidden', { visible: false }), selected: false, chrome: chrome() },
{ label: 'Locked', node: node('Locked', { locked: true }), selected: false, chrome: chrome() },
{
label: 'Component',
node: node('Component', { type: 'COMPONENT' }),
selected: false,
chrome: chrome()
},
{
label: 'Dragging',
node: node('Dragging'),
selected: false,
chrome: chrome({ draggingId: 'Dragging' })
},
{
label: 'Child drop',
node: node('Child drop'),
selected: false,
chrome: chrome({
instruction: { type: 'make-child' },
instructionTargetId: 'Child drop'
})
},
{
label: 'Drop above',
node: node('Drop above'),
selected: false,
chrome: chrome({
instruction: { type: 'reorder-above' },
instructionTargetId: 'Drop above'
})
},
{
label: 'Drop below',
node: node('Drop below'),
selected: false,
chrome: chrome({
instruction: { type: 'reorder-below' },
instructionTargetId: 'Drop below'
})
}
]
</script>
<template>
<div class="w-72 rounded-lg border border-border bg-panel p-2 shadow-lg">
<div class="mb-2 text-[11px] font-semibold tracking-wider text-muted uppercase">
Layer Tree states
</div>
<div class="space-y-1">
<div v-for="state in states" :key="state.label" :aria-label="state.label">
<LayerTreeNodeRow
:node="state.node"
:level="1"
has-children
:selected="state.selected"
pad-left="8px"
:expanded="state.label === 'Normal'"
:actions="actions"
:chrome="state.chrome"
/>
</div>
<div aria-label="Rename">
<LayerTreeRenameRow
:node="node('Rename')"
:has-children="false"
pad-left="8px"
:expanded="false"
:actions="actions"
:rename-controls="renameControls"
/>
</div>
</div>
</div>
</template>

View file

@ -12,6 +12,7 @@ export interface LayerTreeChrome {
draggingId: string | null
instruction: LayerDragInstruction | null
instructionTargetId: string | null
focused: boolean
indent: number
}

View file

@ -0,0 +1,20 @@
import { type ComputedRef, type InjectionKey, computed, inject, provide } from 'vue'
import type { ComponentUI } from '@/components/ui/types'
import type { LayerTreeTheme } from '@/theme/layer-tree'
export type LayerTreeUI = ComponentUI<LayerTreeTheme>
const LAYER_TREE_UI_KEY: InjectionKey<ComputedRef<LayerTreeUI | undefined>> =
Symbol('layer-tree-ui')
export function provideLayerTreeUI(ui: () => LayerTreeUI | undefined) {
provide(LAYER_TREE_UI_KEY, computed(ui))
}
export function useLayerTreeUI(): ComputedRef<LayerTreeUI | undefined> {
return inject(
LAYER_TREE_UI_KEY,
computed(() => undefined)
)
}

92
src/theme/layer-tree.ts Normal file
View file

@ -0,0 +1,92 @@
const layerTreeTheme = {
slots: {
viewport: 'scrollbar-thin h-full overflow-y-auto px-1',
row: 'group/row relative flex w-full cursor-pointer items-center gap-1 rounded border-none bg-transparent py-1 pr-1 text-left text-xs text-surface hover:bg-hover',
disclosure:
'flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface',
disclosurePlaceholder: 'w-4 shrink-0',
icon: 'size-3 shrink-0',
label: 'min-w-0 flex-1 truncate',
actions: 'flex shrink-0 items-center gap-0.5',
action:
'flex size-4 items-center justify-center rounded text-current outline-none hover:bg-white/15 focus-visible:ring-1 focus-visible:ring-panel-focus',
actionIcon: 'size-3',
dropIndicator: 'pointer-events-none absolute bg-accent',
renameRow: 'flex w-full items-center gap-1 py-1',
renameIcon: 'size-3 shrink-0 opacity-70',
renameInput:
'min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none'
},
variants: {
selected: {
true: { row: 'bg-panel-selected text-surface hover:bg-panel-selected' },
false: { row: 'bg-transparent text-surface hover:bg-hover' }
},
focused: {
true: {},
false: {}
},
dragging: {
true: { row: 'opacity-30' },
false: {}
},
visible: {
true: {},
false: { row: 'opacity-50' }
},
component: {
true: { icon: 'text-component opacity-100' },
false: { icon: 'opacity-70' }
},
expanded: {
true: { disclosure: 'rotate-90' },
false: { disclosure: 'rotate-0' }
},
actionsVisible: {
true: {},
false: {
actions: 'opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100'
}
},
actionActive: {
true: { actionIcon: 'opacity-100' },
false: {
actionIcon: 'opacity-0 group-hover/row:opacity-70 group-focus-within/row:opacity-70'
}
},
childDropTarget: {
true: {
row: 'bg-accent/15 text-surface'
},
false: {}
},
dropPosition: {
child: {
dropIndicator: 'inset-y-1 rounded border border-accent bg-accent/10'
},
above: { dropIndicator: 'top-0 h-0.5' },
below: { dropIndicator: 'bottom-0 h-0.5' }
}
},
compoundVariants: [
{
selected: true,
focused: false,
class: { row: 'bg-panel-selected-muted text-surface hover:bg-panel-selected-muted' }
}
],
defaultVariants: {
selected: false,
focused: false,
dragging: false,
visible: true,
component: false,
expanded: false,
actionsVisible: true,
actionActive: false,
childDropTarget: false
}
}
export type LayerTreeTheme = typeof layerTreeTheme
export default layerTreeTheme

View file

@ -2,19 +2,23 @@ import { test, expect } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
const NODE_COUNT = 1200
const NODE_COUNT = 5000
test('large layer trees stay virtualized and scrollable', async ({ page }) => {
const canvas = new CanvasHelper(page)
await page.goto('/?test&no-rulers')
await canvas.waitForInit()
await page.evaluate((count: number) => {
const replaceStartedAt = await page.evaluate((count: number) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const Graph = store.graph.constructor as new () => typeof store.graph
const graph = new Graph()
const pageId = graph.getPages()[0]?.id
if (!pageId) throw new Error('Page not initialized')
for (let i = 0; i < count; i++) {
store.graph.createNode('RECTANGLE', store.state.currentPageId, {
graph.createNode('RECTANGLE', pageId, {
name: `Layer ${String(i + 1).padStart(4, '0')}`,
x: (i % 40) * 24,
y: Math.floor(i / 40) * 24,
@ -30,7 +34,9 @@ test('large layer trees stay virtualized and scrollable', async ({ page }) => {
]
})
}
store.requestRender()
const startedAt = performance.now()
store.replaceGraph(graph)
return startedAt
}, NODE_COUNT)
await canvas.waitForRender()
@ -39,13 +45,21 @@ test('large layer trees stay virtualized and scrollable', async ({ page }) => {
const rows = page.getByTestId('layers-item')
await expect(rows.first()).toContainText('Layer 0001')
await expect.poll(() => rows.count()).toBeLessThan(200)
await expect.poll(() => rows.count()).toBeLessThan(250)
await expect
.poll(() => page.evaluate((startedAt) => performance.now() - startedAt, replaceStartedAt))
.toBeLessThan(5000)
await scroller.evaluate((el) => {
el.scrollTop = el.scrollHeight
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageNode = store.graph.getNode(store.state.currentPageId)
const lastId = pageNode?.childIds.at(-1)
if (!lastId) throw new Error('Last layer not found')
store.select([lastId])
})
const lastRow = rows.filter({ hasText: 'Layer 1200' }).first()
const lastRow = rows.filter({ hasText: 'Layer 5000' }).first()
await expect(lastRow).toBeVisible()
const scrollBefore = await scroller.evaluate((el) => el.scrollTop)
@ -78,5 +92,56 @@ test('large layer trees stay virtualized and scrollable', async ({ page }) => {
store.renameNode(lastId, 'Last layer renamed')
})
await expect(rows.filter({ hasText: 'Last layer renamed' }).first()).toBeVisible()
const renamedRow = rows.filter({ hasText: 'Last layer renamed' }).first()
await expect(renamedRow).toBeVisible()
await renamedRow.dblclick()
const renameInput = page.getByTestId('layers-item-input')
await expect(renameInput).toBeFocused()
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const firstId = store.graph.getNode(store.state.currentPageId)?.childIds[0]
if (!firstId) throw new Error('First layer not found')
store.updateNodeWithUndo(firstId, { y: 48 }, 'Move first layer again')
})
await expect(renameInput).toBeFocused()
})
test('layer tree supports range and additive selection', async ({ page }) => {
const canvas = new CanvasHelper(page)
await page.goto('/?test&no-rulers')
await canvas.waitForInit()
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const Graph = store.graph.constructor as new () => typeof store.graph
const graph = new Graph()
const pageId = graph.getPages()[0]?.id
if (!pageId) throw new Error('Page not initialized')
for (let index = 1; index <= 8; index++) {
graph.createNode('RECTANGLE', pageId, { name: `Layer ${index}` })
}
store.replaceGraph(graph)
})
const rows = page.getByTestId('layers-item')
await rows.filter({ hasText: 'Layer 2' }).click()
await page.keyboard.down('Shift')
await rows.filter({ hasText: 'Layer 5' }).click()
await page.keyboard.up('Shift')
const selectedRows = page.getByTestId('layers-item').and(page.locator('[data-selected]'))
await expect(selectedRows).toHaveCount(4)
await expect(rows.filter({ hasText: 'Layer 2' })).toHaveAttribute('data-selected')
await expect(rows.filter({ hasText: 'Layer 5' })).toHaveAttribute('data-selected')
await page.keyboard.down('Meta')
await rows.filter({ hasText: 'Layer 7' }).click()
await page.keyboard.up('Meta')
await expect(selectedRows).toHaveCount(5)
await expect(rows.filter({ hasText: 'Layer 7' })).toHaveAttribute('data-selected')
})

View file

@ -0,0 +1,101 @@
import { describe, expect, test } from 'bun:test'
import {
buildLayerTreeModel,
layerSelectionForTarget,
patchLayerNode,
visibleLayerRows
} from '@open-pencil/vue'
import { createRect, firstPageId, makeSceneGraph } from '#tests/helpers/scene'
describe('layer tree model', () => {
test('builds indexed nested items in scene order', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const frame = graph.createNode('FRAME', pageId, { name: 'Frame' })
const child = createRect(graph, frame.id, { name: 'Child' })
const sibling = createRect(graph, pageId, { name: 'Sibling' })
const model = buildLayerTreeModel(graph, pageId)
expect(model.items.map((node) => node.id)).toEqual([frame.id, sibling.id])
expect(model.items[0]?.children?.map((node) => node.id)).toEqual([child.id])
expect(model.byId.get(child.id)?.name).toBe('Child')
})
test('derives only rows made visible by expansion', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const frame = graph.createNode('FRAME', pageId, { name: 'Frame' })
const nested = graph.createNode('FRAME', frame.id, { name: 'Nested' })
const child = createRect(graph, nested.id, { name: 'Child' })
const model = buildLayerTreeModel(graph, pageId)
expect(visibleLayerRows(model.items, new Set()).map((row) => row.node.id)).toEqual([frame.id])
expect(
visibleLayerRows(model.items, new Set([frame.id, nested.id])).map((row) => [
row.node.id,
row.level
])
).toEqual([
[frame.id, 1],
[nested.id, 2],
[child.id, 3]
])
})
test('patches an indexed node without replacing its identity', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const sceneNode = createRect(graph, pageId, { name: 'Before' })
const model = buildLayerTreeModel(graph, pageId)
const layerNode = model.byId.get(sceneNode.id)
expect(layerNode).toBeDefined()
if (!layerNode) return
graph.updateNode(sceneNode.id, { name: 'After', visible: false })
const updated = graph.getNode(sceneNode.id)
expect(updated).toBeDefined()
if (!updated) return
expect(patchLayerNode(layerNode, updated)).toBe(true)
expect(model.items[0]).toBe(layerNode)
expect(layerNode).toMatchObject({ name: 'After', visible: false })
expect(patchLayerNode(layerNode, updated)).toBe(false)
})
})
describe('layer tree selection', () => {
const rows = ['a', 'b', 'c', 'd']
test('replaces, toggles, and ranges from the anchor', () => {
expect(
layerSelectionForTarget(rows, new Set(['a']), 'a', 'c', {
additive: false,
range: true
})
).toEqual(new Set(['a', 'b', 'c']))
expect(
layerSelectionForTarget(rows, new Set(['a']), 'a', 'c', {
additive: true,
range: true
})
).toEqual(new Set(['a', 'b', 'c']))
expect(
layerSelectionForTarget(rows, new Set(['a', 'b']), 'a', 'b', {
additive: true,
range: false
})
).toEqual(new Set(['a']))
})
test('falls back to the target when the anchor is not visible', () => {
expect(
layerSelectionForTarget(rows, new Set(['a']), 'missing', 'd', {
additive: true,
range: true
})
).toEqual(new Set(['d']))
})
})

View file

@ -1,5 +1,8 @@
{
"name": "@open-pencil/architecture-tools",
"private": true,
"type": "module"
"type": "module",
"scripts": {
"test": "bun test"
}
}

View file

@ -0,0 +1,140 @@
import { parse as parseVueSfc } from 'vue/compiler-sfc'
import { createTextRule } from './support.ts'
const VUE_DIRECTIVE_NODE = 7
const DYNAMIC_CLASS_ALLOWLIST = new Set([
'src/components/CollabPanel/CollabAvatarStack.vue:37',
'src/components/CollabPanel/CollabSharePopover.vue:20',
'src/components/LayersPanel.vue:27',
'src/components/LayersPanel.vue:35',
'src/components/MobileHud/MobilePresencePopover.vue:52',
'src/components/PagesPanel.vue:99',
'src/components/PagesPanel.vue:133',
'src/components/TabBar.vue:53',
'src/components/Toolbar/MobileToolbar.vue:79',
'src/components/Toolbar/MobileToolbar.vue:171',
'src/components/Toolbar/ToolButton.vue:20',
'src/components/Toolbar/ToolFlyout.vue:81',
'src/components/Toolbar/ToolFlyout.vue:104',
'src/components/chat/ProviderConnectionTestButton.vue:67',
'src/components/fill-picker/GradientEditor.vue:52',
'src/components/properties/LayoutSection/FlexControls.vue:225',
'src/components/properties/binding/demo/BindingFieldDemoItem.vue:54',
'src/components/variables/VariablesDialog.vue:269',
'src/components/variables/VariablesDialog.vue:324'
])
type UnknownRecord = Record<string, unknown>
type ExpressionNode = UnknownRecord & { type: string }
type VueTemplateNode = {
type?: number
name?: string
arg?: { content?: string }
exp?: { ast?: unknown }
props?: VueTemplateNode[]
children?: VueTemplateNode[]
loc?: { start?: { line?: number; column?: number } }
}
function isUnknownRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null
}
function isExpressionNode(value: unknown): value is ExpressionNode {
return isUnknownRecord(value) && 'type' in value && typeof value.type === 'string'
}
function staticClassValues(node: unknown): string[] {
if (!isExpressionNode(node)) return []
if (node.type === 'StringLiteral' && typeof node.value === 'string') return [node.value]
if (
node.type === 'TemplateLiteral' &&
Array.isArray(node.expressions) &&
node.expressions.length === 0
) {
const quasis = Array.isArray(node.quasis) ? node.quasis : []
return quasis.flatMap((quasi) => {
if (!isExpressionNode(quasi) || !isUnknownRecord(quasi.value)) return []
return typeof quasi.value.cooked === 'string' ? [quasi.value.cooked] : []
})
}
if (node.type === 'ArrayExpression' && Array.isArray(node.elements)) {
return node.elements.flatMap(staticClassValues)
}
return []
}
function propertyClassName(property: ExpressionNode): string | null {
if (property.type !== 'ObjectProperty' || !isExpressionNode(property.key)) return null
return property.key.type === 'StringLiteral' && typeof property.key.value === 'string'
? property.key.value
: null
}
function hasDynamicClass(node: unknown, visited = new Set<ExpressionNode>()): boolean {
if (!isExpressionNode(node) || visited.has(node)) return false
visited.add(node)
if (node.type === 'ConditionalExpression') {
const classes = [...staticClassValues(node.consequent), ...staticClassValues(node.alternate)]
if (classes.some((value) => value.trim().length > 0)) return true
}
if (node.type === 'ObjectExpression' && Array.isArray(node.properties)) {
for (const property of node.properties) {
if (!isExpressionNode(property)) continue
if (propertyClassName(property)?.trim()) return true
}
}
for (const [key, value] of Object.entries(node)) {
if (key === 'loc' || key === 'start' || key === 'end') continue
if (Array.isArray(value)) {
if (value.some((child) => hasDynamicClass(child, visited))) return true
} else if (hasDynamicClass(value, visited)) {
return true
}
}
return false
}
function walkVueTemplateAst(node: VueTemplateNode, visitor: (node: VueTemplateNode) => void) {
visitor(node)
for (const prop of node.props ?? []) walkVueTemplateAst(prop, visitor)
for (const child of node.children ?? []) walkVueTemplateAst(child, visitor)
}
export function dynamicClassDiagnostics(sourceRel: string, content: string) {
if (!sourceRel.startsWith('src/components/') || !sourceRel.endsWith('.vue')) return []
const template = parseVueSfc(content, { filename: sourceRel }).descriptor.template?.ast
if (!template) return []
const diagnostics: Array<{ message: string; line?: number; column?: number }> = []
walkVueTemplateAst(template as VueTemplateNode, (node) => {
if (
node.type !== VUE_DIRECTIVE_NODE ||
node.name !== 'bind' ||
node.arg?.content !== 'class' ||
!hasDynamicClass(node.exp?.ast)
) {
return
}
const line = node.loc?.start?.line
if (line && DYNAMIC_CLASS_ALLOWLIST.has(`${sourceRel}:${line}`)) return
diagnostics.push({
message:
'Move visual-state Tailwind classes into a typed src/theme/** Tailwind Variants theme and bind semantic data-* state.',
line,
column: node.loc?.start?.column
})
})
return diagnostics
}
export const noDynamicTailwindStateClasses = createTextRule(
'open-pencil/no-dynamic-tailwind-state-classes',
dynamicClassDiagnostics
)

View file

@ -2,6 +2,7 @@ import path from 'node:path'
import { parse as parseVueSfc } from 'vue/compiler-sfc'
import { noDynamicTailwindStateClasses } from './dynamic-tailwind-classes.ts'
import {
collectFolders,
createFileRule,
@ -512,6 +513,7 @@ export const openPencilArchitecturePlugin = {
noAppImportsInSharedUi,
noPropertyPanelInternalsOutsidePanel,
noProductionTestIdsInSharedLayers,
noDynamicTailwindStateClasses,
noNativeTitleAttributesInVue,
noShortcutTextInLabels,
noHardcodedMacOSShortcutGlyphs,

View file

@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test'
import { dynamicClassDiagnostics } from '../src/steiger-rules/dynamic-tailwind-classes'
function component(classBinding: string) {
return `<template><button :class="${classBinding}" /></template>`
}
describe('dynamic Tailwind state classes', () => {
test('reports conditional utility strings', () => {
const diagnostics = dynamicClassDiagnostics(
'src/components/example/ExampleButton.vue',
component("active ? 'bg-accent text-white' : 'text-muted'")
)
expect(diagnostics).toHaveLength(1)
})
test('reports object-style utility maps', () => {
const diagnostics = dynamicClassDiagnostics(
'src/components/example/ExampleButton.vue',
component("{ 'opacity-50': disabled }")
)
expect(diagnostics).toHaveLength(1)
})
test('allows only audited locations inside legacy files', () => {
const audited = `<template>${'\n'.repeat(26)}<button :class="active ? 'bg-hover' : 'text-muted'" /></template>`
expect(dynamicClassDiagnostics('src/components/LayersPanel.vue', audited)).toEqual([])
expect(
dynamicClassDiagnostics(
'src/components/LayersPanel.vue',
component("active ? 'bg-hover' : 'text-muted'")
)
).toHaveLength(1)
})
test('allows resolved theme slots and semantic state', () => {
const source = `<template><button :data-active="active || undefined" :class="styles.button({ class: ui?.button })" /></template>`
expect(dynamicClassDiagnostics('src/components/example/ExampleButton.vue', source)).toEqual([])
})
test('ignores static classes', () => {
expect(
dynamicClassDiagnostics(
'src/components/example/ExampleButton.vue',
'<template><button class="bg-transparent text-muted hover:bg-hover" /></template>'
)
).toEqual([])
})
})