Merge branch 'master' into mcp-v2-webmcp
This commit is contained in:
commit
38e80a4567
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
127
src/app/demo/announcement/component.ts
Normal file
127
src/app/demo/announcement/component.ts
Normal 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 }
|
||||
}
|
||||
9
src/app/demo/announcement/content.ts
Normal file
9
src/app/demo/announcement/content.ts
Normal 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.'
|
||||
141
src/app/demo/announcement/section.ts
Normal file
141
src/app/demo/announcement/section.ts
Normal 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 }
|
||||
}
|
||||
60
src/app/demo/announcement/tokens.ts
Normal file
60
src/app/demo/announcement/tokens.ts
Normal 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>
|
||||
|
|
@ -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
10
src/app/demo/fonts.ts
Normal 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')
|
||||
])
|
||||
}
|
||||
213
src/app/demo/paint/samples.ts
Normal file
213
src/app/demo/paint/samples.ts
Normal 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 })
|
||||
])
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
75
src/app/demo/paint/section.ts
Normal file
75
src/app/demo/paint/section.ts
Normal 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
34
src/app/demo/reference.ts
Normal 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))
|
||||
}
|
||||
49
src/app/demo/typography/content.ts
Normal file
49
src/app/demo/typography/content.ts
Normal 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']
|
||||
}
|
||||
]
|
||||
39
src/app/demo/typography/samples.ts
Normal file
39
src/app/demo/typography/samples.ts
Normal 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
|
||||
}
|
||||
212
src/app/demo/typography/section.ts
Normal file
212
src/app/demo/typography/section.ts
Normal 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
20
src/app/demo/viewport.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 |
116
tests/e2e/canvas/demo-workspace.spec.ts
Normal file
116
tests/e2e/canvas/demo-workspace.spec.ts
Normal 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 |
27
tests/e2e/canvas/ruler-visibility.spec.ts
Normal file
27
tests/e2e/canvas/ruler-visibility.spec.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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?.()
|
||||
|
|
|
|||
48
tests/engine/app/demo/viewport.test.ts
Normal file
48
tests/engine/app/demo/viewport.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
54
tests/engine/editor/undo/history-events.test.ts
Normal file
54
tests/engine/editor/undo/history-events.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
34
tests/helpers/canvas/demo-startup.ts
Normal file
34
tests/helpers/canvas/demo-startup.ts
Normal 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
24
tests/helpers/demo.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue