feat(app): migrate paint effect and export panels

- Share compact item-row and paint-field anatomy across Fill, Stroke, Effects, and Export
- Add COLOR binding transactions with picker rollback and one-step undo
- Replace bespoke panel test hooks with semantic selectors and visual coverage
This commit is contained in:
Danila Poyarkov 2026-07-14 17:06:28 +03:00
parent 29e2172643
commit 5371ed11b8
49 changed files with 1303 additions and 823 deletions

View file

@ -22,6 +22,7 @@
- Rebuild Layout size fields with shared variable binding, inline sizing modes, semantic field anatomy, and one-step Hug/Fill-to-Fixed editing.
- Replace the fragmented Vue SDK color-picker model helpers with `useColorModel()`, providing precise Reka bridges, extensible formats, and shared RGB, HSL, HSB, and OkHCL channel behavior.
- Add accessible color-channel sliders, binding-aware fill primitives, and keyboard-operable gradient stops while separating fill state from popover composition.
- Rebuild Fill, Stroke, Effects, and Export controls with shared compact item rows, semantic actions, binding-aware paint fields, and reversible color-picker edits.
- Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress.
- Add desktop image drag-and-drop into the Tauri app window.
- Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page.

View file

@ -12,7 +12,7 @@ const SDK_COMPONENT_PAGES = [
{ text: 'SegmentedControl', slug: 'segmented-control', canonical: true },
{ text: 'PropertyListRoot', slug: 'property-list-root' },
{ text: 'PropertyListItem', slug: 'property-list-item' },
{ text: 'ColorPickerRoot', slug: 'color-picker-root' },
{ text: 'ColorPickerRoot', slug: 'color-picker-root', canonical: true },
{ text: 'ColorInputRoot', slug: 'color-input-root' },
{ text: 'ChannelSlider', slug: 'channel-slider', canonical: true },
{ text: 'FillRoot', slug: 'fill-root', canonical: true },
@ -57,6 +57,7 @@ const SDK_ADVANCED_PAGES = [
{ text: 'useNodeProps', slug: 'use-node-props' },
{ text: 'useSceneComputed', slug: 'use-scene-computed' },
{ text: 'useColorVariableBinding', slug: 'use-color-variable-binding' },
{ text: 'useColorBindingProvider', slug: 'use-color-binding-provider', canonical: true },
{ text: 'useFillPicker', slug: 'use-fill-picker', canonical: true },
{ text: 'useGradientStops', slug: 'use-gradient-stops' },
{ text: 'useFontPicker', slug: 'use-font-picker' },

View file

@ -16,6 +16,7 @@ These APIs are public, but they are more specialized than the main component and
## Picker, variables, locale, and editor internals
- [useColorVariableBinding](./use-color-variable-binding)
- [useColorBindingProvider](./use-color-binding-provider)
- [useFillPicker](./use-fill-picker)
- [useGradientStops](./use-gradient-stops)
- [useFontPicker](./use-font-picker)

View file

@ -0,0 +1,37 @@
---
title: useColorBindingProvider
description: OpenPencil COLOR variable provider for BindableValue fields.
---
# useColorBindingProvider
`useColorBindingProvider()` adapts OpenPencil COLOR variables to the generic `BindingProvider<Color>`
contract. It resolves current-mode colors, binds indexed paint paths, creates variables in a Colors
collection, updates all collection modes when requested, and exposes editor undo transactions.
Use it with `BindableValueRoot` and explicit targets such as `fills/0/color` or
`strokes/0/color`. Picker focus and opening remain non-destructive; the consumer chooses when an
actual color mutation begins and commits.
```ts twoslash
import type { Color } from '@open-pencil/scene-graph'
import type { BindingTarget } from '@open-pencil/vue'
import { useColorBindingProvider } from '@open-pencil/vue'
const provider = useColorBindingProvider()
const targets: BindingTarget[] = [
{ nodeId: 'rectangle-id', path: 'fills/0/color' }
]
const value: Color = { r: 0.2, g: 0.5, b: 0.9, a: 1 }
provider.getState(targets)
provider.resolve('variable-id')
```
The composable requires an editor provided by `provideEditor()`.
## Related APIs
- [BindableValue](../components/bindable-value)
- [FillSwatch](../components/fill-swatch)
- [useColorModel](../composables/use-color-model)

View file

@ -0,0 +1,5 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
export default defineComponentMetaLoader([
'packages/vue/src/primitives/ColorPicker/ColorPickerRoot.vue'
])

View file

@ -1,59 +1,54 @@
---
title: ColorPickerRoot
description: Headless popover-based color picker primitive.
description: Headless popover-based color picker with interaction lifecycle events.
---
<script setup lang="ts">
import { data } from './color-picker-root.data'
</script>
# ColorPickerRoot
`ColorPickerRoot` is a headless popover-based color picker primitive.
`ColorPickerRoot` composes a color swatch trigger with a popover surface while leaving the editor UI
to its slots. The trigger slot receives the current swatch style; the default slot receives the
current scene-graph color.
It provides:
`openChange` reports the complete picker interaction boundary. `cancel` fires before an Escape
close, allowing BindableValue consumers to roll back a variable detach and paint update together.
Opening or focusing the picker does not emit a color update.
- a trigger slot with swatch background styling
- a default trigger fallback
- a content slot with `color` and `update()`
```vue twoslash
<script setup lang="ts">
import { ref } from 'vue'
import type { Color } from '@open-pencil/scene-graph'
import { ColorPickerRoot } from '@open-pencil/vue'
## Props
const color = ref<Color>({ r: 0.2, g: 0.5, b: 0.9, a: 1 })
</script>
<SdkPropsTable
:rows="[
{ name: 'color', type: 'Color', description: 'Current color value.', required: true },
{ name: 'contentClass', type: 'string | undefined', description: 'Optional class for the popover content.' },
{ name: 'swatchClass', type: 'string | undefined', description: 'Optional class for the default trigger button.' }
]"
/>
## Events
<SdkEventsTable
:rows="[
{ name: 'update', payload: 'color: Color', description: 'Emitted when the color changes.' }
]"
/>
## Slots
<SdkSlotsTable
:rows="[
{ name: 'trigger', props: '{ style: Record<string, string> }', description: 'Custom trigger with swatch background style.' },
{ name: 'default', props: '{ color: Color, update: (color: Color) => void }', description: 'Main color editor content.' }
]"
/>
## Example
```vue
<ColorPickerRoot :color="color" @update="color = $event">
<template #trigger="{ style }">
<button class="size-6 rounded border" :style="style" />
</template>
<template #default="{ color, update }">
<MyColorEditor :color="color" @change="update" />
</template>
</ColorPickerRoot>
<template>
<ColorPickerRoot
:color="color"
@update="color = $event"
@open-change="open => console.log(open)"
@cancel="console.log('cancel')"
>
<template #trigger="{ style }">
<button :style="style" aria-label="Edit color" />
</template>
<template #default="{ color: currentColor }">
<output>{{ currentColor.r }}, {{ currentColor.g }}, {{ currentColor.b }}</output>
</template>
</ColorPickerRoot>
</template>
```
## Generated API reference
<SdkComponentAPI :components="data.components" />
## Related APIs
- [ColorInputRoot](./color-input-root)
- [useColorModel](../composables/use-color-model)
- [BindableValue](./bindable-value)

View file

@ -101,6 +101,10 @@ export class UndoManager {
this.batches = []
}
get isBatching(): boolean {
return this.batches.length > 0
}
get canUndo(): boolean {
return this.undoStack.length > 0
}

View file

@ -179,6 +179,7 @@ These exports are intentionally public, but they are lower-level or more special
- `useNodeProps()`
- `useEditorPropertyList()`
- `useSceneComputed()`
- `useColorBindingProvider()`
- `useColorVariableBinding()`
- `provideBindingProvider()`
- `useBindingProvider()`

View file

@ -0,0 +1,68 @@
import type { Editor } from '@open-pencil/core/editor'
import { randomHex } from '@open-pencil/core/random'
import type { VariableCollection } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import { useOpenPencilBindingProvider } from '#vue/controls/binding-provider/open-pencil'
import type { BindingTarget } from '#vue/controls/binding-provider/types'
const FALLBACK_COLOR_VARIABLE_NAME = 'New color'
function colorCollection(editor: Editor): VariableCollection {
const existing = editor
.getCollections()
.find((collection) =>
collection.variableIds.some((variableId) => editor.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: []
}
editor.addCollection(collection)
return collection
}
export function createAndBindColorVariable(
editor: Editor,
target: BindingTarget,
value: Color,
name = FALLBACK_COLOR_VARIABLE_NAME
) {
const collection = colorCollection(editor)
const id = `var:${randomHex(8)}`
editor.addVariable({
id,
name: name.trim() || FALLBACK_COLOR_VARIABLE_NAME,
type: 'COLOR',
collectionId: collection.id,
valuesByMode: Object.fromEntries(
collection.modes.map((mode) => [mode.modeId, structuredClone(value)])
),
description: '',
hiddenFromPublishing: false
})
editor.bindVariable(target.nodeId, target.path, id)
}
export function setColorVariableValue(editor: Editor, variableId: string, value: Color) {
const variable = editor.getVariable(variableId)
if (!variable) return
const collection = editor.getCollection(variable.collectionId)
if (!collection) return
for (const mode of collection.modes)
editor.updateVariableValue(variableId, mode.modeId, structuredClone(value))
}
export function useColorBindingProvider() {
return useOpenPencilBindingProvider<Color>({
type: 'COLOR',
resolve: (editor, variableId) => editor.resolveColorVariable(variableId),
create: createAndBindColorVariable,
setValue: setColorVariableValue
})
}

View file

@ -1,3 +1,7 @@
export {
createAndBindColorVariable,
useColorBindingProvider
} from '#vue/controls/binding-provider/color'
export {
BINDING_PROVIDER_KEY,
provideBindingProvider,

View file

@ -98,15 +98,19 @@ export function createEffectControlActions(expandedIndex: Ref<number | null>) {
patch(index, { color })
}
function handleRemove(removeFn: (index: number) => void, index: number) {
removeFn(index)
function adjustExpandedAfterRemove(index: number) {
if (expandedIndex.value === index) expandedIndex.value = null
else if (expandedIndex.value !== null && expandedIndex.value > index) expandedIndex.value--
}
function handleRemove(removeFn: (index: number) => void, index: number) {
removeFn(index)
adjustExpandedAfterRemove(index)
}
function toggleExpand(index: number) {
expandedIndex.value = expandedIndex.value === index ? null : index
}
return { updateType, updateColor, handleRemove, toggleExpand }
return { updateType, updateColor, handleRemove, adjustExpandedAfterRemove, toggleExpand }
}

View file

@ -34,6 +34,7 @@ export function useEditorPropertyList<K extends PropertyListKey>(propKey: K) {
void editor.state.sceneVersion
return selectedNodes.value[0] ?? null
})
const selectedNodeIds = computed(() => selectedNodes.value.map((node) => node.id))
const isMulti = computed(() => selectedNodes.value.length > 1)
const active = computed(() => selectedNodes.value.length > 0)
const isMixed = computed(() => isArrayMixed(propKey))
@ -159,5 +160,14 @@ export function useEditorPropertyList<K extends PropertyListKey>(propKey: K) {
reorder
}
return { items, isMixed, isMulti, active, activeNode, actions }
return {
items,
isMixed,
isMulti,
active,
activeNode,
selectedNodeIds,
flush: batch.flush,
actions
}
}

View file

@ -4,6 +4,8 @@ import type { UndoManager } from '@open-pencil/scene-graph'
const BATCH_IDLE_MS = 300
type BatchAwareUndoManager = UndoManager & { readonly isBatching: boolean }
export function useUndoBatch(undo: UndoManager) {
let batchKey: string | null = null
@ -26,6 +28,7 @@ export function useUndoBatch(undo: UndoManager) {
}
function ensure(key: string, label: string) {
if (batchKey === null && (undo as BatchAwareUndoManager).isBatching) return
if (batchKey !== key) {
flush()
undo.beginBatch(label)

View file

@ -257,7 +257,8 @@ export {
provideBindingProvider,
useBindingProvider,
useOpenPencilBindingProvider,
useNumberBindingProvider
useNumberBindingProvider,
useColorBindingProvider
} from '#vue/controls/binding-provider'
export type {
BindingMutationSource,

View file

@ -10,22 +10,37 @@ export interface ColorPickerUI {
swatch?: string
}
const { color, ui } = defineProps<{
const {
color,
label = 'Edit color',
ui
} = defineProps<{
color: Color
label?: string
ui?: ColorPickerUI
}>()
const emit = defineEmits<{ update: [color: Color] }>()
const emit = defineEmits<{
update: [color: Color]
openChange: [open: boolean]
cancel: []
}>()
const swatchBg = computed(() => colorToCSS(color))
function cancelFromEscape(event: KeyboardEvent) {
event.stopPropagation()
emit('cancel')
}
</script>
<template>
<PopoverRoot>
<PopoverRoot @update:open="emit('openChange', $event)">
<PopoverTrigger as-child>
<slot name="trigger" :style="{ background: swatchBg }">
<button
data-test-id="color-picker-swatch"
type="button"
:aria-label="label"
:class="ui?.swatch"
:style="{ background: swatchBg }"
/>
@ -34,10 +49,11 @@ const swatchBg = computed(() => colorToCSS(color))
<PopoverPortal>
<PopoverContent
data-test-id="color-picker-popover"
:class="ui?.content"
:side-offset="4"
side="left"
data-picker-content
@escape-key-down="cancelFromEscape"
>
<slot :color="color" />
</PopoverContent>

View file

@ -38,6 +38,7 @@ function commandShortcuts(...commands: EditorCommandId[]): ShortcutDefinition[]
function shouldIgnoreShortcut(event: KeyboardEvent, options: KeyboardShortcutOptions) {
return (
(event.target instanceof Element && event.target.closest('[data-picker-content]') !== null) ||
isEditing(event) ||
options.inputFocused.value ||
!!options.store.state.editingTextId ||

View file

@ -6,9 +6,17 @@ import { usePopoverUI } from '@/components/ui/popover'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { OkHCLControls } from '@open-pencil/vue'
import type { VNode } from 'vue'
const { color, okhcl = null } = defineProps<{ color: Color; okhcl?: OkHCLControls | null }>()
const emit = defineEmits<{ update: [color: Color] }>()
defineSlots<{
trigger?(props: { style: { background: string } }): VNode[]
}>()
const emit = defineEmits<{
update: [color: Color]
openChange: [open: boolean]
cancel: []
}>()
const cls = usePopoverUI({ content: 'w-56 p-2' })
</script>
@ -20,7 +28,12 @@ const cls = usePopoverUI({ content: 'w-56 p-2' })
swatch: 'size-5 shrink-0 cursor-pointer rounded border border-border p-0'
}"
@update="emit('update', $event)"
@open-change="emit('openChange', $event)"
@cancel="emit('cancel')"
>
<template v-if="$slots.trigger" #trigger="trigger">
<slot name="trigger" v-bind="trigger" />
</template>
<template #default="{ color: currentColor }">
<ColorPickerPanel :color="currentColor" :okhcl="okhcl" @update="emit('update', $event)" />
</template>

View file

@ -11,6 +11,7 @@ const ctx = useColorPickerPanelContext()
>
<input
type="number"
aria-label="Red"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(ctx.rgbColor.r)"
min="0"
@ -19,6 +20,7 @@ const ctx = useColorPickerPanelContext()
/>
<input
type="number"
aria-label="Green"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(ctx.rgbColor.g)"
min="0"
@ -27,6 +29,7 @@ const ctx = useColorPickerPanelContext()
/>
<input
type="number"
aria-label="Blue"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(ctx.rgbColor.b)"
min="0"

View file

@ -33,14 +33,23 @@ const {
okhcl?: OkHCLControls | null
swatchBackground?: string
}>()
const emit = defineEmits<{ update: [fill: Fill] }>()
const emit = defineEmits<{
update: [fill: Fill]
openChange: [open: boolean]
cancel: []
}>()
const cls = usePopoverUI({ content: 'w-60 p-2' })
const { panels } = useI18n()
function cancelFromEscape(event: KeyboardEvent) {
event.stopPropagation()
emit('cancel')
}
</script>
<template>
<FillRoot :fill="fill" @update="emit('update', $event)" v-slot="root">
<PopoverRoot>
<PopoverRoot @update:open="emit('openChange', $event)">
<PopoverTrigger as-child>
<button
type="button"
@ -58,7 +67,13 @@ const { panels } = useI18n()
</PopoverTrigger>
<PopoverPortal>
<PopoverContent :class="cls.content" :side-offset="4" side="left">
<PopoverContent
:class="cls.content"
:side-offset="4"
side="left"
data-picker-content
@escape-key-down="cancelFromEscape"
>
<div class="mb-2 flex items-center gap-0.5">
<Tip :label="panels.solid">
<button

View file

@ -1,28 +0,0 @@
<script setup lang="ts">
import Tip from '@/components/ui/Tip.vue'
interface BoundVariableButtonProps {
label: string
}
defineOptions({ inheritAttrs: false })
const { label } = defineProps<BoundVariableButtonProps>()
const emit = defineEmits<{
detach: []
}>()
</script>
<template>
<Tip :label="label">
<button
v-bind="$attrs"
:aria-label="label"
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>

View file

@ -1,119 +0,0 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import NumberField from '@/components/inputs/NumberField.vue'
import BoundVariableButton from '@/components/properties/BoundVariableButton.vue'
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue'
import IconButton from '@/components/ui/IconButton.vue'
import Tip from '@/components/ui/Tip.vue'
import { useI18n, vTestId } from '@open-pencil/vue'
import {
opacityFromPercent,
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 { Color } from '@open-pencil/scene-graph/primitives'
const { item, index, activeNodeId, bindingApi, variableColor, removeLabel } = defineProps<{
item: { opacity: number; visible: boolean }
index: number
activeNodeId?: string | null
bindingApi: ColorVariableBindingApi
variableColor?: Color
removeLabel: string
}>()
const emit = defineEmits<{
patch: [changes: Record<string, unknown>]
toggleVisibility: []
remove: []
}>()
const { panels, dialogs } = useI18n()
const attrs = useAttrs()
const testPrefix = computed(() => {
const rowId = attrs['data-test-id']
if (rowId === 'stroke-item') return 'stroke'
return 'fill'
})
const visibilityDataTestId = computed(() => `${testPrefix.value}-visibility-${index}`)
const applyVariableDataTestId = computed(() => `${testPrefix.value}-apply-variable-${index}`)
const unbindDataTestId = computed(() => `${testPrefix.value}-unbind-variable`)
</script>
<template>
<div class="group flex items-center gap-1.5 py-0.5">
<div class="min-w-0 flex flex-1 items-center gap-1.5">
<slot />
</div>
<Tip :label="panels.opacity">
<NumberField
class="w-12 shrink-0"
suffix="%"
:model-value="opacityPercent(item.opacity)"
:min="0"
:max="100"
@update:model-value="emit('patch', { opacity: opacityFromPercent($event) })"
/>
</Tip>
<VariablePickerPopover
v-if="
activeNodeId &&
(bindingApi.colorVariables.value.length > 0 ||
(variableColor && bindingApi.createAndBindVariable)) &&
!bindingApi.getBoundVariable(activeNodeId, index)
"
v-model:search-term="bindingApi.searchTerm.value"
:variables="bindingApi.filteredVariables.value"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:data-test-id="applyVariableDataTestId"
: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"
:swatch-background="(variableId) => variableSwatchBackground(bindingApi, variableId)"
@select="activeNodeId && bindingApi.bindVariable(activeNodeId, index, $event.id)"
@create="
activeNodeId &&
variableColor &&
bindingApi.createAndBindVariable?.(activeNodeId, index, variableColor, $event)
"
/>
<BoundVariableButton
v-else-if="activeNodeId && bindingApi.getBoundVariable(activeNodeId, index)"
:data-test-id="unbindDataTestId"
:label="panels.detachVariable"
@detach="bindingApi.unbindVariable(activeNodeId, index)"
/>
<Tip :label="panels.toggleVisibility">
<button
v-test-id="visibilityDataTestId"
:data-visible="item.visible ? 'true' : 'false'"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="emit('toggleVisibility')"
>
<icon-lucide-eye v-if="item.visible" data-test-id="visibility-icon-on" class="size-3.5" />
<icon-lucide-eye-off v-else data-test-id="visibility-icon-off" class="size-3.5" />
</button>
</Tip>
<IconButton :label="removeLabel" class="shrink-0" @click="emit('remove')">
<icon-lucide-minus class="size-3.5" />
</IconButton>
</div>
</template>

View file

@ -1,19 +1,29 @@
<script setup lang="ts">
import AppSelect from '@/components/ui/AppSelect.vue'
import { useEffectsControls, useI18n } from '@open-pencil/vue'
import ColorInput from '@/components/ColorPicker/ColorInput.vue'
import NumberField from '@/components/inputs/NumberField.vue'
import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import FillSwatch from '@/components/ui/FillSwatch.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import { vTestId, useEffectsControls, useI18n } from '@open-pencil/vue'
import { colorToCSS } from '@open-pencil/core/color'
import type { Effect } from '@open-pencil/scene-graph'
import type { Effect, Fill } from '@open-pencil/scene-graph'
const effectsCtx = useEffectsControls()
const { panels } = useI18n()
function effectPreview(effect: Effect): Fill {
return {
type: 'SOLID',
color: effect.color,
opacity: 1,
visible: effect.visible
}
}
</script>
<template>
@ -22,11 +32,10 @@ const { panels } = useI18n()
prop-key="effects"
:label="panels.effects"
>
<PanelSection :label="panels.effects" data-test-id="effects-section">
<PanelSection :label="panels.effects">
<template #actions>
<IconButton
:label="panels.addEffect"
data-test-id="effects-section-add"
@click="actions.add(effectsCtx.createDefaultEffect())"
>
<icon-lucide-plus class="size-3.5" />
@ -36,79 +45,77 @@ const { panels } = useI18n()
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedEffectsHelp }}</p>
<div
v-for="(effect, i) in items"
:key="`${i}:${effect.visible ? 'visible' : 'hidden'}`"
data-test-id="effect-item"
:data-test-index="i"
v-for="(effect, index) in items"
:key="`${index}:${effect.visible ? 'visible' : 'hidden'}`"
:data-effect-index="index"
data-effect-group
>
<div class="group flex items-center gap-1.5 py-0.5">
<PropertyItemRow
prop-key="effects"
:index="index"
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeEffect"
@remove="effectsCtx.adjustExpandedAfterRemove(index)"
>
<Tip
:label="
effectsCtx.expandedIndex.value === i
effectsCtx.expandedIndex.value === index
? panels.collapseEffectSettings
: panels.expandEffectSettings
"
>
<button
v-if="effectsCtx.isShadow(effect.type)"
class="size-5 shrink-0 cursor-pointer rounded border border-border"
:style="{ background: colorToCSS(effect.color) }"
@click="effectsCtx.toggleExpand(i)"
/>
<button
v-else
class="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input"
@click="effectsCtx.toggleExpand(i)"
type="button"
:aria-expanded="effectsCtx.expandedIndex.value === index"
:aria-label="
effectsCtx.expandedIndex.value === index
? panels.collapseEffectSettings
: panels.expandEffectSettings
"
data-property="effect-expand"
class="flex size-5 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded border border-border bg-input p-0"
@click="effectsCtx.toggleExpand(index)"
>
<icon-lucide-blend class="size-3 text-muted" />
<FillSwatch
v-if="effectsCtx.isShadow(effect.type)"
:fill="effectPreview(effect)"
class="size-full border-0"
/>
<icon-lucide-blend v-else class="size-3 text-muted" />
</button>
</Tip>
<AppSelect
class="min-w-0 flex-1"
:model-value="effect.type"
:options="effectsCtx.effectOptions"
:label="panels.effects"
data-property="effect-type"
@update:model-value="
effectsCtx.updateType(actions.patch, activeNode, i, $event as Effect['type'])
effectsCtx.updateType(actions.patch, activeNode, index, $event as Effect['type'])
"
/>
</PropertyItemRow>
<Tip :label="panels.toggleVisibility">
<button
v-test-id="`effect-visibility-${i}`"
:data-visible="effect.visible ? 'true' : 'false'"
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="actions.toggleVisibility(i)"
>
<icon-lucide-eye
v-if="effect.visible"
data-test-id="visibility-icon-on"
class="size-3.5"
/>
<icon-lucide-eye-off v-else data-test-id="visibility-icon-off" class="size-3.5" />
</button>
</Tip>
<IconButton
:label="panels.removeEffect"
@click="effectsCtx.handleRemove(actions.remove, i)"
>
<icon-lucide-minus class="size-3.5" />
</IconButton>
</div>
<div class="flex flex-col gap-1.5 py-1.5">
<div
v-if="effectsCtx.expandedIndex.value === index"
class="ml-[26px] flex flex-col gap-1.5 py-1.5"
data-slot="effect-settings"
>
<template v-if="effectsCtx.isShadow(effect.type)">
<div class="flex items-center gap-1.5">
<Tip :label="panels.xAxis">
<NumberField
icon="X"
:model-value="effect.offset.x"
data-property="effect-offset-x"
@update:model-value="
effectsCtx.scrubEffect(activeNode, i, {
effectsCtx.scrubEffect(activeNode, index, {
offset: { ...effect.offset, x: $event }
})
"
@commit="
effectsCtx.commitEffect(activeNode, i, {
effectsCtx.commitEffect(activeNode, index, {
offset: { ...effect.offset, x: $event }
})
"
@ -118,13 +125,14 @@ const { panels } = useI18n()
<NumberField
icon="Y"
:model-value="effect.offset.y"
data-property="effect-offset-y"
@update:model-value="
effectsCtx.scrubEffect(activeNode, i, {
effectsCtx.scrubEffect(activeNode, index, {
offset: { ...effect.offset, y: $event }
})
"
@commit="
effectsCtx.commitEffect(activeNode, i, {
effectsCtx.commitEffect(activeNode, index, {
offset: { ...effect.offset, y: $event }
})
"
@ -138,25 +146,32 @@ const { panels } = useI18n()
icon="B"
:model-value="effect.radius"
:min="0"
@update:model-value="effectsCtx.scrubEffect(activeNode, i, { radius: $event })"
@commit="effectsCtx.commitEffect(activeNode, i, { radius: $event })"
data-property="effect-radius"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, { radius: $event })
"
@commit="effectsCtx.commitEffect(activeNode, index, { radius: $event })"
/>
</Tip>
<Tip :label="panels.spread">
<NumberField
icon="S"
:model-value="effect.spread"
@update:model-value="effectsCtx.scrubEffect(activeNode, i, { spread: $event })"
@commit="effectsCtx.commitEffect(activeNode, i, { spread: $event })"
data-property="effect-spread"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, { spread: $event })
"
@commit="effectsCtx.commitEffect(activeNode, index, { spread: $event })"
/>
</Tip>
</div>
<div class="flex items-center gap-1.5">
<ColorInput
class="min-w-0 flex-1"
:color="effect.color"
editable
@update="effectsCtx.updateColor(actions.patch, i, $event)"
@update="effectsCtx.updateColor(actions.patch, index, $event)"
/>
<Tip :label="panels.opacity">
<NumberField
@ -165,13 +180,14 @@ const { panels } = useI18n()
:model-value="Math.round(effect.color.a * 100)"
:min="0"
:max="100"
data-property="effect-opacity"
@update:model-value="
effectsCtx.scrubEffect(activeNode, i, {
effectsCtx.scrubEffect(activeNode, index, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
@commit="
effectsCtx.commitEffect(activeNode, i, {
effectsCtx.commitEffect(activeNode, index, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
@ -180,16 +196,16 @@ const { panels } = useI18n()
</div>
</template>
<template v-else>
<NumberField
class="w-24 flex-none"
icon="B"
:model-value="effect.radius"
:min="0"
@update:model-value="effectsCtx.scrubEffect(activeNode, i, { radius: $event })"
@commit="effectsCtx.commitEffect(activeNode, i, { radius: $event })"
/>
</template>
<NumberField
v-else
class="w-24 flex-none"
icon="B"
:model-value="effect.radius"
:min="0"
data-property="effect-radius"
@update:model-value="effectsCtx.scrubEffect(activeNode, index, { radius: $event })"
@commit="effectsCtx.commitEffect(activeNode, index, { radius: $event })"
/>
</div>
</div>
</PanelSection>

View file

@ -5,10 +5,12 @@ import { computed, ref, shallowRef, watch } from 'vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import ExportScaleInput from '@/components/properties/ExportScaleInput.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelItemRow from '@/components/ui/panel/PanelItemRow.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import { useEditorStore } from '@/app/editor/active-store'
import { useExport, useI18n } from '@open-pencil/vue'
import { CHECKERBOARD_BACKGROUND } from '@/theme/checkerboard'
import type { ExportFormatId } from '@open-pencil/vue'
@ -106,9 +108,9 @@ watch(previewKey, updatePreview, { flush: 'post' })
</script>
<template>
<PanelSection :label="panels.export" data-test-id="export-section">
<PanelSection :label="panels.export">
<template #actions>
<IconButton :label="panels.addExport" data-test-id="export-section-add" @click="addSetting">
<IconButton :label="panels.addExport" @click="addSetting">
<icon-lucide-plus class="size-3.5" />
</IconButton>
</template>
@ -116,33 +118,40 @@ watch(previewKey, updatePreview, { flush: 'post' })
{{ panels.mixed }}
</p>
<div
v-for="(setting, i) in activeSettings"
:key="`${targetIds.join(',')}:${i}`"
data-test-id="export-item"
:data-test-index="i"
class="flex items-center gap-1.5 py-0.5"
<PanelItemRow
v-for="(setting, index) in activeSettings"
:key="`${targetIds.join(',')}:${index}`"
data-property="exportSettings"
:data-index="index"
>
<ExportScaleInput
v-if="formatSupportsScale(setting.format)"
data-test-id="export-scale-input"
:model-value="setting.scale"
:presets="scales"
:clamp="clampExportScale"
:label="panels.exportScale"
@update:model-value="updateScale(i, $event)"
/>
<div v-if="formatSupportsScale(setting.format)" class="w-24 shrink-0">
<ExportScaleInput
:model-value="setting.scale"
:presets="scales"
:clamp="clampExportScale"
:label="panels.exportScale"
data-property="export-scale"
@update:model-value="updateScale(index, $event)"
/>
</div>
<AppSelect
data-test-id="app-select-trigger"
:model-value="setting.format"
:options="FORMAT_OPTIONS"
:label="panels.exportFormat"
@update:model-value="updateFormat(i, $event as ExportFormatId)"
:ui="{ trigger: 'w-auto flex-1' }"
data-property="export-format"
@update:model-value="updateFormat(index, $event as ExportFormatId)"
/>
<IconButton :label="panels.removeExport" class="shrink-0" @click="removeSetting(i)">
<icon-lucide-minus class="size-3.5" />
</IconButton>
</div>
<template #rail="{ removeClass }">
<IconButton
:label="panels.removeExport"
:class="[removeClass, 'shrink-0']"
@click="removeSetting(index)"
>
<icon-lucide-minus class="size-3.5" />
</IconButton>
</template>
</PanelItemRow>
<button
v-if="activeSettings.length > 0"
@ -167,15 +176,7 @@ watch(previewKey, updatePreview, { flush: 'post' })
</Tip>
<div v-if="showPreview && previewUrl" class="mt-1 overflow-hidden rounded border border-border">
<img
:src="previewUrl"
class="block w-full"
style="
image-rendering: auto;
background: repeating-conic-gradient(var(--color-checkerboard) 0% 25%, transparent 0% 50%)
50% / 16px 16px;
"
/>
<img :src="previewUrl" :class="['block w-full', CHECKERBOARD_BACKGROUND]" />
</div>
<div
v-else-if="showPreview"

View file

@ -1,114 +1,148 @@
<script setup lang="ts">
import { useFillControls, useOkHCL, useI18n, inputValue } from '@open-pencil/vue'
import { colorToHexRaw, parseColor } from '@open-pencil/core/color'
import {
BindableValueRoot,
useColorBindingProvider,
useFillControls,
useI18n,
useOkHCL
} from '@open-pencil/vue'
import FillPicker from '@/components/fill-picker/FillPicker.vue'
import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue'
import PaintField from '@/components/properties/paint/PaintField.vue'
import PaintValue from '@/components/properties/paint/PaintValue.vue'
import {
applyPaintMutation,
cancelPaintMutation,
commitPaintMutation,
paintBindingTargets
} from '@/components/properties/paint/binding'
import { fillLabel } from '@/components/properties/fill-label'
import { createFillOkhclAdapter } from '@/components/properties/paint/okhcl'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelSection from '@/components/ui/panel/PanelSection.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 type { Fill, SceneNode } from '@open-pencil/scene-graph'
import { colorToHexRaw } from '@open-pencil/core/color'
import type { Fill } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { BindableValueActions } from '@open-pencil/vue'
const fillCtx = useFillControls()
const okhcl = useOkHCL()
const { panels } = useI18n()
const colorProvider = useColorBindingProvider()
const { panels, dialogs } = useI18n()
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)
function displayFill(fill: Fill, resolvedColor: Color | undefined): Fill {
return fill.type === 'SOLID' && resolvedColor ? { ...fill, color: resolvedColor } : fill
}
function updateFillHex(
activeNode: SceneNode | null | undefined,
index: number,
function updatePickerFill(
binding: BindableValueActions<Color>,
flush: () => void,
nextFill: Fill,
update: (fill: Fill) => void
) {
applyPaintMutation(binding, flush, () => update(nextFill))
}
function updateSolidColor(
binding: BindableValueActions<Color>,
flush: () => void,
fill: Fill,
hex: string,
update: (index: number, fill: Fill) => void
color: Color,
update: (fill: Fill) => void
) {
if (fill.type !== 'SOLID') return
const parsed = parseColor(hex.startsWith('#') ? hex : `#${hex}`)
if (!parsed) return
updateFill(activeNode, index, { ...fill, color: { ...parsed, a: fill.color.a } }, update)
if (applyPaintMutation(binding, flush, () => update({ ...fill, color })))
commitPaintMutation(binding)
}
</script>
<template>
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, actions }"
v-slot="{ items, isMixed, activeNode, selectedNodeIds, flush, actions }"
prop-key="fills"
:label="panels.fill"
>
<PanelSection :label="panels.fill" data-test-id="fill-section">
<PanelSection :label="panels.fill">
<template #actions>
<IconButton
:label="panels.addFill"
data-test-id="fill-section-add"
@click="actions.add({ ...fillCtx.defaultFill })"
>
<IconButton :label="panels.addFill" @click="actions.add({ ...fillCtx.defaultFill })">
<icon-lucide-plus class="size-3.5" />
</IconButton>
</template>
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
<ColorStyleRow
v-for="(fill, i) in items"
:key="`${i}:${fill.visible ? 'visible' : 'hidden'}`"
:item="fill"
:index="i"
:active-node-id="activeNode?.id ?? null"
:binding-api="fillCtx"
:variable-color="fill.type === 'SOLID' ? fill.color : undefined"
data-test-id="fill-item"
:data-test-index="i"
:remove-label="panels.removeFill"
@patch="actions.patch(i, $event)"
@toggle-visibility="actions.toggleVisibility(i)"
@remove="actions.remove(i)"
>
<FillPicker
:fill="activeNode ? displayFillWithBoundVariable(fillCtx, activeNode.id, i, fill) : fill"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, i)"
:swatch-background="
activeNode ? boundVariableSwatchBackground(fillCtx, activeNode.id, i) : undefined
"
@update="updateFill(activeNode, i, $event, actions.update)"
/>
<input
v-if="
fill.type === 'SOLID' && !(activeNode && fillCtx.getBoundVariable(activeNode.id, i))
"
data-test-id="fill-hex-input"
class="min-w-0 flex-1 border-none bg-transparent font-mono text-xs text-surface outline-none"
:value="colorToHexRaw(fill.color)"
maxlength="6"
@change="updateFillHex(activeNode, i, fill, inputValue($event), actions.update)"
/>
<span
v-else
class="min-w-0 flex-1 truncate font-mono text-xs"
:class="
activeNode && fillCtx.getBoundVariable(activeNode.id, i)
? 'rounded bg-violet-500/10 px-1 text-violet-400'
: 'text-surface'
"
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
<PropertyItemRow
v-for="(fill, index) in items"
:key="`${index}:${fill.visible ? 'visible' : 'hidden'}`"
prop-key="fills"
:index="index"
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeFill"
>
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'fills', index)"
:value="fill.color"
batch-label="Change fill color"
>
{{ fillLabel(fill, activeNode ? fillCtx.getBoundVariable(activeNode.id, i) : undefined) }}
</span>
</ColorStyleRow>
<PaintField
:opacity="fill.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
>
<template #preview>
<FillPicker
:fill="displayFill(fill, binding.resolvedValue)"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, index)"
@update="
updatePickerFill(binding.actions, flush, $event, (next) =>
actions.update(index, next)
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
/>
</template>
<template #value>
<PaintValue
v-if="fill.type === 'SOLID'"
:color="fill.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.fill"
@update="
updateSolidColor(binding.actions, flush, fill, $event, (next) =>
actions.update(index, next)
)
"
/>
<span v-else class="min-w-0 flex-1 truncate font-mono text-xs text-surface">
{{ fillLabel(fill) }}
</span>
</template>
<template v-if="fill.type === 'SOLID'" #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(fill.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
</PropertyItemRow>
</PanelSection>
</PropertyListRoot>
</template>

View file

@ -17,6 +17,8 @@ defineSlots<{
props: PropertyListRootSlotProps<K> & {
isMulti: boolean
activeNode: SceneNode | null
selectedNodeIds: string[]
flush: () => void
}
): VNode[]
}>()
@ -43,6 +45,8 @@ const context = useEditorPropertyList(propKey)
v-bind="slotProps"
:is-multi="context.isMulti.value"
:active-node="context.activeNode.value"
:selected-node-ids="context.selectedNodeIds.value"
:flush="context.flush"
/>
</HeadlessPropertyListRoot>
</template>

