From 68d15440b1740bc90605c8e685ffbd08cbf95c44 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 5 May 2026 23:49:20 +0300 Subject: [PATCH] feat(app): add variable type creation menu --- packages/vue/src/i18n/messages.ts | 12 +++ packages/vue/src/i18n/useI18n.ts | 2 + packages/vue/src/locales/de.json | 13 ++- packages/vue/src/locales/es.json | 13 ++- packages/vue/src/locales/fr.json | 13 ++- packages/vue/src/locales/it.json | 13 ++- packages/vue/src/locales/pl.json | 13 ++- packages/vue/src/locales/ru.json | 13 ++- packages/vue/src/locales/zh-CN.json | 13 ++- packages/vue/src/variables/helpers.ts | 31 +++++-- src/components/VariablesDialog.vue | 80 +++++++++++++++--- tests/e2e/variables-dialog.spec.ts | 18 ++++ tests/engine/variables-editor.test.ts | 115 ++++++++++++++++++++++++++ 13 files changed, 326 insertions(+), 23 deletions(-) create mode 100644 tests/engine/variables-editor.test.ts diff --git a/packages/vue/src/i18n/messages.ts b/packages/vue/src/i18n/messages.ts index 459ff3a36..2a17ff04d 100644 --- a/packages/vue/src/i18n/messages.ts +++ b/packages/vue/src/i18n/messages.ts @@ -173,6 +173,7 @@ export const panelMessages = i18n('panels', { exportPreview: 'Preview', exportRenderingPreview: 'Rendering preview…', create: 'Create', + add: 'Add', createVariable: 'Create variable', createColorVariable: params('Create color variable from {value}'), createNumberVariable: params('Create number variable from {value}'), @@ -233,6 +234,17 @@ export const panelMessages = i18n('panels', { sizingFixedPx: 'Fixed (px)' }) +export const variableTypeMessages = i18n('variableTypes', { + color: 'Color', + colorHint: 'Paint values', + number: 'Number', + numberHint: 'Sizes, spacing, opacity', + text: 'Text', + textHint: 'Copy and labels', + boolean: 'Boolean', + booleanHint: 'True or false' +}) + export const pageMessages = i18n('pages', { newPage: 'New page', rename: 'Rename', diff --git a/packages/vue/src/i18n/useI18n.ts b/packages/vue/src/i18n/useI18n.ts index f2e07461b..99800ba7f 100644 --- a/packages/vue/src/i18n/useI18n.ts +++ b/packages/vue/src/i18n/useI18n.ts @@ -4,6 +4,7 @@ import { commandMessages, toolMessages, panelMessages, + variableTypeMessages, pageMessages, dialogMessages } from '#vue/i18n/messages' @@ -36,6 +37,7 @@ export function useI18n() { commands: useStore(commandMessages), tools: useStore(toolMessages), panels: useStore(panelMessages), + variableTypes: useStore(variableTypeMessages), pages: useStore(pageMessages), dialogs: useStore(dialogMessages), locale: useStore(locale) as Ref, diff --git a/packages/vue/src/locales/de.json b/packages/vue/src/locales/de.json index 2b47de8fa..030a1ae65 100644 --- a/packages/vue/src/locales/de.json +++ b/packages/vue/src/locales/de.json @@ -174,7 +174,8 @@ "variableName": "Variablenname", "create": "Erstellen", "createNumberVariable": "Zahlenvariable aus {value} erstellen", - "strokeDash": "Gestrichelter Strich" + "strokeDash": "Gestrichelter Strich", + "add": "Hinzufügen" }, "pages": { "newPage": "Neue Seite", @@ -225,5 +226,15 @@ "updateInstalled": "OpenPencil {version} wurde installiert{size}. Neustart jetzt.", "updateCheckFailed": "Updates konnten nicht geprüft werden: {error}", "updateUnavailable": "Updates sind noch nicht verfügbar. Veröffentliche zuerst ein signiertes Release mit latest.json." + }, + "variableTypes": { + "color": "Farbe", + "colorHint": "Farbwerte", + "number": "Zahl", + "numberHint": "Größen, Abstände, Deckkraft", + "text": "Text", + "textHint": "Text und Beschriftungen", + "boolean": "Boolean", + "booleanHint": "Wahr oder falsch" } } diff --git a/packages/vue/src/locales/es.json b/packages/vue/src/locales/es.json index 43a505124..da8fb4835 100644 --- a/packages/vue/src/locales/es.json +++ b/packages/vue/src/locales/es.json @@ -174,7 +174,8 @@ "variableName": "Nombre de variable", "create": "Crear", "createNumberVariable": "Crear variable numérica desde {value}", - "strokeDash": "Trazo discontinuo" + "strokeDash": "Trazo discontinuo", + "add": "Añadir" }, "pages": { "newPage": "Nueva página", @@ -225,5 +226,15 @@ "updateInstalled": "OpenPencil {version} se instaló{size}. Reiniciando ahora.", "updateCheckFailed": "No se pudo buscar actualizaciones: {error}", "updateUnavailable": "Las actualizaciones aún no están disponibles. Publica primero una versión firmada con latest.json." + }, + "variableTypes": { + "color": "Color", + "colorHint": "Valores de pintura", + "number": "Número", + "numberHint": "Tamaños, espaciado, opacidad", + "text": "Texto", + "textHint": "Texto y etiquetas", + "boolean": "Booleano", + "booleanHint": "Verdadero o falso" } } diff --git a/packages/vue/src/locales/fr.json b/packages/vue/src/locales/fr.json index 5a260dd4f..a480c72b3 100644 --- a/packages/vue/src/locales/fr.json +++ b/packages/vue/src/locales/fr.json @@ -174,7 +174,8 @@ "variableName": "Nom de la variable", "create": "Créer", "createNumberVariable": "Créer une variable numérique depuis {value}", - "strokeDash": "Trait en pointillés" + "strokeDash": "Trait en pointillés", + "add": "Ajouter" }, "pages": { "newPage": "Nouvelle page", @@ -225,5 +226,15 @@ "updateInstalled": "OpenPencil {version} a été installé{size}. Redémarrage maintenant.", "updateCheckFailed": "Impossible de rechercher des mises à jour : {error}", "updateUnavailable": "Les mises à jour ne sont pas encore disponibles. Publiez d’abord une version signée avec latest.json." + }, + "variableTypes": { + "color": "Couleur", + "colorHint": "Valeurs de peinture", + "number": "Nombre", + "numberHint": "Tailles, espacements, opacité", + "text": "Texte", + "textHint": "Texte et libellés", + "boolean": "Booléen", + "booleanHint": "Vrai ou faux" } } diff --git a/packages/vue/src/locales/it.json b/packages/vue/src/locales/it.json index eab7cc7e0..0727c3a22 100644 --- a/packages/vue/src/locales/it.json +++ b/packages/vue/src/locales/it.json @@ -174,7 +174,8 @@ "variableName": "Nome variabile", "create": "Crea", "createNumberVariable": "Crea variabile numerica da {value}", - "strokeDash": "Tratto tratteggiato" + "strokeDash": "Tratto tratteggiato", + "add": "Aggiungi" }, "pages": { "newPage": "Nuova pagina", @@ -225,5 +226,15 @@ "updateInstalled": "OpenPencil {version} è stato installato{size}. Riavvio in corso.", "updateCheckFailed": "Impossibile controllare gli aggiornamenti: {error}", "updateUnavailable": "Gli aggiornamenti non sono ancora disponibili. Pubblica prima una release firmata con latest.json." + }, + "variableTypes": { + "color": "Colore", + "colorHint": "Valori colore", + "number": "Numero", + "numberHint": "Dimensioni, spaziatura, opacità", + "text": "Testo", + "textHint": "Testi ed etichette", + "boolean": "Booleano", + "booleanHint": "Vero o falso" } } diff --git a/packages/vue/src/locales/pl.json b/packages/vue/src/locales/pl.json index 7b9b1d076..6c7b47613 100644 --- a/packages/vue/src/locales/pl.json +++ b/packages/vue/src/locales/pl.json @@ -174,7 +174,8 @@ "variableName": "Nazwa zmiennej", "create": "Utwórz", "createNumberVariable": "Utwórz zmienną liczbową z {value}", - "strokeDash": "Obrys przerywany" + "strokeDash": "Obrys przerywany", + "add": "Dodaj" }, "pages": { "newPage": "Nowa strona", @@ -225,5 +226,15 @@ "updateInstalled": "OpenPencil {version} został zainstalowany{size}. Ponowne uruchamianie.", "updateCheckFailed": "Nie udało się sprawdzić aktualizacji: {error}", "updateUnavailable": "Aktualizacje nie są jeszcze dostępne. Najpierw opublikuj podpisane wydanie z latest.json." + }, + "variableTypes": { + "color": "Kolor", + "colorHint": "Wartości wypełnień", + "number": "Liczba", + "numberHint": "Rozmiary, odstępy, krycie", + "text": "Tekst", + "textHint": "Tekst i etykiety", + "boolean": "Boolowski", + "booleanHint": "Prawda lub fałsz" } } diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json index 2daa6fc7e..d95061ce7 100644 --- a/packages/vue/src/locales/ru.json +++ b/packages/vue/src/locales/ru.json @@ -174,7 +174,8 @@ "variableName": "Имя переменной", "create": "Создать", "createNumberVariable": "Создать числовую переменную из {value}", - "strokeDash": "Пунктирная обводка" + "strokeDash": "Пунктирная обводка", + "add": "Добавить" }, "pages": { "newPage": "Новая страница", @@ -225,5 +226,15 @@ "updateInstalled": "OpenPencil {version} установлен{size}. Перезапуск.", "updateCheckFailed": "Не удалось проверить обновления: {error}", "updateUnavailable": "Обновления пока недоступны. Сначала опубликуйте подписанный релиз с latest.json." + }, + "variableTypes": { + "color": "Цвет", + "colorHint": "Значения заливок", + "number": "Число", + "numberHint": "Размеры, отступы, прозрачность", + "text": "Текст", + "textHint": "Текст и подписи", + "boolean": "Логический", + "booleanHint": "Истина или ложь" } } diff --git a/packages/vue/src/locales/zh-CN.json b/packages/vue/src/locales/zh-CN.json index ced731312..e0e72f3da 100644 --- a/packages/vue/src/locales/zh-CN.json +++ b/packages/vue/src/locales/zh-CN.json @@ -174,7 +174,8 @@ "variableName": "变量名称", "create": "创建", "createNumberVariable": "从 {value} 创建数字变量", - "strokeDash": "虚线描边" + "strokeDash": "虚线描边", + "add": "添加" }, "pages": { "newPage": "新建页面", @@ -225,5 +226,15 @@ "updateInstalled": "OpenPencil {version} 已安装{size}。正在重启。", "updateCheckFailed": "无法检查更新:{error}", "updateUnavailable": "更新尚不可用。请先发布带有 latest.json 的已签名版本。" + }, + "variableTypes": { + "color": "颜色", + "colorHint": "绘制值", + "number": "数字", + "numberHint": "尺寸、间距、不透明度", + "text": "文本", + "textHint": "文案和标签", + "boolean": "布尔值", + "booleanHint": "真或假" } } diff --git a/packages/vue/src/variables/helpers.ts b/packages/vue/src/variables/helpers.ts index 8d776c670..9a74d779f 100644 --- a/packages/vue/src/variables/helpers.ts +++ b/packages/vue/src/variables/helpers.ts @@ -2,7 +2,12 @@ import { colorToHexRaw, parseColor } from '@open-pencil/core/color' import { randomHex } from '@open-pencil/core/random' import type { Editor } from '@open-pencil/core/editor' -import type { Variable, VariableCollection, VariableValue } from '@open-pencil/core/scene-graph' +import type { + Variable, + VariableCollection, + VariableType, + VariableValue +} from '@open-pencil/core/scene-graph' import type { Ref } from 'vue' export function createVariableCollectionActions(editor: Editor, activeCollectionId: Ref) { @@ -34,20 +39,34 @@ export function createVariableValueActions( editor: Editor, getActiveCollection: () => VariableCollection | null ) { - function addVariable() { + function defaultVariableValue(type: VariableType): VariableValue { + if (type === 'COLOR') return { r: 0, g: 0, b: 0, a: 1 } + if (type === 'FLOAT') return 0 + if (type === 'BOOLEAN') return false + return '' + } + + function defaultVariableName(type: VariableType): string { + if (type === 'COLOR') return 'New color' + if (type === 'FLOAT') return 'New number' + if (type === 'BOOLEAN') return 'New boolean' + return 'New text' + } + + function addVariable(type: VariableType = 'COLOR') { const col = getActiveCollection() if (!col) return const id = `var:${randomHex(8)}` const valuesByMode: Record = {} for (const mode of col.modes) { - valuesByMode[mode.modeId] = { r: 0, g: 0, b: 0, a: 1 } + valuesByMode[mode.modeId] = defaultVariableValue(type) } editor.addVariable({ id, - name: 'New variable', - type: 'COLOR', + name: defaultVariableName(type), + type, collectionId: col.id, valuesByMode, description: '', @@ -83,7 +102,7 @@ export function createVariableValueActions( const num = parseFloat(raw) return isNaN(num) ? undefined : num } - if (variable.type === 'BOOLEAN') return raw === 'true' + if (variable.type === 'BOOLEAN') return raw.toLowerCase() === 'true' return raw } diff --git a/src/components/VariablesDialog.vue b/src/components/VariablesDialog.vue index 0d158e848..90118e5d1 100644 --- a/src/components/VariablesDialog.vue +++ b/src/components/VariablesDialog.vue @@ -8,6 +8,11 @@ import { DialogPortal, DialogRoot, DialogTitle, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuRoot, + DropdownMenuTrigger, TabsContent, TabsList, TabsRoot, @@ -25,18 +30,49 @@ import IconX from '~icons/lucide/x' import ColorInput from '@/components/ColorPicker/ColorInput.vue' import Tip from './ui/Tip.vue' import { useDialogUI } from '@/components/ui/dialog' +import { useMenuUI } from '@/components/ui/menu' + +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 variableTypeIcons: Record = { +const variableTypeIcons: Record = { COLOR: IconPalette, FLOAT: IconHash, STRING: IconType, BOOLEAN: IconToggleLeft } -const { dialogs, panels } = useI18n() +const { dialogs, panels, variableTypes: variableTypeText } = useI18n() + +const variableTypes: Array<{ + type: VariableType + label: () => string + description: () => string +}> = [ + { + type: 'COLOR', + label: () => variableTypeText.value.color, + description: () => variableTypeText.value.colorHint + }, + { + type: 'FLOAT', + label: () => variableTypeText.value.number, + description: () => variableTypeText.value.numberHint + }, + { + type: 'STRING', + label: () => variableTypeText.value.text, + description: () => variableTypeText.value.textHint + }, + { + type: 'BOOLEAN', + label: () => variableTypeText.value.boolean, + description: () => variableTypeText.value.booleanHint + } +] const ctx = useVariablesEditor({ colorInput: ColorInput, @@ -203,14 +239,38 @@ watch(collectionInput, (input) => { - +
+ {{ panels.createVariable }} + + + + + + + + + + {{ item.label() }} + {{ item.description() }} + + + + + +
diff --git a/tests/e2e/variables-dialog.spec.ts b/tests/e2e/variables-dialog.spec.ts index 8b2ca2003..0abd71525 100644 --- a/tests/e2e/variables-dialog.spec.ts +++ b/tests/e2e/variables-dialog.spec.ts @@ -57,6 +57,24 @@ test('search filters variable rows', async () => { canvas.assertNoErrors() }) +test('add variable menu creates non-color variable types', async () => { + await page.locator('[data-test-id="variables-search-input"]').fill('') + await canvas.waitForRender() + + await page.locator('[data-test-id="variables-add-variable"]').click() + await page.locator('[data-test-id="variables-add-float"]').click() + await expect(page.locator('[data-test-id="variable-row"]').filter({ hasText: 'New number' })).toHaveCount(1) + + await page.locator('[data-test-id="variables-add-variable"]').click() + await page.locator('[data-test-id="variables-add-string"]').click() + await expect(page.locator('[data-test-id="variable-row"]').filter({ hasText: 'New text' })).toHaveCount(1) + + await page.locator('[data-test-id="variables-add-variable"]').click() + await page.locator('[data-test-id="variables-add-boolean"]').click() + await expect(page.locator('[data-test-id="variable-row"]').filter({ hasText: 'New boolean' })).toHaveCount(1) + canvas.assertNoErrors() +}) + test('click name cell activates editable input', async () => { await page.locator('[data-test-id="variables-search-input"]').fill('') await canvas.waitForRender() diff --git a/tests/engine/variables-editor.test.ts b/tests/engine/variables-editor.test.ts new file mode 100644 index 000000000..85248ebcb --- /dev/null +++ b/tests/engine/variables-editor.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'bun:test' +import { ref } from 'vue' + +import { createVariableCollectionActions, createVariableValueActions } from '#vue/variables/helpers' + +import type { Editor } from '@open-pencil/core/editor' +import type { Variable, VariableCollection } from '@open-pencil/core/scene-graph' + +function createEditorHarness() { + const collections = new Map() + const variables = new Map() + const collection: VariableCollection = { + id: 'col:test', + name: 'Test', + modes: [ + { modeId: 'default', name: 'Default' }, + { modeId: 'dark', name: 'Dark' } + ], + defaultModeId: 'default', + variableIds: [] + } + collections.set(collection.id, collection) + const editor = { + addCollection(item: VariableCollection) { + collections.set(item.id, item) + }, + renameCollection(id: string, name: string) { + const item = collections.get(id) + if (item) item.name = name + }, + addVariable(item: Variable) { + variables.set(item.id, item) + collections.get(item.collectionId)?.variableIds.push(item.id) + }, + removeVariable(id: string) { + variables.delete(id) + }, + renameVariable(id: string, name: string) { + const item = variables.get(id) + if (item) item.name = name + }, + updateVariableValue(id: string, modeId: string, value: unknown) { + const item = variables.get(id) + if (item) item.valuesByMode[modeId] = value as never + }, + getVariable(id: string) { + return variables.get(id) + } + } as unknown as Editor + + return { editor, collection, collections, variables } +} + +describe('variables editor helpers', () => { + test('creates variables with type-specific default values for every mode', () => { + const { editor, collection, variables } = createEditorHarness() + const actions = createVariableValueActions(editor, () => collection) + + actions.addVariable('COLOR') + actions.addVariable('FLOAT') + actions.addVariable('STRING') + actions.addVariable('BOOLEAN') + + const created = [...variables.values()] + expect(created.map((item) => item.type)).toEqual(['COLOR', 'FLOAT', 'STRING', 'BOOLEAN']) + expect(created.map((item) => item.name)).toEqual([ + 'New color', + 'New number', + 'New text', + 'New boolean' + ]) + expect(created.map((item) => item.valuesByMode.default)).toEqual([ + { r: 0, g: 0, b: 0, a: 1 }, + 0, + '', + false + ]) + expect(created.map((item) => item.valuesByMode.dark)).toEqual([ + { r: 0, g: 0, b: 0, a: 1 }, + 0, + '', + false + ]) + }) + + test('parses edited values by variable type', () => { + const { editor, collection } = createEditorHarness() + const actions = createVariableValueActions(editor, () => collection) + const variable = { + id: 'var:bool', + name: 'Flag', + type: 'BOOLEAN', + collectionId: collection.id, + valuesByMode: { default: false }, + description: '', + hiddenFromPublishing: false + } satisfies Variable + + expect(actions.parseVariableValue(variable, 'TRUE')).toBe(true) + expect(actions.parseVariableValue(variable, 'false')).toBe(false) + expect(actions.parseVariableValue({ ...variable, type: 'FLOAT' }, '12.5')).toBe(12.5) + expect(actions.parseVariableValue({ ...variable, type: 'STRING' }, 'hello')).toBe('hello') + }) + + test('creates and activates new collections', () => { + const { editor, collections } = createEditorHarness() + const activeCollectionId = ref('') + const actions = createVariableCollectionActions(editor, activeCollectionId) + + actions.addCollection() + + expect(activeCollectionId.value).toStartWith('col:') + expect(collections.get(activeCollectionId.value)?.name).toBe('New collection') + }) +})