diff --git a/CHANGELOG.md b/CHANGELOG.md index cfe371d1c..5e30ad022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 909562ed7..e86b0c82c 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -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 ?? diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 60417b9d0..5ca931480 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -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 diff --git a/packages/scene-graph/src/undo.ts b/packages/scene-graph/src/undo.ts index 268aa556f..7b930d2e2 100644 --- a/packages/scene-graph/src/undo.ts +++ b/packages/scene-graph/src/undo.ts @@ -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 { diff --git a/packages/vue/src/editor/selection-capabilities/use.ts b/packages/vue/src/editor/selection-capabilities/use.ts index 3b28f51dc..e8df44799 100644 --- a/packages/vue/src/editor/selection-capabilities/use.ts +++ b/packages/vue/src/editor/selection-capabilities/use.ts @@ -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) } } diff --git a/src/app/demo/announcement/component.ts b/src/app/demo/announcement/component.ts new file mode 100644 index 000000000..201dd8a87 --- /dev/null +++ b/src/app/demo/announcement/component.ts @@ -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 + +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 } +} diff --git a/src/app/demo/announcement/content.ts b/src/app/demo/announcement/content.ts new file mode 100644 index 000000000..e21a7aeb6 --- /dev/null +++ b/src/app/demo/announcement/content.ts @@ -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.' diff --git a/src/app/demo/announcement/section.ts b/src/app/demo/announcement/section.ts new file mode 100644 index 000000000..342e78cde --- /dev/null +++ b/src/app/demo/announcement/section.ts @@ -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 } +} diff --git a/src/app/demo/announcement/tokens.ts b/src/app/demo/announcement/tokens.ts new file mode 100644 index 000000000..45d7764e4 --- /dev/null +++ b/src/app/demo/announcement/tokens.ts @@ -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 diff --git a/src/app/demo/document.ts b/src/app/demo/document.ts index e6954f320..3a6aa94f8 100644 --- a/src/app/demo/document.ts +++ b/src/app/demo/document.ts @@ -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() } diff --git a/src/app/demo/fonts.ts b/src/app/demo/fonts.ts new file mode 100644 index 000000000..13e030c3d --- /dev/null +++ b/src/app/demo/fonts.ts @@ -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') + ]) +} diff --git a/src/app/demo/paint/samples.ts b/src/app/demo/paint/samples.ts new file mode 100644 index 000000000..d2cd04830 --- /dev/null +++ b/src/app/demo/paint/samples.ts @@ -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 }) + ]) + } + ] + } + ] +} diff --git a/src/app/demo/paint/section.ts b/src/app/demo/paint/section.ts new file mode 100644 index 000000000..9efbb27f3 --- /dev/null +++ b/src/app/demo/paint/section.ts @@ -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 } + ) +} diff --git a/src/app/demo/reference.ts b/src/app/demo/reference.ts new file mode 100644 index 000000000..ca49fdd8c --- /dev/null +++ b/src/app/demo/reference.ts @@ -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)) +} diff --git a/src/app/demo/typography/content.ts b/src/app/demo/typography/content.ts new file mode 100644 index 000000000..690cb6689 --- /dev/null +++ b/src/app/demo/typography/content.ts @@ -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'] + } +] diff --git a/src/app/demo/typography/samples.ts b/src/app/demo/typography/samples.ts new file mode 100644 index 000000000..ac1b5cb85 --- /dev/null +++ b/src/app/demo/typography/samples.ts @@ -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 +} diff --git a/src/app/demo/typography/section.ts b/src/app/demo/typography/section.ts new file mode 100644 index 000000000..2c6559801 --- /dev/null +++ b/src/app/demo/typography/section.ts @@ -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 } +} diff --git a/src/app/demo/viewport.ts b/src/app/demo/viewport.ts new file mode 100644 index 000000000..1a401c744 --- /dev/null +++ b/src/app/demo/viewport.ts @@ -0,0 +1,20 @@ +import type { Editor } from '@open-pencil/core/editor' + +type DemoViewportEditor = Pick + +/** 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 +} diff --git a/src/app/editor/session/create.ts b/src/app/editor/session/create.ts index c67f4c2aa..328e06c29 100644 --- a/src/app/editor/session/create.ts +++ b/src/app/editor/session/create.ts @@ -54,6 +54,7 @@ export function createEditorStore(initialGraph?: SceneGraph) { height: IS_BROWSER ? window.innerHeight : 1080 } }) + const canvasReadiness = Promise.withResolvers() 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: Event, handler: EditorPreparationEvents[Event] diff --git a/src/components/EditorCanvas.vue b/src/components/EditorCanvas.vue index 88167e2af..9e6b33b08 100644 --- a/src/components/EditorCanvas.vue +++ b/src/components/EditorCanvas.vue @@ -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 diff --git a/tests/e2e/app/menu.spec.ts b/tests/e2e/app/menu.spec.ts index 74b12d3e2..470c92115 100644 --- a/tests/e2e/app/menu.spec.ts +++ b/tests/e2e/app/menu.spec.ts @@ -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 () => { diff --git a/tests/e2e/canvas/demo-effects.spec.ts b/tests/e2e/canvas/demo-effects.spec.ts deleted file mode 100644 index 1a8d35c16..000000000 --- a/tests/e2e/canvas/demo-effects.spec.ts +++ /dev/null @@ -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') -}) diff --git a/tests/e2e/canvas/demo-effects.spec.ts-snapshots/demo-effects-section-openpencil-darwin.png b/tests/e2e/canvas/demo-effects.spec.ts-snapshots/demo-effects-section-openpencil-darwin.png deleted file mode 100644 index 7bf6a2704..000000000 Binary files a/tests/e2e/canvas/demo-effects.spec.ts-snapshots/demo-effects-section-openpencil-darwin.png and /dev/null differ diff --git a/tests/e2e/canvas/demo-workspace.spec.ts b/tests/e2e/canvas/demo-workspace.spec.ts new file mode 100644 index 000000000..6aeb96efc --- /dev/null +++ b/tests/e2e/canvas/demo-workspace.spec.ts @@ -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']) +}) diff --git a/tests/e2e/canvas/demo-workspace.spec.ts-snapshots/demo-announcements-openpencil-darwin.png b/tests/e2e/canvas/demo-workspace.spec.ts-snapshots/demo-announcements-openpencil-darwin.png new file mode 100644 index 000000000..7bc2ac784 Binary files /dev/null and b/tests/e2e/canvas/demo-workspace.spec.ts-snapshots/demo-announcements-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/demo-workspace.spec.ts-snapshots/demo-paint-openpencil-darwin.png b/tests/e2e/canvas/demo-workspace.spec.ts-snapshots/demo-paint-openpencil-darwin.png new file mode 100644 index 000000000..290f2c619 Binary files /dev/null and b/tests/e2e/canvas/demo-workspace.spec.ts-snapshots/demo-paint-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/demo-workspace.spec.ts-snapshots/demo-typography-openpencil-darwin.png b/tests/e2e/canvas/demo-workspace.spec.ts-snapshots/demo-typography-openpencil-darwin.png new file mode 100644 index 000000000..7c1c5102f Binary files /dev/null and b/tests/e2e/canvas/demo-workspace.spec.ts-snapshots/demo-typography-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/instance-sizing.spec.ts-snapshots/instance-fill-sizing-openpencil-darwin.png b/tests/e2e/canvas/instance-sizing.spec.ts-snapshots/instance-fill-sizing-openpencil-darwin.png index 7ad08de0e..624d64518 100644 Binary files a/tests/e2e/canvas/instance-sizing.spec.ts-snapshots/instance-fill-sizing-openpencil-darwin.png and b/tests/e2e/canvas/instance-sizing.spec.ts-snapshots/instance-fill-sizing-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/ruler-visibility.spec.ts b/tests/e2e/canvas/ruler-visibility.spec.ts new file mode 100644 index 000000000..0c36243af --- /dev/null +++ b/tests/e2e/canvas/ruler-visibility.spec.ts @@ -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() + }) +} diff --git a/tests/e2e/color-picker/demo-card.spec.ts b/tests/e2e/color-picker/demo-card.spec.ts index bd07f3352..b220fab29 100644 --- a/tests/e2e/color-picker/demo-card.spec.ts +++ b/tests/e2e/color-picker/demo-card.spec.ts @@ -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[0]['page'], @@ -24,6 +25,7 @@ async function dragSlider( async function selectDemoCard(page: Parameters[0]['page'], canvas: CanvasHelper) { await page.goto('/demo') await canvas.waitForInit() + await selectDemoReferencePage(page) await page.evaluate(() => { const store = window.openPencil?.getStore?.() diff --git a/tests/e2e/components/assets-panel.spec.ts b/tests/e2e/components/assets-panel.spec.ts index 06be45367..a2f7a1552 100644 --- a/tests/e2e/components/assets-panel.spec.ts +++ b/tests/e2e/components/assets-panel.spec.ts @@ -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() }) diff --git a/tests/e2e/stroke-picker/basic.spec.ts b/tests/e2e/stroke-picker/basic.spec.ts index ca4ac7850..30bcefcc2 100644 --- a/tests/e2e/stroke-picker/basic.spec.ts +++ b/tests/e2e/stroke-picker/basic.spec.ts @@ -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?.() diff --git a/tests/engine/app/demo/viewport.test.ts b/tests/engine/app/demo/viewport.test.ts new file mode 100644 index 000000000..17d7e8852 --- /dev/null +++ b/tests/engine/app/demo/viewport.test.ts @@ -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() + } +}) diff --git a/tests/engine/editor/undo/history-events.test.ts b/tests/engine/editor/undo/history-events.test.ts new file mode 100644 index 000000000..e8892a1f9 --- /dev/null +++ b/tests/engine/editor/undo/history-events.test.ts @@ -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 = [] + 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() + } +}) diff --git a/tests/helpers/canvas/demo-startup.ts b/tests/helpers/canvas/demo-startup.ts new file mode 100644 index 000000000..28d4e80ee --- /dev/null +++ b/tests/helpers/canvas/demo-startup.ts @@ -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 | 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 + } +} diff --git a/tests/helpers/demo.ts b/tests/helpers/demo.ts new file mode 100644 index 000000000..f4f1bbafa --- /dev/null +++ b/tests/helpers/demo.ts @@ -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) + }) +}