diff --git a/packages/core/src/editor/variables.ts b/packages/core/src/editor/variables.ts index 06dddf426..d74deb01e 100644 --- a/packages/core/src/editor/variables.ts +++ b/packages/core/src/editor/variables.ts @@ -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() + 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 } } diff --git a/packages/core/src/scene-graph/index.ts b/packages/core/src/scene-graph/index.ts index c29c63b65..25af14d37 100644 --- a/packages/core/src/scene-graph/index.ts +++ b/packages/core/src/scene-graph/index.ts @@ -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, diff --git a/packages/core/src/scene-graph/variables.ts b/packages/core/src/scene-graph/variables.ts index 3bf52cc03..4efe31e9c 100644 --- a/packages/core/src/scene-graph/variables.ts +++ b/packages/core/src/scene-graph/variables.ts @@ -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, diff --git a/packages/vue/src/i18n/messages.ts b/packages/vue/src/i18n/messages.ts index 3cb7cc0d1..4614da3b2 100644 --- a/packages/vue/src/i18n/messages.ts +++ b/packages/vue/src/i18n/messages.ts @@ -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', diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json index d95061ce7..69f73e565 100644 --- a/packages/vue/src/locales/ru.json +++ b/packages/vue/src/locales/ru.json @@ -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": "Цвет", diff --git a/packages/vue/src/locales/zh-CN.json b/packages/vue/src/locales/zh-CN.json index e0e72f3da..709fc7849 100644 --- a/packages/vue/src/locales/zh-CN.json +++ b/packages/vue/src/locales/zh-CN.json @@ -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": "颜色", diff --git a/packages/vue/src/variables/dialog/use.ts b/packages/vue/src/variables/dialog/use.ts index c218fb797..a33a9e43f 100644 --- a/packages/vue/src/variables/dialog/use.ts +++ b/packages/vue/src/variables/dialog/use.ts @@ -6,6 +6,7 @@ export function useVariablesDialogState() { const variables = useVariables() const editingCollectionId = ref(null) + const editingModeId = ref(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 } } diff --git a/packages/vue/src/variables/helpers.ts b/packages/vue/src/variables/helpers.ts index 095a49945..c4a17ee20 100644 --- a/packages/vue/src/variables/helpers.ts +++ b/packages/vue/src/variables/helpers.ts @@ -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( diff --git a/src/components/VariablesDialog.vue b/src/components/VariablesDialog.vue index 634f586a5..62b407c3f 100644 --- a/src/components/VariablesDialog.vue +++ b/src/components/VariablesDialog.vue @@ -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('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 = { COLOR: IconPalette, @@ -81,10 +88,26 @@ const ctx = useVariablesEditor({ deleteIcon: IconX }) const collectionInput = templateRef('collectionInput') +const modeInput = templateRef('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) +}