feat(app): migrate position and appearance panels

- Move independent-corner presentation and batched mutations into AppearanceControls

- Rebuild Position and Appearance with aligned panel grids and semantic locators

- Replace type text headers with compact node icons and add visual coverage
This commit is contained in:
Danila Poyarkov 2026-07-13 14:47:48 +03:00
parent 1d60d3a407
commit 12031b76c9
29 changed files with 631 additions and 353 deletions

View file

@ -18,6 +18,7 @@
- Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions.
- Add headless PropertySection, SegmentedControl, and typed PropertyList anatomy, with controlled list events and an undo-aware OpenPencil adapter.
- Refine variable-bound number fields with a quiet identity pill, one picker affordance, an accessible variable combobox, and non-destructive focus behavior.
- Redesign Position and Appearance controls with aligned panel grids, SDK-owned independent-corner state, and compact type-icon selection headers.
- Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress.
- Add desktop image drag-and-drop into the Tauri app window.
- Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page.

View file

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

View file

@ -1,13 +1,29 @@
---
title: AppearanceControlsRoot
description: Headless root primitive for opacity, visibility, and corner-radius controls.
description: Headless root primitive for opacity, visibility, blend mode, and corner-radius controls.
---
<script setup lang="ts">
import { data } from './appearance-controls-root.data'
</script>
# AppearanceControlsRoot
`AppearanceControlsRoot` exposes the slot contract returned by `useAppearance()` as a structural primitive.
`AppearanceControlsRoot` exposes the slot contract returned by `useAppearance()` as a structural
primitive. Use it when you want reusable appearance controls with custom presentation.
Use it when you want reusable appearance controls with custom presentation.
The root owns selection-derived presentation decisions, including `showIndependentCorners`.
That state becomes active when the selected node explicitly uses independent corners or when an
imported node contains unequal corner values with a stale uniform flag. Consumers should render
from this state rather than maintaining a parallel local expansion ref.
Multi-node independent-corner toggles and per-corner commits are grouped into one undo entry.
## Generated API reference
The following tables are extracted from the Vue source and JSDoc during the documentation build.
<SdkComponentAPI :components="data.components" />
## Related APIs

View file

