diff --git a/CHANGELOG.md b/CHANGELOG.md index c3db85e18..953ff17e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/docs/programmable/sdk/api/components/appearance-controls-root.data.ts b/packages/docs/programmable/sdk/api/components/appearance-controls-root.data.ts new file mode 100644 index 000000000..69fc4cd0b --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/appearance-controls-root.data.ts @@ -0,0 +1,5 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +export default defineComponentMetaLoader([ + 'packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue' +]) diff --git a/packages/docs/programmable/sdk/api/components/appearance-controls-root.md b/packages/docs/programmable/sdk/api/components/appearance-controls-root.md index 205807c1c..b6e676f0c 100644 --- a/packages/docs/programmable/sdk/api/components/appearance-controls-root.md +++ b/packages/docs/programmable/sdk/api/components/appearance-controls-root.md @@ -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. --- + + # 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. + + ## Related APIs diff --git a/packages/docs/programmable/sdk/api/composables/use-appearance.md b/packages/docs/programmable/sdk/api/composables/use-appearance.md index 974d20f8d..18287fb25 100644 --- a/packages/docs/programmable/sdk/api/composables/use-appearance.md +++ b/packages/docs/programmable/sdk/api/composables/use-appearance.md @@ -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](../) diff --git a/packages/vue/README.md b/packages/vue/README.md index b6eae7278..3f14923b8 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -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 diff --git a/packages/vue/src/controls/appearance/helpers.ts b/packages/vue/src/controls/appearance/helpers.ts index 6959c0de3..7ce77ddde 100644 --- a/packages/vue/src/controls/appearance/helpers.ts +++ b/packages/vue/src/controls/appearance/helpers.ts @@ -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, - 'Uniform corner radius' - ) - } else { - editor.updateNodeWithUndo( - n.id, - { - independentCorners: true, - topLeftRadius: n.cornerRadius, - topRightRadius: n.cornerRadius, - bottomRightRadius: n.cornerRadius, - bottomLeftRadius: n.cornerRadius - } as Partial, - '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, + '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, + '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, `Change ${key}`) - } + editor.undo.runBatch(`Change ${key}`, () => { + for (const n of nodes.value) { + editor.commitNodeUpdate(n.id, { [key]: previous } as Partial, `Change ${key}`) + } + }) } else { const n = node.value if (n) { diff --git a/packages/vue/src/controls/appearance/types.ts b/packages/vue/src/controls/appearance/types.ts new file mode 100644 index 000000000..29ee745d5 --- /dev/null +++ b/packages/vue/src/controls/appearance/types.ts @@ -0,0 +1,5 @@ +export type CornerRadiusKey = + | 'topLeftRadius' + | 'topRightRadius' + | 'bottomRightRadius' + | 'bottomLeftRadius' diff --git a/packages/vue/src/controls/node-props/helpers.ts b/packages/vue/src/controls/node-props/helpers.ts index 29e1b18e1..415bebf0f 100644 --- a/packages/vue/src/controls/node-props/helpers.ts +++ b/packages/vue/src/controls/node-props/helpers.ts @@ -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(key: K): MixedValue { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index be7177032..7df4233f5 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -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' diff --git a/packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue b/packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue index 4fe8bff7d..2122502d5 100644 --- a/packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue +++ b/packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue @@ -1,10 +1,13 @@ @@ -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" > -
- {{ panels.mixed }} - {{ - panels.layersCount({ count: String(multiCount) }) - }} - -
+ + + + {{ panels.layersCount({ count: String(multiCount) }) }} + + + @@ -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" > -
- {{ - node.type - }} - {{ node.name }} - -
+ + + {{ node.name }} + +
-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>(() => { - 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>(() => [ + { 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(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({ - get: () => (blendModeValue.value === MIXED ? 'MIXED' : blendModeValue.value), - set: (value) => { - if (value !== 'MIXED') setBlendMode(value) - } -}) diff --git a/src/components/properties/PositionSection.vue b/src/components/properties/PositionSection.vue index c8b9bcea5..dbea41fe6 100644 --- a/src/components/properties/PositionSection.vue +++ b/src/components/properties/PositionSection.vue @@ -1,11 +1,12 @@ + + + + diff --git a/src/components/ui/panel/index.ts b/src/components/ui/panel/index.ts index e9b7fafcd..16988854e 100644 --- a/src/components/ui/panel/index.ts +++ b/src/components/ui/panel/index.ts @@ -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' diff --git a/src/theme/panel/header.ts b/src/theme/panel/header.ts new file mode 100644 index 000000000..ffdc63e5c --- /dev/null +++ b/src/theme/panel/header.ts @@ -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 diff --git a/tests/e2e/color-picker/demo-card.spec.ts b/tests/e2e/color-picker/demo-card.spec.ts index af759f849..d0ac8fc2a 100644 --- a/tests/e2e/color-picker/demo-card.spec.ts +++ b/tests/e2e/color-picker/demo-card.spec.ts @@ -43,8 +43,9 @@ async function selectDemoCard(page: Parameters[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[0]['page']) { diff --git a/tests/e2e/components.spec.ts b/tests/e2e/components.spec.ts index 06fe8ac27..99fa7c56d 100644 --- a/tests/e2e/components.spec.ts +++ b/tests/e2e/components.spec.ts @@ -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 () => { diff --git a/tests/e2e/design/panel.spec.ts b/tests/e2e/design/panel.spec.ts index 93ae2b39c..02ffe7f46 100644 --- a/tests/e2e/design/panel.spec.ts +++ b/tests/e2e/design/panel.spec.ts @@ -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() diff --git a/tests/e2e/design/panel.spec.ts-snapshots/design-panel-position-appearance-openpencil-darwin.png b/tests/e2e/design/panel.spec.ts-snapshots/design-panel-position-appearance-openpencil-darwin.png new file mode 100644 index 000000000..0c0cf5d08 Binary files /dev/null and b/tests/e2e/design/panel.spec.ts-snapshots/design-panel-position-appearance-openpencil-darwin.png differ diff --git a/tests/e2e/properties/corner-stroke-toggles.spec.ts b/tests/e2e/properties/corner-stroke-toggles.spec.ts index c6da11128..70aed46bb 100644 --- a/tests/e2e/properties/corner-stroke-toggles.spec.ts +++ b/tests/e2e/properties/corner-stroke-toggles.spec.ts @@ -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() diff --git a/tests/e2e/properties/number-field.spec.ts b/tests/e2e/properties/number-field.spec.ts index a70448e19..2f0caa28f 100644 --- a/tests/e2e/properties/number-field.spec.ts +++ b/tests/e2e/properties/number-field.spec.ts @@ -11,7 +11,7 @@ function xField() { async function editField(field: ReturnType) { await field.click() - return field.getByRole('spinbutton', { name: 'X' }) + return field.getByRole('spinbutton', { name: 'X Axis' }) } async function numericFieldValue(field: ReturnType): Promise { @@ -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') diff --git a/tests/e2e/properties/panel.spec.ts b/tests/e2e/properties/panel.spec.ts index 80fae4816..3e915bacf 100644 --- a/tests/e2e/properties/panel.spec.ts +++ b/tests/e2e/properties/panel.spec.ts @@ -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) diff --git a/tests/e2e/properties/visibility.spec.ts b/tests/e2e/properties/visibility.spec.ts index a7bd98fb1..2a2a2b8eb 100644 --- a/tests/e2e/properties/visibility.spec.ts +++ b/tests/e2e/properties/visibility.spec.ts @@ -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) diff --git a/tests/e2e/tools/section.spec.ts b/tests/e2e/tools/section.spec.ts index c80ef6ed6..2dfad9a85 100644 --- a/tests/e2e/tools/section.spec.ts +++ b/tests/e2e/tools/section.spec.ts @@ -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(() => { diff --git a/tests/engine/vue/controls/appearance.test.ts b/tests/engine/vue/controls/appearance.test.ts new file mode 100644 index 000000000..c7f89bb85 --- /dev/null +++ b/tests/engine/vue/controls/appearance.test.ts @@ -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(node) + const nodes = ref([node]) + const isMulti = ref(multi) + + function merged(key: K): MixedValue { + 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) + }) +})