diff --git a/CHANGELOG.md b/CHANGELOG.md index 918918d93..edc8f0e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Use `@use-gesture/vanilla` for wheel gesture lifecycle handling and faster trackpad zoom behavior. - Add auto-layout inspector controls for min/max dimensions, Auto gap distribution, wrap cross-axis gap, and two-axis padding controls. - Add signed Tauri updater configuration, release artifacts, startup update checks, and a native Check for Updates menu item. +- Add inspector variable binding controls for fill and stroke colors, including existing variable selection and named color variable creation. ### Fixes @@ -26,6 +27,7 @@ - Keep the global startup loader visible until CanvasKit fonts load and the first font-backed render completes, avoiding a flash of missing text in opened files and the demo. - Fix hot reload creating duplicate editor tabs during development. - Improve layout inspector dropdown anchoring and icon clarity for spacing and padding controls. +- Fix bound color variable inspector swatches to display the resolved variable color and detach the binding when edited directly. ### Performance diff --git a/lint/plugin.js b/lint/plugin.js index 181e7f6b4..302b840ae 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -1135,6 +1135,70 @@ const noComponentRootSiblingFolder = { } } +const noUselessPassThroughWrappers = { + meta: { + docs: { + description: 'Disallow functions that only return another function call with the same arguments' + } + }, + create(context) { + function paramNames(params) { + const names = [] + for (const param of params ?? []) { + if (param.type !== 'Identifier') return null + names.push(param.name) + } + return names + } + + function returnedCall(body) { + if (!body) return null + if (body.type === 'CallExpression') return body + if (body.type !== 'BlockStatement') return null + const statements = body.body?.filter((statement) => statement.type !== 'EmptyStatement') ?? [] + if (statements.length !== 1) return null + const statement = statements[0] + if (statement.type !== 'ReturnStatement') return null + return statement.argument?.type === 'CallExpression' ? statement.argument : null + } + + function calleeName(callee) { + return callee?.type === 'Identifier' ? callee.name : null + } + + function isSameArgumentForwarding(args, params) { + if (args?.length !== params.length) return false + return args.every((arg, index) => arg.type === 'Identifier' && arg.name === params[index]) + } + + function check(node, name, params, body) { + const names = paramNames(params) + if (!names) return + const call = returnedCall(body) + if (!call || !isSameArgumentForwarding(call.arguments, names)) return + const target = calleeName(call.callee) + if (!target || target === name) return + context.report({ + node, + message: `Remove pass-through wrapper '${name}'. Call '${target}' directly or give the wrapper real domain logic.` + }) + } + + return { + FunctionDeclaration(node) { + if (!node.id?.name) return + check(node, node.id.name, node.params, node.body) + }, + VariableDeclarator(node) { + if (node.id?.type !== 'Identifier') return + const init = node.init + if (!init || (init.type !== 'ArrowFunctionExpression' && init.type !== 'FunctionExpression')) return + check(node, node.id.name, init.params, init.body) + } + } + } +} + const noFunctionAliasImports = { meta: { docs: { @@ -1229,6 +1293,7 @@ const plugin = { 'component-namespace-pascal-case': componentNamespacePascalCase, 'non-component-source-directories-kebab-case': nonComponentSourceDirectoriesKebabCase, 'no-component-root-sibling-folder': noComponentRootSiblingFolder, + 'no-useless-pass-through-wrappers': noUselessPassThroughWrappers, 'no-function-alias-imports': noFunctionAliasImports, 'no-flat-kiwi-modules': noFlatKiwiModules } diff --git a/oxlint.json b/oxlint.json index 0a81a2a71..38730b458 100644 --- a/oxlint.json +++ b/oxlint.json @@ -116,6 +116,12 @@ "open-pencil/max-composition-root-lines": ["error", { "max": 260 }] }, "overrides": [ + { + "files": ["src/**/*.ts"], + "rules": { + "open-pencil/no-useless-pass-through-wrappers": "error" + } + }, { "files": ["**/*.test.ts", "**/*.test.tsx"], "rules": { diff --git a/packages/vue/src/controls/color-variable-binding/use.ts b/packages/vue/src/controls/color-variable-binding/use.ts index 7e72dbc7e..a47499f51 100644 --- a/packages/vue/src/controls/color-variable-binding/use.ts +++ b/packages/vue/src/controls/color-variable-binding/use.ts @@ -1,47 +1,67 @@ -import { useEditor } from '#vue/editor/context' -import { useFilter } from 'reka-ui' -import { computed, ref } from 'vue' +import { useVariableBinding } from '#vue/controls/variable-binding/use' -import type { Variable } from '@open-pencil/core/scene-graph' +import { randomHex } from '@open-pencil/core/random' + +const FALLBACK_COLOR_VARIABLE_NAME = 'New color' + +import type { VariableCollection } from '@open-pencil/core/scene-graph' +import type { Color } from '@open-pencil/core/types' type ColorBindingKind = 'fills' | 'strokes' export function useColorVariableBinding(kind: ColorBindingKind) { - const store = useEditor() - const colorVariables = computed(() => store.getVariablesByType('COLOR')) - const searchTerm = ref('') - const { contains } = useFilter({ sensitivity: 'base' }) - const filteredVariables = computed(() => { - if (!searchTerm.value) return colorVariables.value - return colorVariables.value.filter((v) => contains(v.name, searchTerm.value)) + const binding = useVariableBinding({ + type: 'COLOR', + path: (index) => `${kind}/${index}/color` }) - function bindingPath(index: number) { - return `${kind}/${index}/color` + function colorCollection(): VariableCollection { + const existing = binding.store + .getCollections() + .find((collection) => + collection.variableIds.some( + (variableId) => binding.store.getVariable(variableId)?.type === 'COLOR' + ) + ) + if (existing) return existing + + const collection: VariableCollection = { + id: `col:${randomHex(8)}`, + name: 'Colors', + modes: [{ modeId: 'default', name: 'Mode 1' }], + defaultModeId: 'default', + variableIds: [] + } + binding.store.addCollection(collection) + return collection } - function getBoundVariable(nodeId: string, index: number): Variable | undefined { - const n = store.getNode(nodeId) - if (!n) return undefined - const varId = n.boundVariables[bindingPath(index)] - return varId ? store.getVariable(varId) : undefined - } - - function bindVariable(nodeId: string, index: number, variableId: string) { - store.bindVariable(nodeId, bindingPath(index), variableId) - } - - function unbindVariable(nodeId: string, index: number) { - store.unbindVariable(nodeId, bindingPath(index)) + function createAndBindVariable( + nodeId: string, + index: number, + color: Color, + name = FALLBACK_COLOR_VARIABLE_NAME + ) { + const collection = colorCollection() + const id = `var:${randomHex(8)}` + binding.store.addVariable({ + id, + name: name.trim() || FALLBACK_COLOR_VARIABLE_NAME, + type: 'COLOR', + collectionId: collection.id, + valuesByMode: Object.fromEntries(collection.modes.map((mode) => [mode.modeId, color])), + description: '', + hiddenFromPublishing: false + }) + binding.bindVariable(nodeId, id, index) } return { - store, - colorVariables, - searchTerm, - filteredVariables, - getBoundVariable, - bindVariable, - unbindVariable + ...binding, + colorVariables: binding.variables, + bindVariable: (nodeId: string, index: number, variableId: string) => + binding.bindVariable(nodeId, variableId, index), + unbindVariable: (nodeId: string, index: number) => binding.unbindVariable(nodeId, index), + createAndBindVariable } } diff --git a/packages/vue/src/controls/variable-binding/use.ts b/packages/vue/src/controls/variable-binding/use.ts new file mode 100644 index 000000000..fc57e3610 --- /dev/null +++ b/packages/vue/src/controls/variable-binding/use.ts @@ -0,0 +1,67 @@ +import { useEditor } from '#vue/editor/context' +import { useSceneComputed } from '#vue/internal/scene-computed/use' +import { useFilter } from 'reka-ui' +import { computed, ref } from 'vue' + +import type { Variable, VariableType } from '@open-pencil/core/scene-graph' + +export type VariableBindingState = 'unbound' | 'bound' | 'mixed' + +export interface UseVariableBindingOptions { + type: VariableType + path: string | ((index: number) => string) +} + +export function useVariableBinding(options: UseVariableBindingOptions) { + const store = useEditor() + const searchTerm = ref('') + const variables = useSceneComputed(() => store.getVariablesByType(options.type)) + const { contains } = useFilter({ sensitivity: 'base' }) + + const filteredVariables = computed(() => { + if (!searchTerm.value) return variables.value + return variables.value.filter((variable) => contains(variable.name, searchTerm.value)) + }) + + function bindingPath(index?: number) { + if (typeof options.path === 'string') return options.path + return options.path(index ?? 0) + } + + function getBoundVariable(nodeId: string, index?: number): Variable | undefined { + const node = store.getNode(nodeId) + if (!node) return undefined + const variableId = node.boundVariables[bindingPath(index)] + return variableId ? store.getVariable(variableId) : undefined + } + + function getBindingState(nodeIds: string[], index?: number): VariableBindingState { + const variableIds = new Set() + for (const nodeId of nodeIds) { + const node = store.getNode(nodeId) + variableIds.add(node?.boundVariables[bindingPath(index)]) + } + if (variableIds.size > 1) return 'mixed' + return variableIds.has(undefined) ? 'unbound' : 'bound' + } + + function bindVariable(nodeId: string, variableId: string, index?: number) { + store.bindVariable(nodeId, bindingPath(index), variableId) + } + + function unbindVariable(nodeId: string, index?: number) { + store.unbindVariable(nodeId, bindingPath(index)) + } + + return { + store, + searchTerm, + variables, + filteredVariables, + bindingPath, + getBoundVariable, + getBindingState, + bindVariable, + unbindVariable + } +} diff --git a/packages/vue/src/i18n/messages.ts b/packages/vue/src/i18n/messages.ts index b60c6ad49..ce9e665af 100644 --- a/packages/vue/src/i18n/messages.ts +++ b/packages/vue/src/i18n/messages.ts @@ -171,7 +171,10 @@ export const panelMessages = i18n('panels', { strokeAlignOutside: 'Outside', exportPreview: 'Preview', exportRenderingPreview: 'Rendering preview…', + create: 'Create', createVariable: 'Create variable', + createColorVariable: params('Create color variable from {value}'), + variableName: 'Variable name', mixed: 'Mixed', layersCount: params('{count} layers'), goToMainComponent: 'Go to Main Component', diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index e8133d8bd..8d606b306 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -55,6 +55,11 @@ export { useExport } from '#vue/document/export/use' export type { ExportFormatId } from '#vue/document/export/use' export { useFillControls } from '#vue/controls/fill/use' export { useColorVariableBinding } from '#vue/controls/color-variable-binding/use' +export { useVariableBinding } from '#vue/controls/variable-binding/use' +export type { + VariableBindingState, + UseVariableBindingOptions +} from '#vue/controls/variable-binding/use' export { useEffectsControls } from '#vue/controls/effects/use' export { useStrokeControls } from '#vue/controls/stroke/use' export { useOkHCL } from '#vue/controls/okhcl/use' diff --git a/packages/vue/src/locales/de.json b/packages/vue/src/locales/de.json index f2c832979..b52bb598d 100644 --- a/packages/vue/src/locales/de.json +++ b/packages/vue/src/locales/de.json @@ -169,7 +169,10 @@ "strokeAlignOutside": "Außen", "exportPreview": "Vorschau", "exportRenderingPreview": "Vorschau wird gerendert…", - "createVariable": "Variable erstellen" + "createVariable": "Variable erstellen", + "createColorVariable": "Farbvariable aus {value} erstellen", + "variableName": "Variablenname", + "create": "Erstellen" }, "pages": { "newPage": "Neue Seite", diff --git a/packages/vue/src/locales/es.json b/packages/vue/src/locales/es.json index da9b866a6..f52c73f03 100644 --- a/packages/vue/src/locales/es.json +++ b/packages/vue/src/locales/es.json @@ -169,7 +169,10 @@ "strokeAlignOutside": "Exterior", "exportPreview": "Vista previa", "exportRenderingPreview": "Renderizando vista previa…", - "createVariable": "Crear variable" + "createVariable": "Crear variable", + "createColorVariable": "Crear variable de color desde {value}", + "variableName": "Nombre de variable", + "create": "Crear" }, "pages": { "newPage": "Nueva página", diff --git a/packages/vue/src/locales/fr.json b/packages/vue/src/locales/fr.json index d092f13a4..7afa9a0e0 100644 --- a/packages/vue/src/locales/fr.json +++ b/packages/vue/src/locales/fr.json @@ -169,7 +169,10 @@ "strokeAlignOutside": "Extérieur", "exportPreview": "Aperçu", "exportRenderingPreview": "Rendu de l’aperçu…", - "createVariable": "Créer une variable" + "createVariable": "Créer une variable", + "createColorVariable": "Créer une variable de couleur depuis {value}", + "variableName": "Nom de la variable", + "create": "Créer" }, "pages": { "newPage": "Nouvelle page", diff --git a/packages/vue/src/locales/it.json b/packages/vue/src/locales/it.json index 1430a07bf..256b48126 100644 --- a/packages/vue/src/locales/it.json +++ b/packages/vue/src/locales/it.json @@ -169,7 +169,10 @@ "strokeAlignOutside": "Esterno", "exportPreview": "Anteprima", "exportRenderingPreview": "Rendering anteprima…", - "createVariable": "Crea variabile" + "createVariable": "Crea variabile", + "createColorVariable": "Crea variabile colore da {value}", + "variableName": "Nome variabile", + "create": "Crea" }, "pages": { "newPage": "Nuova pagina", diff --git a/packages/vue/src/locales/pl.json b/packages/vue/src/locales/pl.json index 12373873a..8f162109e 100644 --- a/packages/vue/src/locales/pl.json +++ b/packages/vue/src/locales/pl.json @@ -169,7 +169,10 @@ "strokeAlignOutside": "Zewnętrzny", "exportPreview": "Podgląd", "exportRenderingPreview": "Renderowanie podglądu…", - "createVariable": "Utwórz zmienną" + "createVariable": "Utwórz zmienną", + "createColorVariable": "Utwórz zmienną koloru z {value}", + "variableName": "Nazwa zmiennej", + "create": "Utwórz" }, "pages": { "newPage": "Nowa strona", diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json index 8d82760e9..15c1ceae3 100644 --- a/packages/vue/src/locales/ru.json +++ b/packages/vue/src/locales/ru.json @@ -169,7 +169,10 @@ "strokeAlignOutside": "Снаружи", "exportPreview": "Предпросмотр", "exportRenderingPreview": "Рендеринг предпросмотра…", - "createVariable": "Создать переменную" + "createVariable": "Создать переменную", + "createColorVariable": "Создать переменную цвета из {value}", + "variableName": "Имя переменной", + "create": "Создать" }, "pages": { "newPage": "Новая страница", diff --git a/packages/vue/src/locales/zh-CN.json b/packages/vue/src/locales/zh-CN.json index d3e0cab2b..9924b5216 100644 --- a/packages/vue/src/locales/zh-CN.json +++ b/packages/vue/src/locales/zh-CN.json @@ -169,7 +169,10 @@ "strokeAlignOutside": "外部", "exportPreview": "预览", "exportRenderingPreview": "正在渲染预览…", - "createVariable": "创建变量" + "createVariable": "创建变量", + "createColorVariable": "从 {value} 创建颜色变量", + "variableName": "变量名称", + "create": "创建" }, "pages": { "newPage": "新建页面", diff --git a/src/components/FillPicker.vue b/src/components/FillPicker.vue index 3ccbc23a4..62fbf2aff 100644 --- a/src/components/FillPicker.vue +++ b/src/components/FillPicker.vue @@ -22,7 +22,15 @@ function tabClass(active: boolean) { ) } -const { fill, okhcl = null } = defineProps<{ fill: Fill; okhcl?: OkHCLControls | null }>() +const { + fill, + okhcl = null, + swatchBackground +} = defineProps<{ + fill: Fill + okhcl?: OkHCLControls | null + swatchBackground?: string +}>() const emit = defineEmits<{ update: [fill: Fill] }>() const cls = usePopoverUI({ content: 'w-60 p-2' }) const { panels } = useI18n() @@ -39,7 +47,7 @@ const { panels } = useI18n()