@ -12,7 +12,8 @@ It exposes selection-derived UI state for:
- visibility
- opacity
- corner radius
- independent corner radii
- independent corner radii, including imported unequal-corner state
- blend mode
## Usage
@ -29,6 +30,7 @@ const {
visibilityState,
opacityPercent,
cornerRadiusValue,
showIndependentCorners,
toggleVisibility,
toggleIndependentCorners,
} = useAppearance()
@ -49,6 +51,10 @@ appearance.updateCornerProp('topLeftRadius', 12)
appearance.commitCornerProp('topLeftRadius', 12, 8)
```
Render the per-corner editor from `showIndependentCorners`. It accounts for both the explicit
scene-node flag and imported nodes whose corner values differ. Multi-selection toggles and commits
are grouped into one undo entry.
## Related APIs
- [SDK API Overview](../)

View file

@ -96,8 +96,10 @@ adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and sa
expressions such as `+10`, `*2`, `50%`, and `12*8+4`. `BindableValue` composes fields with a
generic `BindingProvider` and supports detach-on-edit, read-only, and edit-variable policies.
Focusing a bound NumberField is non-destructive; the configured policy begins only on the first
value mutation. `PropertyListRoot` is controlled and editor-agnostic; OpenPencil panels connect it to selection and
undo through `useEditorPropertyList()`.
value mutation. `AppearanceControlsRoot` exposes selection-derived independent-corner presentation
state so consumers do not need parallel expansion heuristics. `PropertyListRoot` is controlled and
editor-agnostic; OpenPencil panels connect it to selection and undo through
`useEditorPropertyList()`.
## Public API tiers

View file

@ -4,6 +4,7 @@ import type { ComputedRef } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import type { BlendMode, SceneNode } from '@open-pencil/scene-graph'
import type { CornerRadiusKey } from '#vue/controls/appearance/types'
import { MIXED, type MixedValue } from '#vue/controls/node-props/use'
const CORNER_RADIUS_TYPES = new Set([
@ -25,6 +26,14 @@ type AppearanceActionOptions = AppearanceStateOptions & {
editor: Editor
}
function hasUnequalCorners(node: SceneNode) {
return !(
node.topLeftRadius === node.topRightRadius &&
node.topLeftRadius === node.bottomRightRadius &&
node.topLeftRadius === node.bottomLeftRadius
)
}
export function createAppearanceState({ node, nodes, isMulti, merged }: AppearanceStateOptions) {
const hasCornerRadius = computed(() => {
if (isMulti.value) return nodes.value.every((n) => CORNER_RADIUS_TYPES.has(n.type))
@ -36,6 +45,12 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan
return node.value?.independentCorners ?? false
})
const showIndependentCorners = computed(() => {
if (isMulti.value) return false
const selected = node.value
return selected ? selected.independentCorners || hasUnequalCorners(selected) : false
})
const cornerRadiusValue = computed(() => {
if (isMulti.value) return merged('cornerRadius')
return node.value?.cornerRadius ?? 0
@ -60,6 +75,7 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan
return {
hasCornerRadius,
independentCorners,
showIndependentCorners,
cornerRadiusValue,
opacityPercent,
blendModeValue,
@ -106,40 +122,51 @@ export function createAppearanceActions({ editor, node, nodes, isMulti }: Appear
function toggleIndependentCorners() {
const selected = node.value
const singleTarget = selected ? [selected] : []
const targets = isMulti.value ? nodes.value : singleTarget
for (const n of targets) {
if (n.independentCorners) {
const uniform = n.topLeftRadius
editor.updateNodeWithUndo(
n.id,
{
independentCorners: false,
cornerRadius: uniform,
topLeftRadius: uniform,
topRightRadius: uniform,
bottomRightRadius: uniform,
bottomLeftRadius: uniform
} as Partial<SceneNode>,
'Uniform corner radius'
)
} else {
editor.updateNodeWithUndo(
n.id,
{
independentCorners: true,
topLeftRadius: n.cornerRadius,
topRightRadius: n.cornerRadius,
bottomRightRadius: n.cornerRadius,
bottomLeftRadius: n.cornerRadius
} as Partial<SceneNode>,
'Independent corner radii'
)
const targets = isMulti.value ? [...nodes.value] : []
if (!isMulti.value && selected) targets.push(selected)
if (targets.length === 0) return
const makeIndependent = !targets.every(
(target) => target.independentCorners || hasUnequalCorners(target)
)
editor.undo.runBatch(
makeIndependent ? 'Independent corner radii' : 'Uniform corner radius',
() => {
for (const target of targets) {
if (makeIndependent) {
if (target.independentCorners) continue
editor.updateNodeWithUndo(
target.id,
{
independentCorners: true,
topLeftRadius: target.cornerRadius,
topRightRadius: target.cornerRadius,
bottomRightRadius: target.cornerRadius,
bottomLeftRadius: target.cornerRadius
} as Partial<SceneNode>,
'Independent corner radii'
)
} else {
const uniform = target.topLeftRadius
editor.updateNodeWithUndo(
target.id,
{
independentCorners: false,
cornerRadius: uniform,
topLeftRadius: uniform,
topRightRadius: uniform,
bottomRightRadius: uniform,
bottomLeftRadius: uniform
} as Partial<SceneNode>,
'Uniform corner radius'
)
}
}
}
}
)
}
function updateCornerProp(key: string, value: number) {
function updateCornerProp(key: CornerRadiusKey, value: number) {
if (isMulti.value) {
for (const n of nodes.value) editor.updateNode(n.id, { [key]: value })
} else {
@ -148,11 +175,13 @@ export function createAppearanceActions({ editor, node, nodes, isMulti }: Appear
}
}
function commitCornerProp(key: string, _value: number, previous: number) {
function commitCornerProp(key: CornerRadiusKey, _value: number, previous: number) {
if (isMulti.value) {
for (const n of nodes.value) {
editor.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
}
editor.undo.runBatch(`Change ${key}`, () => {
for (const n of nodes.value) {
editor.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
}
})
} else {
const n = node.value
if (n) {

View file

@ -0,0 +1,5 @@
export type CornerRadiusKey =
| 'topLeftRadius'
| 'topRightRadius'
| 'bottomRightRadius'
| 'bottomLeftRadius'

View file

@ -63,7 +63,7 @@ export function createNodePropSelectionState(store: Editor) {
return store.getSelectedNodes()
})
const isMulti = computed(() => nodes.value.length > 1)
const active = computed(() => node.value || isMulti.value)
const active = computed(() => node.value !== null || isMulti.value)
const activeNode = computed(() => node.value ?? (nodes.value[0] as SceneNode | undefined) ?? null)
function merged<K extends keyof SceneNode>(key: K): MixedValue<SceneNode[K]> {

View file

@ -139,6 +139,12 @@ export type { LayerDragInstruction, LayerTreeContext, LayerNode } from '#vue/pri
export { LayoutControlsRoot, useLayoutControlsContext } from '#vue/primitives/LayoutControls'
export type { LayoutControlsContext } from '#vue/primitives/LayoutControls'
export { AppearanceControlsRoot } from '#vue/primitives/AppearanceControls'
export type {
AppearanceControlsActions,
AppearanceControlsRootSlotProps,
AppearanceControlsRootSlots
} from '#vue/primitives/AppearanceControls'
export type { CornerRadiusKey } from '#vue/controls/appearance/types'
export { PageListRoot } from '#vue/primitives/PageList'
export { PositionControlsRoot } from '#vue/primitives/PositionControls'
export { useEditorPropertyList } from '#vue/controls/property-list'

View file

@ -1,10 +1,13 @@
<script setup lang="ts">
import { useAppearance } from '#vue/controls/appearance/use'
import type { AppearanceControlsRootSlots } from '#vue/primitives/AppearanceControls/types'
const ctx = useAppearance()
defineSlots<AppearanceControlsRootSlots>()
const actions = {
updateProp: ctx.updateProp,
commitProp: ctx.commitProp,
setBlendMode: ctx.setBlendMode,
toggleVisibility: ctx.toggleVisibility,
toggleIndependentCorners: ctx.toggleIndependentCorners,
updateCornerProp: ctx.updateCornerProp,
@ -19,8 +22,10 @@ const actions = {
:active="ctx.active.value"
:has-corner-radius="ctx.hasCornerRadius.value"
:independent-corners="ctx.independentCorners.value"
:show-independent-corners="ctx.showIndependentCorners.value"
:corner-radius-value="ctx.cornerRadiusValue.value"
:opacity-percent="ctx.opacityPercent.value"
:blend-mode-value="ctx.blendModeValue.value"
:visibility-state="ctx.visibilityState.value"
:actions="actions"
/>

View file

@ -1 +1,6 @@
export { default as AppearanceControlsRoot } from '#vue/primitives/AppearanceControls/AppearanceControlsRoot.vue'
export type {
AppearanceControlsActions,
AppearanceControlsRootSlotProps,
AppearanceControlsRootSlots
} from '#vue/primitives/AppearanceControls/types'

View file

@ -0,0 +1,35 @@
import type { VNode } from 'vue'
import type { BlendMode, SceneNode } from '@open-pencil/scene-graph'
import type { CornerRadiusKey } from '#vue/controls/appearance/types'
import type { MixedValue } from '#vue/controls/node-props/use'
export interface AppearanceControlsActions {
updateProp(key: string, value: number): void
commitProp(key: string, value: number, previous: number): void
setBlendMode(value: BlendMode): void
toggleVisibility(): void
toggleIndependentCorners(): void
updateCornerProp(key: CornerRadiusKey, value: number): void
commitCornerProp(key: CornerRadiusKey, value: number, previous: number): void
}
export interface AppearanceControlsRootSlotProps {
node: SceneNode | null
isMulti: boolean
active: boolean
hasCornerRadius: boolean
independentCorners: MixedValue<boolean>
showIndependentCorners: boolean
cornerRadiusValue: MixedValue<number>
opacityPercent: MixedValue<number>
blendModeValue: MixedValue<BlendMode>
visibilityState: 'visible' | 'hidden' | 'mixed'
actions: AppearanceControlsActions
}
export interface AppearanceControlsRootSlots {
/** Complete selection-derived appearance state and mutation actions. */
default(props: AppearanceControlsRootSlotProps): VNode[]
}

View file

@ -3,6 +3,10 @@ import { computed, ref } from 'vue'
import { useI18n, useSelectionState, useEditorCommands } from '@open-pencil/vue'
import { COMPONENT_TYPES, nodeIcon } from '@/app/editor/icons'
import PanelHeader from '@/components/ui/panel/PanelHeader.vue'
import Tip from '@/components/ui/Tip.vue'
import VariablesDialog from './variables/VariablesDialog.vue'
import AppearanceSection from './properties/AppearanceSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
@ -25,9 +29,10 @@ const { getCommand } = useEditorCommands()
const goToMainComponent = getCommand('selection.goToMainComponent')
const detachInstance = getCommand('selection.detachInstance')
const isComponentType = computed(() => {
const t = node.value?.type
return t === 'COMPONENT' || t === 'COMPONENT_SET' || t === 'INSTANCE'
const type = node.value?.type
return type ? COMPONENT_TYPES.has(type) : false
})
const selectedIcon = computed(() => (node.value ? nodeIcon(node.value) : undefined))
const { panels } = useI18n()
</script>
@ -38,16 +43,17 @@ const { panels } = useI18n()
data-test-id="design-panel-multi"
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
>
<div
data-test-id="design-multi-header"
class="flex items-center gap-1.5 border-b border-border px-3 py-2"
>
<span class="text-[11px] text-muted">{{ panels.mixed }}</span>
<span class="text-xs font-semibold">{{
panels.layersCount({ count: String(multiCount) })
}}</span>
<SelectionActionsControl :show-boolean-operations="showBooleanOperations" />
</div>
<PanelHeader>
<template #icon>
<icon-lucide-layers-3 class="size-panel-icon" aria-hidden="true" />
</template>
<span role="heading" aria-level="2">
{{ panels.layersCount({ count: String(multiCount) }) }}
</span>
<template #actions>
<SelectionActionsControl :show-boolean-operations="showBooleanOperations" />
</template>
</PanelHeader>
<PositionSection />
<AppearanceSection />
<FillSection />
@ -62,16 +68,19 @@ const { panels } = useI18n()
data-test-id="design-panel-single"
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
>
<div
data-test-id="design-node-header"
class="flex items-center gap-1.5 border-b border-border px-3 py-2"
>
<span class="text-[11px]" :class="isComponentType ? 'text-component' : 'text-muted'">{{
node.type
}}</span>
<span class="text-xs font-semibold">{{ node.name }}</span>
<SelectionActionsControl />
</div>
<PanelHeader :component="isComponentType">
<template #icon>
<Tip :label="node.type">
<span role="img" :aria-label="node.type" class="contents">
<component :is="selectedIcon" class="size-panel-icon" />
</span>
</Tip>
</template>
<span role="heading" aria-level="2">{{ node.name }}</span>
<template #actions>
<SelectionActionsControl />
</template>
</PanelHeader>
<!-- Component actions -->
<div

View file

@ -1,233 +1,226 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { MIXED, useAppearance, useI18n } from '@open-pencil/vue'
import { AppearanceControlsRoot, MIXED, useI18n } from '@open-pencil/vue'
import NumberField from '@/components/inputs/NumberField.vue'
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
import PanelRail from '@/components/ui/panel/PanelRail.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import type { BlendMode } from '@open-pencil/scene-graph'
const { panels } = useI18n()
type BlendModeSelectValue = BlendMode | 'MIXED'
const blendModeOptions = computed<Array<{ value: BlendModeSelectValue; label: string }>>(() => {
const options: Array<{ value: BlendModeSelectValue; label: string }> = [
{ value: 'PASS_THROUGH', label: panels.value.blendModePassThrough },
{ value: 'NORMAL', label: panels.value.blendModeNormal },
{ value: 'DARKEN', label: panels.value.blendModeDarken },
{ value: 'MULTIPLY', label: panels.value.blendModeMultiply },
{ value: 'COLOR_BURN', label: panels.value.blendModeColorBurn },
{ value: 'LIGHTEN', label: panels.value.blendModeLighten },
{ value: 'SCREEN', label: panels.value.blendModeScreen },
{ value: 'COLOR_DODGE', label: panels.value.blendModeColorDodge },
{ value: 'OVERLAY', label: panels.value.blendModeOverlay },
{ value: 'SOFT_LIGHT', label: panels.value.blendModeSoftLight },
{ value: 'HARD_LIGHT', label: panels.value.blendModeHardLight },
{ value: 'DIFFERENCE', label: panels.value.blendModeDifference },
{ value: 'EXCLUSION', label: panels.value.blendModeExclusion },
{ value: 'HUE', label: panels.value.blendModeHue },
{ value: 'SATURATION', label: panels.value.blendModeSaturation },
{ value: 'COLOR', label: panels.value.blendModeColor },
{ value: 'LUMINOSITY', label: panels.value.blendModeLuminosity }
]
return blendModeValue.value === MIXED
? [{ value: 'MIXED', label: panels.value.mixed }, ...options]
: options
})
const {
node,
isMulti,
active,
hasCornerRadius,
independentCorners,
cornerRadiusValue,
opacityPercent,
blendModeValue,
visibilityState,
setBlendMode,
updateProp,
commitProp,
toggleVisibility,
toggleIndependentCorners,
updateCornerProp,
commitCornerProp
} = useAppearance()
const baseBlendModeOptions = computed<Array<{ value: BlendModeSelectValue; label: string }>>(() => [
{ value: 'PASS_THROUGH', label: panels.value.blendModePassThrough },
{ value: 'NORMAL', label: panels.value.blendModeNormal },
{ value: 'DARKEN', label: panels.value.blendModeDarken },
{ value: 'MULTIPLY', label: panels.value.blendModeMultiply },
{ value: 'COLOR_BURN', label: panels.value.blendModeColorBurn },
{ value: 'LIGHTEN', label: panels.value.blendModeLighten },
{ value: 'SCREEN', label: panels.value.blendModeScreen },
{ value: 'COLOR_DODGE', label: panels.value.blendModeColorDodge },
{ value: 'OVERLAY', label: panels.value.blendModeOverlay },
{ value: 'SOFT_LIGHT', label: panels.value.blendModeSoftLight },
{ value: 'HARD_LIGHT', label: panels.value.blendModeHardLight },
{ value: 'DIFFERENCE', label: panels.value.blendModeDifference },
{ value: 'EXCLUSION', label: panels.value.blendModeExclusion },
{ value: 'HUE', label: panels.value.blendModeHue },
{ value: 'SATURATION', label: panels.value.blendModeSaturation },
{ value: 'COLOR', label: panels.value.blendModeColor },
{ value: 'LUMINOSITY', label: panels.value.blendModeLuminosity }
])
const manualExpanded = ref<boolean | null>(null)
const showIndependentCorners = computed(() => {
if (manualExpanded.value !== null) return manualExpanded.value
if (independentCorners.value === true) return true
const n = node.value
if (!n) return false
return !(
n.topLeftRadius === n.topRightRadius &&
n.topLeftRadius === n.bottomRightRadius &&
n.topLeftRadius === n.bottomLeftRadius
)
})
function onToggleCorners() {
manualExpanded.value = !showIndependentCorners.value
toggleIndependentCorners()
function blendModeOptions(value: BlendMode | typeof MIXED) {
return value === MIXED
? [{ value: 'MIXED' as const, label: panels.value.mixed }, ...baseBlendModeOptions.value]
: baseBlendModeOptions.value
}
const blendModeSelectValue = computed<BlendModeSelectValue>({
get: () => (blendModeValue.value === MIXED ? 'MIXED' : blendModeValue.value),
set: (value) => {
if (value !== 'MIXED') setBlendMode(value)
}
})
</script>
<template>
<PanelSection v-if="active" :label="panels.appearance">
<template #actions>
<IconButton
:label="panels.toggleVisibility"
:active="visibilityState === 'hidden'"
data-test-id="appearance-visibility"
@click="toggleVisibility"
>
<icon-lucide-eye v-if="visibilityState === 'visible'" class="size-3.5" />
<icon-lucide-eye-off v-else-if="visibilityState === 'hidden'" class="size-3.5" />
<icon-lucide-eye v-else class="size-3.5 opacity-50" />
</IconButton>
</template>
<AppearanceControlsRoot
v-slot="{
node,
isMulti,
active,
hasCornerRadius,
independentCorners,
showIndependentCorners,
cornerRadiusValue,
opacityPercent,
blendModeValue,
visibilityState,
actions
}"
>
<PanelSection v-if="active" :label="panels.appearance">
<template #actions>
<IconButton
:label="panels.toggleVisibility"
:active="visibilityState === 'hidden'"
@click="actions.toggleVisibility"
>
<icon-lucide-eye v-if="visibilityState === 'visible'" class="size-3.5" />
<icon-lucide-eye-off v-else-if="visibilityState === 'hidden'" class="size-3.5" />
<icon-lucide-eye v-else class="size-3.5 opacity-50" />
</IconButton>
</template>
<div class="grid grid-cols-[minmax(0,3fr)_minmax(0,2fr)] gap-1.5">
<Tip :label="panels.blendMode">
<AppSelect
v-model="blendModeSelectValue"
class="w-full"
:label="panels.blendMode"
:options="blendModeOptions"
<PanelGrid columns="two">
<PanelFieldGroup :label="panels.blendMode">
<AppSelect
:model-value="blendModeValue === MIXED ? 'MIXED' : blendModeValue"
class="w-full"
:label="panels.blendMode"
:options="blendModeOptions(blendModeValue)"
@update:model-value="
(value: BlendModeSelectValue) => value !== 'MIXED' && actions.setBlendMode(value)
"
/>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.opacity">
<VariableNumberField
v-if="node && !isMulti"
suffix="%"
:aria-label="panels.opacity"
:model-value="opacityPercent"
:min="0"
:max="100"
:node-id="node.id"
binding-path="opacity"
@update:model-value="actions.updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => actions.commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</VariableNumberField>
<NumberField
v-else
suffix="%"
data-property="opacity"
:aria-label="panels.opacity"
:model-value="opacityPercent"
:min="0"
:max="100"
@update:model-value="actions.updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => actions.commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</NumberField>
</PanelFieldGroup>
</PanelGrid>
<PanelGrid
v-if="hasCornerRadius && !showIndependentCorners"
columns="fill-rail"
class="mt-panel"
>
<PanelFieldGroup :label="panels.radius">
<VariableNumberField
v-if="node && !isMulti"
:aria-label="panels.radius"
:model-value="cornerRadiusValue"
:min="0"
:node-id="node.id"
binding-path="cornerRadius"
@update:model-value="actions.updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => actions.commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</VariableNumberField>
<NumberField
v-else
data-property="cornerRadius"
:aria-label="panels.radius"
:model-value="cornerRadiusValue"
:min="0"
@update:model-value="actions.updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => actions.commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</NumberField>
</PanelFieldGroup>
<PanelRail>
<IconButton
:label="panels.independentCornerRadii"
size="md"
:active="independentCorners === true"
@click="actions.toggleIndependentCorners"
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</PanelRail>
</PanelGrid>
<PanelGrid
v-else-if="hasCornerRadius && !isMulti && node"
columns="two-rail"
class="mt-panel"
data-corner-grid
>
<VariableNumberField
label="TL"
:model-value="node.topLeftRadius"
:min="0"
:node-id="node.id"
binding-path="topLeftRadius"
@update:model-value="actions.updateCornerProp('topLeftRadius', $event)"
@commit="(v: number, p: number) => actions.commitCornerProp('topLeftRadius', v, p)"
/>
</Tip>
<Tip :label="panels.opacity">
<VariableNumberField
v-if="node"
suffix="%"
:model-value="opacityPercent"
:min="0"
:max="100"
:node-id="node.id"
binding-path="opacity"
@update:model-value="updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</VariableNumberField>
<NumberField
v-else
suffix="%"
:model-value="opacityPercent"
:min="0"
:max="100"
@update:model-value="updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</NumberField>
</Tip>
</div>
<div v-if="hasCornerRadius" class="mt-1.5 flex gap-1.5">
<Tip :label="panels.radius">
<VariableNumberField
v-if="!showIndependentCorners && node"
:model-value="cornerRadiusValue"
label="TR"
:model-value="node.topRightRadius"
:min="0"
:node-id="node.id"
binding-path="cornerRadius"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
binding-path="topRightRadius"
@update:model-value="actions.updateCornerProp('topRightRadius', $event)"
@commit="(v: number, p: number) => actions.commitCornerProp('topRightRadius', v, p)"
/>
<PanelRail>
<IconButton
:label="panels.independentCornerRadii"
size="md"
active
@click="actions.toggleIndependentCorners"
>
<icon-lucide-square-round-corner class="size-3" />
</template>
</VariableNumberField>
<NumberField
v-else-if="!showIndependentCorners"
:model-value="cornerRadiusValue"
</IconButton>
</PanelRail>
<VariableNumberField
label="BL"
:model-value="node.bottomLeftRadius"
:min="0"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</NumberField>
</Tip>
<IconButton
:label="panels.independentCornerRadii"
size="md"
class="size-[26px] shrink-0"
:active="showIndependentCorners"
data-test-id="independent-corners-toggle"
@click="onToggleCorners"
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</div>
<div
v-if="hasCornerRadius && showIndependentCorners && !isMulti && node"
data-test-id="independent-corners-grid"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<VariableNumberField
data-test-id="corner-tl-input"
label="TL"
:model-value="node.topLeftRadius"
:min="0"
:node-id="node.id"
binding-path="topLeftRadius"
@update:model-value="updateCornerProp('topLeftRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('topLeftRadius', v, p)"
/>
<VariableNumberField
data-test-id="corner-tr-input"
label="TR"
:model-value="node.topRightRadius"
:min="0"
:node-id="node.id"
binding-path="topRightRadius"
@update:model-value="updateCornerProp('topRightRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)"
/>
<VariableNumberField
data-test-id="corner-bl-input"
label="BL"
:model-value="node.bottomLeftRadius"
:min="0"
:node-id="node.id"
binding-path="bottomLeftRadius"
@update:model-value="updateCornerProp('bottomLeftRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)"
/>
<VariableNumberField
data-test-id="corner-br-input"
label="BR"
:model-value="node.bottomRightRadius"
:min="0"
:node-id="node.id"
binding-path="bottomRightRadius"
@update:model-value="updateCornerProp('bottomRightRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)"
/>
</div>
</PanelSection>
:node-id="node.id"
binding-path="bottomLeftRadius"
@update:model-value="actions.updateCornerProp('bottomLeftRadius', $event)"
@commit="(v: number, p: number) => actions.commitCornerProp('bottomLeftRadius', v, p)"
/>
<VariableNumberField
label="BR"
:model-value="node.bottomRightRadius"
:min="0"
:node-id="node.id"
binding-path="bottomRightRadius"
@update:model-value="actions.updateCornerProp('bottomRightRadius', $event)"
@commit="(v: number, p: number) => actions.commitCornerProp('bottomRightRadius', v, p)"
/>
<PanelRail />
</PanelGrid>
</PanelSection>
</AppearanceControlsRoot>
</template>

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import { PositionControlsRoot, useI18n } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import NumberField from '@/components/inputs/NumberField.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelRow from '@/components/ui/panel/PanelRow.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import { useEditorStore } from '@/app/editor/active-store'
import { PositionControlsRoot, useI18n } from '@open-pencil/vue'
const { panels } = useI18n()
const store = useEditorStore()
@ -15,8 +16,8 @@ function handleAlign(
axis: 'horizontal' | 'vertical',
pos: 'min' | 'center' | 'max'
) {
const es = store.state.nodeEditState
if (es && es.selectedVertexIndices.size >= 2) {
const editState = store.state.nodeEditState
if (editState && editState.selectedVertexIndices.size >= 2) {
store.nodeEditAlignVertices(axis, pos)
} else {
nodeAlign(axis, pos)
@ -28,13 +29,12 @@ function handleAlign(
<PositionControlsRoot
v-slot="{ active, isMulti, xValue, yValue, wValue, hValue, rotationValue, actions }"
>
<PanelSection v-if="active" :label="panels.position" data-test-id="position-section">
<PanelRow class="mb-1.5 gap-2">
<PanelRow gap="sm">
<PanelSection v-if="active" :label="panels.position">
<div role="toolbar" :aria-label="panels.position" class="mb-panel flex justify-between">
<div class="flex gap-0.5">
<IconButton
:label="panels.alignLeft"
size="md"
data-test-id="position-align-left"
@click="handleAlign(actions.align, 'horizontal', 'min')"
>
<icon-lucide-align-start-vertical class="size-3.5" />
@ -42,7 +42,6 @@ function handleAlign(
<IconButton
:label="panels.alignCenterHorizontally"
size="md"
data-test-id="position-align-center-h"
@click="handleAlign(actions.align, 'horizontal', 'center')"
>
<icon-lucide-align-center-vertical class="size-3.5" />
@ -50,17 +49,15 @@ function handleAlign(
<IconButton
:label="panels.alignRight"
size="md"
data-test-id="position-align-right"
@click="handleAlign(actions.align, 'horizontal', 'max')"
>
<icon-lucide-align-end-vertical class="size-3.5" />
</IconButton>
</PanelRow>
<PanelRow gap="sm">
</div>
<div class="flex gap-0.5">
<IconButton
:label="panels.alignTop"
size="md"
data-test-id="position-align-top"
@click="handleAlign(actions.align, 'vertical', 'min')"
>
<icon-lucide-align-start-horizontal class="size-3.5" />
@ -68,7 +65,6 @@ function handleAlign(
<IconButton
:label="panels.alignCenterVertically"
size="md"
data-test-id="position-align-center-v"
@click="handleAlign(actions.align, 'vertical', 'center')"
>
<icon-lucide-align-center-horizontal class="size-3.5" />
@ -76,19 +72,19 @@ function handleAlign(
<IconButton
:label="panels.alignBottom"
size="md"
data-test-id="position-align-bottom"
@click="handleAlign(actions.align, 'vertical', 'max')"
>
<icon-lucide-align-end-horizontal class="size-3.5" />
</IconButton>
</PanelRow>
</PanelRow>
</div>
</div>
<PanelRow cols="two">
<PanelGrid columns="two">
<Tip :label="panels.xAxis">
<NumberField
icon="X"
data-property="x"
:aria-label="panels.xAxis"
:model-value="xValue"
@update:model-value="actions.updateProp('x', $event)"
@commit="(v: number, p: number) => actions.commitProp('x', v, p)"
@ -98,18 +94,20 @@ function handleAlign(
<NumberField
icon="Y"
data-property="y"
:aria-label="panels.yAxis"
:model-value="yValue"
@update:model-value="actions.updateProp('y', $event)"
@commit="(v: number, p: number) => actions.commitProp('y', v, p)"
/>
</Tip>
</PanelRow>
</PanelGrid>
<PanelRow v-if="isMulti" cols="two" class="mt-1.5">
<PanelGrid v-if="isMulti" columns="two" class="mt-panel">
<Tip :label="panels.width">
<NumberField
icon="W"
data-property="width"
:aria-label="panels.width"
:model-value="wValue"
:min="1"
@update:model-value="actions.updateProp('width', $event)"
@ -120,18 +118,18 @@ function handleAlign(
<NumberField
icon="H"
data-property="height"
:aria-label="panels.height"
:model-value="hValue"
:min="1"
@update:model-value="actions.updateProp('height', $event)"
@commit="(v: number, p: number) => actions.commitProp('height', v, p)"
/>
</Tip>
</PanelRow>
</PanelGrid>
<PanelRow cols="fill" class="mt-1.5">
<div class="mt-panel grid grid-cols-[minmax(0,1fr)_repeat(3,var(--spacing-control))] gap-0.5">
<Tip :label="panels.rotation">
<NumberField
class="flex-1"
suffix="°"
data-property="rotation"
:aria-label="panels.rotation"
@ -146,34 +144,16 @@ function handleAlign(
</template>
</NumberField>
</Tip>
<IconButton
:label="panels.flipHorizontal"
size="md"
class="shrink-0"
data-test-id="position-flip-horizontal"
@click="actions.flip('horizontal')"
>
<IconButton :label="panels.flipHorizontal" size="md" @click="actions.flip('horizontal')">
<icon-lucide-flip-horizontal-2 class="size-3.5" />
</IconButton>
<IconButton
:label="panels.flipVertical"
size="md"
class="shrink-0"
data-test-id="position-flip-vertical"
@click="actions.flip('vertical')"
>
<IconButton :label="panels.flipVertical" size="md" @click="actions.flip('vertical')">
<icon-lucide-flip-vertical-2 class="size-3.5" />
</IconButton>
<IconButton
:label="panels.rotate90"
size="md"
class="shrink-0"
data-test-id="position-rotate-90"
@click="actions.rotate(90)"
>
<IconButton :label="panels.rotate90" size="md" @click="actions.rotate(90)">
<icon-lucide-rotate-cw-square class="size-3.5" />
</IconButton>
</PanelRow>
</div>
</PanelSection>
</PositionControlsRoot>
</template>

View file

@ -2,9 +2,11 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { TooltipProvider } from 'reka-ui'
import { expect, userEvent, within } from 'storybook/test'
import { ref } from 'vue'
import MoreIcon from '~icons/lucide/ellipsis'
import EyeIcon from '~icons/lucide/eye'
import LinkIcon from '~icons/lucide/link'
import RotateIcon from '~icons/lucide/rotate-ccw'
import SquareIcon from '~icons/lucide/square'
import AppInput from '@/components/ui/AppInput.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
@ -13,6 +15,7 @@ import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import PanelFieldGroup from './PanelFieldGroup.vue'
import PanelGrid from './PanelGrid.vue'
import PanelHeader from './PanelHeader.vue'
import PanelRail from './PanelRail.vue'
import PanelSection from './PanelSection.vue'
@ -40,12 +43,15 @@ export const StateMatrix: Story = {
EyeIcon,
IconButton,
LinkIcon,
MoreIcon,
PanelFieldGroup,
PanelGrid,
PanelHeader,
PanelRail,
PanelSection,
RotateIcon,
SegmentedControl,
SquareIcon,
TooltipProvider
},
setup() {
@ -80,10 +86,13 @@ export const StateMatrix: Story = {
template: `
<TooltipProvider>
<div class="w-[320px] overflow-hidden rounded-lg border border-border bg-panel shadow-xl">
<header class="border-b border-border px-panel-x py-panel-y">
<p class="text-xs font-semibold">Properties</p>
<p class="mt-1 text-[11px] text-muted">Panel foundation states</p>
</header>
<PanelHeader>
<template #icon><SquareIcon class="size-panel-icon" /></template>
<span role="heading" aria-level="2">Rectangle</span>
<template #actions>
<IconButton label="Selection actions"><MoreIcon class="size-panel-icon" /></IconButton>
</template>
</PanelHeader>
<PanelSection label="Layout">
<template #actions>

