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:
parent
9919d8710a
commit
7bfebce0bf
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ defineExpose({ rowEl })
|
|||
:has-children="hasChildren"
|
||||
:is-selected="isSelected"
|
||||
:is-dragging="isDragging"
|
||||
:focused="ctx.focused.value"
|
||||
:pad-left="padLeft"
|
||||
:actions="actions"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
112
packages/vue/src/primitives/LayerTree/model.ts
Normal file
112
packages/vue/src/primitives/LayerTree/model.ts
Normal 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
|
||||
}
|
||||
41
src/components/LayerTree/LayerTree.stories.ts
Normal file
41
src/components/LayerTree/LayerTree.stories.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,62 +113,98 @@ 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">
|
||||
<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"
|
||||
<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"
|
||||
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()
|
||||
}
|
||||
"
|
||||
>
|
||||
<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"
|
||||
<LayerTreeItem
|
||||
v-slot="{ node, isSelected, padLeft, actions }"
|
||||
:node="toLayerNode(item.value)"
|
||||
: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>
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
</TreeVirtualizer>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
141
src/components/LayerTree/demo/LayerTreeThemeDemo.vue
Normal file
141
src/components/LayerTree/demo/LayerTreeThemeDemo.vue
Normal 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>
|
||||
|
|
@ -12,6 +12,7 @@ export interface LayerTreeChrome {
|
|||
draggingId: string | null
|
||||
instruction: LayerDragInstruction | null
|
||||
instructionTargetId: string | null
|
||||
focused: boolean
|
||||
indent: number
|
||||
}
|
||||
|
||||
|
|
|
|||
20
src/components/LayerTree/ui.ts
Normal file
20
src/components/LayerTree/ui.ts
Normal 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
92
src/theme/layer-tree.ts
Normal 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
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
101
tests/engine/vue/layer-tree/model.test.ts
Normal file
101
tests/engine/vue/layer-tree/model.test.ts
Normal 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']))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"name": "@open-pencil/architecture-tools",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
tools/architecture/src/steiger-rules/dynamic-tailwind-classes.ts
Normal file
140
tools/architecture/src/steiger-rules/dynamic-tailwind-classes.ts
Normal 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
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
50
tools/architecture/tests/dynamic-classes.test.ts
Normal file
50
tools/architecture/tests/dynamic-classes.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue