feat(app): add variable type creation menu

This commit is contained in:
Danila Poyarkov 2026-05-05 23:49:20 +03:00
parent 3bd01095eb
commit 68d15440b1
13 changed files with 326 additions and 23 deletions

View file

@ -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',

View file

@ -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<Locale>,

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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 dabord 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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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": "Истина или ложь"
}
}

View file

@ -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": "真或假"
}
}

View file

@ -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<string>) {
@ -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<string, VariableValue> = {}
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
}

View file

@ -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<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 variableTypeIcons: Record<string, Component> = {
const variableTypeIcons: Record<VariableType, Component> = {
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) => {
</table>
</div>
<button
data-test-id="variables-add-variable"
class="flex w-full shrink-0 cursor-pointer items-center gap-1.5 border-t border-border bg-transparent px-4 py-2 text-xs text-muted hover:bg-hover hover:text-surface"
@click="ctx.addVariable"
>
<icon-lucide-plus class="size-3.5" />
{{ panels.createVariable }}
</button>
<div class="flex w-full shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-2">
<span class="text-xs text-muted">{{ panels.createVariable }}</span>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
data-test-id="variables-add-variable"
class="flex cursor-pointer items-center gap-1.5 rounded bg-hover px-2.5 py-1.5 text-xs text-surface hover:bg-border"
>
<icon-lucide-plus class="size-3.5" />
{{ panels.add }}
<icon-lucide-chevron-down class="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent side="top" :side-offset="8" align="end" :class="menuCls.content">
<DropdownMenuItem
v-for="item in variableTypes"
:key="item.type"
:class="menuCls.item"
:data-test-id="`variables-add-${item.type.toLowerCase()}`"
@select="ctx.addVariable(item.type)"
>
<component :is="variableTypeIcons[item.type]" :class="menuCls.icon" />
<span class="flex min-w-0 flex-1 flex-col">
<span>{{ item.label() }}</span>
<span class="truncate text-[10px] text-muted">{{ item.description() }}</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</TabsContent>
</TabsRoot>
</template>

View file

@ -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()

View file

@ -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<string, VariableCollection>()
const variables = new Map<string, Variable>()
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')
})
})