feat(app): add color variable binding picker
This commit is contained in:
parent
b248e0c53a
commit
c7db8c6004
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
packages/vue/src/controls/variable-binding/use.ts
Normal file
67
packages/vue/src/controls/variable-binding/use.ts
Normal file
|
|
@ -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<string | undefined>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -169,7 +169,10 @@
|
|||
"strokeAlignOutside": "Снаружи",
|
||||
"exportPreview": "Предпросмотр",
|
||||
"exportRenderingPreview": "Рендеринг предпросмотра…",
|
||||
"createVariable": "Создать переменную"
|
||||
"createVariable": "Создать переменную",
|
||||
"createColorVariable": "Создать переменную цвета из {value}",
|
||||
"variableName": "Имя переменной",
|
||||
"create": "Создать"
|
||||
},
|
||||
"pages": {
|
||||
"newPage": "Новая страница",
|
||||
|
|
|
|||
|
|
@ -169,7 +169,10 @@
|
|||
"strokeAlignOutside": "外部",
|
||||
"exportPreview": "预览",
|
||||
"exportRenderingPreview": "正在渲染预览…",
|
||||
"createVariable": "创建变量"
|
||||
"createVariable": "创建变量",
|
||||
"createColorVariable": "从 {value} 创建颜色变量",
|
||||
"variableName": "变量名称",
|
||||
"create": "创建"
|
||||
},
|
||||
"pages": {
|
||||
"newPage": "新建页面",
|
||||
|
|
|
|||
|
|
@ -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()
|
|||
<button
|
||||
data-test-id="fill-picker-swatch"
|
||||
class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
|
||||
:style="style"
|
||||
:style="{ ...style, background: swatchBackground ?? style.background }"
|
||||
/>
|
||||
</template>
|
||||
<template #default="{ fill: currentFill, category, toSolid, toGradient, toImage, update }">
|
||||
|
|
|
|||
24
src/components/properties/BoundVariableButton.vue
Normal file
24
src/components/properties/BoundVariableButton.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
|
||||
const { label, testId } = defineProps<{
|
||||
label: string
|
||||
testId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
detach: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tip :label="label">
|
||||
<button
|
||||
:data-test-id="testId"
|
||||
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
|
||||
@click="emit('detach')"
|
||||
>
|
||||
<icon-lucide-diamond-minus class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
</template>
|
||||
|
|
@ -1,21 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import ScrubInput from '@/components/ScrubInput.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import BoundVariableButton from '@/components/properties/BoundVariableButton.vue'
|
||||
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue'
|
||||
import { useIconButtonUI } from '@/components/ui/icon-button'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import {
|
||||
|
|
@ -23,17 +11,29 @@ import {
|
|||
opacityPercent,
|
||||
variableSwatchBackground
|
||||
} from '@/components/properties/color-style-row'
|
||||
import { colorToHexRaw } from '@open-pencil/core/color'
|
||||
|
||||
import type { ColorVariableBindingApi } from '@/components/properties/color-style-row'
|
||||
import type { Variable } from '@open-pencil/core/scene-graph'
|
||||
import type { Color } from '@open-pencil/core/types'
|
||||
|
||||
const { item, index, activeNodeId, bindingApi, visibilityTestId, unbindTestId } = defineProps<{
|
||||
const {
|
||||
item,
|
||||
index,
|
||||
activeNodeId,
|
||||
bindingApi,
|
||||
visibilityTestId,
|
||||
applyVariableTestId,
|
||||
unbindTestId,
|
||||
variableColor
|
||||
} = defineProps<{
|
||||
item: { opacity: number; visible: boolean }
|
||||
index: number
|
||||
activeNodeId?: string | null
|
||||
bindingApi: ColorVariableBindingApi
|
||||
visibilityTestId: string
|
||||
applyVariableTestId?: string
|
||||
unbindTestId?: string
|
||||
variableColor?: Color
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -43,7 +43,6 @@ const emit = defineEmits<{
|
|||
}>()
|
||||
|
||||
const { panels, dialogs } = useI18n()
|
||||
const varPopoverOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -61,72 +60,43 @@ const varPopoverOpen = ref(false)
|
|||
@update:model-value="emit('patch', { opacity: opacityFromPercent($event) })"
|
||||
/>
|
||||
|
||||
<PopoverRoot
|
||||
<VariablePickerPopover
|
||||
v-if="
|
||||
activeNodeId &&
|
||||
bindingApi.colorVariables.value.length > 0 &&
|
||||
(bindingApi.colorVariables.value.length > 0 ||
|
||||
(variableColor && bindingApi.createAndBindVariable)) &&
|
||||
!bindingApi.getBoundVariable(activeNodeId, index)
|
||||
"
|
||||
@update:open="varPopoverOpen = $event"
|
||||
>
|
||||
<Tip :label="panels.applyVariable" :disabled="varPopoverOpen">
|
||||
<PopoverTrigger
|
||||
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
|
||||
>
|
||||
<icon-lucide-link class="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
</Tip>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
:side-offset="8"
|
||||
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
|
||||
>
|
||||
<ComboboxRoot
|
||||
@update:model-value="
|
||||
activeNodeId && bindingApi.bindVariable(activeNodeId, index, ($event as Variable).id)
|
||||
"
|
||||
>
|
||||
<ComboboxInput
|
||||
:model-value="bindingApi.searchTerm.value"
|
||||
:placeholder="dialogs.search"
|
||||
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
|
||||
@update:model-value="bindingApi.searchTerm.value = String($event)"
|
||||
/>
|
||||
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
|
||||
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted">{{
|
||||
panels.noVariablesFound
|
||||
}}</ComboboxEmpty>
|
||||
<ComboboxItem
|
||||
v-for="v in bindingApi.filteredVariables.value"
|
||||
:key="v.id"
|
||||
:value="v"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
|
||||
>
|
||||
<div
|
||||
class="size-3 shrink-0 rounded-sm border border-border"
|
||||
:style="{ background: variableSwatchBackground(bindingApi, v.id) }"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{{ v.name }}</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
v-model:search-term="bindingApi.searchTerm.value"
|
||||
:variables="bindingApi.filteredVariables.value"
|
||||
:trigger-label="panels.applyVariable"
|
||||
:search-placeholder="dialogs.search"
|
||||
:empty-label="panels.noVariablesFound"
|
||||
:trigger-test-id="applyVariableTestId"
|
||||
:create-label="
|
||||
variableColor && bindingApi.createAndBindVariable
|
||||
? panels.createColorVariable({ value: colorToHexRaw(variableColor) })
|
||||
: undefined
|
||||
"
|
||||
:create-name-placeholder="panels.variableName"
|
||||
:create-submit-label="panels.create"
|
||||
:create-default-name="bindingApi.searchTerm.value"
|
||||
:create-test-id="applyVariableTestId ? `${applyVariableTestId}-create` : undefined"
|
||||
:swatch-background="(variableId) => variableSwatchBackground(bindingApi, variableId)"
|
||||
@select="activeNodeId && bindingApi.bindVariable(activeNodeId, index, $event.id)"
|
||||
@create="
|
||||
activeNodeId &&
|
||||
variableColor &&
|
||||
bindingApi.createAndBindVariable?.(activeNodeId, index, variableColor, $event)
|
||||
"
|
||||
/>
|
||||
|
||||
<Tip
|
||||
<BoundVariableButton
|
||||
v-else-if="activeNodeId && bindingApi.getBoundVariable(activeNodeId, index)"
|
||||
:test-id="unbindTestId"
|
||||
:label="panels.detachVariable"
|
||||
>
|
||||
<button
|
||||
:data-test-id="unbindTestId"
|
||||
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
|
||||
@click="bindingApi.unbindVariable(activeNodeId, index)"
|
||||
>
|
||||
<icon-lucide-unlink class="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
@detach="bindingApi.unbindVariable(activeNodeId, index)"
|
||||
/>
|
||||
|
||||
<button
|
||||
:data-test-id="visibilityTestId"
|
||||
|
|
|
|||
|
|
@ -3,17 +3,33 @@ import { PropertyListRoot, useFillControls, useOkHCL, useI18n } from '@open-penc
|
|||
|
||||
import FillPicker from '@/components/FillPicker.vue'
|
||||
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
|
||||
import {
|
||||
boundVariableSwatchBackground,
|
||||
displayFillWithBoundVariable
|
||||
} from '@/components/properties/color-style-row'
|
||||
import { fillLabel } from '@/components/properties/fill-label'
|
||||
import { createFillOkhclAdapter } from '@/components/properties/fill-okhcl'
|
||||
import { useIconButtonUI } from '@/components/ui/icon-button'
|
||||
import { useSectionUI } from '@/components/ui/section'
|
||||
|
||||
import type { Fill } from '@open-pencil/core/scene-graph'
|
||||
import type { Fill, SceneNode } from '@open-pencil/core/scene-graph'
|
||||
|
||||
const fillCtx = useFillControls()
|
||||
const okhcl = useOkHCL()
|
||||
const { panels } = useI18n()
|
||||
const sectionCls = useSectionUI()
|
||||
|
||||
function updateFill(
|
||||
activeNode: SceneNode | null | undefined,
|
||||
index: number,
|
||||
fill: Fill,
|
||||
update: (index: number, fill: Fill) => void
|
||||
) {
|
||||
if (activeNode && fillCtx.getBoundVariable(activeNode.id, index)) {
|
||||
fillCtx.unbindVariable(activeNode.id, index)
|
||||
}
|
||||
update(index, fill)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -41,7 +57,9 @@ const sectionCls = useSectionUI()
|
|||
:index="i"
|
||||
:active-node-id="activeNode?.id ?? null"
|
||||
:binding-api="fillCtx"
|
||||
:variable-color="fill.type === 'SOLID' ? fill.color : undefined"
|
||||
:visibility-test-id="`fill-visibility-${i}`"
|
||||
:apply-variable-test-id="`fill-apply-variable-${i}`"
|
||||
unbind-test-id="fill-unbind-variable"
|
||||
data-test-id="fill-item"
|
||||
:data-test-index="i"
|
||||
|
|
@ -50,9 +68,12 @@ const sectionCls = useSectionUI()
|
|||
@remove="remove(i)"
|
||||
>
|
||||
<FillPicker
|
||||
:fill="fill"
|
||||
:fill="activeNode ? displayFillWithBoundVariable(fillCtx, activeNode.id, i, fill) : fill"
|
||||
:okhcl="createFillOkhclAdapter(okhcl, activeNode, i)"
|
||||
@update="update(i, $event)"
|
||||
:swatch-background="
|
||||
activeNode ? boundVariableSwatchBackground(fillCtx, activeNode.id, i) : undefined
|
||||
"
|
||||
@update="updateFill(activeNode, i, $event, update)"
|
||||
/>
|
||||
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@open-pencil/vue'
|
||||
|
||||
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
|
||||
import { boundVariableColor } from '@/components/properties/color-style-row'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import ColorInput from '@/components/ColorPicker/ColorInput.vue'
|
||||
import ScrubInput from '@/components/ScrubInput.vue'
|
||||
|
|
@ -18,7 +19,7 @@ import Tip from '@/components/ui/Tip.vue'
|
|||
import { useIconButtonUI } from '@/components/ui/icon-button'
|
||||
import { useSectionUI } from '@/components/ui/section'
|
||||
|
||||
import type { SceneNode, Stroke } from '@open-pencil/core/scene-graph'
|
||||
import type { Color, SceneNode, Stroke } from '@open-pencil/core/scene-graph'
|
||||
|
||||
const strokeCtx = useStrokeControls()
|
||||
const strokeVarCtx = useColorVariableBinding('strokes')
|
||||
|
|
@ -28,6 +29,18 @@ const sectionCls = useSectionUI()
|
|||
|
||||
const expandedSides = ref(false)
|
||||
|
||||
function updateStrokeColor(
|
||||
activeNode: SceneNode | null | undefined,
|
||||
index: number,
|
||||
color: Color,
|
||||
patch: (index: number, changes: Record<string, unknown>) => void
|
||||
) {
|
||||
if (activeNode && strokeVarCtx.getBoundVariable(activeNode.id, index)) {
|
||||
strokeVarCtx.unbindVariable(activeNode.id, index)
|
||||
}
|
||||
patch(index, applySolidStrokeColor(color))
|
||||
}
|
||||
|
||||
function onToggleSides(activeNode: SceneNode) {
|
||||
const next = !expandedSides.value
|
||||
expandedSides.value = next
|
||||
|
|
@ -73,7 +86,9 @@ function onToggleSides(activeNode: SceneNode) {
|
|||
:index="i"
|
||||
:active-node-id="activeNode?.id ?? null"
|
||||
:binding-api="strokeVarCtx"
|
||||
:variable-color="stroke.color"
|
||||
:visibility-test-id="`stroke-visibility-${i}`"
|
||||
:apply-variable-test-id="`stroke-apply-variable-${i}`"
|
||||
unbind-test-id="stroke-unbind-variable"
|
||||
data-test-id="stroke-item"
|
||||
:data-test-index="i"
|
||||
|
|
@ -83,7 +98,11 @@ function onToggleSides(activeNode: SceneNode) {
|
|||
>
|
||||
<ColorInput
|
||||
class="min-w-0 flex-1"
|
||||
:color="stroke.color"
|
||||
:color="
|
||||
activeNode
|
||||
? (boundVariableColor(strokeVarCtx, activeNode.id, i) ?? stroke.color)
|
||||
: stroke.color
|
||||
"
|
||||
:okhcl="
|
||||
activeNode
|
||||
? {
|
||||
|
|
@ -97,7 +116,7 @@ function onToggleSides(activeNode: SceneNode) {
|
|||
: null
|
||||
"
|
||||
editable
|
||||
@update="patch(i, applySolidStrokeColor($event))"
|
||||
@update="updateStrokeColor(activeNode, i, $event, patch)"
|
||||
/>
|
||||
</ColorStyleRow>
|
||||
|
||||
|
|
|
|||
168
src/components/properties/VariablePickerPopover.vue
Normal file
168
src/components/properties/VariablePickerPopover.vue
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import type { Variable } from '@open-pencil/core/scene-graph'
|
||||
|
||||
const searchTerm = defineModel<string>('searchTerm', { default: '' })
|
||||
|
||||
const {
|
||||
variables,
|
||||
triggerLabel,
|
||||
searchPlaceholder,
|
||||
emptyLabel,
|
||||
createLabel,
|
||||
createNamePlaceholder = 'Variable name',
|
||||
createSubmitLabel = 'Create',
|
||||
createDefaultName = '',
|
||||
createTestId,
|
||||
triggerTestId,
|
||||
swatchBackground
|
||||
} = defineProps<{
|
||||
variables: Variable[]
|
||||
triggerLabel: string
|
||||
searchPlaceholder: string
|
||||
emptyLabel: string
|
||||
createLabel?: string
|
||||
createNamePlaceholder?: string
|
||||
createSubmitLabel?: string
|
||||
createDefaultName?: string
|
||||
createTestId?: string
|
||||
triggerTestId?: string
|
||||
swatchBackground?: (variableId: string) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [variable: Variable]
|
||||
create: [name: string]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const creating = ref(false)
|
||||
const createName = ref('')
|
||||
const createInput = ref<HTMLInputElement | null>(null)
|
||||
const canCreate = computed(() => createName.value.trim().length > 0)
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!value) creating.value = false
|
||||
})
|
||||
|
||||
function startCreate() {
|
||||
creating.value = true
|
||||
createName.value = createDefaultName
|
||||
void nextTick(() => {
|
||||
createInput.value?.focus()
|
||||
createInput.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
const name = createName.value.trim()
|
||||
if (!name) return
|
||||
emit('create', name)
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-model:open="open">
|
||||
<PopoverTrigger
|
||||
:data-test-id="triggerTestId"
|
||||
:aria-label="triggerLabel"
|
||||
:title="triggerLabel"
|
||||
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
|
||||
>
|
||||
<icon-lucide-diamond-plus class="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
align="center"
|
||||
:side-offset="8"
|
||||
:collision-padding="8"
|
||||
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
|
||||
>
|
||||
<ComboboxRoot
|
||||
:open="true"
|
||||
:ignore-filter="true"
|
||||
@update:model-value="
|
||||
($event) => {
|
||||
if ($event) {
|
||||
emit('select', $event as Variable)
|
||||
open = false
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<ComboboxInput
|
||||
v-model="searchTerm"
|
||||
:placeholder="searchPlaceholder"
|
||||
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
|
||||
/>
|
||||
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
|
||||
<div v-if="variables.length === 0" class="px-2 py-3 text-center text-[11px] text-muted">
|
||||
{{ emptyLabel }}
|
||||
</div>
|
||||
<ComboboxItem
|
||||
v-for="variable in variables"
|
||||
:key="variable.id"
|
||||
:value="variable"
|
||||
:text-value="variable.name"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
|
||||
>
|
||||
<div
|
||||
v-if="swatchBackground"
|
||||
class="size-3 shrink-0 rounded-sm border border-border"
|
||||
:style="{ background: swatchBackground(variable.id) }"
|
||||
/>
|
||||
<icon-lucide-diamond v-else class="size-3 shrink-0 text-violet-400" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ variable.name }}</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
<div v-if="createLabel" class="border-t border-border">
|
||||
<form
|
||||
v-if="creating"
|
||||
class="flex items-center gap-1.5 p-1.5"
|
||||
@submit.prevent="submitCreate"
|
||||
@keydown.esc.prevent="creating = false"
|
||||
>
|
||||
<input
|
||||
ref="createInput"
|
||||
v-model="createName"
|
||||
:placeholder="createNamePlaceholder"
|
||||
class="min-w-0 flex-1 rounded border border-border bg-transparent px-1.5 py-1 text-[11px] text-surface outline-none placeholder:text-muted focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
:data-test-id="createTestId"
|
||||
:disabled="!canCreate"
|
||||
class="rounded border border-border bg-panel px-1.5 py-1 text-[11px] text-surface hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="submit"
|
||||
>
|
||||
{{ createSubmitLabel }}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
v-else
|
||||
:data-test-id="createTestId"
|
||||
class="flex w-full cursor-pointer items-center gap-1.5 bg-transparent px-2 py-1.5 text-left text-[11px] text-muted hover:bg-hover hover:text-surface"
|
||||
@click="startCreate"
|
||||
>
|
||||
<icon-lucide-plus class="size-3" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ createLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</ComboboxRoot>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { colorToCSS } from '@open-pencil/core/color'
|
||||
|
||||
import type { Color, Variable } from '@open-pencil/core/scene-graph'
|
||||
import type { Color, Fill, Variable } from '@open-pencil/core/scene-graph'
|
||||
|
||||
export type ColorVariableBindingApi = {
|
||||
store: {
|
||||
|
|
@ -12,6 +12,7 @@ export type ColorVariableBindingApi = {
|
|||
getBoundVariable: (nodeId: string, index: number) => Variable | undefined
|
||||
bindVariable: (nodeId: string, index: number, variableId: string) => void
|
||||
unbindVariable: (nodeId: string, index: number) => void
|
||||
createAndBindVariable?: (nodeId: string, index: number, color: Color, name?: string) => void
|
||||
}
|
||||
|
||||
export function opacityPercent(opacity: number) {
|
||||
|
|
@ -22,7 +23,52 @@ export function opacityFromPercent(percent: number) {
|
|||
return Math.max(0, Math.min(1, percent / 100))
|
||||
}
|
||||
|
||||
export function variableSwatchBackground(bindingApi: ColorVariableBindingApi, variableId: string) {
|
||||
const color = bindingApi.store.resolveColorVariable(variableId) as Color | null
|
||||
return color ? colorToCSS(color) : '#000'
|
||||
function isColor(value: unknown): value is Color {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'r' in value &&
|
||||
'g' in value &&
|
||||
'b' in value &&
|
||||
'a' in value &&
|
||||
typeof value.r === 'number' &&
|
||||
typeof value.g === 'number' &&
|
||||
typeof value.b === 'number' &&
|
||||
typeof value.a === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
export function variableSwatchBackground(bindingApi: ColorVariableBindingApi, variableId: string) {
|
||||
const color = bindingApi.store.resolveColorVariable(variableId)
|
||||
return isColor(color) ? colorToCSS(color) : 'transparent'
|
||||
}
|
||||
|
||||
export function boundVariableColor(
|
||||
bindingApi: ColorVariableBindingApi,
|
||||
nodeId: string,
|
||||
index: number
|
||||
): Color | undefined {
|
||||
const variable = bindingApi.getBoundVariable(nodeId, index)
|
||||
if (!variable) return undefined
|
||||
const color = bindingApi.store.resolveColorVariable(variable.id)
|
||||
return isColor(color) ? color : undefined
|
||||
}
|
||||
|
||||
export function boundVariableSwatchBackground(
|
||||
bindingApi: ColorVariableBindingApi,
|
||||
nodeId: string,
|
||||
index: number
|
||||
): string | undefined {
|
||||
const color = boundVariableColor(bindingApi, nodeId, index)
|
||||
return color ? colorToCSS(color) : undefined
|
||||
}
|
||||
|
||||
export function displayFillWithBoundVariable(
|
||||
bindingApi: ColorVariableBindingApi,
|
||||
nodeId: string,
|
||||
index: number,
|
||||
fill: Fill
|
||||
): Fill {
|
||||
const color = fill.type === 'SOLID' ? boundVariableColor(bindingApi, nodeId, index) : undefined
|
||||
return color ? { ...fill, color } : fill
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,71 @@ test('variable bind badge appears on fill', async () => {
|
|||
canvas.assertNoErrors()
|
||||
})
|
||||
|
||||
test('fill color can bind an existing variable', async () => {
|
||||
await canvas.clearCanvas()
|
||||
await canvas.drawRect(200, 200, 80, 80)
|
||||
|
||||
const variableId = await page.evaluate(() => {
|
||||
const store = window.__OPEN_PENCIL_STORE__!
|
||||
const col = store.graph.createCollection('Colors')
|
||||
const variable = store.graph.createVariable('test-brand-red', 'COLOR', col.id, {
|
||||
r: 1,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1
|
||||
})
|
||||
store.state.sceneVersion++
|
||||
return variable.id
|
||||
})
|
||||
await canvas.waitForRender()
|
||||
|
||||
await page.locator('[data-test-id="fill-apply-variable-0"]').click()
|
||||
await page.getByText('test-brand-red', { exact: true }).click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
await expect(page.locator('[data-test-id="fill-unbind-variable"]')).toBeVisible()
|
||||
const fillSwatch = page.locator('[data-test-id="fill-picker-swatch"]')
|
||||
await expect(fillSwatch).toHaveCSS('background-color', 'rgb(255, 0, 0)')
|
||||
await fillSwatch.click()
|
||||
const colorInputs = page.locator('[role="dialog"] input[type="number"]:not(.hidden)')
|
||||
await expect(colorInputs.first()).toHaveValue('255')
|
||||
await colorInputs.first().fill('0')
|
||||
await colorInputs.first().press('Enter')
|
||||
await canvas.waitForRender()
|
||||
await expect(page.locator('[data-test-id="fill-unbind-variable"]')).toBeHidden()
|
||||
const boundVariableId = await page.evaluate(() => {
|
||||
const store = window.__OPEN_PENCIL_STORE__!
|
||||
const id = [...store.state.selectedIds][0]
|
||||
return id ? (store.getNode(id)?.boundVariables['fills/0/color'] ?? null) : null
|
||||
})
|
||||
expect(boundVariableId).toBeNull()
|
||||
canvas.assertNoErrors()
|
||||
})
|
||||
|
||||
test('fill color can create and bind a variable', async () => {
|
||||
await canvas.clearCanvas()
|
||||
await canvas.drawRect(200, 200, 80, 80)
|
||||
|
||||
await page.locator('[data-test-id="fill-apply-variable-0"]').click()
|
||||
await expect(page.getByText(/Create color variable from #?[0-9A-F]{6}/)).toBeVisible()
|
||||
await page.locator('[data-test-id="fill-apply-variable-0-create"]').click()
|
||||
await page.getByPlaceholder('Variable name').fill('Surface/default')
|
||||
await page.locator('[data-test-id="fill-apply-variable-0-create"]').click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
await expect(page.locator('[data-test-id="fill-unbind-variable"]')).toBeVisible()
|
||||
const boundVariable = await page.evaluate(() => {
|
||||
const store = window.__OPEN_PENCIL_STORE__!
|
||||
const id = [...store.state.selectedIds][0]
|
||||
if (!id) return null
|
||||
const node = store.getNode(id)
|
||||
const variableId = node?.boundVariables['fills/0/color']
|
||||
return variableId ? store.getVariable(variableId)?.name : null
|
||||
})
|
||||
expect(boundVariable).toBe('Surface/default')
|
||||
canvas.assertNoErrors()
|
||||
})
|
||||
|
||||
test('alignment buttons align nodes to same X', async () => {
|
||||
await canvas.clearCanvas()
|
||||
await canvas.drawRect(50, 200, 60, 60)
|
||||
|
|
|
|||
114
variables-ui-roadmap.md
Normal file
114
variables-ui-roadmap.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Variables UI Roadmap
|
||||
|
||||
OpenPencil already has local variable data structures and a basic variables dialog. The next work should make variables usable from the inspector fields where users edit real design properties, then expand the management UI.
|
||||
|
||||
## Product model
|
||||
|
||||
Variables should feel like a design-system layer:
|
||||
|
||||
- Variables are grouped into collections.
|
||||
- Collections contain modes.
|
||||
- Variables may be grouped by slash paths, e.g. `color/text/primary`.
|
||||
- Variables can be bound to node/page properties.
|
||||
- Bound properties resolve through the active mode context.
|
||||
|
||||
Supported variable types:
|
||||
|
||||
- Color — fills, strokes, text color, effect colors, page background.
|
||||
- Number — dimensions, layout spacing, radius, opacity, stroke width, typography sizes.
|
||||
- String — text content and eventually font names.
|
||||
- Boolean — visibility and eventually boolean design state.
|
||||
|
||||
## UI principles
|
||||
|
||||
- Existing inspector fields must support variable binding directly; a standalone variables table is not enough.
|
||||
- Every bindable field should have the same three states:
|
||||
- Direct value with an apply-variable affordance.
|
||||
- Bound value with variable name and resolved preview.
|
||||
- Mixed value/binding for multi-selection.
|
||||
- Variable pickers should be filtered by type and scope.
|
||||
- Variable names should stay readable in cramped inspector rows.
|
||||
- Warning/error states should be copyable when they carry diagnostic text.
|
||||
|
||||
## Phase 1 — Field-level binding primitives
|
||||
|
||||
Create reusable UI for applying variables to existing fields.
|
||||
|
||||
- Add a shared variable picker popover.
|
||||
- Add a bound-variable pill/button pattern.
|
||||
- Replace the current one-off fill/stroke color variable popover with the shared primitive.
|
||||
- Keep current core path convention for color bindings: `fills/{index}/color`, `strokes/{index}/color`.
|
||||
- Preserve existing fill/stroke behavior and tests.
|
||||
|
||||
Initial target fields:
|
||||
|
||||
- Fill color rows.
|
||||
- Stroke color rows.
|
||||
|
||||
## Phase 2 — Number bindings in the inspector
|
||||
|
||||
Add reusable number-variable binding support and integrate the high-impact fields first:
|
||||
|
||||
- Width and height.
|
||||
- Min/max width and height.
|
||||
- Corner radius and independent radii.
|
||||
- Auto-layout gap and padding.
|
||||
- Stroke width.
|
||||
- Opacity.
|
||||
- Font size, line height, and letter spacing.
|
||||
|
||||
## Phase 3 — Variables management view
|
||||
|
||||
Improve the variables editor itself after field binding patterns are established.
|
||||
|
||||
- Make the variables dialog larger, closer to an edge-to-edge variables view.
|
||||
- Replace collection tabs with a collection sidebar.
|
||||
- Add a variable type picker for `+ Variable`.
|
||||
- Render slash-path groups as grouped rows.
|
||||
- Improve type-specific value cells.
|
||||
- Add clearer empty states.
|
||||
|
||||
## Phase 4 — Mode management
|
||||
|
||||
Make modes editable as first-class collection columns.
|
||||
|
||||
- Add mode.
|
||||
- Rename mode.
|
||||
- Duplicate mode.
|
||||
- Delete mode.
|
||||
- Reorder mode.
|
||||
- Treat the left-most mode as the default mode, matching Figma’s model.
|
||||
- Add undo/redo for mode operations.
|
||||
|
||||
## Phase 5 — Mode context
|
||||
|
||||
Allow pages/frames/layers to choose variable modes.
|
||||
|
||||
Resolution order should be:
|
||||
|
||||
1. Explicit mode on the node for the collection.
|
||||
2. Parent-chain explicit mode.
|
||||
3. Page explicit mode.
|
||||
4. Collection default mode.
|
||||
|
||||
Inspector UI:
|
||||
|
||||
- Page variables section: collection → selected mode.
|
||||
- Selected frame/layer Appearance section: collection → Auto/Default/specific mode.
|
||||
|
||||
## Phase 6 — Aliases and token workflows
|
||||
|
||||
- Add alias editing in value cells.
|
||||
- Filter alias picker by same variable type.
|
||||
- Show resolved value previews.
|
||||
- Prevent alias cycles.
|
||||
- Add DTCG token import/export later.
|
||||
|
||||
## Non-goals for the first implementation
|
||||
|
||||
- Remote libraries.
|
||||
- Publishing workflows.
|
||||
- Extended collections.
|
||||
- Prototype variable actions.
|
||||
- Expressions.
|
||||
- Team/workspace default modes.
|
||||
Loading…
Reference in a new issue