From c1d7fc80b056e8abbfcfc695e5010032c2b098fa Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 27 Mar 2026 17:06:33 +0300 Subject: [PATCH] Fix property panel visibility toggles, independent corners/stroke sides, extract shared ColorStyleRow - Fix fill/stroke/effect/appearance visibility eye toggle (stale clone reads) - Fix independent corner radii toggle not showing 4 inputs - Fix stroke sides toggle: replace dropdown with direct toggle like corners - Copy uniform weight to all sides when expanding stroke sides - Batch multi-selection visibility toggles into single undo entry - Extract ColorStyleRow shared by FillSection and StrokeSection - Replace useFillVariableBinding/useStrokeVariableBinding with useColorVariableBinding(kind) - AppearanceSection uses useAppearance() directly instead of slot wrapper - Add sceneVersion dependency to useNodeProps and PropertyListRoot computeds - Add E2E tests for visibility toggles and corner/stroke side toggles --- .../programmable/sdk/api/advanced/index.md | 2 +- .../advanced/use-color-variable-binding.md | 25 ++ .../api/advanced/use-fill-variable-binding.md | 15 - .../sdk/api/composables/use-fill-controls.md | 8 +- packages/vue/README.md | 2 +- .../vue/src/PropertyList/PropertyListRoot.vue | 32 +- packages/vue/src/controls/useAppearance.ts | 21 +- ...eBinding.ts => useColorVariableBinding.ts} | 28 +- packages/vue/src/controls/useFillControls.ts | 4 +- packages/vue/src/controls/useNodeProps.ts | 10 +- packages/vue/src/index.ts | 2 +- src/components/FillPicker.vue | 7 + .../properties/AppearanceSection.vue | 319 +++++++++--------- src/components/properties/ColorStyleRow.vue | 149 ++++++++ src/components/properties/EffectsSection.vue | 5 +- src/components/properties/FillSection.vue | 122 +------ src/components/properties/StrokeSection.vue | 152 +++------ tests/e2e/corner-stroke-toggles.spec.ts | 92 +++++ tests/e2e/design-panel.spec.ts | 74 +++- tests/e2e/property-visibility.spec.ts | 100 ++++++ 20 files changed, 738 insertions(+), 431 deletions(-) create mode 100644 packages/docs/programmable/sdk/api/advanced/use-color-variable-binding.md delete mode 100644 packages/docs/programmable/sdk/api/advanced/use-fill-variable-binding.md rename packages/vue/src/controls/{useFillVariableBinding.ts => useColorVariableBinding.ts} (59%) create mode 100644 src/components/properties/ColorStyleRow.vue create mode 100644 tests/e2e/corner-stroke-toggles.spec.ts create mode 100644 tests/e2e/property-visibility.spec.ts diff --git a/packages/docs/programmable/sdk/api/advanced/index.md b/packages/docs/programmable/sdk/api/advanced/index.md index 79888b81f..a20664bb6 100644 --- a/packages/docs/programmable/sdk/api/advanced/index.md +++ b/packages/docs/programmable/sdk/api/advanced/index.md @@ -15,7 +15,7 @@ These APIs are public, but they are more specialized than the main component and ## Picker and editor internals -- [useFillVariableBinding](./use-fill-variable-binding) +- [useColorVariableBinding](./use-color-variable-binding) - [useFillPicker](./use-fill-picker) - [useGradientStops](./use-gradient-stops) - [useFontPicker](./use-font-picker) diff --git a/packages/docs/programmable/sdk/api/advanced/use-color-variable-binding.md b/packages/docs/programmable/sdk/api/advanced/use-color-variable-binding.md new file mode 100644 index 000000000..b8156d138 --- /dev/null +++ b/packages/docs/programmable/sdk/api/advanced/use-color-variable-binding.md @@ -0,0 +1,25 @@ +--- +title: useColorVariableBinding +description: Variable-binding helper for fill and stroke color editors. +--- + +# useColorVariableBinding + +`useColorVariableBinding(kind)` exposes search, binding, and unbinding helpers for color variables used by fill and stroke editors. + +Use it when building color UIs that need to connect fills or strokes to design variables. + +## Usage + +```ts +import { useColorVariableBinding } from '@open-pencil/vue' + +const fillBinding = useColorVariableBinding('fills') +const strokeBinding = useColorVariableBinding('strokes') +``` + +## Related APIs + +- [useFillControls](../composables/use-fill-controls) +- [useStrokeControls](../composables/use-stroke-controls) +- [FillPickerRoot](../components/fill-picker-root) diff --git a/packages/docs/programmable/sdk/api/advanced/use-fill-variable-binding.md b/packages/docs/programmable/sdk/api/advanced/use-fill-variable-binding.md deleted file mode 100644 index 52cca1660..000000000 --- a/packages/docs/programmable/sdk/api/advanced/use-fill-variable-binding.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: useFillVariableBinding -description: Variable-binding helper for fill color editors. ---- - -# useFillVariableBinding - -`useFillVariableBinding()` exposes search, binding, and unbinding helpers for color variables used by fill editors. - -Use it when building fill UIs that need to connect solid fills to design variables. - -## Related APIs - -- [useFillControls](../composables/use-fill-controls) -- [FillPickerRoot](../components/fill-picker-root) diff --git a/packages/docs/programmable/sdk/api/composables/use-fill-controls.md b/packages/docs/programmable/sdk/api/composables/use-fill-controls.md index add37617c..78a0fc2db 100644 --- a/packages/docs/programmable/sdk/api/composables/use-fill-controls.md +++ b/packages/docs/programmable/sdk/api/composables/use-fill-controls.md @@ -1,13 +1,13 @@ --- title: useFillControls -description: Fill-panel composable with default fill behavior and variable binding support. +description: Fill-panel composable with default fill behavior. --- # useFillControls `useFillControls()` is the fill-property composable used by fill editing UIs. -It builds on variable-binding support and adds a reusable default fill value. +It adds a reusable default fill value. ## Usage @@ -19,11 +19,9 @@ const fills = useFillControls() ## What it gives you -Along with fill binding behavior, it exposes: +It exposes: - `defaultFill` -- fill variable-binding helpers -- selection-aware fill editing state ## Practical examples diff --git a/packages/vue/README.md b/packages/vue/README.md index ab8a865c0..507535453 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -140,7 +140,7 @@ These exports are intentionally public, but they are lower-level or more special - `useNodeProps()` - `useSceneComputed()` -- `useFillVariableBinding()` +- `useColorVariableBinding()` - `useFillPicker()` - `useGradientStops()` - `useFontPicker()` diff --git a/packages/vue/src/PropertyList/PropertyListRoot.vue b/packages/vue/src/PropertyList/PropertyListRoot.vue index 157bd1f33..0e11ea779 100644 --- a/packages/vue/src/PropertyList/PropertyListRoot.vue +++ b/packages/vue/src/PropertyList/PropertyListRoot.vue @@ -3,6 +3,7 @@ import { computed } from 'vue' import { useEditor } from '@open-pencil/vue/context/editorContext' import { useNodeProps } from '@open-pencil/vue/controls/useNodeProps' +import { useSceneComputed } from '@open-pencil/vue/internal/useSceneComputed' import { providePropertyList } from './context' import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core' @@ -26,16 +27,21 @@ const emit = defineEmits<{ const editor = useEditor() const { isArrayMixed } = useNodeProps() -const selectedNodes = computed(() => editor.getSelectedNodes()) -const activeNode = computed( - () => editor.getSelectedNode() ?? selectedNodes.value[0] ?? null -) +const selectedNodes = useSceneComputed(() => { + void editor.state.sceneVersion + return editor.getSelectedNodes() +}) +const activeNode = useSceneComputed(() => { + void editor.state.sceneVersion + return editor.getSelectedNode() ?? selectedNodes.value[0] ?? null +}) const isMulti = computed(() => selectedNodes.value.length > 1) const active = computed(() => selectedNodes.value.length > 0) const isMixed = computed(() => isArrayMixed(propKey)) -const items = computed(() => { +const items = useSceneComputed(() => { + void editor.state.sceneVersion if (isMixed.value) return [] return (activeNode.value?.[propKey] ?? []) as ArrayItemType[] }) @@ -90,10 +96,17 @@ function patch(index: number, changes: Record) { function toggleVisibility(index: number) { emit('toggleVisibility', index) - for (const n of targetNodes()) { - const arr = n[propKey] as Array<{ visible: boolean }> + const nodes = targetNodes() + if (nodes.length === 0) return + if (nodes.length > 1) { + editor.undo.beginBatch(`Toggle ${propKey} visibility`) + } + for (const n of nodes) { + const liveNode = editor.getNode(n.id) + if (!liveNode) continue + const arr = liveNode[propKey] as Array<{ visible: boolean }> if (!arr[index]) continue - const newArr = [...n[propKey]] as Array<{ visible: boolean }> + const newArr = [...liveNode[propKey]] as Array<{ visible: boolean }> newArr[index] = { ...newArr[index], visible: !arr[index].visible } editor.updateNodeWithUndo( n.id, @@ -101,6 +114,9 @@ function toggleVisibility(index: number) { `Toggle ${propKey} visibility` ) } + if (nodes.length > 1) { + editor.undo.commitBatch() + } } providePropertyList({ diff --git a/packages/vue/src/controls/useAppearance.ts b/packages/vue/src/controls/useAppearance.ts index 32244612a..31ec2b4bd 100644 --- a/packages/vue/src/controls/useAppearance.ts +++ b/packages/vue/src/controls/useAppearance.ts @@ -51,15 +51,24 @@ export function useAppearance() { function toggleVisibility() { if (isMulti.value) { - const allVisible = nodes.value.every((n) => n.visible) - for (const n of nodes.value) { + const liveNodes = nodes.value + .map((n) => editor.getNode(n.id)) + .filter((n): n is SceneNode => n != null) + if (liveNodes.length === 0) return + const allVisible = liveNodes.every((n) => n.visible) + editor.undo.beginBatch('Toggle visibility') + for (const n of liveNodes) { editor.updateNodeWithUndo(n.id, { visible: !allVisible }, 'Toggle visibility') } - } else { - const n = node.value - if (!n) return - editor.updateNodeWithUndo(n.id, { visible: !n.visible }, 'Toggle visibility') + editor.undo.commitBatch() + return } + + const selected = node.value + if (!selected) return + const liveNode = editor.getNode(selected.id) + if (!liveNode) return + editor.updateNodeWithUndo(liveNode.id, { visible: !liveNode.visible }, 'Toggle visibility') } function toggleIndependentCorners() { diff --git a/packages/vue/src/controls/useFillVariableBinding.ts b/packages/vue/src/controls/useColorVariableBinding.ts similarity index 59% rename from packages/vue/src/controls/useFillVariableBinding.ts rename to packages/vue/src/controls/useColorVariableBinding.ts index 43634687f..27071e893 100644 --- a/packages/vue/src/controls/useFillVariableBinding.ts +++ b/packages/vue/src/controls/useColorVariableBinding.ts @@ -5,13 +5,9 @@ import { useEditor } from '@open-pencil/vue/context/editorContext' import type { Variable } from '@open-pencil/core' -/** - * Returns helpers for binding fill colors to color variables. - * - * This composable is used by fill editing UIs that need variable search, - * binding, and unbinding behavior. - */ -export function useFillVariableBinding() { +type ColorBindingKind = 'fills' | 'strokes' + +export function useColorVariableBinding(kind: ColorBindingKind) { const store = useEditor() const colorVariables = computed(() => store.getVariablesByType('COLOR')) const searchTerm = ref('') @@ -21,19 +17,23 @@ export function useFillVariableBinding() { return colorVariables.value.filter((v) => contains(v.name, searchTerm.value)) }) + function bindingPath(index: number) { + return `${kind}/${index}/color` + } + function getBoundVariable(nodeId: string, index: number): Variable | undefined { const n = store.getNode(nodeId) if (!n) return undefined - const varId = n.boundVariables[`fills/${index}/color`] + const varId = n.boundVariables[bindingPath(index)] return varId ? store.getVariable(varId) : undefined } - function bindFillVariable(nodeId: string, index: number, variableId: string) { - store.bindVariable(nodeId, `fills/${index}/color`, variableId) + function bindVariable(nodeId: string, index: number, variableId: string) { + store.bindVariable(nodeId, bindingPath(index), variableId) } - function unbindFillVariable(nodeId: string, index: number) { - store.unbindVariable(nodeId, `fills/${index}/color`) + function unbindVariable(nodeId: string, index: number) { + store.unbindVariable(nodeId, bindingPath(index)) } return { @@ -42,7 +42,7 @@ export function useFillVariableBinding() { searchTerm, filteredVariables, getBoundVariable, - bindFillVariable, - unbindFillVariable + bindVariable, + unbindVariable } } diff --git a/packages/vue/src/controls/useFillControls.ts b/packages/vue/src/controls/useFillControls.ts index 0716f14b7..a5e3b814e 100644 --- a/packages/vue/src/controls/useFillControls.ts +++ b/packages/vue/src/controls/useFillControls.ts @@ -1,6 +1,6 @@ import { DEFAULT_SHAPE_FILL } from '@open-pencil/core' -import { useFillVariableBinding } from './useFillVariableBinding' +import { useColorVariableBinding } from './useColorVariableBinding' /** * Returns fill-related panel helpers and a reusable default fill value. @@ -9,7 +9,7 @@ import { useFillVariableBinding } from './useFillVariableBinding' * fill editing UIs. */ export function useFillControls() { - const ctx = useFillVariableBinding() + const ctx = useColorVariableBinding('fills') return { ...ctx, diff --git a/packages/vue/src/controls/useNodeProps.ts b/packages/vue/src/controls/useNodeProps.ts index 057c1d52e..69cb38c02 100644 --- a/packages/vue/src/controls/useNodeProps.ts +++ b/packages/vue/src/controls/useNodeProps.ts @@ -21,8 +21,14 @@ type ArrayItem = Fill | Stroke | Effect | Record */ export function useNodeProps() { const store = useEditor() - const node = useSceneComputed(() => store.getSelectedNode() ?? null) - const nodes = useSceneComputed(() => store.getSelectedNodes()) + const node = useSceneComputed(() => { + void store.state.sceneVersion + return store.getSelectedNode() ?? null + }) + const nodes = useSceneComputed(() => { + void store.state.sceneVersion + return store.getSelectedNodes() + }) const isMulti = computed(() => nodes.value.length > 1) const active = computed(() => node.value || isMulti.value) const activeNode = computed(() => node.value ?? (nodes.value[0] as SceneNode | undefined) ?? null) diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index ec02f2eac..0ad7078d3 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -52,7 +52,7 @@ export { useTypography } from './controls/useTypography' export type { UseTypographyOptions } from './controls/useTypography' export { useExport } from './controls/useExport' export { useFillControls } from './controls/useFillControls' -export { useFillVariableBinding } from './controls/useFillVariableBinding' +export { useColorVariableBinding } from './controls/useColorVariableBinding' export { useEffectsControls } from './controls/useEffectsControls' export { useStrokeControls } from './controls/useStrokeControls' diff --git a/src/components/FillPicker.vue b/src/components/FillPicker.vue index 2e4d934d7..0a272b0a0 100644 --- a/src/components/FillPicker.vue +++ b/src/components/FillPicker.vue @@ -34,6 +34,13 @@ const { panels } = useI18n() swatch-class="size-5 shrink-0 cursor-pointer rounded border border-border p-0" @update="emit('update', $event)" > + diff --git a/src/components/properties/StrokeSection.vue b/src/components/properties/StrokeSection.vue index 1790067ec..bf912f6ad 100644 --- a/src/components/properties/StrokeSection.vue +++ b/src/components/properties/StrokeSection.vue @@ -1,30 +1,34 @@ - - - - - - - - - - - - - - - - - {{ opt.label }} - - - - - + + +
{ + page = await browser.newPage() + await page.goto('/') + canvas = new CanvasHelper(page) + await canvas.waitForInit() +}) + +test.afterAll(async () => { + await page.close() +}) + +async function getSelectedNodeFlags() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const id = [...store.state.selectedIds][0] + if (!id) return null + const n = store.graph.getNode(id) + if (!n) return null + return { + type: n.type, + independentCorners: n.independentCorners, + independentStrokeWeights: n.independentStrokeWeights + } + }) +} + +async function drawFrame(x: number, y: number, w: number, h: number) { + await canvas.pressKey('f') + await canvas.drag(x, y, x + w, y + h) + await canvas.waitForRender() +} + +test('independent corners toggle shows per-corner inputs', async () => { + await drawFrame(120, 120, 120, 80) + await canvas.waitForRender() + + const flags = await getSelectedNodeFlags() + expect(flags!.type).toBe('FRAME') + expect(flags!.independentCorners).toBe(false) + + const toggle = page.locator('[data-test-id="independent-corners-toggle"]') + await expect(toggle).toBeVisible() + + await toggle.click() + await canvas.waitForRender() + + expect((await getSelectedNodeFlags())!.independentCorners).toBe(true) + const grid = page.locator('[data-test-id="independent-corners-grid"]') + await expect(grid).toBeVisible() + const cornerInputs = grid.locator('[data-test-id="scrub-input"]') + expect(await cornerInputs.count()).toBe(4) + + await toggle.click() + await canvas.waitForRender() + await expect(grid).not.toBeVisible() +}) + +test('stroke sides toggle shows per-side weight inputs', async () => { + await drawFrame(300, 50, 120, 80) + await canvas.waitForRender() + + const addStroke = page.locator('[data-test-id="stroke-section-add"]') + await expect(addStroke).toBeVisible() + await addStroke.click() + await canvas.waitForRender() + + const toggle = page.locator('[data-test-id="stroke-sides-toggle"]') + await expect(toggle).toBeVisible({ timeout: 5000 }) + + const sectionInputsBefore = await page.locator('[data-test-id="stroke-section"] [data-test-id="scrub-input"]').count() + + await toggle.click() + await canvas.waitForRender() + + const sectionInputsAfter = await page.locator('[data-test-id="stroke-section"] [data-test-id="scrub-input"]').count() + expect(sectionInputsAfter).toBeGreaterThan(sectionInputsBefore) + + await toggle.click() + await canvas.waitForRender() + + const sectionInputsFinal = await page.locator('[data-test-id="stroke-section"] [data-test-id="scrub-input"]').count() + expect(sectionInputsFinal).toBe(sectionInputsBefore) +}) diff --git a/tests/e2e/design-panel.spec.ts b/tests/e2e/design-panel.spec.ts index 906eb281a..d97badc46 100644 --- a/tests/e2e/design-panel.spec.ts +++ b/tests/e2e/design-panel.spec.ts @@ -130,7 +130,7 @@ test('adding an effect creates effect item', async () => { await addBtn.click() await canvas.waitForRender() - const effectItems = effectsSection().locator('[data-test-id="effects-item"]') + const effectItems = effectsSection().locator('[data-test-id="effect-item"]') await expect(effectItems.first()).toBeVisible() const id = await getSelectedId() @@ -172,6 +172,78 @@ test('visibility toggle in appearance section works', async () => { expect(restored!.visible).toBe(true) }) +test('fill stroke and effect visibility toggles update on repeated clicks and support undo redo', async () => { + const id = await getSelectedId() + expect(id).toBeTruthy() + + const fillButton = page.locator('[data-test-id="fill-visibility-0"]') + await expect(fillButton).toBeVisible() + + const initial = await getNode(id!) + expect(initial!.fills[0]?.visible).toBe(true) + + await fillButton.click() + await canvas.waitForRender() + await expect(fillButton.locator('icon-lucide-eye-off')).toBeVisible() + expect((await getNode(id!))!.fills[0]?.visible).toBe(false) + + await fillButton.click() + await canvas.waitForRender() + await expect(fillButton.locator('icon-lucide-eye')).toBeVisible() + expect((await getNode(id!))!.fills[0]?.visible).toBe(true) + + await canvas.undo() + expect((await getNode(id!))!.fills[0]?.visible).toBe(false) + await canvas.redo() + expect((await getNode(id!))!.fills[0]?.visible).toBe(true) + + const strokeAddButton = strokeSection().locator('[data-test-id="stroke-section-add"]') + await strokeAddButton.click() + await canvas.waitForRender() + + const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]') + await expect(strokeButton).toBeVisible() + expect((await getNode(id!))!.strokes[0]?.visible).toBe(true) + + await strokeButton.click() + await canvas.waitForRender() + await expect(strokeButton.locator('icon-lucide-eye-off')).toBeVisible() + expect((await getNode(id!))!.strokes[0]?.visible).toBe(false) + + await strokeButton.click() + await canvas.waitForRender() + await expect(strokeButton.locator('icon-lucide-eye')).toBeVisible() + expect((await getNode(id!))!.strokes[0]?.visible).toBe(true) + + await canvas.undo() + expect((await getNode(id!))!.strokes[0]?.visible).toBe(false) + await canvas.redo() + expect((await getNode(id!))!.strokes[0]?.visible).toBe(true) + + const effectAddButton = effectsSection().locator('[data-test-id="effects-section-add"]') + await effectAddButton.click() + await canvas.waitForRender() + + const effectButton = page.locator('[data-test-id="effect-visibility-0"]') + await expect(effectButton).toBeVisible() + expect((await getNode(id!))!.effects[0]?.visible).toBe(true) + + await effectButton.click() + await canvas.waitForRender() + await expect(effectButton.locator('icon-lucide-eye-off')).toBeVisible() + expect((await getNode(id!))!.effects[0]?.visible).toBe(false) + + await effectButton.click() + await canvas.waitForRender() + await expect(effectButton.locator('icon-lucide-eye')).toBeVisible() + expect((await getNode(id!))!.effects[0]?.visible).toBe(true) + + await canvas.undo() + expect((await getNode(id!))!.effects[0]?.visible).toBe(false) + await canvas.redo() + expect((await getNode(id!))!.effects[0]?.visible).toBe(true) +}) + test('deselecting shows empty design panel', async () => { await page.keyboard.press('Escape') await canvas.waitForRender() diff --git a/tests/e2e/property-visibility.spec.ts b/tests/e2e/property-visibility.spec.ts new file mode 100644 index 000000000..740537dab --- /dev/null +++ b/tests/e2e/property-visibility.spec.ts @@ -0,0 +1,100 @@ +import { expect, test, type Page } from '@playwright/test' + +import { CanvasHelper } from '../helpers/canvas' + +let page: Page +let canvas: CanvasHelper + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage() + await page.goto('/') + canvas = new CanvasHelper(page) + await canvas.waitForInit() +}) + +test.afterAll(async () => { + await page.close() +}) + +function getSelectedNode() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const id = [...store.state.selectedIds][0] + if (!id) return null + const n = store.graph.getNode(id) + if (!n) return null + return { + fills: n.fills, + strokes: n.strokes, + visible: n.visible + } + }) +} + +test('fill visibility supports repeat click and undo redo', async () => { + await canvas.drawRect(120, 120, 120, 80) + await canvas.waitForRender() + + const fillButton = page.locator('[data-test-id="fill-visibility-0"]') + await expect(fillButton).toBeVisible() + expect((await getSelectedNode())!.fills[0]?.visible).toBe(true) + + await fillButton.click() + await canvas.waitForRender() + expect((await getSelectedNode())!.fills[0]?.visible).toBe(false) + + await fillButton.click() + await canvas.waitForRender() + expect((await getSelectedNode())!.fills[0]?.visible).toBe(true) + + await canvas.undo() + expect((await getSelectedNode())!.fills[0]?.visible).toBe(false) + + await canvas.redo() + expect((await getSelectedNode())!.fills[0]?.visible).toBe(true) +}) + +test('stroke visibility supports repeat click and undo redo', async () => { + await page.locator('[data-test-id="stroke-section-add"]').click() + await canvas.waitForRender() + + const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]') + await expect(strokeButton).toBeVisible() + expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true) + + await strokeButton.click() + await canvas.waitForRender() + expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false) + + await strokeButton.click() + await canvas.waitForRender() + expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true) + + await canvas.undo() + expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false) + + await canvas.redo() + expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true) +}) + +test('appearance visibility supports repeat click and undo redo in one step', async () => { + const visibilityButton = page.locator('[data-test-id="appearance-visibility"]') + await expect(visibilityButton).toBeVisible() + expect((await getSelectedNode())!.visible).toBe(true) + + await visibilityButton.click() + await canvas.waitForRender() + expect((await getSelectedNode())!.visible).toBe(false) + + await visibilityButton.click() + await canvas.waitForRender() + expect((await getSelectedNode())!.visible).toBe(true) + + await canvas.undo() + expect((await getSelectedNode())!.visible).toBe(false) + + await canvas.undo() + expect((await getSelectedNode())!.visible).toBe(true) +})