feat(variables): add mode management and collection deletion

Core:
- Add addMode, removeMode, renameMode, setDefaultMode to SceneGraph
- Add editor actions with full undo: addMode, removeMode, renameMode,
  setDefaultMode, duplicateMode, setActiveMode

Vue SDK:
- Wire all mode and collection actions through SDK helpers
- Add mode rename state to dialog composable
- Remove HTMLInputElement casts from commit handlers

UI:
- Mode management lives in the table column headers (Figma-style):
  double-click to rename, right-click for context menu (rename,
  duplicate, set as default, delete), + button after last mode column
- Collection ⋯ dropdown menu with rename and delete
- Use shared menu UI styles throughout
- i18n for en, ru, zh-CN
This commit is contained in:
Danila Poyarkov 2026-05-06 21:36:04 +03:00
parent 2147aeb327
commit 107e9f3d51
9 changed files with 453 additions and 10 deletions

View file

@ -1,3 +1,4 @@
import { randomHex } from '#core/random'
import type { Variable, VariableCollection, VariableType, VariableValue } from '#core/scene-graph'
import type { EditorContext } from './types'
@ -156,6 +157,133 @@ export function createVariableActions(ctx: EditorContext) {
ctx.requestRender()
}
function addMode(collectionId: string, name?: string): string | undefined {
const collection = ctx.graph.variableCollections.get(collectionId)
if (!collection) return undefined
const modeId = `mode:${randomHex(8)}`
const modeName = name ?? `Mode ${collection.modes.length + 1}`
ctx.graph.addMode(collectionId, modeId, modeName)
ctx.undo.push({
label: 'Add mode',
forward: () => {
ctx.graph.addMode(collectionId, modeId, modeName)
ctx.requestRender()
},
inverse: () => {
ctx.graph.removeMode(collectionId, modeId)
ctx.requestRender()
}
})
ctx.requestRender()
return modeId
}
function removeMode(collectionId: string, modeId: string) {
const collection = ctx.graph.variableCollections.get(collectionId)
if (!collection || collection.modes.length <= 1) return
const modeIndex = collection.modes.findIndex((m) => m.modeId === modeId)
const modeName = collection.modes[modeIndex]?.name ?? ''
const wasDefault = collection.defaultModeId === modeId
const valueSnapshots = new Map<string, VariableValue>()
for (const varId of collection.variableIds) {
const v = ctx.graph.variables.get(varId)
if (v?.valuesByMode[modeId] !== undefined) {
valueSnapshots.set(varId, structuredClone(v.valuesByMode[modeId]))
}
}
ctx.graph.removeMode(collectionId, modeId)
ctx.undo.push({
label: 'Remove mode',
forward: () => {
ctx.graph.removeMode(collectionId, modeId)
ctx.requestRender()
},
inverse: () => {
ctx.graph.addMode(collectionId, modeId, modeName)
const col = ctx.graph.variableCollections.get(collectionId)
if (col && modeIndex !== -1) {
const mode = col.modes.pop()
if (mode) col.modes.splice(modeIndex, 0, mode)
}
for (const [varId, value] of valueSnapshots) {
const v = ctx.graph.variables.get(varId)
if (v) v.valuesByMode[modeId] = structuredClone(value)
}
if (wasDefault) ctx.graph.setDefaultMode(collectionId, modeId)
ctx.requestRender()
}
})
ctx.requestRender()
}
function renameMode(collectionId: string, modeId: string, newName: string) {
const collection = ctx.graph.variableCollections.get(collectionId)
if (!collection) return
const mode = collection.modes.find((m) => m.modeId === modeId)
if (!mode) return
const prevName = mode.name
ctx.graph.renameMode(collectionId, modeId, newName)
ctx.undo.push({
label: 'Rename mode',
forward: () => {
ctx.graph.renameMode(collectionId, modeId, newName)
ctx.requestRender()
},
inverse: () => {
ctx.graph.renameMode(collectionId, modeId, prevName)
ctx.requestRender()
}
})
ctx.requestRender()
}
function setDefaultMode(collectionId: string, modeId: string) {
const collection = ctx.graph.variableCollections.get(collectionId)
if (!collection) return
const prevDefault = collection.defaultModeId
ctx.graph.setDefaultMode(collectionId, modeId)
ctx.undo.push({
label: 'Set default mode',
forward: () => {
ctx.graph.setDefaultMode(collectionId, modeId)
ctx.requestRender()
},
inverse: () => {
ctx.graph.setDefaultMode(collectionId, prevDefault)
ctx.requestRender()
}
})
ctx.requestRender()
}
function duplicateMode(collectionId: string, sourceModeId: string): string | undefined {
const collection = ctx.graph.variableCollections.get(collectionId)
if (!collection) return undefined
const sourceMode = collection.modes.find((m) => m.modeId === sourceModeId)
if (!sourceMode) return undefined
const modeId = `mode:${randomHex(8)}`
const modeName = `${sourceMode.name} copy`
ctx.graph.addMode(collectionId, modeId, modeName, sourceModeId)
ctx.undo.push({
label: 'Duplicate mode',
forward: () => {
ctx.graph.addMode(collectionId, modeId, modeName, sourceModeId)
ctx.requestRender()
},
inverse: () => {
ctx.graph.removeMode(collectionId, modeId)
ctx.requestRender()
}
})
ctx.requestRender()
return modeId
}
function setActiveMode(collectionId: string, modeId: string) {
ctx.graph.setActiveMode(collectionId, modeId)
ctx.requestRender()
}
function updateVariableValue(id: string, modeId: string, value: VariableValue) {
const variable = ctx.graph.variables.get(id)
if (!variable) return
@ -194,6 +322,12 @@ export function createVariableActions(ctx: EditorContext) {
addVariable,
removeVariable,
renameVariable,
updateVariableValue
updateVariableValue,
addMode,
removeMode,
renameMode,
setDefaultMode,
duplicateMode,
setActiveMode
}
}