View file

@ -0,0 +1,47 @@
<script lang="ts">
import type { VNode } from 'vue'
import type { ClassValue } from 'tailwind-variants'
import type { ComponentUI } from '@/components/ui/types'
import type { PanelHeaderTheme } from '@/theme/panel/header'
export interface PanelHeaderProps {
component?: boolean
class?: ClassValue
ui?: ComponentUI<PanelHeaderTheme>
}
export interface PanelHeaderSlots {
icon?(): VNode[]
default(): VNode[]
actions?(): VNode[]
}
</script>
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import theme from '@/theme/panel/header'
const { component = false, class: className, ui } = defineProps<PanelHeaderProps>()
const slots = defineSlots<PanelHeaderSlots>()
const styles = tv(theme)({ component })
</script>
<template>
<header
data-slot="root"
:data-component="component ? '' : undefined"
:class="styles.root({ class: [ui?.root, className] })"
>
<div data-slot="icon" :class="styles.icon({ class: ui?.icon })">
<slot name="icon" />
</div>
<div data-slot="title" :class="styles.title({ class: ui?.title })">
<slot />
</div>
<div v-if="slots.actions" data-slot="actions" :class="styles.actions({ class: ui?.actions })">
<slot name="actions" />
</div>
</header>
</template>

View file

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