View file

@ -3,41 +3,61 @@ import { ref } from 'vue'
import {
applySolidStrokeColor,
useColorVariableBinding,
useStrokeControls,
BindableValueRoot,
useColorBindingProvider,
useI18n,
useOkHCL,
useI18n
useStrokeControls
} from '@open-pencil/vue'
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import { boundVariableColor } from '@/components/properties/color-style-row'
import AppSelect from '@/components/ui/AppSelect.vue'
import ColorInput from '@/components/ColorPicker/ColorInput.vue'
import ColorPicker from '@/components/ColorPicker/ColorPicker.vue'
import NumberField from '@/components/inputs/NumberField.vue'
import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue'
import PaintField from '@/components/properties/paint/PaintField.vue'
import PaintValue from '@/components/properties/paint/PaintValue.vue'
import {
applyPaintMutation,
cancelPaintMutation,
commitPaintMutation,
paintBindingTargets
} from '@/components/properties/paint/binding'
import { createStrokeOkhclAdapter } from '@/components/properties/paint/okhcl'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import FillSwatch from '@/components/ui/FillSwatch.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import type { Color, SceneNode, Stroke } from '@open-pencil/scene-graph'
import { colorToHexRaw } from '@open-pencil/core/color'
import type { Color, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import type { BindableValueActions } from '@open-pencil/vue'
const strokeCtx = useStrokeControls()
const strokeVarCtx = useColorVariableBinding('strokes')
const colorProvider = useColorBindingProvider()
const okhcl = useOkHCL()
const { panels } = useI18n()
const { panels, dialogs } = useI18n()
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)
function strokePreview(stroke: Stroke, color: Color): Fill {
return {
type: 'SOLID',
color,
opacity: stroke.opacity,
visible: stroke.visible
}
patch(index, applySolidStrokeColor(color))
}
function updateStrokeColor(
binding: BindableValueActions<Color>,
flush: () => void,
color: Color,
patch: (changes: Partial<Stroke>) => void,
commit: boolean
) {
if (!applyPaintMutation(binding, flush, () => patch(applySolidStrokeColor(color)))) return
if (commit) commitPaintMutation(binding)
}
function onToggleSides(activeNode: SceneNode | null) {
@ -61,68 +81,112 @@ function onToggleSides(activeNode: SceneNode | null) {
<template>
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, actions }"
v-slot="{ items, isMixed, activeNode, selectedNodeIds, flush, actions }"
prop-key="strokes"
:label="panels.stroke"
>
<PanelSection :label="panels.stroke" data-test-id="stroke-section">
<PanelSection :label="panels.stroke">
<template #actions>
<IconButton
:label="panels.addStroke"
data-test-id="stroke-section-add"
@click="actions.add(strokeCtx.defaultStroke)"
>
<IconButton :label="panels.addStroke" @click="actions.add(strokeCtx.defaultStroke)">
<icon-lucide-plus class="size-3.5" />
</IconButton>
</template>
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedStrokesHelp }}</p>
<ColorStyleRow
v-for="(stroke, i) in items"
:key="`${i}:${stroke.visible ? 'visible' : 'hidden'}`"
:item="stroke"
:index="i"
:active-node-id="activeNode?.id ?? null"
:binding-api="strokeVarCtx"
:variable-color="stroke.color"
data-test-id="stroke-item"
:data-test-index="i"
<PropertyItemRow
v-for="(stroke, index) in items"
:key="`${index}:${stroke.visible ? 'visible' : 'hidden'}`"
prop-key="strokes"
:index="index"
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeStroke"
@patch="actions.patch(i, $event)"
@toggle-visibility="actions.toggleVisibility(i)"
@remove="actions.remove(i)"
>
<ColorInput
class="min-w-0 flex-1"
:color="
activeNode
? (boundVariableColor(strokeVarCtx, activeNode.id, i) ?? stroke.color)
: stroke.color
"
:okhcl="
activeNode
? {
fieldFormat: okhcl.getFieldFormat(activeNode, i, 'stroke'),
fieldOptions: okhcl.fieldOptions,
okhcl: okhcl.getStrokeOkHCLColor(activeNode, i),
...okhcl.getStrokePreviewInfo(activeNode, i),
setFieldFormat: ($event) => okhcl.setStrokeFieldFormat(activeNode, i, $event),
updateOkHCL: ($event) => okhcl.updateStrokeOkHCL(activeNode, i, $event)
}
: null
"
editable
@update="updateStrokeColor(activeNode, i, $event, actions.patch)"
/>
</ColorStyleRow>
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'strokes', index)"
:value="stroke.color"
batch-label="Change stroke color"
>
<PaintField
:opacity="stroke.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
>
<template #preview>
<ColorPicker
:color="binding.resolvedValue ?? stroke.color"
:okhcl="createStrokeOkhclAdapter(okhcl, activeNode, index)"
@update="
updateStrokeColor(
binding.actions,
flush,
$event,
(changes) => actions.patch(index, changes),
false
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
>
<template #trigger>
<button
type="button"
:aria-label="panels.stroke"
class="size-5 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
>
<FillSwatch
:fill="strokePreview(stroke, binding.resolvedValue ?? stroke.color)"
class="size-full"
/>
</button>
</template>
</ColorPicker>
</template>
<template #value>
<PaintValue
:color="stroke.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.stroke"
@update="
updateStrokeColor(
binding.actions,
flush,
$event,
(changes) => actions.patch(index, changes),
true
)
"
/>
</template>
<template #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(stroke.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
</PropertyItemRow>
<div v-if="!isMixed && items.length > 0" class="mt-1 flex items-center gap-1.5">
<AppSelect
class="w-[72px]"
:label="panels.strokeType"
:ui="{ trigger: 'w-[88px] flex-none' }"
:model-value="strokeCtx.currentAlign(activeNode)"
:options="strokeCtx.alignOptions"
data-property="stroke-align"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode)"
/>
<Tip :label="panels.strokeWeight">
@ -132,6 +196,7 @@ function onToggleSides(activeNode: SceneNode | null) {
icon="W"
:model-value="items[0]?.weight ?? 1"
:min="0"
data-property="stroke-weight"
@update:model-value="actions.patch(0, { weight: $event })"
/>
</Tip>
@ -140,7 +205,7 @@ function onToggleSides(activeNode: SceneNode | null) {
size="md"
class="size-[26px] shrink-0"
:active="expandedSides"
data-test-id="stroke-sides-toggle"
data-property="stroke-sides"
@click="onToggleSides(activeNode)"
>
<icon-lucide-layout-grid class="size-3.5" />
@ -153,7 +218,7 @@ function onToggleSides(activeNode: SceneNode | null) {
size="md"
class="shrink-0"
:active="strokeCtx.dashState(items[0]).on"
data-test-id="stroke-dash-toggle"
data-property="stroke-dash"
@click="actions.patch(0, strokeCtx.toggleDash(items[0]))"
>
<span class="flex items-center gap-0.5">
@ -167,7 +232,7 @@ function onToggleSides(activeNode: SceneNode | null) {
icon="D"
:model-value="items[0]?.dashPattern?.[0] ?? 6"
:min="1"
data-test-id="stroke-dash-length"
data-property="stroke-dash-length"
@update:model-value="actions.patch(0, strokeCtx.setDash(items[0], $event))"
/>
<NumberField
@ -175,7 +240,7 @@ function onToggleSides(activeNode: SceneNode | null) {
icon="G"
:model-value="items[0]?.dashPattern?.[1] ?? items[0]?.dashPattern?.[0] ?? 6"
:min="1"
data-test-id="stroke-dash-gap"
data-property="stroke-dash-gap"
@update:model-value="actions.patch(0, strokeCtx.setGap(items[0], $event))"
/>
</template>
@ -191,6 +256,7 @@ function onToggleSides(activeNode: SceneNode | null) {
:label="side[0].toUpperCase()"
:model-value="strokeCtx.borderWeight(activeNode, side)"
:min="0"
:data-property="`stroke-${side}-weight`"
@update:model-value="strokeCtx.updateBorderWeight(side, $event, activeNode)"
/>
</div>

View file

@ -1,191 +0,0 @@
<script setup lang="ts">
import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
import { vTestId } from '@open-pencil/vue'
import { useTooltipUI } from '@/components/ui/tooltip'
import type { Variable } from '@open-pencil/scene-graph'
const searchTerm = defineModel<string>('searchTerm', { default: '' })
const {
variables,
triggerLabel,
searchPlaceholder,
emptyLabel,
createLabel,
createNamePlaceholder = 'Variable name',
createSubmitLabel = 'Create',
createDefaultName = '',
swatchBackground
} = defineProps<{
variables: Variable[]
triggerLabel: string
searchPlaceholder: string
emptyLabel: string
createLabel?: string
createNamePlaceholder?: string
createSubmitLabel?: string
createDefaultName?: string
swatchBackground?: (variableId: string) => string
}>()
defineOptions({ inheritAttrs: false })
const emit = defineEmits<{
select: [variable: Variable]
create: [name: string]
}>()
const open = ref(false)
const tooltipOpen = ref(false)
const creating = ref(false)
const createName = ref('')
const createInput = ref<HTMLInputElement | null>(null)
const canCreate = computed(() => createName.value.trim().length > 0)
const attrs = useAttrs()
const tooltipCls = useTooltipUI({ content: 'animate-in zoom-in-95 fade-in' })
const createDataTestId = computed(() => {
const triggerId = attrs['data-test-id']
return typeof triggerId === 'string' ? `${triggerId}-create` : undefined
})
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">
<div class="relative shrink-0">
<PopoverTrigger
v-bind="attrs"
:aria-label="triggerLabel"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@pointerdown.prevent.stop
@mouseenter="tooltipOpen = true"
@mouseleave="tooltipOpen = false"
@focus="tooltipOpen = true"
@blur="tooltipOpen = false"
>
<icon-lucide-diamond-plus class="size-3.5" />
</PopoverTrigger>
<div
v-if="tooltipOpen && !open"
role="tooltip"
:class="tooltipCls.content"
class="pointer-events-none absolute right-0 bottom-full z-50 mb-1 whitespace-nowrap"
>
{{ triggerLabel }}
</div>
</div>
<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
v-test-id="createDataTestId"
: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
v-test-id="createDataTestId"
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>

View file

@ -48,7 +48,7 @@ const {
ui
} = defineProps<VariableBindingPickerProps>()
const binding = useBindableValue<number>()
const binding = useBindableValue<unknown>()
const creating = ref(false)
const createName = ref('')
const createInput = ref<HTMLInputElement | null>(null)

View file

@ -1,73 +0,0 @@
import { colorToCSS } from '@open-pencil/core/color'
import type { Color, Fill, Variable } from '@open-pencil/scene-graph'
export type ColorVariableBindingApi = {
store: {
resolveColorVariable: (id: string) => unknown
}
colorVariables: { value: Variable[] }
filteredVariables: { value: Variable[] }
searchTerm: { value: string }
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) {
return Math.round(opacity * 100)
}
export function opacityFromPercent(percent: number) {
return Math.max(0, Math.min(1, percent / 100))
}
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
}

View file

@ -0,0 +1,81 @@
<script setup lang="ts" generic="K extends PropertyListKey">
import { PropertyListItem, PropertyListRemove, PropertyListVisibility } from '@open-pencil/vue'
import PanelItemRow from '@/components/ui/panel/PanelItemRow.vue'
import Tip from '@/components/ui/Tip.vue'
import type { PropertyListItemSlotProps, PropertyListKey } from '@open-pencil/vue'
import type { ClassValue } from 'tailwind-variants'
import type { VNode } from 'vue'
const {
propKey,
index,
visibilityLabel,
removeLabel,
showVisibility = true,
class: className
} = defineProps<{
propKey: K
index: number
visibilityLabel: string
removeLabel: string
showVisibility?: boolean
class?: ClassValue
}>()
const emit = defineEmits<{
remove: [index: number]
toggleVisibility: [index: number]
}>()
defineSlots<{
default(props: PropertyListItemSlotProps<K>): VNode[]
rail?(props: PropertyListItemSlotProps<K>): VNode[]
}>()
</script>
<template>
<PropertyListItem
v-slot="item"
:prop-key="propKey"
:index="index"
:class="className"
:data-property="propKey"
:data-index="index"
as-child
>
<PanelItemRow>
<slot v-bind="item" />
<template #rail="{ removeClass }">
<slot name="rail" v-bind="item" />
<Tip v-if="showVisibility" :label="visibilityLabel">
<PropertyListVisibility
:prop-key="propKey"
:index="index"
:aria-label="visibilityLabel"
class="flex size-control shrink-0 cursor-pointer items-center justify-center rounded-panel border-none bg-transparent p-0 text-muted hover:bg-hover hover:text-surface"
@toggle="emit('toggleVisibility', $event)"
>
<icon-lucide-eye v-if="!item.hidden" class="size-3.5" />
<icon-lucide-eye-off v-else class="size-3.5" />
</PropertyListVisibility>
</Tip>
<Tip :label="removeLabel">
<PropertyListRemove
:prop-key="propKey"
:index="index"
:aria-label="removeLabel"
:class="[
removeClass,
'flex size-control shrink-0 cursor-pointer items-center justify-center rounded-panel border-none bg-transparent p-0 text-muted hover:bg-hover hover:text-surface'
]"
@remove="emit('remove', $event)"
>
<icon-lucide-minus class="size-3.5" />
</PropertyListRemove>
</Tip>
</template>
</PanelItemRow>
</PropertyListItem>
</template>

View file

@ -0,0 +1,68 @@
<script lang="ts">
import type { ClassValue } from 'tailwind-variants'
import type { VNode } from 'vue'
import type { ComponentUI } from '@/components/ui/types'
import type theme from '@/theme/paint-field'
export type PaintFieldUI = ComponentUI<typeof theme>
export interface PaintFieldProps {
opacity: number
opacityLabel: string
class?: ClassValue
ui?: PaintFieldUI
}
export interface PaintFieldSlots {
preview(): VNode[]
value(): VNode[]
binding?(): VNode[]
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import NumberField from '@/components/inputs/NumberField.vue'
import paintFieldTheme from '@/theme/paint-field'
const { opacity, opacityLabel, class: className, ui } = defineProps<PaintFieldProps>()
const emit = defineEmits<{ 'update:opacity': [opacity: number] }>()
defineSlots<PaintFieldSlots>()
const styles = computed(() => tv(paintFieldTheme)())
</script>
<template>
<div
:class="styles.root({ class: [ui?.root, className] })"
data-slot="paint-field"
data-property="paint"
>
<div :class="styles.preview({ class: ui?.preview })" data-slot="preview">
<slot name="preview" />
</div>
<div :class="styles.value({ class: ui?.value })" data-slot="value">
<slot name="value" />
</div>
<div :class="styles.divider({ class: ui?.divider })" data-slot="divider" />
<NumberField
:class="styles.opacity({ class: ui?.opacity })"
:aria-label="opacityLabel"
suffix="%"
:model-value="Math.round(opacity * 100)"
:min="0"
:max="100"
:ui="{
root: 'h-full rounded-none border-0 bg-transparent shadow-none',
leading: 'hidden'
}"
data-property="opacity"
@update:model-value="emit('update:opacity', Math.max(0, Math.min(1, $event / 100)))"
/>
<div v-if="$slots.binding" :class="styles.binding({ class: ui?.binding })" data-slot="binding">
<slot name="binding" />
</div>
</div>
</template>

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { inputValue, useColorModel } from '@open-pencil/vue'
import { BindingPill } from '@/components/ui/binding'
import type { Color } from '@open-pencil/scene-graph/primitives'
const { color, resolvedColor, variableName, label } = defineProps<{
color: Color
resolvedColor?: Color
variableName?: string
label: string
}>()
const emit = defineEmits<{ update: [color: Color] }>()
const displayColor = computed(() => resolvedColor ?? color)
const model = useColorModel({
color: displayColor,
onUpdate: (updated) => emit('update', updated)
})
const tooltip = computed(() => (variableName ? `${variableName} · #${model.hex.value}` : undefined))
</script>
<template>
<BindingPill
v-if="variableName"
class="min-w-0 flex-1"
:label="variableName"
:tooltip="tooltip"
/>
<input
v-else
:aria-label="label"
data-property="color-hex"
class="min-w-0 flex-1 border-none bg-transparent font-mono text-xs text-surface outline-none"
:value="model.hex.value"
maxlength="6"
@change="model.updateHex(inputValue($event))"
/>
</template>

View file

@ -0,0 +1,31 @@
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { BindableValueActions, BindingTarget } from '@open-pencil/vue'
export type PaintBindingKind = 'fills' | 'strokes'
export function paintBindingTargets(
nodeIds: string[],
kind: PaintBindingKind,
index: number
): BindingTarget[] {
return nodeIds.map((nodeId) => ({ nodeId, path: `${kind}/${index}/color` }))
}
export function applyPaintMutation(
actions: BindableValueActions<Color>,
flush: () => void,
update: () => void
): boolean {
flush()
if (!actions.beginMutation('edit')) return false
update()
return true
}
export function commitPaintMutation(actions: BindableValueActions<Color>) {
actions.commitMutation()
}
export function cancelPaintMutation(actions: BindableValueActions<Color>) {
actions.cancelMutation()
}

View file

@ -2,7 +2,7 @@ import type { SceneNode } from '@open-pencil/scene-graph'
import type { useOkHCL } from '@open-pencil/vue'
type OkhclControls = ReturnType<typeof useOkHCL>
type FillFieldFormat = Parameters<OkhclControls['setFillFieldFormat']>[2]
type ColorFieldFormat = Parameters<OkhclControls['setFillFieldFormat']>[2]
type OkhclValue = Parameters<OkhclControls['updateFillOkHCL']>[2]
export function createFillOkhclAdapter(
@ -16,8 +16,25 @@ export function createFillOkhclAdapter(
fieldOptions: okhcl.fieldOptions,
okhcl: okhcl.getFillOkHCLColor(activeNode, index),
...okhcl.getFillPreviewInfo(activeNode, index),
setFieldFormat: (format: FillFieldFormat) =>
setFieldFormat: (format: ColorFieldFormat) =>
okhcl.setFillFieldFormat(activeNode, index, format),
updateOkHCL: (value: OkhclValue) => okhcl.updateFillOkHCL(activeNode, index, value)
}
}
export function createStrokeOkhclAdapter(
okhcl: OkhclControls,
activeNode: SceneNode | null | undefined,
index: number
) {
if (!activeNode) return null
return {
fieldFormat: okhcl.getFieldFormat(activeNode, index, 'stroke'),
fieldOptions: okhcl.fieldOptions,
okhcl: okhcl.getStrokeOkHCLColor(activeNode, index),
...okhcl.getStrokePreviewInfo(activeNode, index),
setFieldFormat: (format: ColorFieldFormat) =>
okhcl.setStrokeFieldFormat(activeNode, index, format),
updateOkHCL: (value: OkhclValue) => okhcl.updateStrokeOkHCL(activeNode, index, value)
}
}

View file

@ -0,0 +1,41 @@
<script lang="ts">
import type { ClassValue } from 'tailwind-variants'
import type { VNode } from 'vue'
import type { ComponentUI } from '@/components/ui/types'
import type theme from '@/theme/panel/item-row'
export type PanelItemRowUI = ComponentUI<typeof theme>
export interface PanelItemRowProps {
class?: ClassValue
ui?: PanelItemRowUI
}
export interface PanelItemRowSlots {
default(): VNode[]
rail?(props: { removeClass: string }): VNode[]
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import itemRowTheme from '@/theme/panel/item-row'
const { class: className, ui } = defineProps<PanelItemRowProps>()
defineSlots<PanelItemRowSlots>()
const styles = computed(() => tv(itemRowTheme)())
</script>
<template>
<div data-slot="item-row" :class="styles.root({ class: [ui?.root, className] })">
<div :class="styles.content({ class: ui?.content })" data-slot="content">
<slot />
</div>
<div v-if="$slots.rail" :class="styles.rail({ class: ui?.rail })" data-slot="rail">
<slot name="rail" :remove-class="styles.remove({ class: ui?.remove })" />
</div>
</div>
</template>

View file

@ -1,6 +1,7 @@
export { default as PanelFieldGroup } from './PanelFieldGroup.vue'
export { default as PanelGrid } from './PanelGrid.vue'
export { default as PanelHeader } from './PanelHeader.vue'
export { default as PanelItemRow } from './PanelItemRow.vue'
export { default as PanelRail } from './PanelRail.vue'
export { default as PanelRow } from './PanelRow.vue'
export { default as PanelSection } from './PanelSection.vue'

10
src/theme/paint-field.ts Normal file
View file

@ -0,0 +1,10 @@
export default {
slots: {
root: 'flex h-control min-w-0 flex-1 items-center overflow-hidden rounded-panel border border-border bg-input shadow-panel-field',
preview: 'flex shrink-0 items-center pl-0.5',
value: 'flex min-w-0 flex-1 items-center px-1.5',
divider: 'h-4 w-px shrink-0 bg-border',
opacity: 'h-full w-14 shrink-0',
binding: 'flex shrink-0 items-center pr-0.5'
}
} as const

View file

@ -0,0 +1,9 @@
export default {
slots: {
root: 'group flex min-h-control items-center gap-panel py-0.5',
content: 'flex min-w-0 flex-1 items-center gap-panel',
rail: 'flex shrink-0 items-center gap-0.5',
remove:
'transition-opacity [@media(hover:hover)]:pointer-events-none [@media(hover:hover)]:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100'
}
} as const

View file

@ -1,6 +1,6 @@
import { expect, expectInViewport, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { propertySection } from '#tests/helpers/properties'
import { propertyItems, propertySection } from '#tests/helpers/properties'
const editor = useEditorSetup()
@ -9,11 +9,11 @@ function designPanel() {
}
function fillSection() {
return editor.page.getByTestId('fill-section')
return propertySection(editor.page, 'Fill')
}
function strokeSection() {
return editor.page.getByTestId('stroke-section')
return propertySection(editor.page, 'Stroke')
}
function positionSection() {
@ -21,7 +21,7 @@ function positionSection() {
}
function effectsSection() {
return editor.page.getByTestId('effects-section')
return propertySection(editor.page, 'Effects')
}
function getNode(id: string) {
@ -85,7 +85,7 @@ test('position section shows X, Y, rotation inputs', async () => {
test('fill section appears with default fill', async () => {
await expect(fillSection()).toBeVisible()
const fillItems = fillSection().getByTestId('fill-item')
const fillItems = propertyItems(editor.page, 'fills')
await expect(fillItems.first()).toBeVisible()
})
@ -123,11 +123,11 @@ test('clicking color area changes fill color', async () => {
})
test('adding a stroke creates stroke section item', async () => {
const addBtn = strokeSection().getByTestId('stroke-section-add')
const addBtn = strokeSection().getByRole('button', { name: 'Add stroke' })
await addBtn.click()
await editor.canvas.waitForRender()
const strokeItems = strokeSection().getByTestId('stroke-item')
const strokeItems = propertyItems(editor.page, 'strokes')
await expect(strokeItems.first()).toBeVisible()
const id = await getSelectedId()
@ -136,11 +136,11 @@ test('adding a stroke creates stroke section item', async () => {
})
test('adding an effect creates effect item', async () => {
const addBtn = effectsSection().getByTestId('effects-section-add')
const addBtn = effectsSection().getByRole('button', { name: 'Add effect' })
await addBtn.click()
await editor.canvas.waitForRender()
const effectItems = effectsSection().getByTestId('effect-item')
const effectItems = propertyItems(editor.page, 'effects')
await expect(effectItems.first()).toBeVisible()
const id = await getSelectedId()
@ -148,12 +148,50 @@ test('adding an effect creates effect item', async () => {
expect(expectDefined(node, 'node node').effects.length).toBe(1)
})
test('effect settings expand semantically and row remove reveals on hover', async () => {
const effectItem = propertyItems(editor.page, 'effects').first()
const expand = effectItem.locator('[data-property="effect-expand"]')
await expect(expand).toHaveAttribute('aria-expanded', 'false')
await expand.click()
await expect(expand).toHaveAttribute('aria-expanded', 'true')
await expect(editor.page.locator('[data-slot="effect-settings"]')).toBeVisible()
const remove = effectItem.getByRole('button', { name: 'Remove effect' })
await expect(remove).toHaveCSS('opacity', '1')
await editor.page.getByRole('tab', { name: 'Design' }).focus()
await editor.page.mouse.move(0, 0)
await expect(remove).toHaveCSS('opacity', '0')
await effectItem.hover()
await expect(remove).toHaveCSS('opacity', '1')
})
test('paint effect and export rows share compact visual anatomy', async () => {
const exportSection = propertySection(editor.page, 'Export')
await exportSection.getByRole('button', { name: 'Add export' }).click()
await editor.canvas.waitForRender()
for (const sectionName of ['Position', 'Layout', 'Appearance']) {
await propertySection(editor.page, sectionName)
.getByRole('button', { name: sectionName })
.click()
}
await editor.page.mouse.move(0, 0)
await expect(designPanel()).toHaveScreenshot('design-panel-paint-effects-export.png')
for (const sectionName of ['Position', 'Layout', 'Appearance']) {
await propertySection(editor.page, sectionName)
.getByRole('button', { name: sectionName })
.click()
}
})
test('adding a second fill shows two fill items', async () => {
const addBtn = fillSection().getByTestId('fill-section-add')
const addBtn = fillSection().getByRole('button', { name: 'Add fill' })
await addBtn.click()
await editor.canvas.waitForRender()
const fillItems = fillSection().getByTestId('fill-item')
const fillItems = propertyItems(editor.page, 'fills')
expect(await fillItems.count()).toBe(2)
const id = await getSelectedId()
@ -269,7 +307,9 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
const id = await getSelectedId()
expect(id).toBeTruthy()
const fillButton = editor.page.getByTestId('fill-visibility-0')
const fillButton = propertyItems(editor.page, 'fills')
.first()
.getByRole('button', { name: 'Toggle visibility' })
await expect(fillButton).toBeVisible()
const initial = await getNode(expectDefined(id, 'selected id'))
@ -277,7 +317,7 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
await fillButton.click()
await editor.canvas.waitForRender()
await expect(fillButton).toHaveAttribute('data-visible', 'false')
await expect(fillButton).toHaveAttribute('aria-pressed', 'false')
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').fills[0]
?.visible
@ -285,7 +325,7 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
await fillButton.click()
await editor.canvas.waitForRender()
await expect(fillButton).toHaveAttribute('data-visible', 'true')
await expect(fillButton).toHaveAttribute('aria-pressed', 'true')
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').fills[0]
?.visible
@ -302,11 +342,13 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
?.visible
).toBe(true)
const strokeAddButton = strokeSection().getByTestId('stroke-section-add')
const strokeAddButton = strokeSection().getByRole('button', { name: 'Add stroke' })
await strokeAddButton.click()
await editor.canvas.waitForRender()
const strokeButton = editor.page.getByTestId('stroke-visibility-0')
const strokeButton = propertyItems(editor.page, 'strokes')
.first()
.getByRole('button', { name: 'Toggle visibility' })
await expect(strokeButton).toBeVisible()
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').strokes[0]
@ -315,7 +357,7 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
await strokeButton.click()
await editor.canvas.waitForRender()
await expect(strokeButton).toHaveAttribute('data-visible', 'false')
await expect(strokeButton).toHaveAttribute('aria-pressed', 'false')
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').strokes[0]
?.visible
@ -323,7 +365,7 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
await strokeButton.click()
await editor.canvas.waitForRender()
await expect(strokeButton).toHaveAttribute('data-visible', 'true')
await expect(strokeButton).toHaveAttribute('aria-pressed', 'true')
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').strokes[0]
?.visible
@ -340,11 +382,13 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
?.visible
).toBe(true)
const effectAddButton = effectsSection().getByTestId('effects-section-add')
const effectAddButton = effectsSection().getByRole('button', { name: 'Add effect' })
await effectAddButton.click()
await editor.canvas.waitForRender()
const effectButton = editor.page.getByTestId('effect-visibility-0')
const effectButton = propertyItems(editor.page, 'effects')
.first()
.getByRole('button', { name: 'Toggle visibility' })
await expect(effectButton).toBeVisible()
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').effects[0]
@ -353,7 +397,7 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
await effectButton.click()
await editor.canvas.waitForRender()
await expect(effectButton).toHaveAttribute('data-visible', 'false')
await expect(effectButton).toHaveAttribute('aria-pressed', 'false')
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').effects[0]
?.visible
@ -361,7 +405,7 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
await effectButton.click()
await editor.canvas.waitForRender()
await expect(effectButton).toHaveAttribute('data-visible', 'true')
await expect(effectButton).toHaveAttribute('aria-pressed', 'true')
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').effects[0]
?.visible

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -2,6 +2,7 @@ import { test, expect, type Page } from '@playwright/test'
import { expectInViewport } from '#tests/e2e/fixtures'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertyItems, propertySection } from '#tests/helpers/properties'
let page: Page
let canvas: CanvasHelper
@ -23,7 +24,7 @@ test.afterAll(async () => {
})
function exportItems() {
return page.getByTestId('export-item')
return propertyItems(page, 'exportSettings')
}
function exportButton() {
@ -80,7 +81,7 @@ test('new selection starts with empty export settings', async () => {
test('add export row increases row count', async () => {
const before = await exportItems().count()
await page.getByTestId('export-section-add').click()
await propertySection(page, 'Export').getByRole('button', { name: 'Add export' }).click()
await canvas.waitForRender()
const after = await exportItems().count()
@ -90,11 +91,12 @@ test('add export row increases row count', async () => {
})
test('remove export row decreases row count', async () => {
await page.getByTestId('export-section-add').click()
await propertySection(page, 'Export').getByRole('button', { name: 'Add export' }).click()
await canvas.waitForRender()
const before = await exportItems().count()
await exportItems().first().locator('button').last().click()
await exportItems().first().hover()
await exportItems().first().getByRole('button', { name: 'Remove export' }).click()
await canvas.waitForRender()
const after = await exportItems().count()
@ -103,7 +105,7 @@ test('remove export row decreases row count', async () => {
})
test('format selector changes to JPG', async () => {
const formatTrigger = exportItems().first().getByTestId('app-select-trigger').last()
const formatTrigger = exportItems().first().getByRole('combobox', { name: 'Export format' })
await expect(formatTrigger).toHaveAttribute('aria-label', 'Export format')
await formatTrigger.click()
@ -121,7 +123,7 @@ test('format selector changes to JPG', async () => {
})
test('format selector does not offer FIG as an export format', async () => {
const formatTrigger = exportItems().first().getByTestId('app-select-trigger').last()
const formatTrigger = exportItems().first().getByRole('combobox', { name: 'Export format' })
await formatTrigger.click()
await expect(page.locator('[role="option"]').filter({ hasText: '.fig' })).toHaveCount(0)
@ -130,13 +132,13 @@ test('format selector does not offer FIG as an export format', async () => {
})
test('SVG format hides scale selector', async () => {
const formatTrigger = exportItems().first().getByTestId('app-select-trigger').last()
const formatTrigger = exportItems().first().getByRole('combobox', { name: 'Export format' })
await formatTrigger.click()
await page.locator('[role="option"]').filter({ hasText: 'SVG' }).click()
await canvas.waitForRender()
const selects = exportItems().first().getByTestId('app-select-trigger')
const selects = exportItems().first().getByRole('combobox', { name: 'Export format' })
await expect(selects).toHaveCount(1)
canvas.assertNoErrors()
})
@ -149,12 +151,12 @@ test('format selector works with multiple export rows', async () => {
]
])
const firstRowFormat = exportItems().nth(0).getByTestId('app-select-trigger').last()
const firstRowFormat = exportItems().nth(0).getByRole('combobox', { name: 'Export format' })
await firstRowFormat.click()
await page.locator('[role="option"]').filter({ hasText: 'JPG' }).click()
await canvas.waitForRender()
const secondRowFormat = exportItems().nth(1).getByTestId('app-select-trigger').last()
const secondRowFormat = exportItems().nth(1).getByRole('combobox', { name: 'Export format' })
await secondRowFormat.click()
await page.locator('[role="option"]').filter({ hasText: 'PDF' }).click()
await canvas.waitForRender()
@ -217,14 +219,14 @@ test('a single export format downloads the file directly', async () => {
})
test('preview toggle shows image with blob src', async () => {
const formatTrigger = exportItems().first().getByTestId('app-select-trigger').last()
const formatTrigger = exportItems().first().getByRole('combobox', { name: 'Export format' })
await formatTrigger.click()
await page.locator('[role="option"]').filter({ hasText: 'PNG' }).click()
await canvas.waitForRender()
await page.getByTestId('export-preview-toggle').click()
const img = page.getByTestId('export-section').locator('img')
const img = propertySection(page, 'Export').locator('img')
await expect(img).toBeVisible({ timeout: 10000 })
const src = await img.getAttribute('src')
@ -235,7 +237,7 @@ test('preview toggle shows image with blob src', async () => {
test('multi-select add and edit applies to all selected layers', async () => {
await createRectangles(2)
await page.getByTestId('export-section-add').click()
await propertySection(page, 'Export').getByRole('button', { name: 'Add export' }).click()
await canvas.waitForRender()
await expect(exportButton()).toContainText('Export 2 layers')
@ -244,7 +246,7 @@ test('multi-select add and edit applies to all selected layers', async () => {
[{ scale: 1, format: 'png' }]
])
const scaleInput = exportItems().first().getByTestId('export-scale-input')
const scaleInput = exportItems().first().getByRole('textbox', { name: 'Export scale' })
await scaleInput.fill('2.5x')
await scaleInput.press('Enter')
await canvas.waitForRender()
@ -259,14 +261,14 @@ test('multi-select add and edit applies to all selected layers', async () => {
test('mixed export settings are indicated', async () => {
await createRectangles(2, [[{ scale: 1, format: 'png' }], [{ scale: 2, format: 'jpg' }]])
await expect(page.getByTestId('export-section')).toContainText('Mixed')
await expect(propertySection(page, 'Export')).toContainText('Mixed')
canvas.assertNoErrors()
})
test('undo reverts export setting edits', async () => {
await createRectangles(2)
await page.getByTestId('export-section-add').click()
await propertySection(page, 'Export').getByRole('button', { name: 'Add export' }).click()
await canvas.waitForRender()
expect(await selectedExportSettings()).toEqual([
[{ scale: 1, format: 'png' }],

View file

@ -100,34 +100,25 @@ test('stroke sides toggle shows per-side weight inputs', async () => {
await drawFrame(300, 50, 120, 80)
await canvas.waitForRender()
const addStroke = page.getByTestId('stroke-section-add')
const addStroke = propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' })
await expect(addStroke).toBeVisible()
await addStroke.click()
await canvas.waitForRender()
const toggle = page.getByTestId('stroke-sides-toggle')
const toggle = page.locator('[data-property="stroke-sides"]')
await expect(toggle).toBeVisible({ timeout: 5000 })
const sectionInputsBefore = await page
.getByTestId('stroke-section')
.getByRole('spinbutton')
.count()
const sectionInputsBefore = await propertySection(page, 'Stroke').getByRole('spinbutton').count()
await toggle.click()
await canvas.waitForRender()
const sectionInputsAfter = await page
.getByTestId('stroke-section')
.getByRole('spinbutton')
.count()
const sectionInputsAfter = await propertySection(page, 'Stroke').getByRole('spinbutton').count()
expect(sectionInputsAfter).toBeGreaterThan(sectionInputsBefore)
await toggle.click()
await canvas.waitForRender()
const sectionInputsFinal = await page
.getByTestId('stroke-section')
.getByRole('spinbutton')
.count()
const sectionInputsFinal = await propertySection(page, 'Stroke').getByRole('spinbutton').count()
expect(sectionInputsFinal).toBe(sectionInputsBefore)
})

View file

@ -1,6 +1,6 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { propertyField, propertySection } from '#tests/helpers/properties'
import { propertyField, propertyItems, propertySection } from '#tests/helpers/properties'
import { getPageChildren, getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup()
@ -72,9 +72,9 @@ test('fill gradient switch changes fill type', async () => {
await editor.canvas.drawRect(300, 300, 80, 80)
await editor.canvas.waitForRender()
await expect(editor.page.getByTestId('fill-section')).toBeVisible({ timeout: 5000 })
await expect(propertySection(editor.page, 'Fill')).toBeVisible({ timeout: 5000 })
const fillItem = editor.page.getByTestId('fill-item').first()
const fillItem = propertyItems(editor.page, 'fills').first()
await expect(fillItem).toBeVisible({ timeout: 5000 })
const fillSwatch = fillItem.getByTestId('fill-picker-swatch')
await expect(fillSwatch).toBeVisible({ timeout: 5000 })
@ -86,6 +86,7 @@ test('fill gradient switch changes fill type', async () => {
const node = expectDefined(await getSelectedNode(editor.page), 'gradient-filled node')
expect(node.fills[0]?.type).toBe('GRADIENT_LINEAR')
await fillSwatch.click()
editor.canvas.assertNoErrors()
})
@ -105,7 +106,7 @@ test('variable bind badge appears on fill', async () => {
})
await editor.canvas.waitForRender()
await expect(editor.page.getByTestId('fill-unbind-variable')).toBeVisible()
await expect(propertyItems(editor.page, 'fills').first().getByText('brand-red')).toBeVisible()
editor.canvas.assertNoErrors()
})
@ -113,7 +114,7 @@ test('fill color can bind an existing variable', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
await editor.page.evaluate(() => {
const variableId = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const col = store.graph.createCollection('Colors')
@ -128,20 +129,32 @@ test('fill color can bind an existing variable', async () => {
})
await editor.canvas.waitForRender()
await editor.page.getByTestId('fill-apply-variable-0').click()
const fillItem = propertyItems(editor.page, 'fills').first()
await fillItem.getByLabel('Apply variable').click()
await editor.page.getByText('test-brand-red', { exact: true }).click()
await editor.canvas.waitForRender()
await expect(editor.page.getByTestId('fill-unbind-variable')).toBeVisible()
const fillSwatch = editor.page.getByTestId('fill-picker-swatch')
await expect(fillSwatch).toHaveCSS('background-color', 'rgb(255, 0, 0)')
await expect(fillItem.getByText('test-brand-red')).toBeVisible()
const fillSwatch = fillItem.getByTestId('fill-picker-swatch')
await expect(fillSwatch.locator('[data-slot="swatch"] > span')).toHaveCSS(
'background-color',
'rgb(255, 0, 0)'
)
await fillSwatch.click()
const colorInputs = editor.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')
const redInput = editor.page.getByRole('spinbutton', { name: 'Red' })
await expect(redInput).toHaveValue('255')
await redInput.fill('0')
await redInput.press('Enter')
await fillSwatch.click()
await expect(editor.page.locator('[data-picker-content]')).toHaveCount(0)
await editor.canvas.waitForRender()
await expect(editor.page.getByTestId('fill-unbind-variable')).toBeHidden()
await expect(fillItem.getByText('test-brand-red')).toHaveCount(0)
const undoLabel = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.undo.undoLabel
})
expect(undoLabel).toBe('Change fill color')
const boundVariableId = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
@ -149,6 +162,77 @@ test('fill color can bind an existing variable', async () => {
return id ? (store.getNode(id)?.boundVariables['fills/0/color'] ?? null) : null
})
expect(boundVariableId).toBeNull()
await editor.canvas.undo()
await editor.canvas.waitForRender()
const undoLabelAfter = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.undo.undoLabel
})
expect(undoLabelAfter).toBe('Bind variable')
const restoredBinding = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
return id ? store.getNode(id)?.boundVariables['fills/0/color'] : undefined
})
expect(restoredBinding).toBe(variableId)
await expect(fillItem.getByText('test-brand-red')).toBeVisible()
editor.canvas.assertNoErrors()
})
test('bound fill picker opens non-destructively and Escape rolls back color edits', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const before = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const collection = store.graph.createCollection('Colors')
const variable = store.graph.createVariable('rollback-brand', 'COLOR', collection.id, {
r: 1,
g: 0,
b: 0,
a: 1
})
const id = [...store.state.selectedIds][0]
if (!id) throw new Error('Expected selected node')
store.graph.bindVariable(id, 'fills/0/color', variable.id)
store.state.sceneVersion++
const node = store.getNode(id)
return { color: node?.fills[0]?.color, binding: node?.boundVariables['fills/0/color'] }
})
await editor.canvas.waitForRender()
const fillItem = propertyItems(editor.page, 'fills').first()
await fillItem.getByTestId('fill-picker-swatch').click()
await expect(fillItem.getByText('rollback-brand')).toBeVisible()
const opened = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
const node = id ? store.getNode(id) : null
return { color: node?.fills[0]?.color, binding: node?.boundVariables['fills/0/color'] }
})
expect(opened).toEqual(before)
const area = editor.page.locator('.cursor-crosshair').first()
const box = expectDefined(await area.boundingBox(), 'color area bounds')
await editor.page.mouse.click(box.x + box.width - 8, box.y + 8)
await editor.page.keyboard.press('Escape')
await editor.canvas.waitForRender()
const after = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
const node = id ? store.getNode(id) : null
return { color: node?.fills[0]?.color, binding: node?.boundVariables['fills/0/color'] }
})
expect(after).toEqual(before)
await expect(fillItem.getByText('rollback-brand')).toBeVisible()
editor.canvas.assertNoErrors()
})
@ -156,14 +240,15 @@ test('fill color can create and bind a variable', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
await editor.page.getByTestId('fill-apply-variable-0').click()
const fillItem = propertyItems(editor.page, 'fills').first()
await fillItem.getByLabel('Apply variable').click()
await expect(editor.page.getByText(/Create color variable from #?[0-9A-F]{6}/)).toBeVisible()
await editor.page.getByTestId('fill-apply-variable-0-create').click()
await editor.page.getByText(/Create color variable from #?[0-9A-F]{6}/).click()
await editor.page.getByPlaceholder('Variable name').fill('Surface/default')
await editor.page.getByTestId('fill-apply-variable-0-create').click()
await editor.page.getByRole('button', { name: 'Create', exact: true }).click()
await editor.canvas.waitForRender()
await expect(editor.page.getByTestId('fill-unbind-variable')).toBeVisible()
await expect(fillItem.getByText('Surface/default')).toBeVisible()
const boundVariable = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')

View file

@ -1,6 +1,6 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { propertySection } from '#tests/helpers/properties'
import { propertyItems, propertySection } from '#tests/helpers/properties'
import { getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup()
@ -9,7 +9,9 @@ test('fill visibility supports repeat click and undo redo', async () => {
await editor.canvas.drawRect(120, 120, 120, 80)
await editor.canvas.waitForRender()
const fillButton = editor.page.getByTestId('fill-visibility-0')
const fillButton = propertyItems(editor.page, 'fills')
.first()
.getByRole('button', { name: 'Toggle visibility' })
await expect(fillButton).toBeVisible()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(
true
@ -39,10 +41,12 @@ test('fill visibility supports repeat click and undo redo', async () => {
})
test('stroke visibility supports repeat click and undo redo', async () => {
await editor.page.getByTestId('stroke-section-add').click()
await propertySection(editor.page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
await editor.canvas.waitForRender()
const strokeButton = editor.page.getByTestId('stroke-visibility-0')
const strokeButton = propertyItems(editor.page, 'strokes')
.first()
.getByRole('button', { name: 'Toggle visibility' })
await expect(strokeButton).toBeVisible()
expect(
expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible
@ -84,7 +88,7 @@ test('multi-selection list add is one undo step', async () => {
return [...store.state.selectedIds].map((id) => store.getNode(id)?.strokes.length ?? -1)
})
await editor.page.getByTestId('stroke-section-add').click()
await propertySection(editor.page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
await editor.canvas.waitForRender()
expect(await strokeCounts()).toEqual([1, 1])

View file

@ -1,29 +1,27 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertyItems, propertySection } from '#tests/helpers/properties'
async function dragSlider(page: Page, canvas: CanvasHelper, testId: string, ratio: number) {
const slider = page.getByTestId(testId).locator('input[type="range"]')
const slider = page.getByTestId(testId).locator(':scope > [data-orientation="horizontal"]')
const box = await slider.boundingBox()
if (!box) throw new Error(`Missing slider: ${testId}`)
const y = box.y + box.height / 2
await page.mouse.move(box.x + 2, y)
await page.mouse.down()
await page.mouse.move(box.x + Math.max(2, Math.min(box.width - 2, box.width * ratio)), y, {
steps: 5
await slider.click({
position: {
x: Math.max(2, Math.min(box.width - 2, box.width * ratio)),
y: box.height / 2
}
})
await page.mouse.up()
await canvas.waitForRender()
}
async function openStrokePicker(page: Page) {
await page
.getByTestId('stroke-item')
.getByTestId('color-picker-popover')
.waitFor({ state: 'detached' })
.catch(() => undefined)
await page.getByTestId('stroke-item').locator('button').first().click()
await expect(page.getByTestId('color-picker-popover')).toBeVisible()
await propertyItems(page, 'strokes')
.first()
.getByRole('button', { name: 'Stroke', exact: true })
.click()
await expect(page.locator('[data-picker-content]')).toBeVisible()
}
async function chooseFormat(page: Page, label: 'RGB' | 'HSL' | 'HSB' | 'OkHCL') {
@ -47,7 +45,7 @@ test('stroke picker updates stroke color on a rectangle', async ({ page }) => {
await canvas.waitForInit()
await canvas.drawRect(120, 120, 180, 120)
await page.getByTestId('stroke-section-add').click()
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
await canvas.waitForRender()
const before = await getSelectedStroke(page)
@ -69,7 +67,7 @@ test('stroke picker alpha slider updates stroke opacity and alpha', async ({ pag
await canvas.waitForInit()
await canvas.drawRect(120, 120, 180, 120)
await page.getByTestId('stroke-section-add').click()
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
await canvas.waitForRender()
await openStrokePicker(page)
@ -108,7 +106,7 @@ test('stroke picker hsb saturation and brightness sliders update stroke color on
})
await canvas.waitForRender()
await expect(page.getByTestId('stroke-item')).toBeVisible()
await expect(propertyItems(page, 'strokes')).toBeVisible()
await openStrokePicker(page)
await chooseFormat(page, 'HSB')
@ -132,3 +130,75 @@ test('stroke picker hsb saturation and brightness sliders update stroke color on
beforeB?.color.b !== afterB?.color.b
).toBe(true)
})
test('bound stroke picker is non-destructive, rolls back Escape, and detaches in one undo step', async ({
page
}) => {
const canvas = new CanvasHelper(page)
await page.goto('/')
await canvas.waitForInit()
await canvas.drawRect(120, 120, 180, 120)
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
await canvas.waitForRender()
const before = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const collection = store.graph.createCollection('Colors')
const variable = store.graph.createVariable('stroke-brand', 'COLOR', collection.id, {
r: 0.9,
g: 0.1,
b: 0.2,
a: 1
})
const id = [...store.state.selectedIds][0]
if (!id) throw new Error('Expected selected node')
store.graph.bindVariable(id, 'strokes/0/color', variable.id)
store.state.sceneVersion++
const node = store.getNode(id)
return { color: node?.strokes[0]?.color, binding: node?.boundVariables['strokes/0/color'] }
})
await canvas.waitForRender()
await openStrokePicker(page)
expect(
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
return id ? store.getNode(id)?.boundVariables['strokes/0/color'] : undefined
})
).toBe(before.binding)
await dragSlider(page, canvas, 'color-slider-hue', 0.55)
await page.keyboard.press('Escape')
await canvas.waitForRender()
const afterEscape = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
const node = id ? store.getNode(id) : null
return { color: node?.strokes[0]?.color, binding: node?.boundVariables['strokes/0/color'] }
})
expect(afterEscape).toEqual(before)
await openStrokePicker(page)
await dragSlider(page, canvas, 'color-slider-hue', 0.75)
await propertyItems(page, 'strokes')
.first()
.getByRole('button', { name: 'Stroke', exact: true })
.click()
await canvas.waitForRender()
expect((await getSelectedStroke(page))?.color).not.toEqual(before.color)
await canvas.undo()
await canvas.waitForRender()
const restored = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
const node = id ? store.getNode(id) : null
return { color: node?.strokes[0]?.color, binding: node?.boundVariables['strokes/0/color'] }
})
expect(restored).toEqual(before)
})

View file

@ -1,4 +1,5 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { propertyItems, propertySection } from '#tests/helpers/properties'
const editor = useEditorSetup()
@ -8,9 +9,11 @@ test('tooltips stay hoverable and clickable in WebKit', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(180, 180, 80, 80)
const strokeItems = editor.page.getByTestId('stroke-item')
const strokeItems = propertyItems(editor.page, 'strokes')
const strokeCount = await strokeItems.count()
const strokeAdd = editor.page.getByTestId('stroke-section-add')
const strokeAdd = propertySection(editor.page, 'Stroke').getByRole('button', {
name: 'Add stroke'
})
await strokeAdd.hover()
await expect(
editor.page.locator('[role=tooltip]').filter({ hasText: 'Add stroke' })
@ -18,13 +21,15 @@ test('tooltips stay hoverable and clickable in WebKit', async () => {
await strokeAdd.click()
await expect(strokeItems).toHaveCount(strokeCount + 1)
const effectAdd = editor.page.getByTestId('effects-section-add')
const effectAdd = propertySection(editor.page, 'Effects').getByRole('button', {
name: 'Add effect'
})
await effectAdd.hover()
await expect(
editor.page.locator('[role=tooltip]').filter({ hasText: 'Add effect' })
).toBeVisible()
await effectAdd.click()
await expect(editor.page.getByTestId('effect-item')).toHaveCount(1)
await expect(propertyItems(editor.page, 'effects')).toHaveCount(1)
await editor.page.keyboard.press('Meta+J')
await expect(editor.page.getByTestId('provider-setup')).toBeVisible()

View file

@ -90,9 +90,12 @@ test('color swatch opens color picker', async () => {
await editor.page.getByTestId('variables-section-open').click()
await expect(editor.page.getByTestId('variables-dialog')).toBeVisible({ timeout: 3000 })
const swatch = editor.page.getByTestId('variable-row').first().getByTestId('color-picker-swatch')
const swatch = editor.page
.getByTestId('variable-row')
.first()
.getByRole('button', { name: 'Edit color' })
await expect(swatch).toBeVisible({ timeout: 3000 })
await swatch.click()
await expect(editor.page.getByTestId('color-picker-popover')).toBeVisible({ timeout: 5000 })
await expect(editor.page.locator('[data-picker-content]')).toBeVisible({ timeout: 5000 })
editor.canvas.assertNoErrors()
})

View file

@ -0,0 +1,73 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import type { Color, Fill } from '@open-pencil/scene-graph'
import {
createAndBindColorVariable,
setColorVariableValue
} from '#vue/controls/binding-provider/color'
const fill: Fill = {
type: 'SOLID',
color: { r: 0.2, g: 0.4, b: 0.6, a: 1 },
opacity: 1,
visible: true
}
function setup() {
const editor = createEditor()
const page = editor.graph.getPages()[0]
if (!page) throw new Error('Expected initial page')
const node = editor.graph.createNode('RECTANGLE', page.id, {
x: 0,
y: 0,
width: 100,
height: 100,
fills: [structuredClone(fill)]
})
return { editor, node }
}
describe('color binding provider helpers', () => {
test('creates a color collection, variable, and indexed binding', () => {
const { editor, node } = setup()
createAndBindColorVariable(
editor,
{ nodeId: node.id, path: 'fills/0/color' },
fill.color,
'Brand/accent'
)
const variableId = editor.getNode(node.id)?.boundVariables['fills/0/color']
expect(variableId).toBeString()
const variable = variableId ? editor.getVariable(variableId) : undefined
expect(variable?.name).toBe('Brand/accent')
expect(variable?.type).toBe('COLOR')
expect(variableId ? editor.resolveColorVariable(variableId) : undefined).toEqual(fill.color)
})
test('reuses a color collection and updates every mode with structured colors', () => {
const { editor, node } = setup()
const collection = editor.graph.createCollection('Product colors')
editor.graph.addMode(collection.id, 'dark', 'Dark')
editor.graph.createVariable('Existing', 'COLOR', collection.id, fill.color)
createAndBindColorVariable(
editor,
{ nodeId: node.id, path: 'fills/0/color' },
fill.color,
'Brand/accent'
)
expect(editor.getCollections()).toHaveLength(1)
const variableId = editor.getNode(node.id)?.boundVariables['fills/0/color']
if (!variableId) throw new Error('Expected color binding')
const next: Color = { r: 0.9, g: 0.1, b: 0.3, a: 0.75 }
setColorVariableValue(editor, variableId, next)
const variable = editor.getVariable(variableId)
expect(variable).toBeDefined()
for (const mode of collection.modes) expect(variable?.valuesByMode[mode.modeId]).toEqual(next)
})
})

View file

@ -7,3 +7,13 @@ export function propertySection(page: Page, name: string): Locator {
export function propertyField(page: Page, property: string): Locator {
return page.locator(`[data-property=${JSON.stringify(property)}]`)
}
export function propertyItems(page: Page, property: string): Locator {
return page.locator(`[data-property=${JSON.stringify(property)}][data-index]`)
}
export function propertyItem(page: Page, property: string, index = 0): Locator {
return page.locator(
`[data-property=${JSON.stringify(property)}][data-index=${JSON.stringify(String(index))}]`
)
}