Merge branch 'master' into mcp-v2-webmcp

This commit is contained in:
Danila Poyarkov 2026-09-15 19:55:49 +03:00 committed by GitHub
commit 38e80a4567
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1382 additions and 64 deletions

View file

@ -40,6 +40,7 @@
### Changed
- Explore editable component, typography, and paint comparisons in the demo, with the original examples preserved on a reference page.
- Use compact desktop Home search actions with consistent responsive layout and control sizing.
- Keep applied and available Effect styles concise, and collapse equal independent corner fields when all four use the same variable.
- Keep pixel-grid rounding invisible while showing alignment guides only for real geometry, objects, and canvas/layout guides.
@ -61,6 +62,7 @@
### Fixed
- Keep Undo and Redo commands available as edit history changes, without requiring another scene edit.
- Avoid recursive desktop HTTP proxy requests when font downloads intercept Tauri IPC traffic.
- Keep FIT image fills proportional, centered, and fully visible without stretching or cropped edges.
- Preserve edited instance text, including cleared labels, when saving and reopening `.fig` files.

View file

@ -54,7 +54,7 @@ export { createDefaultEditorState } from './state'
export function createEditor(options?: EditorOptions) {
let _graph = options?.graph ?? new SceneGraph()
const skipInitialGraphSetup = options?.skipInitialGraphSetup ?? false
const undo = new UndoManager()
const undo = new UndoManager({ onChange: () => emitEditorEvent('history:changed') })
const _loadFont = options?.loadFont ?? fontManager.loadFont.bind(fontManager)
const _getViewportSize =
options?.getViewportSize ??

View file

@ -122,6 +122,7 @@ export interface EditorEvents extends SceneGraphEvents {
'render:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
'repaint:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
'graph:replaced': (graph: SceneGraph) => void
'history:changed': () => void
'selection:changed': (selectedIds: string[], previousIds: string[]) => void
'tool:changed': (tool: Tool, previousTool: Tool) => void
'page:changed': (pageId: string, previousPageId: string) => void

View file

@ -7,6 +7,8 @@ export interface UndoEntry {
export interface UndoManagerOptions {
limit?: number
/** Called after recording, undoing, redoing, or clearing committed history. */
onChange?: () => void
}
interface UndoBatch {
@ -22,9 +24,11 @@ export class UndoManager {
private redoStack: UndoEntry[] = []
private batches: UndoBatch[] = []
private readonly limit: number
private readonly onChange: (() => void) | undefined
constructor(options: UndoManagerOptions = {}) {
this.limit = options.limit ?? DEFAULT_HISTORY_LIMIT
this.onChange = options.onChange
}
apply(entry: UndoEntry): void {
@ -54,6 +58,7 @@ export class UndoManager {
if (!entry) return null
entry.inverse()
this.redoStack.push(entry)
this.onChange?.()
return entry.label
}
@ -62,6 +67,7 @@ export class UndoManager {
if (!entry) return null
entry.forward()
this.undoStack.push(entry)
this.onChange?.()
return entry.label
}
@ -101,6 +107,7 @@ export class UndoManager {
this.undoStack = []
this.redoStack = []
this.batches = []
this.onChange?.()
}
get isBatching(): boolean {
@ -148,6 +155,7 @@ export class UndoManager {
}
this.redoStack = []
this.trimUndoStack()
this.onChange?.()
}
private trimUndoStack(): void {

View file

@ -1,7 +1,8 @@
import { computed } from 'vue'
import { computed, shallowRef, triggerRef } from 'vue'
import { canMakeBooleanSourceNode, hasVisibleStrokeSourceNode } from '@open-pencil/core/canvas'
import { useEditorEvent } from '#vue/editor/events/use'
import { useSelectionState } from '#vue/editor/selection-state/use'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
@ -15,6 +16,8 @@ import { useSceneComputed } from '#vue/internal/scene-computed/use'
export function useSelectionCapabilities() {
const selection = useSelectionState()
const { editor, selectedIds, selectedNode, selectedCount, hasSelection } = selection
const history = shallowRef(editor.undo)
useEditorEvent('history:changed', () => triggerRef(history))
const selectedNodesCanFlatten = useSceneComputed(() => {
const nodes = editor.getSelectedNodes()
@ -73,8 +76,8 @@ export function useSelectionCapabilities() {
),
// In vector edit mode, undo/redo route to the session-local history —
// keep the commands enabled so the shortcut reaches them.
canUndo: useSceneComputed(() => editor.state.nodeEditState != null || editor.undo.canUndo),
canRedo: useSceneComputed(() => editor.state.nodeEditState != null || editor.undo.canRedo),
canUndo: useSceneComputed(() => editor.state.nodeEditState != null || history.value.canUndo),
canRedo: useSceneComputed(() => editor.state.nodeEditState != null || history.value.canRedo),
canZoomToSelection: computed(() => hasSelection.value)
}
}

View file

@ -0,0 +1,127 @@
import {
Component,
Frame,
Instance,
Rectangle,
Text,
renderTree
} from '@open-pencil/core/design-jsx'
import type { ComponentPropertyDefinition, SceneGraph } from '@open-pencil/scene-graph'
import { ANNOUNCEMENT } from './content'
import type { AnnouncementTokens } from './tokens'
export const ANNOUNCEMENT_PROPERTIES = {
title: {
id: 'announcement-title',
name: 'Headline',
type: 'TEXT',
defaultValue: ANNOUNCEMENT.title
},
description: {
id: 'announcement-description',
name: 'Description',
type: 'TEXT',
defaultValue: ANNOUNCEMENT.description
},
details: {
id: 'announcement-details',
name: 'Details',
type: 'TEXT',
defaultValue: ANNOUNCEMENT.details
},
showDetails: {
id: 'announcement-show-details',
name: 'Show details',
type: 'BOOLEAN',
defaultValue: 'true'
}
} satisfies Record<string, ComponentPropertyDefinition>
interface AnnouncementSourceParents {
graphicParentId: string
componentParentId: string
}
export async function createAnnouncementComponent(
graph: SceneGraph,
parents: AnnouncementSourceParents,
tokens: AnnouncementTokens
) {
const graphic = await renderTree(
graph,
Component({
name: 'Announcement / motif',
grid: true,
columns: '1fr 1fr',
rows: '1fr 1fr',
gap: 6,
w: 56,
h: 56,
children: [1, 0.35, 0.35, 1].map((opacity) =>
Rectangle({ w: 'fill', h: 'fill', rounded: 3, bg: tokens.accent, opacity })
)
}),
{ parentId: parents.graphicParentId }
)
const text = { font: 'Inter', color: tokens.ink }
const component = await renderTree(
graph,
Component({
name: 'Announcement',
properties: Object.values(ANNOUNCEMENT_PROPERTIES),
flex: 'row',
w: 600,
h: 'hug',
p: tokens.padding,
gap: tokens.padding,
bg: tokens.surface,
rounded: 16,
children: [
Instance({ name: 'Shared motif', of: graphic.id }),
Frame({
name: 'Content',
flex: 'col',
w: 'fill',
h: 'hug',
gap: tokens.gap,
children: [
Text({
...text,
name: 'Headline',
w: 'fill',
size: 28,
weight: 600,
children: ANNOUNCEMENT.title,
propertyRefs: [{ propertyId: ANNOUNCEMENT_PROPERTIES.title.id, field: 'TEXT' }]
}),
Text({
...text,
name: 'Description',
w: 'fill',
size: 14,
lineHeight: 22,
children: ANNOUNCEMENT.description,
propertyRefs: [{ propertyId: ANNOUNCEMENT_PROPERTIES.description.id, field: 'TEXT' }]
}),
Text({
...text,
name: 'Details',
w: 'fill',
size: 10,
lineHeight: 16,
color: tokens.muted,
children: ANNOUNCEMENT.details,
propertyRefs: [
{ propertyId: ANNOUNCEMENT_PROPERTIES.details.id, field: 'TEXT' },
{ propertyId: ANNOUNCEMENT_PROPERTIES.showDetails.id, field: 'VISIBLE' }
]
})
]
})
]
}),
{ parentId: parents.componentParentId }
)
return { componentId: component.id, graphicId: graphic.id }
}

View file

@ -0,0 +1,9 @@
export const ANNOUNCEMENT = {
title: 'Open studio',
description:
'Bring a work in progress. Share an idea, ask a question, or spend an hour designing together.',
details: 'SAMPLE EVENT · SATURDAY, 14:00 · ONLINE'
}
export const EDITING_PROMPT =
'Try editing the main headline, changing the Accent token, or hiding Details on one instance.'

View file

@ -0,0 +1,141 @@
import { Frame, Instance, Text, renderTree } from '@open-pencil/core/design-jsx'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { ANNOUNCEMENT_PROPERTIES, createAnnouncementComponent } from './component'
import { EDITING_PROMPT } from './content'
import { createAnnouncementTokens } from './tokens'
export async function createAnnouncementSection(graph: SceneGraph, parentId: string) {
const tokens = createAnnouncementTokens(graph)
const label = { font: 'Inter', size: 12, color: '#536487' }
const root = await renderTree(
graph,
Frame({
name: 'Announcement system',
flex: 'col',
w: 'hug',
h: 'hug',
gap: 32,
p: 32,
bg: '#FFFFFF',
children: Text({
font: 'Inter',
size: 20,
weight: 600,
color: '#172554',
children: 'One announcement. Different spaces.'
})
}),
{ parentId }
)
const examples = await renderTree(
graph,
Frame({ name: 'Linked examples', flex: 'row', w: 'hug', h: 'hug', gap: 32 }),
{ parentId: root.id }
)
const sources = await renderTree(
graph,
Frame({
name: 'Editable sources',
flex: 'col',
w: 'hug',
h: 'hug',
gap: 16,
children: Text({ ...label, children: 'EDIT THE SOURCES BELOW · WATCH THE EXAMPLES ABOVE' })
}),
{ parentId: root.id }
)
const sourceRow = await renderTree(
graph,
Frame({ name: 'Source components', flex: 'row', w: 'hug', h: 'hug', gap: 24 }),
{ parentId: sources.id }
)
const graphicSource = await renderTree(
graph,
Frame({
name: 'Shared motif source',
flex: 'col',
w: 192,
h: 'hug',
gap: 16,
children: [
Text({ ...label, color: '#9747FF', children: '01 / SHARED MOTIF' }),
Text({
...label,
w: 'fill',
lineHeight: 18,
children: 'Edit these shapes. Every announcement uses this component.'
})
]
}),
{ parentId: sourceRow.id }
)
const announcementSource = await renderTree(
graph,
Frame({
name: 'Main announcement source',
flex: 'col',
w: 600,
h: 'hug',
gap: 16,
children: [
Text({ ...label, color: '#9747FF', children: '02 / MAIN ANNOUNCEMENT' }),
Text({
...label,
w: 'fill',
lineHeight: 18,
children: 'Edit the headline or description here to update both linked examples.'
})
]
}),
{ parentId: sourceRow.id }
)
const { componentId, graphicId } = await createAnnouncementComponent(
graph,
{
graphicParentId: graphicSource.id,
componentParentId: announcementSource.id
},
tokens
)
const instanceIds: string[] = []
for (const example of [
{ label: 'WIDE / ALL DETAILS', width: 600, showDetails: 'true' },
{ label: 'NARROW / DETAILS HIDDEN', width: 360, showDetails: 'false' }
]) {
const slot = await renderTree(
graph,
Frame({
name: example.label,
flex: 'col',
w: example.width,
h: 'hug',
gap: 16,
children: Text({ ...label, children: example.label })
}),
{ parentId: examples.id }
)
const instance = await renderTree(
graph,
Instance({
of: componentId,
w: 'fill',
properties: { [ANNOUNCEMENT_PROPERTIES.showDetails.id]: example.showDetails }
}),
{ parentId: slot.id }
)
instanceIds.push(instance.id)
}
await renderTree(
graph,
Text({
...label,
name: 'Editing instructions',
w: 900,
lineHeight: 18,
children: EDITING_PROMPT
}),
{ parentId: root.id }
)
return { rootId: root.id, componentId, graphicId, instanceIds }
}

View file

@ -0,0 +1,60 @@
import { parseColor } from '@open-pencil/core/color'
import { defineVars } from '@open-pencil/core/design-jsx'
import type { SceneGraph } from '@open-pencil/scene-graph'
export function createAnnouncementTokens(graph: SceneGraph) {
graph.addCollection({
id: 'announcement-colors',
name: 'Announcement / Color',
modes: [{ modeId: 'default', name: 'Default' }],
defaultModeId: 'default',
variableIds: []
})
for (const [id, name, hex] of [
['announcement-accent', 'Accent', '#4F46E5'],
['announcement-surface', 'Surface', '#EEF2FF'],
['announcement-ink', 'Text/Primary', '#172554'],
['announcement-muted', 'Text/Secondary', '#536487']
]) {
graph.addVariable({
id,
name,
type: 'COLOR',
collectionId: 'announcement-colors',
valuesByMode: { default: parseColor(hex) },
description: '',
hiddenFromPublishing: false
})
}
graph.addCollection({
id: 'announcement-spacing',
name: 'Announcement / Spacing',
modes: [{ modeId: 'default', name: 'Default' }],
defaultModeId: 'default',
variableIds: []
})
for (const [id, name, value] of [
['announcement-padding', 'Padding', 24],
['announcement-gap', 'Content gap', 12]
] as const) {
graph.addVariable({
id,
name,
type: 'FLOAT',
collectionId: 'announcement-spacing',
valuesByMode: { default: value },
description: '',
hiddenFromPublishing: false
})
}
return defineVars({
accent: 'announcement-accent',
surface: 'announcement-surface',
ink: 'announcement-ink',
muted: 'announcement-muted',
padding: 'announcement-padding',
gap: 'announcement-gap'
})
}
export type AnnouncementTokens = ReturnType<typeof createAnnouncementTokens>

View file

@ -1,37 +1,53 @@
import { computeAllLayouts } from '@open-pencil/core/layout'
import { createAppPreviewSection } from '@/app/demo/sections/app-preview'
import { createComponentsSection } from '@/app/demo/sections/components'
import { createEffectsSection } from '@/app/demo/sections/effects'
import { createStandaloneShapes } from '@/app/demo/sections/standalone'
import { createDemoVariables } from '@/app/demo/sections/variables'
import type { EditorStore } from '@/app/editor/session'
export function createDemoShapes(store: EditorStore) {
import { createAnnouncementSection } from './announcement/section'
import { loadDemoFonts } from './fonts'
import { createPaintSection } from './paint/section'
import { createReferenceExamples } from './reference'
import { createTypographySection } from './typography/section'
import { fitDemoPagesOnFirstVisit } from './viewport'
export async function createDemoShapes(store: EditorStore) {
const { graph } = store
const { btnCompId, badgeCompId } = createComponentsSection(store)
computeAllLayouts(store.graph)
const { sidebarId, headerId, frameId, headerTitle, chartTitle } = createAppPreviewSection(
store,
btnCompId,
badgeCompId
const referencePageId = store.state.currentPageId
await store.canvasReady
await loadDemoFonts()
// A file opened while the canvas was loading must not receive demo content.
if (
store.graph !== graph ||
store.state.currentPageId !== referencePageId ||
graph.getPages().length !== 1 ||
graph.getChildren(referencePageId).length > 0
)
return
createStandaloneShapes(store)
createDemoVariables(store)
createEffectsSection(store)
graph.updateNode(referencePageId, { name: 'Reference · original examples' })
await createReferenceExamples(store)
if (store.graph !== graph) return
graph.bindVariable(sidebarId, 'fills/0/color', 'var-bg')
graph.bindVariable(headerId, 'fills/0/color', 'var-bg')
graph.bindVariable(frameId, 'fills/0/color', 'var-bg-secondary')
graph.bindVariable(headerTitle, 'fills/0/color', 'var-text-primary')
graph.bindVariable(chartTitle, 'fills/0/color', 'var-text-primary')
const announcements = graph.addPage('01 · Components & variables')
const typography = graph.addPage('02 · Typography')
const paint = graph.addPage('03 · Paint & effects')
await createAnnouncementSection(graph, announcements.id)
await createTypographySection(graph, typography.id)
await createPaintSection(graph, paint.id)
computeAllLayouts(store.graph)
for (const page of graph.getPages()) {
await store.loadFontsForNodes(page.childIds)
computeAllLayouts(graph, page.id)
}
if (store.graph !== graph) return
store.movePage(referencePageId, graph.getPages().length - 1)
store.undo.clear()
fitDemoPagesOnFirstVisit(
store,
graph.getPages().map((page) => page.id)
)
await store.switchPage(announcements.id)
if (store.graph !== graph) return
store.clearSelection()
void store.loadFontsForNodes(graph.getPages().flatMap((page) => page.childIds)).then(() => {
store.requestRender()
return undefined
})
store.zoomToFit()
store.requestRender()
}

10
src/app/demo/fonts.ts Normal file
View file

@ -0,0 +1,10 @@
import { loadFont } from '@/app/editor/fonts'
export async function loadDemoFonts() {
await Promise.all([
loadFont('Inter', 'Regular'),
loadFont('Inter', 'Medium'),
loadFont('Inter', 'SemiBold'),
loadFont('Inter', 'Bold')
])
}

View file

@ -0,0 +1,213 @@
import {
Ellipse,
Frame,
Rectangle,
Text,
backgroundBlur,
dropShadow,
innerShadow,
layerBlur,
linearGradient,
radialGradient,
solid
} from '@open-pencil/core/design-jsx'
import type { TreeNode } from '@open-pencil/core/design-jsx'
export interface PaintExample {
title: string
caption: string
artwork: TreeNode
}
export interface PaintGroup {
title: string
examples: PaintExample[]
}
/** Only specimen artwork has fixed geometry; labels and section placement use layout. */
export function createPaintGroups(): PaintGroup[] {
const tile = { w: 72, h: 72, rounded: 16, bg: '#4F46E5' }
const pair = (children: TreeNode[]) =>
Frame({ flex: 'row', w: 232, h: 104, gap: 32, justify: 'center', items: 'center', children })
const stops = [
['#4F46E5', 0],
['#38BDF8', 1]
] as const
const gradientStops = [...stops]
return [
{
title: '01 / PAINT · THE SAME PALETTE, DIFFERENT FILLS',
examples: [
{
title: 'Linear gradient',
caption: 'Indigo to sky, across a flat surface.',
artwork: Rectangle({
w: 232,
h: 104,
rounded: 12,
fills: [linearGradient(gradientStops)]
})
},
{
title: 'Radial gradient',
caption: 'The same stops, radiating from the center.',
artwork: Rectangle({
w: 232,
h: 104,
rounded: 12,
fills: [radialGradient(gradientStops)]
})
},
{
title: 'Layered fills',
caption: 'A warm Overlay fill above the gradient.',
artwork: Rectangle({
w: 232,
h: 104,
rounded: 12,
fills: [
linearGradient(gradientStops),
solid('#FBBF24', { blendMode: 'OVERLAY', opacity: 0.65 })
]
})
}
]
},
{
title: '02 / DEPTH · UNCHANGED GEOMETRY, DIFFERENT SHADOWS',
examples: [
{
title: 'Drop shadow',
caption: 'Flat on the left; an outer shadow on the right.',
artwork: pair([
Rectangle(tile),
Rectangle({ ...tile, effects: [dropShadow({ y: 6, radius: 10, color: '#17255440' })] })
])
},
{
title: 'Inner shadow',
caption: 'The shadow stays inside the second shape.',
artwork: pair([
Rectangle(tile),
Rectangle({ ...tile, effects: [innerShadow({ y: 5, radius: 10, color: '#172554AA' })] })
])
},
{
title: 'Combined depth',
caption: 'Outer and inner shadows on the same shape.',
artwork: pair([
Rectangle(tile),
Rectangle({
...tile,
effects: [
dropShadow({ y: 6, radius: 10, color: '#17255440' }),
innerShadow({ y: 3, radius: 6, color: '#FFFFFF77' })
]
})
])
}
]
},
{
title: '03 / BLUR · KNOW WHAT IS BEING SOFTENED',
examples: [
{
title: 'Layer blur',
caption: 'Only the right-hand shape is blurred.',
artwork: pair([Rectangle(tile), Rectangle({ ...tile, effects: [layerBlur(6)] })])
},
{
title: 'Background blur',
caption: 'A translucent pane softens the stripes behind it.',
artwork: Frame({
w: 232,
h: 104,
overflow: 'hidden',
rounded: 12,
children: [
...['#4F46E5', '#38BDF8', '#4F46E5', '#38BDF8'].map((bg, index) =>
Rectangle({ x: index * 58, y: 0, w: 58, h: 104, bg })
),
Rectangle({
name: 'Frosted pane',
x: 40,
y: 16,
w: 152,
h: 72,
rounded: 10,
bg: '#FFFFFF55',
effects: [backgroundBlur(10)]
})
]
})
},
{
title: 'Text shadow',
caption: 'An effect on live glyphs, not a raster image.',
artwork: Frame({
flex: 'col',
w: 232,
h: 104,
justify: 'center',
children: Text({
font: 'Inter',
size: 32,
weight: 600,
color: '#4F46E5',
children: 'Still editable',
effects: [dropShadow({ y: 4, radius: 5, color: '#17255466' })]
})
})
}
]
},
{
title: '04 / COMPOSITION · BLENDING, MASKING, AND CORNERS',
examples: [
{
title: 'Multiply + Screen',
caption: 'Overlapping layers retain their blend modes.',
artwork: Frame({
w: 232,
h: 104,
children: [
Ellipse({ x: 24, y: 12, w: 80, h: 80, bg: '#3B82F6', blendMode: 'MULTIPLY' }),
Ellipse({ x: 76, y: 12, w: 80, h: 80, bg: '#F43F5E', blendMode: 'MULTIPLY' }),
Ellipse({ x: 128, y: 12, w: 80, h: 80, bg: '#FBBF24', blendMode: 'SCREEN' })
]
})
},
{
title: 'Alpha mask',
caption: 'The ellipse masks the following stripe layers.',
artwork: Frame({
w: 232,
h: 104,
children: [
Ellipse({
name: 'Editable mask',
x: 64,
y: 4,
w: 104,
h: 96,
bg: '#FFFFFF',
mask: 'alpha'
}),
...['#4F46E5', '#38BDF8', '#FBBF24'].map((bg, index) =>
Rectangle({ x: 44, y: 4 + index * 32, w: 144, h: 32, bg })
)
]
})
},
{
title: 'Independent corners',
caption: 'Equal corners versus alternating corner radii.',
artwork: pair([
Rectangle(tile),
Rectangle({ ...tile, roundedTL: 28, roundedTR: 4, roundedBR: 28, roundedBL: 4 })
])
}
]
}
]
}

View file

@ -0,0 +1,75 @@
import { Frame, Text, renderTree } from '@open-pencil/core/design-jsx'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { createPaintGroups } from './samples'
export function createPaintSection(graph: SceneGraph, parentId: string) {
const text = { font: 'Inter', color: '#172554' }
const label = { ...text, size: 12, color: '#536487' }
return renderTree(
graph,
Frame({
name: 'Paint and effects',
flex: 'col',
w: 1056,
h: 'hug',
p: 32,
gap: 32,
bg: '#FFFFFF',
children: [
Text({ ...text, size: 20, weight: 600, children: 'Same shapes. Different possibilities.' }),
...createPaintGroups().map((group) =>
Frame({
name: group.title,
flex: 'col',
w: 'fill',
h: 'hug',
gap: 16,
children: [
Text({ ...label, children: group.title }),
Frame({
name: 'Editable comparisons',
flex: 'row',
w: 'fill',
h: 'hug',
gap: 24,
children: group.examples.map((example) =>
Frame({
name: example.title,
flex: 'col',
w: 'fill',
h: 'hug',
gap: 8,
children: [
Frame({
name: `${example.title} / artwork`,
flex: 'row',
w: 'fill',
h: 'hug',
p: 16,
justify: 'center',
rounded: 12,
bg: '#EEF2FF',
children: example.artwork
}),
Text({ ...text, size: 14, weight: 600, children: example.title }),
Text({ ...label, w: 'fill', lineHeight: 18, children: example.caption })
]
})
)
})
]
})
),
Text({
...label,
w: 'fill',
lineHeight: 18,
children:
'Select the actual shape or text layer to inspect its fills and effects. Every example stays editable.'
})
]
}),
{ parentId }
)
}

34
src/app/demo/reference.ts Normal file
View file

@ -0,0 +1,34 @@
import { computeAllLayouts } from '@open-pencil/core/layout'
import { createAppPreviewSection } from '@/app/demo/sections/app-preview'
import { createComponentsSection } from '@/app/demo/sections/components'
import { createEffectsSection } from '@/app/demo/sections/effects'
import { createStandaloneShapes } from '@/app/demo/sections/standalone'
import { createDemoVariables } from '@/app/demo/sections/variables'
import type { EditorStore } from '@/app/editor/session'
export async function createReferenceExamples(store: EditorStore) {
const { graph } = store
const { btnCompId, badgeCompId } = createComponentsSection(store)
computeAllLayouts(store.graph)
const { sidebarId, headerId, frameId, headerTitle, chartTitle } = createAppPreviewSection(
store,
btnCompId,
badgeCompId
)
createStandaloneShapes(store)
createDemoVariables(store)
createEffectsSection(store)
graph.bindVariable(sidebarId, 'fills/0/color', 'var-bg')
graph.bindVariable(headerId, 'fills/0/color', 'var-bg')
graph.bindVariable(frameId, 'fills/0/color', 'var-bg-secondary')
graph.bindVariable(headerTitle, 'fills/0/color', 'var-text-primary')
graph.bindVariable(chartTitle, 'fills/0/color', 'var-text-primary')
computeAllLayouts(store.graph)
store.clearSelection()
await store.loadFontsForNodes(graph.getPages().flatMap((page) => page.childIds))
}

View file

@ -0,0 +1,49 @@
import type { SceneNode } from '@open-pencil/scene-graph'
export const ARTICLE = {
eyebrow: 'A NOTE ON MAKING THINGS',
title: 'Leave room for\na better idea.',
introduction: 'Good typography gives every thought a place.',
body: 'Start with a clear headline. Give the supporting text enough space to breathe. Keep the details quiet, but never so quiet that they disappear.',
detail: 'FIELD NOTES / 01 · EDITABLE TEXT, NOT OUTLINES'
}
interface FeatureComparison {
title: string
text: string
font?: string
enabled: SceneNode['fontFeatures']
disabled: SceneNode['fontFeatures']
labels: readonly [string, string]
}
export const FEATURE_COMPARISONS: readonly FeatureComparison[] = [
{
title: 'Discretionary ligatures',
text: 'What!? Really?!',
font: 'Inter',
enabled: [{ tag: 'DLIG', enabled: true }],
disabled: [{ tag: 'DLIG', enabled: false }],
labels: ['DLIG ON', 'DLIG OFF']
},
{
title: 'Kerning',
text: 'AVATAR WAVE',
enabled: [{ tag: 'KERN', enabled: true }],
disabled: [{ tag: 'KERN', enabled: false }],
labels: ['KERN ON', 'KERN OFF']
},
{
title: 'Tabular and proportional figures',
text: '1,111.00 / 8,888.00',
enabled: [
{ tag: 'TNUM', enabled: true },
{ tag: 'PNUM', enabled: false }
],
disabled: [
{ tag: 'TNUM', enabled: false },
{ tag: 'PNUM', enabled: true }
],
labels: ['TNUM ON · PNUM OFF', 'TNUM OFF · PNUM ON']
}
]

View file

@ -0,0 +1,39 @@
import { Text, renderTree } from '@open-pencil/core/design-jsx'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
interface TypographySample {
name: string
text: string
font?: string
fontFeatures?: SceneNode['fontFeatures']
decoration?: Pick<
SceneNode,
'textDecoration' | 'textDecorationStyle' | 'textDecorationFills' | 'textDecorationThickness'
>
}
/** These native OpenType/decoration fields are not Design JSX shorthands. */
export async function createTypographySample(
graph: SceneGraph,
parentId: string,
sample: TypographySample
) {
const result = await renderTree(
graph,
Text({
name: sample.name,
children: sample.text,
font: sample.font ?? 'Inter',
size: 24,
lineHeight: 36,
w: 'fill',
color: '#172554'
}),
{ parentId }
)
graph.updateNode(result.id, {
fontFeatures: sample.fontFeatures ?? [],
...sample.decoration
})
return result.id
}

View file

@ -0,0 +1,212 @@
import { parseColor } from '@open-pencil/core/color'
import { Frame, Text, renderTree } from '@open-pencil/core/design-jsx'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { ARTICLE, FEATURE_COMPARISONS } from './content'
import { createTypographySample } from './samples'
export async function createTypographySection(graph: SceneGraph, parentId: string) {
const text = { font: 'Inter', color: '#172554' }
const label = { ...text, size: 12, color: '#536487' }
const root = await renderTree(
graph,
Frame({
name: 'Typography workbench',
flex: 'col',
w: 1056,
h: 'hug',
gap: 32,
p: 32,
bg: '#FFFFFF',
children: [
Text({ ...text, size: 20, weight: 600, children: 'Type that holds together.' }),
Frame({
name: 'Hierarchy in context',
flex: 'row',
w: 'fill',
h: 'hug',
gap: 32,
children: [
Frame({
name: 'Editable article',
flex: 'col',
w: 600,
h: 'hug',
gap: 16,
p: 24,
rounded: 16,
bg: '#EEF2FF',
children: [
Text({ ...label, size: 10, children: ARTICLE.eyebrow }),
Text({
...text,
name: 'Article headline',
w: 'fill',
size: 36,
lineHeight: 42,
weight: 600,
children: ARTICLE.title
}),
Text({
...text,
name: 'Article introduction',
w: 'fill',
size: 18,
lineHeight: 26,
children: ARTICLE.introduction
}),
Text({
...text,
name: 'Article body',
w: 'fill',
size: 14,
lineHeight: 22,
children: ARTICLE.body
}),
Text({ ...label, size: 10, children: ARTICLE.detail })
]
}),
Frame({
name: 'Type hierarchy guide',
flex: 'col',
w: 'fill',
h: 'hug',
gap: 24,
children: [
Text({ ...label, children: '01 / HIERARCHY' }),
...[
{
title: 'Headline · 36 / 42',
description: 'The first thing to read. Keep its rhythm when the words change.'
},
{
title: 'Introduction · 18 / 26',
description: 'A second voice, not a second headline.'
},
{
title: 'Body · 14 / 22',
description: 'Readable lines, generous leading, and a content-sized frame.'
},
{
title: 'Details · 10',
description: 'Supporting information with enough contrast to stay useful.'
}
].map((item) =>
Frame({
flex: 'col',
w: 'fill',
h: 'hug',
gap: 6,
children: [
Text({ ...text, size: 14, weight: 600, children: item.title }),
Text({ ...label, w: 'fill', lineHeight: 18, children: item.description })
]
})
)
]
})
]
})
]
}),
{ parentId }
)
const features = await renderTree(
graph,
Frame({
name: 'OpenType comparisons',
flex: 'col',
w: 'fill',
h: 'hug',
gap: 16,
children: Text({ ...label, children: '02 / OPENTYPE · THE SAME TEXT, DIFFERENT FEATURES' })
}),
{ parentId: root.id }
)
const comparisonRow = await renderTree(
graph,
Frame({ name: 'Feature pairs', flex: 'row', w: 'fill', h: 'hug', gap: 24 }),
{ parentId: features.id }
)
for (const comparison of FEATURE_COMPARISONS) {
const column = await renderTree(
graph,
Frame({
name: comparison.title,
flex: 'col',
w: 'fill',
h: 'hug',
gap: 8,
children: Text({ ...text, size: 14, weight: 600, children: comparison.title })
}),
{ parentId: comparisonRow.id }
)
for (const [index, settings] of [comparison.enabled, comparison.disabled].entries()) {
await renderTree(graph, Text({ ...label, size: 10, children: comparison.labels[index] }), {
parentId: column.id
})
await createTypographySample(graph, column.id, {
name: `${comparison.title} / ${comparison.labels[index]}`,
text: comparison.text,
font: comparison.font,
fontFeatures: settings
})
}
}
await renderTree(
graph,
Text({
...label,
w: 'fill',
lineHeight: 18,
children:
'Feature support depends on the font. Raw tags are preserved even when a typeface has no matching alternate.'
}),
{ parentId: features.id }
)
const decorations = await renderTree(
graph,
Frame({
name: 'Text decorations',
flex: 'col',
w: 'fill',
h: 'hug',
gap: 16,
children: Text({ ...label, children: '03 / DECORATION · NATIVE TEXT, NOT DRAWN LINES' })
}),
{ parentId: root.id }
)
const decorationRow = await renderTree(
graph,
Frame({ name: 'Decoration samples', flex: 'row', w: 'fill', h: 'hug', gap: 24 }),
{ parentId: decorations.id }
)
for (const sample of [
{ name: 'Wavy underline', style: 'WAVY', color: '#E5484D', thickness: 1.6 },
{ name: 'Dotted underline', style: 'DOTTED', color: '#4F46E5', thickness: 2 }
] as const) {
const column = await renderTree(
graph,
Frame({ name: sample.name, flex: 'col', w: 'fill', h: 'hug', gap: 8 }),
{ parentId: decorationRow.id }
)
await createTypographySample(graph, column.id, {
name: sample.name,
text: 'Keep this thought.',
decoration: {
textDecoration: 'UNDERLINE',
textDecorationStyle: sample.style,
textDecorationFills: [
{ type: 'SOLID', color: parseColor(sample.color), opacity: 1, visible: true }
],
textDecorationThickness: sample.thickness
}
})
await renderTree(
graph,
Text({ ...label, children: `${sample.name} · ${sample.thickness}px` }),
{ parentId: column.id }
)
}
return { rootId: root.id }
}

20
src/app/demo/viewport.ts Normal file
View file

@ -0,0 +1,20 @@
import type { Editor } from '@open-pencil/core/editor'
type DemoViewportEditor = Pick<Editor, 'onEditorEvent' | 'zoomToFit'>
/** Fit a demo page once, then preserve the user's own viewport on return visits. */
export function fitDemoPagesOnFirstVisit(store: DemoViewportEditor, pageIds: string[]) {
const pending = new Set(pageIds)
const unbindPage = store.onEditorEvent('page:changed', (pageId) => {
if (!pending.delete(pageId)) return
store.zoomToFit()
if (pending.size === 0) dispose()
})
const unbindGraph = store.onEditorEvent('graph:replaced', dispose)
function dispose() {
unbindPage()
unbindGraph()
}
return dispose
}

View file

@ -54,6 +54,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
height: IS_BROWSER ? window.innerHeight : 1080
}
})
const canvasReadiness = Promise.withResolvers<undefined>()
const io = new IORegistry(BUILTIN_IO_FORMATS)
bindClipboardNotifications(editor)
@ -190,6 +191,8 @@ export function createEditorStore(initialGraph?: SceneGraph) {
...editor,
state,
preparationController,
canvasReady: canvasReadiness.promise,
markCanvasReady: () => canvasReadiness.resolve(undefined),
onPreparationEvent<Event extends EditorPreparationEventName>(
event: Event,
handler: EditorPreparationEvents[Event]

View file

@ -58,9 +58,10 @@ function updatePaneCursor(cx: number, cy: number) {
}
const getRenderState = paneId ? () => store.getPaneRenderState(paneId) : undefined
const onViewportResize = paneId
? (width: number, height: number) => store.resizePane(paneId, width, height)
: undefined
const onViewportResize = (width: number, height: number) => {
if (paneId) store.resizePane(paneId, width, height)
if (isActivePane.value) store.setViewportSize(width, height)
}
const { updateCursor } = useCanvasCollaborationAwareness(store, collab)
const { selectAtContextPoint } = createCanvasContextSelection(canvasRef, store)
@ -73,6 +74,7 @@ const shouldSuspendRender = () =>
useCanvas(sceneCanvasRef, store, {
layer: 'scene',
sceneRenderer: appRuntimeConfig.sceneRenderer,
onReady: store.markCanvasReady,
shouldSuspendRender,
showRulers: false,
getRenderState,
@ -85,6 +87,9 @@ const { hitTestSectionTitle, hitTestComponentLabel, hitTestFrameTitle } = useCan
store,
{
layer: 'overlays',
get showRulers() {
return appRuntimeConfig.showRulers && store.state.showRulers
},
shouldSuspendRender,
getRenderState,
onViewportResize

View file

@ -112,11 +112,17 @@ test('Undo via Edit menu works', async () => {
expect(beforeUndo).toBe(1)
await editor.page.locator('[role="menubar"] [role="menuitem"]', { hasText: 'Edit' }).click()
await editor.page.locator('[role="menu"] [role="menuitem"]', { hasText: 'Undo' }).click()
const undoItem = editor.page.getByRole('menuitem', { name: /^Undo\b/ })
await expect(undoItem).toBeEnabled()
await undoItem.click()
await editor.canvas.waitForRender()
const afterUndo = await getStoreStateNumber('selectedIds')
expect(afterUndo).toBe(0)
await editor.page.getByRole('menuitem', { name: 'Edit', exact: true }).click()
await expect(editor.page.getByRole('menuitem', { name: /^Redo\b/ })).toBeEnabled()
await editor.page.keyboard.press('Escape')
})
test('Duplicate via Edit menu works', async () => {

View file

@ -1,23 +0,0 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
const editor = useEditorSetup('/demo?test&no-chrome&no-rulers')
async function expectCanvas(name: string) {
editor.canvas.assertNoErrors()
const buffer = await editor.canvas.screenshotCanvasRegion()
expect(buffer).toMatchSnapshot(`${name}.png`)
}
test('demo effects section showcases renderer features', async () => {
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.state.zoom = 0.82
store.state.panX = 8
store.state.panY = -556
store.clearSelection()
store.requestRender()
})
await editor.canvas.waitForRender()
await expectCanvas('demo-effects-section')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,116 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import type * as DemoStartup from '#tests/helpers/canvas/demo-startup'
import { waitForDemo } from '#tests/helpers/demo'
async function openDemo(page: Page) {
const canvas = new CanvasHelper(page)
await page.goto('/demo?no-chrome&no-rulers')
await canvas.waitForInit()
await waitForDemo(page)
return canvas
}
test.use({ viewport: { width: 1200, height: 1300 } })
for (const [name, snapshot] of [
['01 · Components & variables', 'demo-announcements'],
['02 · Typography', 'demo-typography'],
['03 · Paint & effects', 'demo-paint']
] as const) {
test(`demo startup and page navigation: ${name}`, async ({ page }) => {
const canvas = await openDemo(page)
const bounds = await page.evaluate(async (name) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('Editor unavailable')
const page = store.graph.getPages().find((page) => page.name === name)
if (!page) throw new Error(`Missing page: ${name}`)
await store.switchPage(page.id)
const [root] = store.graph.getChildren(page.id)
return {
x: root.x * store.state.zoom + store.state.panX,
y: root.y * store.state.zoom + store.state.panY,
width: root.width * store.state.zoom,
height: root.height * store.state.zoom
}
}, name)
const box = await canvas.canvas.boundingBox()
if (!box) throw new Error('Canvas unavailable')
expect(bounds.x).toBeGreaterThanOrEqual(0)
expect(bounds.y).toBeGreaterThanOrEqual(0)
expect(bounds.x + bounds.width).toBeLessThanOrEqual(box.width)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(box.height)
await canvas.waitForRender()
canvas.assertNoErrors()
expect(await canvas.screenshotCanvasRegion(1200, 1300)).toMatchSnapshot(`${snapshot}.png`, {
maxDiffPixels: 100
})
})
}
test('demo completion preserves a document replaced during its final page switch', async ({
page
}) => {
await page.goto('/?test&no-chrome&no-rulers')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
const result = await page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('Editor unavailable')
const fixtureURL = '/tests/helpers/canvas/demo-startup.ts'
const { replaceGraphDuringDemoSwitch }: typeof DemoStartup = await import(fixtureURL)
return replaceGraphDuringDemoSwitch(store)
})
expect(result.actual).toEqual(result.expected)
canvas.assertNoErrors()
})
test('demo tokens and main-component edits survive undo', async ({ page }) => {
await openDemo(page)
const result = await page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('Editor unavailable')
const graph = store.graph
const token = graph.variables.get('announcement-accent')
if (!token) throw new Error('Accent token unavailable')
const original = structuredClone(token.valuesByMode.default)
store.updateVariableValue(token.id, 'default', { r: 0.05, g: 0.55, b: 0.5, a: 1 })
const changed = structuredClone(token.valuesByMode.default)
store.undoAction()
const restored = structuredClone(token.valuesByMode.default)
const component = Array.from(graph.getAllNodes()).find(
(node) => node.type === 'COMPONENT' && node.name === 'Announcement'
)
if (!component) throw new Error('Announcement component unavailable')
const content = graph.getChildren(component.id).find((node) => node.name === 'Content')
if (!content) throw new Error('Component content unavailable')
const headline = graph.getChildren(content.id).find((node) => node.name === 'Headline')
if (!headline) throw new Error('Headline unavailable')
const instances = Array.from(graph.getAllNodes()).filter(
(node) => node.type === 'INSTANCE' && node.componentId === component.id
)
const headlines = () =>
instances.map((instance) => {
const content = graph.getChildren(instance.id).find((node) => node.name === 'Content')
if (!content) throw new Error('Instance content unavailable')
return graph.getChildren(content.id).find((node) => node.name === 'Headline')?.text
})
store.updateNodeWithUndo(
headline.id,
{ text: 'Open studio: bring your next idea' },
'Edit demo headline'
)
await store.loadFontsForNodes([component.id])
const editedHeadlines = headlines()
store.undoAction()
await store.loadFontsForNodes([component.id])
return { original, changed, restored, editedHeadlines, restoredHeadlines: headlines() }
})
expect(result.changed).not.toEqual(result.original)
expect(result.restored).toEqual(result.original)
expect(result.editedHeadlines).toEqual([
'Open studio: bring your next idea',
'Open studio: bring your next idea'
])
expect(result.restoredHeadlines).toEqual(['Open studio', 'Open studio'])
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -0,0 +1,27 @@
import { expect, test } from '#tests/e2e/fixtures'
import { CanvasHelper } from '#tests/helpers/canvas'
for (const suppressed of [false, true]) {
test(`ruler menu changes rendered visibility with no-rulers=${suppressed}`, async ({ page }) => {
await page.goto(suppressed ? '/?test&no-rulers' : '/?test')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
await page.mouse.move(0, 0)
const initial = await canvas.screenshotCanvasRegion()
async function toggleRulers() {
await page.getByRole('button', { name: '100%', exact: true }).click()
await page.getByRole('menuitem', { name: 'Rulers', exact: true }).click()
await page.keyboard.press('Escape')
await page.mouse.move(0, 0)
await canvas.waitForRender()
}
await toggleRulers()
const hidden = await canvas.screenshotCanvasRegion()
expect(hidden.equals(initial)).toBe(suppressed)
await toggleRulers()
expect((await canvas.screenshotCanvasRegion()).equals(initial)).toBe(true)
canvas.assertNoErrors()
})
}

View file

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { selectDemoReferencePage } from '#tests/helpers/demo'
async function dragSlider(
page: Parameters<typeof test>[0]['page'],
@ -24,6 +25,7 @@ async function dragSlider(
async function selectDemoCard(page: Parameters<typeof test>[0]['page'], canvas: CanvasHelper) {
await page.goto('/demo')
await canvas.waitForInit()
await selectDemoReferencePage(page)
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()

View file

@ -169,22 +169,23 @@ test('assets panel groups component sets and inserts the default variant', async
const inserted = await selectedNodeSnapshot(page)
expect(inserted?.type).toBe('INSTANCE')
expect(inserted?.componentId).toBe(ids.secondaryId)
// The spatially first variant is the documented default, regardless of property defaults.
expect(inserted?.componentId).toBe(ids.primaryId)
expect(inserted?.parentId).toBe(inserted?.pageId)
expect(inserted?.width).toBe(132)
expect(inserted?.childTexts).toEqual(['Secondary'])
expect(inserted?.width).toBe(96)
expect(inserted?.childTexts).toEqual(['Primary'])
const variantSection = page.getByRole('region', { name: 'Variants' })
await expect(variantSection).toBeVisible()
await variantSection.getByRole('combobox', { name: 'Type' }).click()
await page.getByRole('option', { name: 'Primary' }).click()
await page.getByRole('option', { name: 'Secondary' }).click()
expectDefined(inserted?.id, 'inserted instance id')
const switched = await selectedNodeSnapshot(page)
expect(switched?.componentId).toBe(ids.primaryId)
expect(switched?.width).toBe(96)
expect(switched?.childTexts).toEqual(['Primary'])
expect(switched?.componentId).toBe(ids.secondaryId)
expect(switched?.width).toBe(132)
expect(switched?.childTexts).toEqual(['Secondary'])
canvas.assertNoErrors()
})

View file

@ -1,6 +1,7 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { selectDemoReferencePage } from '#tests/helpers/demo'
import { propertyItems, propertySection } from '#tests/helpers/properties'
async function dragSlider(page: Page, canvas: CanvasHelper, testId: string, ratio: number) {
@ -85,6 +86,7 @@ test('stroke picker hsb saturation and brightness sliders update stroke color on
const canvas = new CanvasHelper(page)
await page.goto('/demo')
await canvas.waitForInit()
await selectDemoReferencePage(page)
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()

View file

@ -0,0 +1,48 @@
import { expect, spyOn, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { SceneGraph } from '@open-pencil/scene-graph'
import { fitDemoPagesOnFirstVisit } from '@/app/demo/viewport'
test('fits only the first visit and preserves a custom viewport on return', async () => {
const editor = createEditor()
const originalPage = editor.state.currentPageId
const next = editor.graph.addPage('Next')
editor.graph.createNode('FRAME', next.id, { x: 1000, y: 1000, width: 100, height: 100 })
const fit = spyOn(editor, 'zoomToFit')
const stop = fitDemoPagesOnFirstVisit(editor, [next.id])
try {
await editor.switchPage(next.id)
expect(fit).toHaveBeenCalledTimes(1)
editor.pan(120, 80)
const viewport = { panX: editor.state.panX, panY: editor.state.panY, zoom: editor.state.zoom }
await editor.switchPage(originalPage)
await editor.switchPage(next.id)
expect(fit).toHaveBeenCalledTimes(1)
expect({ panX: editor.state.panX, panY: editor.state.panY, zoom: editor.state.zoom }).toEqual(
viewport
)
} finally {
stop()
fit.mockRestore()
editor.dispose()
}
})
test('stops fitting after the demo graph is replaced', async () => {
const editor = createEditor()
const replacement = new SceneGraph()
const nextReplacement = replacement.addPage('User document')
const fit = spyOn(editor, 'zoomToFit')
const stop = fitDemoPagesOnFirstVisit(editor, [nextReplacement.id])
try {
editor.replaceGraph(replacement)
await editor.switchPage(nextReplacement.id)
expect(fit).not.toHaveBeenCalled()
} finally {
stop()
fit.mockRestore()
editor.dispose()
}
})

View file

@ -0,0 +1,54 @@
import { expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
const entry = { label: 'Edit', forward: () => undefined, inverse: () => undefined }
test('publishes history changes independently of scene mutations', () => {
const editor = createEditor()
const observed: Array<[boolean, boolean]> = []
const off = editor.onEditorEvent('history:changed', () => {
observed.push([editor.undo.canUndo, editor.undo.canRedo])
})
const sceneVersion = editor.state.sceneVersion
try {
editor.undo.record(entry)
editor.undo.undo()
editor.undo.redo()
editor.undo.clear()
expect(observed).toEqual([
[true, false],
[false, true],
[true, false],
[false, false]
])
expect(editor.state.sceneVersion).toBe(sceneVersion)
} finally {
off()
editor.dispose()
}
})
test('publishes committed batches and coalescing but not pending or rolled-back batches', () => {
const editor = createEditor()
const labels: Array<string | null> = []
const off = editor.onEditorEvent('history:changed', () => labels.push(editor.undo.undoLabel))
try {
editor.undo.beginBatch('Cancelled')
editor.undo.record(entry)
editor.undo.rollbackBatch()
expect(labels).toEqual([])
editor.undo.beginBatch('Batch', 'same-edit')
editor.undo.record(entry)
editor.undo.beginBatch('Nested')
editor.undo.record(entry)
editor.undo.commitBatch()
expect(labels).toEqual([])
editor.undo.commitBatch()
editor.undo.record({ ...entry, label: 'Coalesced', coalesceKey: 'same-edit' })
expect(labels).toEqual(['Batch', 'Coalesced'])
} finally {
off()
editor.dispose()
}
})

View file

@ -0,0 +1,34 @@
import { SceneGraph } from '@open-pencil/scene-graph'
import { createDemoShapes } from '@/app/demo/document'
import type { EditorStore } from '@/app/editor/session'
export async function replaceGraphDuringDemoSwitch(store: EditorStore) {
const switchPage = store.switchPage
const snapshot = () => ({
selectedIds: [...store.state.selectedIds],
zoom: store.state.zoom,
panX: store.state.panX,
panY: store.state.panY
})
let expected: ReturnType<typeof snapshot> | undefined
store.switchPage = async (...args) => {
const result = await switchPage(...args)
const replacement = new SceneGraph()
const page = replacement.addPage('Replacement document')
const node = replacement.createNode('RECTANGLE', page.id, { width: 100, height: 100 })
store.replaceGraph(replacement)
await switchPage(page.id)
store.select([node.id])
store.zoomToLevel(0.75)
expected = snapshot()
return result
}
try {
await createDemoShapes(store)
if (!expected) throw new Error('Demo did not reach its final page switch')
return { expected, actual: snapshot() }
} finally {
store.switchPage = switchPage
}
}

24
tests/helpers/demo.ts Normal file
View file

@ -0,0 +1,24 @@
import type { Page } from '@playwright/test'
export async function waitForDemo(page: Page) {
await page.waitForFunction(() => {
const store = window.openPencil?.getStore?.()
return (
store?.graph.getNode(store.state.currentPageId)?.name === '01 · Components & variables' &&
store.state.preparation === null
)
})
}
export async function selectDemoReferencePage(page: Page) {
await waitForDemo(page)
await page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const reference = store.graph
.getPages()
.find((page) => page.name === 'Reference · original examples')
if (!reference) throw new Error('Demo reference page not found')
await store.switchPage(reference.id)
})
}