24
src/theme/panel/header.ts Normal file
View file

@ -0,0 +1,24 @@
const panelHeaderTheme = {
slots: {
root: 'grid min-w-0 grid-cols-[var(--spacing-panel-icon)_minmax(0,1fr)_auto] items-center gap-panel border-b border-border px-panel-x py-panel-y text-surface',
icon: 'flex size-panel-icon items-center justify-center text-muted',
title: 'min-w-0 truncate text-xs font-semibold text-surface',
actions:
'flex min-w-0 items-center justify-end gap-0.5 [&_[data-slot=icon-button]]:size-control [&_[data-slot=icon-button]]:rounded-panel'
},
variants: {
component: {
true: {
icon: 'text-component',
title: 'text-component'
},
false: {}
}
},
defaultVariants: {
component: false
}
}
export type PanelHeaderTheme = typeof panelHeaderTheme
export default panelHeaderTheme

View file

@ -43,8 +43,9 @@ async function selectDemoCard(page: Parameters<typeof test>[0]['page'], canvas:
})
await canvas.waitForRender()
await expect(page.getByTestId('design-panel-single')).toBeVisible()
await expect(page.getByTestId('design-node-header')).toContainText('Card')
const designPanel = page.getByTestId('design-panel-single')
await expect(designPanel).toBeVisible()
await expect(designPanel.getByRole('heading', { name: 'Card' })).toBeVisible()
}
async function getSelectedFill(page: Parameters<typeof test>[0]['page']) {

View file

@ -52,9 +52,8 @@ test('create component from selection (⌘⌥K)', async () => {
componentId = selectedId
})
test('component shows purple label in design panel', async () => {
const header = editor.page.getByTestId('design-node-header')
await expect(header).toContainText('COMPONENT')
test('component shows its type icon in the design panel', async () => {
await expect(editor.page.getByRole('img', { name: 'COMPONENT' })).toBeVisible()
})
test('component visible in layers panel', async () => {
@ -103,8 +102,7 @@ test('instance shows INSTANCE type in design panel', async () => {
}, instance.id)
await editor.canvas.waitForRender()
const header = editor.page.getByTestId('design-node-header')
await expect(header).toContainText('INSTANCE')
await expect(editor.page.getByRole('img', { name: 'INSTANCE' })).toBeVisible()
})
test('instance has "Go to Main Component" button', async () => {

View file

@ -8,10 +8,6 @@ function designPanel() {
return editor.page.getByTestId('design-panel-single')
}
function nodeHeader() {
return editor.page.getByTestId('design-node-header')
}
function fillSection() {
return editor.page.getByTestId('fill-section')
}
@ -21,7 +17,7 @@ function strokeSection() {
}
function positionSection() {
return editor.page.getByTestId('position-section')
return propertySection(editor.page, 'Position')
}
function effectsSection() {
@ -73,8 +69,9 @@ test('selecting a rectangle shows design panel with type and name', async () =>
await editor.canvas.waitForRender()
await expect(designPanel()).toBeVisible()
await expect(nodeHeader()).toContainText('RECTANGLE')
await expect(nodeHeader()).toContainText('Rectangle')
await expect(designPanel().getByRole('img', { name: 'RECTANGLE' })).toBeVisible()
await expect(designPanel().getByRole('heading', { name: 'Rectangle' })).toBeVisible()
await expect(designPanel()).toHaveScreenshot('design-panel-position-appearance.png')
})
test('position section shows X, Y, rotation inputs', async () => {
@ -246,7 +243,9 @@ test('mask action toggles mask section and mask type control', async () => {
})
test('visibility toggle in appearance section works', async () => {
const visBtn = editor.page.getByTestId('appearance-visibility')
const visBtn = propertySection(editor.page, 'Appearance').getByRole('button', {
name: 'Toggle visibility'
})
await expect(visBtn).toBeVisible()
const id = await getSelectedId()
@ -393,10 +392,10 @@ test('multi-select shows mixed header and boolean operations', async () => {
await editor.canvas.selectAll()
await editor.canvas.waitForRender()
const multiHeader = editor.page.getByTestId('design-multi-header')
const multiHeader = editor.page
.getByTestId('design-panel-multi')
.getByRole('heading', { name: /layers/ })
await expect(multiHeader).toBeVisible()
await expect(multiHeader).toContainText('Mixed')
await expect(multiHeader).toContainText('layers')
const booleanOperations = editor.page.getByTestId('boolean-operations-trigger')
await booleanOperations.hover()

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -1,6 +1,7 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertySection } from '#tests/helpers/properties'
let page: Page
let canvas: CanvasHelper
@ -48,14 +49,16 @@ test('independent corners toggle shows per-corner inputs', async () => {
expect(flags?.type).toBe('FRAME')
expect(flags?.independentCorners).toBe(false)
const toggle = page.getByTestId('independent-corners-toggle')
const toggle = propertySection(page, 'Appearance').getByRole('button', {
name: 'Independent corner radii'
})
await expect(toggle).toBeVisible()
await toggle.click()
await canvas.waitForRender()
expect((await getSelectedNodeFlags())?.independentCorners).toBe(true)
const grid = page.getByTestId('independent-corners-grid')
const grid = page.locator('[data-corner-grid]')
await expect(grid).toBeVisible()
const cornerInputs = grid.getByRole('spinbutton')
expect(await cornerInputs.count()).toBe(4)
@ -65,6 +68,34 @@ test('independent corners toggle shows per-corner inputs', async () => {
await expect(grid).not.toBeVisible()
})
test('multi-selection independent corners toggle is one undo step', async () => {
await canvas.clearCanvas()
await drawFrame(80, 80, 100, 70)
await drawFrame(240, 80, 100, 70)
await canvas.pressKey('Meta+a')
await canvas.waitForRender()
const independentStates = () =>
page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return [...store.state.selectedIds].map(
(id) => store.graph.getNode(id)?.independentCorners ?? null
)
})
const toggle = propertySection(page, 'Appearance').getByRole('button', {
name: 'Independent corner radii'
})
await toggle.click()
await canvas.waitForRender()
expect(await independentStates()).toEqual([true, true])
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
expect(await independentStates()).toEqual([false, false])
})
test('stroke sides toggle shows per-side weight inputs', async () => {
await drawFrame(300, 50, 120, 80)
await canvas.waitForRender()

View file

@ -11,7 +11,7 @@ function xField() {
async function editField(field: ReturnType<typeof xField>) {
await field.click()
return field.getByRole('spinbutton', { name: 'X' })
return field.getByRole('spinbutton', { name: 'X Axis' })
}
async function numericFieldValue(field: ReturnType<typeof xField>): Promise<number> {
@ -30,7 +30,7 @@ test('NumberField commits arithmetic and relative expressions', async () => {
let input = await editField(field)
await expect(field).not.toHaveAttribute('role')
await expect(input).toHaveAttribute('role', 'spinbutton')
await expect(input).toHaveAttribute('aria-label', 'X')
await expect(input).toHaveAttribute('aria-label', 'X Axis')
await expect(field.getByRole('spinbutton')).toHaveCount(1)
await input.fill('*2')
await input.press('Enter')

View file

@ -41,7 +41,7 @@ test('corner radius uniform sets cornerRadius', async () => {
const scrubContainer = propertyField(editor.page, 'cornerRadius')
await scrubContainer.click()
await editor.canvas.waitForRender()
const input = scrubContainer.getByRole('spinbutton', { name: 'cornerRadius' })
const input = scrubContainer.getByRole('spinbutton', { name: 'Radius' })
await input.fill('12')
await input.press('Enter')
await editor.canvas.waitForRender()
@ -52,13 +52,15 @@ test('corner radius uniform sets cornerRadius', async () => {
})
test('independent corners toggle shows four corner inputs', async () => {
await editor.page.getByTestId('independent-corners-toggle').click()
await propertySection(editor.page, 'Appearance')
.getByRole('button', { name: 'Independent corner radii' })
.click()
await editor.canvas.waitForRender()
await expect(editor.page.getByTestId('corner-tl-input')).toBeVisible()
await expect(editor.page.getByTestId('corner-tr-input')).toBeVisible()
await expect(editor.page.getByTestId('corner-br-input')).toBeVisible()
await expect(editor.page.getByTestId('corner-bl-input')).toBeVisible()
await expect(propertyField(editor.page, 'topLeftRadius')).toBeVisible()
await expect(propertyField(editor.page, 'topRightRadius')).toBeVisible()
await expect(propertyField(editor.page, 'bottomRightRadius')).toBeVisible()
await expect(propertyField(editor.page, 'bottomLeftRadius')).toBeVisible()
editor.canvas.assertNoErrors()
})
@ -246,7 +248,7 @@ test('bound NumberField detach edit is one undo step', async () => {
})
await field.click({ position: { x: 40, y: 13 } })
const input = field.getByRole('spinbutton', { name: 'cornerRadius' })
const input = field.getByRole('spinbutton', { name: 'Radius' })
await input.press('Tab')
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
await editor.canvas.pressKey('Meta+z')
@ -285,7 +287,7 @@ test('alignment buttons align nodes to same X', async () => {
await editor.canvas.pressKey('Meta+a')
await editor.canvas.waitForRender()
await editor.page.getByTestId('position-align-left').click()
await propertySection(editor.page, 'Position').getByRole('button', { name: 'Align left' }).click()
await editor.canvas.waitForRender()
const children = await getPageChildren(editor.page)
@ -298,7 +300,9 @@ test('flip horizontal sets flipX', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
await editor.page.getByTestId('position-flip-horizontal').click()
await propertySection(editor.page, 'Position')
.getByRole('button', { name: 'Flip horizontal' })
.click()
await editor.canvas.waitForRender()
const node = await getSelectedNode(editor.page)

View file

@ -1,5 +1,6 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { propertySection } from '#tests/helpers/properties'
import { getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup()
@ -92,7 +93,9 @@ test('multi-selection list add is one undo step', async () => {
})
test('appearance visibility supports repeat click and undo redo in one step', async () => {
const visibilityButton = editor.page.getByTestId('appearance-visibility')
const visibilityButton = propertySection(editor.page, 'Appearance').getByRole('button', {
name: 'Toggle visibility'
})
await expect(visibilityButton).toBeVisible()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').visible).toBe(true)

View file

@ -5,7 +5,7 @@ const editor = useEditorSetupWithClear('/?test')
test('draw section in full editor without browser errors', async () => {
await editor.canvas.drawSection(100, 100, 240, 160)
await expect(editor.page.getByTestId('design-node-header')).toContainText('SECTION')
await expect(editor.page.getByRole('img', { name: 'SECTION' })).toBeVisible()
await expect
.poll(async () => {
return editor.page.evaluate(() => {

View file

@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test'
import { computed, ref } from 'vue'
import type { SceneNode } from '@open-pencil/scene-graph'
import type { MixedValue } from '@open-pencil/vue'
import { createAppearanceState } from '#vue/controls/appearance/helpers'
import { createRect, firstPageId, makeSceneGraph } from '#tests/helpers/scene'
function appearanceState(node: SceneNode, multi = false) {
const selected = ref<SceneNode | null>(node)
const nodes = ref<SceneNode[]>([node])
const isMulti = ref(multi)
function merged<K extends keyof SceneNode>(key: K): MixedValue<SceneNode[K]> {
const current = selected.value
if (!current) throw new Error('Expected selected node')
return current[key]
}
return createAppearanceState({
node: computed(() => selected.value),
nodes: computed(() => nodes.value),
isMulti: computed(() => isMulti.value),
merged
})
}
function rectangle() {
const graph = makeSceneGraph()
return createRect(graph, firstPageId(graph))
}
describe('appearance control state', () => {
test('keeps equal uniform corners collapsed', () => {
const state = appearanceState(rectangle())
expect(state.showIndependentCorners.value).toBe(false)
})
test('expands corners when the explicit independent flag is set', () => {
const node = rectangle()
node.independentCorners = true
const state = appearanceState(node)
expect(state.showIndependentCorners.value).toBe(true)
})
test('expands imported unequal corners when the explicit flag is stale', () => {
const node = rectangle()
node.independentCorners = false
node.topLeftRadius = 4
node.topRightRadius = 12
const state = appearanceState(node)
expect(state.showIndependentCorners.value).toBe(true)
})
test('leaves the per-corner editor collapsed for multi-selection', () => {
const node = rectangle()
node.independentCorners = true
const state = appearanceState(node, true)
expect(state.showIndependentCorners.value).toBe(false)
})
})