View file

@ -160,6 +160,22 @@ export class SceneGraph {
Variables.setActiveMode(this, collectionId, modeId)
}
addMode(collectionId: string, modeId: string, name: string, sourceMode?: string): void {
Variables.addMode(this, collectionId, modeId, name, sourceMode)
}
removeMode(collectionId: string, modeId: string): void {
Variables.removeMode(this, collectionId, modeId)
}
renameMode(collectionId: string, modeId: string, name: string): void {
Variables.renameMode(this, collectionId, modeId, name)
}
setDefaultMode(collectionId: string, modeId: string): void {
Variables.setDefaultMode(this, collectionId, modeId)
}
resolveVariable(
variableId: string,
modeId?: string,

View file

@ -111,6 +111,61 @@ export function setActiveMode(graph: SceneGraph, collectionId: string, modeId: s
graph.activeMode.set(collectionId, modeId)
}
export function addMode(
graph: SceneGraph,
collectionId: string,
modeId: string,
name: string,
sourceMode?: string
): void {
const collection = graph.variableCollections.get(collectionId)
if (!collection) return
collection.modes.push({ modeId, name })
const sourceModeId = sourceMode ?? collection.defaultModeId
for (const varId of collection.variableIds) {
const variable = graph.variables.get(varId)
if (!variable) continue
variable.valuesByMode[modeId] = structuredClone(
variable.valuesByMode[sourceModeId] ?? Object.values(variable.valuesByMode)[0]
)
}
}
export function removeMode(graph: SceneGraph, collectionId: string, modeId: string): void {
const collection = graph.variableCollections.get(collectionId)
if (!collection || collection.modes.length <= 1) return
collection.modes = collection.modes.filter((m) => m.modeId !== modeId)
if (collection.defaultModeId === modeId) {
collection.defaultModeId = collection.modes[0].modeId
}
for (const varId of collection.variableIds) {
const variable = graph.variables.get(varId)
if (variable) delete variable.valuesByMode[modeId]
}
if (graph.activeMode.get(collectionId) === modeId) {
graph.activeMode.set(collectionId, collection.defaultModeId)
}
}
export function renameMode(
graph: SceneGraph,
collectionId: string,
modeId: string,
name: string
): void {
const collection = graph.variableCollections.get(collectionId)
if (!collection) return
const mode = collection.modes.find((m) => m.modeId === modeId)
if (mode) mode.name = name
}
export function setDefaultMode(graph: SceneGraph, collectionId: string, modeId: string): void {
const collection = graph.variableCollections.get(collectionId)
if (!collection) return
if (!collection.modes.some((m) => m.modeId === modeId)) return
collection.defaultModeId = modeId
}
export function resolveVariable(
graph: SceneGraph,
variableId: string,

View file

@ -260,8 +260,16 @@ export const dialogMessages = i18n('dialogs', {
copy: 'Copy',
copied: 'Copied',
createCollection: 'Create collection',
renameCollection: 'Rename collection',
deleteCollection: 'Delete collection',
localVariables: 'Local variables',
noVariableCollections: 'No variable collections',
modes: 'Modes',
addMode: 'Add mode',
renameMode: 'Rename mode',
duplicateMode: 'Duplicate mode',
deleteMode: 'Delete mode',
setDefaultMode: 'Set as default',
selectLayerForJSX: 'Select a layer to see its JSX code',
connectAIProvider: 'Connect an AI provider to start chatting.',
connect: 'Connect',

View file

@ -225,7 +225,15 @@
"updateInstalledTitle": "Обновление установлено",
"updateInstalled": "OpenPencil {version} установлен{size}. Перезапуск.",
"updateCheckFailed": "Не удалось проверить обновления: {error}",
"updateUnavailable": "Обновления пока недоступны. Сначала опубликуйте подписанный релиз с latest.json."
"updateUnavailable": "Обновления пока недоступны. Сначала опубликуйте подписанный релиз с latest.json.",
"renameCollection": "Переименовать коллекцию",
"deleteCollection": "Удалить коллекцию",
"modes": "Режимы",
"addMode": "Добавить режим",
"renameMode": "Переименовать режим",
"duplicateMode": "Дублировать режим",
"deleteMode": "Удалить режим",
"setDefaultMode": "Установить по умолчанию"
},
"variableTypes": {
"color": "Цвет",

View file

@ -225,7 +225,15 @@
"updateInstalledTitle": "更新已安装",
"updateInstalled": "OpenPencil {version} 已安装{size}。正在重启。",
"updateCheckFailed": "无法检查更新:{error}",
"updateUnavailable": "更新尚不可用。请先发布带有 latest.json 的已签名版本。"
"updateUnavailable": "更新尚不可用。请先发布带有 latest.json 的已签名版本。",
"renameCollection": "重命名集合",
"deleteCollection": "删除集合",
"modes": "模式",
"addMode": "添加模式",
"renameMode": "重命名模式",
"duplicateMode": "复制模式",
"deleteMode": "删除模式",
"setDefaultMode": "设为默认"
},
"variableTypes": {
"color": "颜色",

View file

@ -6,6 +6,7 @@ export function useVariablesDialogState() {
const variables = useVariables()
const editingCollectionId = ref<string | null>(null)
const editingModeId = ref<string | null>(null)
async function focusCollectionInput(input: HTMLInputElement | null) {
if (!input) return
await nextTick()
@ -17,8 +18,10 @@ export function useVariablesDialogState() {
editingCollectionId.value = id
}
function commitRenameCollection(id: string, input: HTMLInputElement) {
function commitRenameCollection(id: string, event: Event) {
if (editingCollectionId.value !== id) return
const input = event.target
if (!(input instanceof HTMLInputElement)) return
const value = input.value.trim()
const col = variables.collections.value.find((collection) => collection.id === id)
if (col && value && value !== col.name) {
@ -27,11 +30,38 @@ export function useVariablesDialogState() {
editingCollectionId.value = null
}
function startRenameMode(modeId: string) {
editingModeId.value = modeId
}
async function focusModeInput(input: HTMLInputElement | null) {
if (!input) return
await nextTick()
input.focus()
input.select()
}
function commitRenameMode(modeId: string, event: Event) {
if (editingModeId.value !== modeId) return
const input = event.target
if (!(input instanceof HTMLInputElement)) return
const value = input.value.trim()
const mode = variables.activeModes.value.find((m) => m.modeId === modeId)
if (mode && value && value !== mode.name) {
variables.renameMode(modeId, value)
}
editingModeId.value = null
}
return {
...variables,
editingCollectionId,
editingModeId,
focusCollectionInput,
focusModeInput,
startRenameCollection,
commitRenameCollection
commitRenameCollection,
startRenameMode,
commitRenameMode
}
}

View file

@ -32,7 +32,60 @@ export function createVariableCollectionActions(editor: Editor, activeCollection
editor.renameCollection(id, newName)
}
return { setActiveCollection, addCollection, renameCollection }
function removeCollection(id: string) {
editor.removeCollection(id)
const cols = [...editor.getCollections()]
activeCollectionId.value = cols[0]?.id ?? ''
}
function addMode(): string | undefined {
const colId = activeCollectionId.value
if (!colId) return undefined
return editor.addMode(colId)
}
function removeMode(modeId: string) {
const colId = activeCollectionId.value
if (!colId) return
editor.removeMode(colId, modeId)
}
function renameMode(modeId: string, newName: string) {
const colId = activeCollectionId.value
if (!colId) return
editor.renameMode(colId, modeId, newName)
}
function setDefaultMode(modeId: string) {
const colId = activeCollectionId.value
if (!colId) return
editor.setDefaultMode(colId, modeId)
}
function duplicateMode(modeId: string): string | undefined {
const colId = activeCollectionId.value
if (!colId) return undefined
return editor.duplicateMode(colId, modeId)
}
function setActiveMode(modeId: string) {
const colId = activeCollectionId.value
if (!colId) return
editor.setActiveMode(colId, modeId)
}
return {
setActiveCollection,
addCollection,
renameCollection,
removeCollection,
addMode,
removeMode,
renameMode,
setDefaultMode,
duplicateMode,
setActiveMode
}
}
export function createVariableValueActions(

View file

@ -2,6 +2,12 @@
import { watch, type Component } from 'vue'
import { templateRef } from '@vueuse/core'
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger,
DialogClose,
DialogContent,
DialogOverlay,
@ -12,6 +18,7 @@ import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger,
TabsContent,
TabsList,
@ -36,7 +43,7 @@ import type { VariableType } from '@open-pencil/core/scene-graph'
const open = defineModel<boolean>('open', { default: false })
const cls = useDialogUI({ content: 'flex h-[75vh] w-[800px] max-w-[90vw] flex-col' })
const menuCls = useMenuUI({ content: 'w-44' })
const menuCls = useMenuUI({ content: 'w-40' })
const variableTypeIcons: Record<VariableType, Component> = {
COLOR: IconPalette,
@ -81,10 +88,26 @@ const ctx = useVariablesEditor({
deleteIcon: IconX
})
const collectionInput = templateRef<HTMLInputElement>('collectionInput')
const modeInput = templateRef<HTMLInputElement>('modeInput')
watch(collectionInput, (input) => {
void ctx.focusCollectionInput(input)
})
watch(modeInput, (input) => {
void ctx.focusModeInput(input)
})
function blurOnEnter(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement) e.target.blur()
}
function getModeId(columnId: string): string | undefined {
return columnId.startsWith('mode-') ? columnId.slice(5) : undefined
}
function modeId(columnId: string): string {
return columnId.slice(5)
}
</script>
<template>
@ -130,8 +153,8 @@ watch(collectionInput, (input) => {
ref="collectionInput"
class="w-24 rounded border border-accent bg-input px-2 py-0.5 text-xs text-surface outline-none"
:value="col.name"
@blur="ctx.commitRenameCollection(col.id, $event.target as HTMLInputElement)"
@keydown.enter="($event.target as HTMLInputElement).blur()"
@blur="ctx.commitRenameCollection(col.id, $event)"
@keydown.enter="blurOnEnter"
@keydown.escape="ctx.editingCollectionId.value = null"
/>
<TabsTrigger
@ -147,6 +170,42 @@ watch(collectionInput, (input) => {
</TabsList>
<div class="flex items-center gap-1.5 px-3">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
data-test-id="variables-collection-menu"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-ellipsis class="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
:side-offset="4"
align="start"
:class="menuCls.content"
>
<DropdownMenuItem
:class="menuCls.item"
@select="ctx.startRenameCollection(ctx.activeCollectionId.value)"
>
<icon-lucide-pencil :class="menuCls.icon" />
{{ dialogs.renameCollection }}
</DropdownMenuItem>
<DropdownMenuSeparator class="mx-1.5 my-1 h-px bg-border" />
<DropdownMenuItem
:class="menuCls.item"
class="text-red-500"
data-test-id="variables-delete-collection"
@select="ctx.removeCollection(ctx.activeCollectionId.value)"
>
<icon-lucide-trash-2 :class="menuCls.icon" />
{{ dialogs.deleteCollection }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<div class="flex items-center gap-1 rounded border border-border px-2 py-0.5">
<icon-lucide-search class="size-3 text-muted" />
<input
@ -196,8 +255,69 @@ watch(collectionInput, (input) => {
class="relative px-4 py-2 text-left text-[11px] font-medium text-muted"
:style="{ width: `${header.getSize()}px` }"
>
<template v-if="getModeId(header.column.id)">
<input
v-if="ctx.editingModeId.value === getModeId(header.column.id)"
ref="modeInput"
class="-mx-1 w-full rounded border border-accent bg-input px-1 py-0 text-[11px] font-medium text-surface outline-none"
:value="header.column.columnDef.header"
@blur="ctx.commitRenameMode(modeId(header.column.id), $event)"
@keydown.enter="blurOnEnter"
@keydown.escape="ctx.editingModeId.value = null"
/>
<ContextMenuRoot v-else>
<ContextMenuTrigger as-child>
<span
class="cursor-default"
:class="
getModeId(header.column.id) === col.defaultModeId
? 'text-surface'
: ''
"
@dblclick="ctx.startRenameMode(modeId(header.column.id))"
>
{{ header.column.columnDef.header }}
</span>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="menuCls.content">
<ContextMenuItem
:class="menuCls.item"
@select="ctx.startRenameMode(modeId(header.column.id))"
>
<icon-lucide-pencil :class="menuCls.icon" />
{{ dialogs.renameMode }}
</ContextMenuItem>
<ContextMenuItem
:class="menuCls.item"
@select="ctx.duplicateMode(modeId(header.column.id))"
>
<icon-lucide-copy :class="menuCls.icon" />
{{ dialogs.duplicateMode }}
</ContextMenuItem>
<ContextMenuItem
v-if="getModeId(header.column.id) !== col.defaultModeId"
:class="menuCls.item"
@select="ctx.setDefaultMode(modeId(header.column.id))"
>
<icon-lucide-pin :class="menuCls.icon" />
{{ dialogs.setDefaultMode }}
</ContextMenuItem>
<ContextMenuSeparator :class="menuCls.separator" />
<ContextMenuItem
:class="[menuCls.item, 'text-red-500']"
:disabled="col.modes.length <= 1"
@select="ctx.removeMode(modeId(header.column.id))"
>
<icon-lucide-trash-2 :class="menuCls.icon" />
{{ dialogs.deleteMode }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<FlexRender
v-if="!header.isPlaceholder"
v-else-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
@ -214,6 +334,17 @@ watch(collectionInput, (input) => {
@dblclick="header.column.resetSize()"
/>
</th>
<th class="w-8 px-1 py-2">
<Tip :label="dialogs.addMode">
<button
data-test-id="variables-add-mode"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
@click="ctx.addMode"
>
<icon-lucide-plus class="size-3" />
</button>
</Tip>
</th>
</tr>
</thead>
<tbody>