openpencil/tests/e2e/code/layer-links.spec.ts
Danila Poyarkov 6a3960a5d4
feat(code): link the Code tab to canvas layers and sync both ways (#805)
* feat(code): link code to canvas layers and underline design issues

Code in the Code tab and layers on the canvas were unrelated: finding the
element behind a layer, or the layer behind an element, meant reading names.

Generated Design JSX and Tailwind JSX report the layer behind each element
in the order elements open, and edited Design JSX keeps the source line of
every element through the sandbox and renderer, so hovering an element
highlights its layer, Cmd/Ctrl-click brings it into view without changing
the selection the code shows, and errors and warnings from the design check
are underlined on the property that causes them.

* feat(code): explain the Code tab when nothing is selected

With no selection the editor showed a starter frame that read like a real
layer. The tab now says it shows the selected layers' code and offers Write
JSX, which opens the editor focused on the starter template.

* refactor(code): group code-to-layer linking into its own domain

Layer link types, issue mapping, and the hover and reveal behavior move
from the Code panel and a component file into src/app/code/layers, with
useCodeLayers as the panel's entry point, so app code no longer imports
types from components.

* fix(code): underline off-scale gaps after the spacing rule renamed its property

* feat(code): mark the layer of the element around the cursor

Hover highlighting and ⌘-click reveal replaced by one model: the element
around the cursor marks its opening and closing tag names and outlines its
layer on the canvas while the editor has focus. ⌘-click also collided
with CodeMirror's add-a-cursor gesture. Read-only Tailwind JSX now takes a
cursor so it links the same way.

Leaving the editor now ends a live Design JSX edit as one undo step.
Before, canvas edits made after typing never reached the code until the
tab was reopened, and their undo entries landed before the edit's.

* feat(code): sync the Code tab and the canvas both ways by patching

Canvas edits now patch the Design JSX a person wrote instead of waiting
for them to leave the editor: each linked element remembers the layer as
Design JSX last wrote it, and a canvas change rewrites only the attributes,
text and child elements that differ from that base, as CodeMirror changes
that keep the cursor, comments, formatting and history. Attributes written
as expressions are never overwritten; the code marks them when the canvas
now differs. Untouched code is regenerated with a minimal text change.

Code edits update layers in place: the new render is reconciled into the
existing layers (reconcileRenderedLayers), which keep their ids, so links,
selection and canvas edits survive typing. Each edit is one coalesced undo
step, replacing the restore-and-rerender preview and the commit on blur.

* feat(code): patch reordered layers and aliased properties in edited code

Reordering layers on the canvas now moves their elements in code a person
wrote: each child element and the blank lines and comments above it form a
block kept as written, and the children are written again in the new
order, staying linked. Children that cannot move safely, such as a loop
between them, keep their order and are marked.

Properties accepted under several names now come from one alias table in
the Design JSX schema, which the renderer resolves through and the patcher
and issue underlines use, so a canvas change to `w` patches `width` where
the person wrote that, instead of adding a second attribute.

* feat(code): keep the cursor in moved code and patch values written in style

A reorder rewrites the children span in one change, which collapsed a
cursor or out-of-sync marker inside a moved element to the span's edge.
The patch now carries where each block moved and places selections and
markers inside it at their new position.

Properties the renderer also reads from style={{ … }} come from a table in
the Design JSX schema instead of a hand-written list, keeping the rule
that an attribute under any of its names wins. The patcher uses it to
update a value written in style where it is, as a number or a px string
as written; values the renderer cannot read, such as '50%', are marked.

The layer patcher is split by concern: syntax helpers, attribute and
style patches, child patches, out-of-sync state and transaction assembly.

* feat(code): show the code's layer on the canvas as a tinted box

The layer of the element around the cursor used the canvas hover slot, so
moving the pointer over the canvas replaced it and the two read the same.
It now has its own shared editor state, codeFocusNodeId, drawn as the hover
outline over a light tint in every pane: hover stays an outline and the
selection keeps its handles, without borrowing the dashed outlines that
already mean component sets, drag parents and ghosts.

* fix(code): write added and removed layers when a reorder cannot move the code

When children could not be moved, such as two written on one line, the
patch marked the order and returned before adding or removing elements,
so a layer created in the same change never reached the code. It now
marks the order and still writes additions and removals.

* refactor(design-jsx): format the rebased layer description and stroke aliases

* feat(code): mount the layer-linked code editor through useCodeMirror

Master moved the code editor onto the shared useCodeMirror composable.
Its layer links, issue underlines, canvas patches, minimal text updates,
autofocus and read-only cursor now sit on that composable instead of a
hand-mounted view.
2026-10-04 10:08:40 +00:00

355 lines
14 KiB
TypeScript

import type { Page } from '@playwright/test'
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
const editor = useEditorSetup('/?test&no-rulers')
function buildScene(page: Page): Promise<void> {
return page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const card = store.graph.createNode('FRAME', pageId, {
name: 'Card',
x: 80,
y: 80,
width: 320,
height: 160,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }]
})
store.graph.createNode('TEXT', card.id, {
name: 'Fine print',
x: 16,
y: 16,
width: 200,
height: 14,
text: 'Terms apply',
fontSize: 10,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
store.graph.createNode('RECTANGLE', card.id, {
name: 'Swatch',
x: 16,
y: 60,
width: 80,
height: 60,
fills: [{ type: 'SOLID', color: { r: 0.2, g: 0.4, b: 0.9, a: 1 }, visible: true, opacity: 1 }]
})
store.select([card.id])
store.requestRender()
})
}
function codeLine(page: Page, text: string) {
return page.locator('[data-slot="code-editor"] .cm-line', { hasText: text })
}
/** The layer under the pointer on the canvas, which is separate from the code's layer. */
function hoveredLayer(page: Page) {
return page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const id = store?.state.hoveredNodeId
return id ? (store.graph.getNode(id)?.name ?? null) : null
})
}
function focusedLayer(page: Page) {
return page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const id = store?.state.codeFocusNodeId
return id ? (store.graph.getNode(id)?.name ?? null) : null
})
}
test.beforeEach(async () => {
await editor.page.reload()
await editor.canvas.waitForInit()
})
async function openCode(page: Page) {
await page.getByTestId('properties-tab-code').click()
await expect(codeLine(page, 'name="Swatch"')).toBeVisible()
}
function activeTags(page: Page) {
return page.locator('[data-slot="code-editor"] .cm-layer-tag')
}
/** Moves focus out of the code, as clicking anywhere else in the app does. */
async function leaveCode(page: Page) {
await page.getByTestId('code-panel-copy').focus()
}
function cardSize(page: Page) {
return page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const card = [...(store?.graph.getAllNodes() ?? [])].find((node) => node.name === 'Card')
return card ? `${card.width}x${card.height}` : null
})
}
test('the element around the cursor marks its tags and its layer', async () => {
await buildScene(editor.page)
await openCode(editor.page)
await codeLine(editor.page, 'name="Swatch"').click({ position: { x: 24, y: 8 } })
await expect(activeTags(editor.page)).toHaveText(['Rectangle'])
await expect.poll(() => focusedLayer(editor.page)).toBe('Swatch')
await codeLine(editor.page, 'name="Fine print"').click({ position: { x: 24, y: 8 } })
// Both the opening and the closing tag name are marked.
await expect(activeTags(editor.page)).toHaveText(['Text', 'Text'])
await expect.poll(() => focusedLayer(editor.page)).toBe('Fine print')
// Canvas hover is a separate mark: hovering another layer keeps the code's layer marked.
await editor.canvas.hover(130, 170)
await expect.poll(() => hoveredLayer(editor.page)).toBe('Swatch')
expect(await focusedLayer(editor.page)).toBe('Fine print')
await editor.canvas.waitForRender()
const canvas = await editor.canvas.canvas.boundingBox()
if (!canvas) throw new Error('Canvas has no bounding box')
expect(
await editor.page.screenshot({
clip: { x: canvas.x + 60, y: canvas.y + 60, width: 360, height: 200 }
})
).toMatchSnapshot('code-focus-and-hover.png')
await leaveCode(editor.page)
await expect(activeTags(editor.page)).toHaveCount(0)
await expect.poll(() => focusedLayer(editor.page)).toBeNull()
})
test('design issues are underlined on the property that causes them', async () => {
await buildScene(editor.page)
await openCode(editor.page)
const warning = editor.page.locator('[data-slot="code-editor"] .cm-lintRange-warning')
await expect(warning).toHaveText('size={10}')
})
test('links follow the code after a live edit', async () => {
await buildScene(editor.page)
await openCode(editor.page)
await codeLine(editor.page, 'name="Card"').click()
await editor.page.keyboard.press('End')
await editor.page.keyboard.type(' ')
await expect(editor.page.getByTestId('code-panel-status')).toHaveText('Updated live')
// The preview replaced the layers; the cursor must resolve to a layer that still exists.
await codeLine(editor.page, 'name="Swatch"').click({ position: { x: 24, y: 8 } })
await expect.poll(() => focusedLayer(editor.page)).toBe('Swatch')
await expect(editor.page.locator('[data-slot="code-editor"] .cm-lintRange-warning')).toHaveText(
'size={10}'
)
})
/** Runs a canvas edit through the store, as a Design panel or canvas gesture commits it. */
function editLayer(page: Page, name: string, changes: Record<string, unknown>) {
return page.evaluate(
({ name, changes }) => {
const store = window.openPencil?.getStore?.()
const node = [...(store?.graph.getAllNodes() ?? [])].find((n) => n.name === name)
if (!store || !node) throw new Error(`${name} not found`)
store.updateNodeWithUndo(node.id, changes, 'Edit')
},
{ name, changes }
)
}
function layerId(page: Page, name: string) {
return page.evaluate((name) => {
const store = window.openPencil?.getStore?.()
return [...(store?.graph.getAllNodes() ?? [])].find((node) => node.name === name)?.id ?? null
}, name)
}
function codeText(page: Page) {
return page.locator('[data-slot="code-editor"] .cm-content').innerText()
}
/** Writes like a person: a comment above the card and an expression for the swatch width. */
async function personalizeCode(page: Page) {
await codeLine(page, 'name="Card"').click()
await page.keyboard.press('ControlOrMeta+Home')
await page.keyboard.type('// Pricing card')
await page.keyboard.press('Enter')
await codeLine(page, 'name="Swatch"').locator('span', { hasText: /^80$/ }).dblclick()
await page.keyboard.type('40 * 2')
await expect(page.getByTestId('code-panel-status')).toHaveText('Updated live')
}
test('code edits update the layers in place', async () => {
await buildScene(editor.page)
await openCode(editor.page)
const swatchId = await layerId(editor.page, 'Swatch')
await codeLine(editor.page, 'name="Swatch"').locator('span', { hasText: /^80$/ }).dblclick()
await editor.page.keyboard.type('120')
await expect
.poll(() =>
editor.page.evaluate(
(id) => (id ? window.openPencil?.getStore?.().graph.getNode(id)?.width : null),
swatchId
)
)
.toBe(120)
})
test('canvas edits patch code a person wrote and keep the rest as written', async () => {
await buildScene(editor.page)
await openCode(editor.page)
await personalizeCode(editor.page)
await editLayer(editor.page, 'Card', { width: 400 })
await editLayer(editor.page, 'Swatch', { width: 100 })
await expect(codeLine(editor.page, 'name="Card"')).toContainText('w={400}')
const text = await codeText(editor.page)
expect(text).toContain('// Pricing card')
// The width the person wrote as an expression is theirs; the canvas does not overwrite it,
// and the code says the canvas now differs.
expect(text).toContain('w={40 * 2}')
await expect(editor.page.locator('[data-slot="code-editor"] .cm-lintRange-info')).toHaveText(
'w={40 * 2}'
)
})
test('layers added or deleted on the canvas are written into the code', async () => {
await buildScene(editor.page)
await openCode(editor.page)
await personalizeCode(editor.page)
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const card = [...(store?.graph.getAllNodes() ?? [])].find((node) => node.name === 'Card')
const note = [...(store?.graph.getAllNodes() ?? [])].find((node) => node.name === 'Fine print')
if (!store || !card || !note) throw new Error('scene not found')
store.graph.createNode('ELLIPSE', card.id, {
name: 'Dot',
x: 200,
y: 60,
width: 24,
height: 24
})
store.graph.deleteNode(note.id)
store.requestRender()
})
await expect(codeLine(editor.page, 'name="Dot"')).toBeVisible()
await expect(codeLine(editor.page, 'name="Fine print"')).toHaveCount(0)
expect(await codeText(editor.page)).toContain('// Pricing card')
await codeLine(editor.page, 'name="Dot"').click({ position: { x: 24, y: 8 } })
await expect.poll(() => focusedLayer(editor.page)).toBe('Dot')
})
test('undo reverts a code edit on the canvas and in the code', async () => {
await buildScene(editor.page)
await openCode(editor.page)
await codeLine(editor.page, 'name="Card"').locator('span', { hasText: /^320$/ }).dblclick()
await editor.page.keyboard.type('360')
await expect.poll(() => cardSize(editor.page)).toBe('360x160')
await editLayer(editor.page, 'Card', { height: 200 })
await expect(codeLine(editor.page, 'name="Card"')).toContainText('h={200}')
await editor.page.evaluate(() => window.openPencil?.getStore?.().undoAction())
await expect(codeLine(editor.page, 'name="Card"')).toContainText('h={160}')
await editor.page.evaluate(() => window.openPencil?.getStore?.().undoAction())
await expect.poll(() => cardSize(editor.page)).toBe('320x160')
await expect(codeLine(editor.page, 'name="Card"')).toContainText('w={320}')
})
test('a value written under another name is patched under that name', async () => {
await buildScene(editor.page)
await openCode(editor.page)
// The person renames `w` to `width`, which Design JSX also accepts.
await codeLine(editor.page, 'name="Card"').locator('span', { hasText: /^w$/ }).dblclick()
await editor.page.keyboard.type('width')
await expect(editor.page.getByTestId('code-panel-status')).toHaveText('Updated live')
await editLayer(editor.page, 'Card', { width: 400 })
await expect(codeLine(editor.page, 'name="Card"')).toContainText('width={400}')
await expect(codeLine(editor.page, 'name="Card"')).not.toContainText(' w={')
})
test('reordering layers on the canvas moves their elements and keeps them linked', async () => {
await buildScene(editor.page)
await openCode(editor.page)
await personalizeCode(editor.page)
// The cursor sits in the swatch element, which the reorder is about to move.
await codeLine(editor.page, 'name="Swatch"').click({ position: { x: 24, y: 8 } })
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const swatch = [...(store?.graph.getAllNodes() ?? [])].find((node) => node.name === 'Swatch')
if (!store || !swatch?.parentId) throw new Error('Swatch not found')
store.graph.reorderChild(swatch.id, swatch.parentId, 0)
store.requestRender()
})
await expect
.poll(async () => {
const text = await codeText(editor.page)
return text.indexOf('name="Swatch"') < text.indexOf('name="Fine print"')
})
.toBe(true)
const text = await codeText(editor.page)
expect(text).toContain('// Pricing card')
expect(text).toContain('w={40 * 2}')
// The cursor moved with its element and still marks the swatch.
await expect(editor.page.locator('[data-slot="code-editor"] .cm-activeLine')).toContainText(
'name="Swatch"'
)
await expect.poll(() => focusedLayer(editor.page)).toBe('Swatch')
})
test('a value written inside style is patched there in its own format', async () => {
await buildScene(editor.page)
await openCode(editor.page)
const code = (await codeText(editor.page))
.replace(/(name="Card"[^>]*?) w=\{320\}/, "$1 style={{ width: '320px' }}")
.replace(/(name="Card"[^>]*?) h=\{160\}/, '$1 h={170}')
await codeLine(editor.page, 'name="Card"').click()
await editor.page.keyboard.press('ControlOrMeta+a')
await editor.page.keyboard.insertText(code)
// Replacing all the code links it again once its preview applies, shown by the new height.
await expect.poll(() => cardSize(editor.page)).toBe('320x170')
await editLayer(editor.page, 'Card', { width: 400 })
await expect(codeLine(editor.page, 'name="Card"')).toContainText("style={{ width: '400px' }}")
await expect(codeLine(editor.page, 'name="Card"')).not.toContainText(' w={')
})
test('a reorder that cannot move the code still writes layers added with it', async () => {
await buildScene(editor.page)
await openCode(editor.page)
// Two children on one line cannot be moved as blocks of their own.
const code = (await codeText(editor.page))
.replace(/<\/Text>\n\s*<Rectangle/, '</Text> <Rectangle')
.replace(/(name="Card"[^>]*?) h=\{160\}/, '$1 h={170}')
await codeLine(editor.page, 'name="Card"').click()
await editor.page.keyboard.press('ControlOrMeta+a')
await editor.page.keyboard.insertText(code)
await expect.poll(() => cardSize(editor.page)).toBe('320x170')
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const swatch = [...(store?.graph.getAllNodes() ?? [])].find((node) => node.name === 'Swatch')
if (!store || !swatch?.parentId) throw new Error('Swatch not found')
store.graph.reorderChild(swatch.id, swatch.parentId, 0)
store.graph.createNode('ELLIPSE', swatch.parentId, { name: 'Dot', width: 24, height: 24 })
store.requestRender()
})
await expect(codeLine(editor.page, 'name="Dot"')).toBeVisible()
await expect(editor.page.locator('[data-slot="code-editor"] .cm-lintRange-info')).toHaveText(
'Frame'
)
})