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:
parent
29e2172643
commit
5371ed11b8
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
|
||||
|
||||
export default defineComponentMetaLoader([
|
||||
'packages/vue/src/primitives/ColorPicker/ColorPickerRoot.vue'
|
||||
])
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ These exports are intentionally public, but they are lower-level or more special
|
|||
- `useNodeProps()`
|
||||
- `useEditorPropertyList()`
|
||||
- `useSceneComputed()`
|
||||
- `useColorBindingProvider()`
|
||||
- `useColorVariableBinding()`
|
||||
- `provideBindingProvider()`
|
||||
- `useBindingProvider()`
|
||||
|
|
|
|||
68
packages/vue/src/controls/binding-provider/color.ts
Normal file
68
packages/vue/src/controls/binding-provider/color.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
export {
|
||||
createAndBindColorVariable,
|
||||
useColorBindingProvider
|
||||
} from '#vue/controls/binding-provider/color'
|
||||
export {
|
||||
BINDING_PROVIDER_KEY,
|
||||
provideBindingProvider,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -257,7 +257,8 @@ export {
|
|||
provideBindingProvider,
|
||||
useBindingProvider,
|
||||
useOpenPencilBindingProvider,
|
||||
useNumberBindingProvider
|
||||
useNumberBindingProvider,
|
||||
useColorBindingProvider
|
||||
} from '#vue/controls/binding-provider'
|
||||
export type {
|
||||
BindingMutationSource,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
81
src/components/properties/item-list/PropertyItemRow.vue
Normal file
81
src/components/properties/item-list/PropertyItemRow.vue
Normal 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>
|
||||
68
src/components/properties/paint/PaintField.vue
Normal file
68
src/components/properties/paint/PaintField.vue
Normal 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>
|
||||
42
src/components/properties/paint/PaintValue.vue
Normal file
42
src/components/properties/paint/PaintValue.vue
Normal 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>
|
||||
31
src/components/properties/paint/binding.ts
Normal file
31
src/components/properties/paint/binding.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
41
src/components/ui/panel/PanelItemRow.vue
Normal file
41
src/components/ui/panel/PanelItemRow.vue
Normal 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>
|
||||
|
|
@ -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
10
src/theme/paint-field.ts
Normal 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
|
||||
9
src/theme/panel/item-row.ts
Normal file
9
src/theme/panel/item-row.ts
Normal 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
|
||||
|
|
@ -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 |
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
73
tests/engine/vue/controls/color-binding-provider.test.ts
Normal file
73
tests/engine/vue/controls/color-binding-provider.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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))}]`
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue