Fix imported fig rendering and file open regressions
This commit is contained in:
parent
0a770248a2
commit
07af72ab24
|
|
@ -12,6 +12,9 @@
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Fix imported `.fig` file open and page-switch regressions — loaded documents now keep graph/store state in sync, remap imported canvas/page children correctly, and recompute imported auto-layout descendants when switching pages
|
||||||
|
- Fix imported text rendering in browser and headless export — preserve stored bounds until fonts are ready, restore missing font-loaded guards, use natural width for `WIDTH_AND_HEIGHT` text, and clip text to node bounds
|
||||||
|
- Fix browser/headless rendering mismatch for imported toolbar/instance content by correcting runtime imported layout recomputation instead of diverging browser rendering behavior
|
||||||
- Fix `set_layout` tool not defaulting to HUG sizing when enabling auto-layout — frames now shrink/grow to fit children instead of keeping fixed dimensions
|
- Fix `set_layout` tool not defaulting to HUG sizing when enabling auto-layout — frames now shrink/grow to fit children instead of keeping fixed dimensions
|
||||||
- Fix save crash when COLOR variable is missing alpha channel
|
- Fix save crash when COLOR variable is missing alpha channel
|
||||||
- Fix console error spam on deployed web app from automation WebSocket reconnect loop
|
- Fix console error spam on deployed web app from automation WebSocket reconnect loop
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { CANVAS_BG_COLOR, IS_BROWSER } from '../constants'
|
|
||||||
import { computeLayout, setTextMeasurer } from '../layout'
|
|
||||||
import { loadFont as defaultLoadFont } from '../fonts'
|
|
||||||
import { prefetchFigmaSchema } from '../clipboard'
|
import { prefetchFigmaSchema } from '../clipboard'
|
||||||
|
import { CANVAS_BG_COLOR, IS_BROWSER } from '../constants'
|
||||||
|
import { loadFont as defaultLoadFont } from '../fonts'
|
||||||
|
import { computeLayout, setTextMeasurer } from '../layout'
|
||||||
import { SceneGraph } from '../scene-graph'
|
import { SceneGraph } from '../scene-graph'
|
||||||
import { TextEditor } from '../text-editor'
|
import { TextEditor } from '../text-editor'
|
||||||
import { UndoManager } from '../undo'
|
import { UndoManager } from '../undo'
|
||||||
|
|
||||||
import { createAlignmentActions } from './alignment'
|
import { createAlignmentActions } from './alignment'
|
||||||
import { createClipboardActions } from './clipboard'
|
import { createClipboardActions } from './clipboard'
|
||||||
import { createComponentActions } from './components'
|
import { createComponentActions } from './components'
|
||||||
|
|
@ -19,10 +18,10 @@ import { createUndoActions } from './undo'
|
||||||
import { createVariableActions } from './variables'
|
import { createVariableActions } from './variables'
|
||||||
import { createViewportActions } from './viewport'
|
import { createViewportActions } from './viewport'
|
||||||
|
|
||||||
import type { SceneNode } from '../scene-graph'
|
|
||||||
import type { SkiaRenderer } from '../renderer/renderer'
|
import type { SkiaRenderer } from '../renderer/renderer'
|
||||||
import type { CanvasKit } from 'canvaskit-wasm'
|
import type { SceneNode } from '../scene-graph'
|
||||||
import type { EditorContext, EditorOptions, EditorState } from './types'
|
import type { EditorContext, EditorOptions, EditorState } from './types'
|
||||||
|
import type { CanvasKit } from 'canvaskit-wasm'
|
||||||
|
|
||||||
export function createDefaultEditorState(pageId: string): EditorState {
|
export function createDefaultEditorState(pageId: string): EditorState {
|
||||||
return {
|
return {
|
||||||
|
|
@ -54,12 +53,15 @@ export function createDefaultEditorState(pageId: string): EditorState {
|
||||||
|
|
||||||
export function createEditor(options?: EditorOptions) {
|
export function createEditor(options?: EditorOptions) {
|
||||||
let _graph = options?.graph ?? new SceneGraph()
|
let _graph = options?.graph ?? new SceneGraph()
|
||||||
|
const skipInitialGraphSetup = options?.skipInitialGraphSetup ?? false
|
||||||
const undo = new UndoManager()
|
const undo = new UndoManager()
|
||||||
const _loadFont = options?.loadFont ?? defaultLoadFont
|
const _loadFont = options?.loadFont ?? defaultLoadFont
|
||||||
const _getViewportSize = options?.getViewportSize ?? (() => {
|
const _getViewportSize =
|
||||||
if (IS_BROWSER) return { width: window.innerWidth, height: window.innerHeight }
|
options?.getViewportSize ??
|
||||||
return { width: 800, height: 600 }
|
(() => {
|
||||||
})
|
if (IS_BROWSER) return { width: window.innerWidth, height: window.innerHeight }
|
||||||
|
return { width: 800, height: 600 }
|
||||||
|
})
|
||||||
let _ck: CanvasKit | null = null
|
let _ck: CanvasKit | null = null
|
||||||
let _renderer: SkiaRenderer | null = null
|
let _renderer: SkiaRenderer | null = null
|
||||||
let _textEditor: TextEditor | null = null
|
let _textEditor: TextEditor | null = null
|
||||||
|
|
@ -153,12 +155,18 @@ export function createEditor(options?: EditorOptions) {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribeToGraph()
|
if (!skipInitialGraphSetup) {
|
||||||
|
subscribeToGraph()
|
||||||
|
}
|
||||||
|
|
||||||
// Build the shared context
|
// Build the shared context
|
||||||
const ctx: EditorContext = {
|
const ctx: EditorContext = {
|
||||||
get graph() { return _graph },
|
get graph() {
|
||||||
set graph(g) { _graph = g },
|
return _graph
|
||||||
|
},
|
||||||
|
set graph(g) {
|
||||||
|
_graph = g
|
||||||
|
},
|
||||||
undo,
|
undo,
|
||||||
state,
|
state,
|
||||||
loadFont: _loadFont,
|
loadFont: _loadFont,
|
||||||
|
|
@ -204,9 +212,15 @@ export function createEditor(options?: EditorOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get graph() { return _graph },
|
get graph() {
|
||||||
get renderer() { return _renderer },
|
return _graph
|
||||||
get textEditor() { return _textEditor },
|
},
|
||||||
|
get renderer() {
|
||||||
|
return _renderer
|
||||||
|
},
|
||||||
|
get textEditor() {
|
||||||
|
return _textEditor
|
||||||
|
},
|
||||||
undo,
|
undo,
|
||||||
state,
|
state,
|
||||||
|
|
||||||
|
|
@ -221,6 +235,7 @@ export function createEditor(options?: EditorOptions) {
|
||||||
requestRepaint,
|
requestRepaint,
|
||||||
setCanvasKit,
|
setCanvasKit,
|
||||||
replaceGraph,
|
replaceGraph,
|
||||||
|
subscribeToGraph,
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
...selection,
|
...selection,
|
||||||
|
|
@ -263,7 +278,8 @@ export function createEditor(options?: EditorOptions) {
|
||||||
|
|
||||||
// Clipboard — bridge functions that need selectedNodes
|
// Clipboard — bridge functions that need selectedNodes
|
||||||
duplicateSelected: () => clipboard.duplicateSelected(selection.getSelectedNodes()),
|
duplicateSelected: () => clipboard.duplicateSelected(selection.getSelectedNodes()),
|
||||||
writeCopyData: (data: DataTransfer) => clipboard.writeCopyData(data, selection.getSelectedNodes()),
|
writeCopyData: (data: DataTransfer) =>
|
||||||
|
clipboard.writeCopyData(data, selection.getSelectedNodes()),
|
||||||
pasteFromHTML: clipboard.pasteFromHTML,
|
pasteFromHTML: clipboard.pasteFromHTML,
|
||||||
deleteSelected: clipboard.deleteSelected,
|
deleteSelected: clipboard.deleteSelected,
|
||||||
storeImage: clipboard.storeImage,
|
storeImage: clipboard.storeImage,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CANVAS_BG_COLOR } from '../constants'
|
import { CANVAS_BG_COLOR } from '../constants'
|
||||||
import { collectFontKeys } from '../fonts'
|
import { collectFontKeys } from '../fonts'
|
||||||
|
import { computeAllLayouts } from '../layout'
|
||||||
|
|
||||||
import type { Color } from '../types'
|
import type { Color } from '../types'
|
||||||
import type { EditorContext } from './types'
|
import type { EditorContext } from './types'
|
||||||
|
|
@ -42,10 +43,16 @@ export function createPageActions(ctx: EditorContext) {
|
||||||
ctx.state.pageColor = { ...CANVAS_BG_COLOR }
|
ctx.state.pageColor = { ...CANVAS_BG_COLOR }
|
||||||
}
|
}
|
||||||
|
|
||||||
const toLoad = collectFontKeys(ctx.graph, ctx.graph.getChildren(pageId).map((n) => n.id))
|
const toLoad = collectFontKeys(
|
||||||
|
ctx.graph,
|
||||||
|
ctx.graph.getChildren(pageId).map((n) => n.id)
|
||||||
|
)
|
||||||
if (toLoad.length > 0) {
|
if (toLoad.length > 0) {
|
||||||
await Promise.all(toLoad.map(([family, style]) => ctx.loadFont(family, style)))
|
await Promise.all(toLoad.map(([family, style]) => ctx.loadFont(family, style)))
|
||||||
}
|
}
|
||||||
|
if (ctx.getRenderer()) {
|
||||||
|
computeAllLayouts(ctx.graph, pageId)
|
||||||
|
}
|
||||||
ctx.requestRender()
|
ctx.requestRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type { Color, Rect, Vector } from '../types'
|
import type { SkiaRenderer } from '../renderer/renderer'
|
||||||
import type { SceneGraph, VectorSegment, VectorVertex } from '../scene-graph'
|
import type { SceneGraph, VectorSegment, VectorVertex } from '../scene-graph'
|
||||||
import type { SnapGuide } from '../snap'
|
import type { SnapGuide } from '../snap'
|
||||||
import type { SkiaRenderer } from '../renderer/renderer'
|
|
||||||
import type { UndoManager } from '../undo'
|
|
||||||
import type { TextEditor } from '../text-editor'
|
import type { TextEditor } from '../text-editor'
|
||||||
|
import type { Color, Rect, Vector } from '../types'
|
||||||
|
import type { UndoManager } from '../undo'
|
||||||
import type { CanvasKit } from 'canvaskit-wasm'
|
import type { CanvasKit } from 'canvaskit-wasm'
|
||||||
|
|
||||||
export type Tool =
|
export type Tool =
|
||||||
|
|
@ -101,6 +101,7 @@ export interface EditorOptions {
|
||||||
state?: EditorState
|
state?: EditorState
|
||||||
loadFont?: (family: string, style: string) => Promise<ArrayBuffer | null>
|
loadFont?: (family: string, style: string) => Promise<ArrayBuffer | null>
|
||||||
getViewportSize?: () => { width: number; height: number }
|
getViewportSize?: () => { width: number; height: number }
|
||||||
|
skipInitialGraphSetup?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorContext {
|
export interface EditorContext {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { VariableType, VariableValue } from '../scene-graph'
|
|
||||||
import { SceneGraph } from '../scene-graph'
|
import { SceneGraph } from '../scene-graph'
|
||||||
|
import { populateAndApplyOverrides } from './instance-overrides'
|
||||||
import {
|
import {
|
||||||
guidToString,
|
guidToString,
|
||||||
nodeChangeToProps,
|
nodeChangeToProps,
|
||||||
|
|
@ -8,12 +7,14 @@ import {
|
||||||
setVariableColorResolver,
|
setVariableColorResolver,
|
||||||
VARIABLE_BINDING_FIELDS_INVERSE
|
VARIABLE_BINDING_FIELDS_INVERSE
|
||||||
} from './kiwi-convert'
|
} from './kiwi-convert'
|
||||||
import { populateAndApplyOverrides } from './instance-overrides'
|
|
||||||
|
import type { VariableType, VariableValue } from '../scene-graph'
|
||||||
|
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from './codec'
|
||||||
import type { InstanceNodeChange } from './instance-overrides'
|
import type { InstanceNodeChange } from './instance-overrides'
|
||||||
|
|
||||||
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from './codec'
|
function buildVariableColorResolver(
|
||||||
|
changeMap: Map<string, NodeChange>
|
||||||
function buildVariableColorResolver(changeMap: Map<string, NodeChange>): (guid: GUID) => Color | null {
|
): (guid: GUID) => Color | null {
|
||||||
// Collect variable data: GUID → entries
|
// Collect variable data: GUID → entries
|
||||||
const varEntries = new Map<string, VariableDataValuesEntry[]>()
|
const varEntries = new Map<string, VariableDataValuesEntry[]>()
|
||||||
const varSetId = new Map<string, string>()
|
const varSetId = new Map<string, string>()
|
||||||
|
|
@ -44,7 +45,7 @@ function buildVariableColorResolver(changeMap: Map<string, NodeChange>): (guid:
|
||||||
const setId = varSetId.get(id)
|
const setId = varSetId.get(id)
|
||||||
const defaultMode = setId ? defaultModes.get(setId) : undefined
|
const defaultMode = setId ? defaultModes.get(setId) : undefined
|
||||||
let entry = defaultMode
|
let entry = defaultMode
|
||||||
? entries.find(e => guidToString(e.modeID) === defaultMode)
|
? entries.find((e) => guidToString(e.modeID) === defaultMode)
|
||||||
: undefined
|
: undefined
|
||||||
if (!entry) entry = entries[0]
|
if (!entry) entry = entries[0]
|
||||||
|
|
||||||
|
|
@ -124,10 +125,7 @@ function resolveDefaultValue(type: VariableType): VariableValue {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function importCollections(
|
function importCollections(changeMap: Map<string, NodeChange>, graph: SceneGraph): void {
|
||||||
changeMap: Map<string, NodeChange>,
|
|
||||||
graph: SceneGraph
|
|
||||||
): void {
|
|
||||||
for (const [id, nc] of changeMap) {
|
for (const [id, nc] of changeMap) {
|
||||||
if (nc.type !== 'VARIABLE_SET') continue
|
if (nc.type !== 'VARIABLE_SET') continue
|
||||||
|
|
||||||
|
|
@ -155,7 +153,9 @@ function importVariableEntries(
|
||||||
for (const [id, nc] of changeMap) {
|
for (const [id, nc] of changeMap) {
|
||||||
if (nc.type !== 'VARIABLE') continue
|
if (nc.type !== 'VARIABLE') continue
|
||||||
|
|
||||||
const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '')
|
const collectionId = nc.variableSetID?.guid
|
||||||
|
? guidToString(nc.variableSetID.guid)
|
||||||
|
: (parentMap.get(id) ?? '')
|
||||||
|
|
||||||
if (!graph.variableCollections.has(collectionId)) {
|
if (!graph.variableCollections.has(collectionId)) {
|
||||||
const parentNc = changeMap.get(collectionId)
|
const parentNc = changeMap.get(collectionId)
|
||||||
|
|
@ -204,6 +204,7 @@ function importPages(
|
||||||
parentMap: Map<string, string>,
|
parentMap: Map<string, string>,
|
||||||
childrenMap: Map<string, string[]>,
|
childrenMap: Map<string, string[]>,
|
||||||
created: Set<string>,
|
created: Set<string>,
|
||||||
|
canvasIdToPageId: Map<string, string>,
|
||||||
createSceneNode: (ncId: string, graphParentId: string) => void
|
createSceneNode: (ncId: string, graphParentId: string) => void
|
||||||
): void {
|
): void {
|
||||||
let docId: string | null = null
|
let docId: string | null = null
|
||||||
|
|
@ -220,6 +221,7 @@ function importPages(
|
||||||
if (!canvasNc) continue
|
if (!canvasNc) continue
|
||||||
if (canvasNc.type === 'CANVAS') {
|
if (canvasNc.type === 'CANVAS') {
|
||||||
const page = graph.addPage(canvasNc.name ?? 'Page')
|
const page = graph.addPage(canvasNc.name ?? 'Page')
|
||||||
|
canvasIdToPageId.set(canvasId, page.id)
|
||||||
if (canvasNc.internalOnly) page.internalOnly = true
|
if (canvasNc.internalOnly) page.internalOnly = true
|
||||||
created.add(canvasId)
|
created.add(canvasId)
|
||||||
for (const childId of childrenMap.get(canvasId) ?? []) {
|
for (const childId of childrenMap.get(canvasId) ?? []) {
|
||||||
|
|
@ -260,10 +262,7 @@ function importVariableBindings(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function remapComponentIds(
|
function remapComponentIds(graph: SceneGraph, guidToNodeId: Map<string, string>): void {
|
||||||
graph: SceneGraph,
|
|
||||||
guidToNodeId: Map<string, string>
|
|
||||||
): void {
|
|
||||||
for (const node of graph.getAllNodes()) {
|
for (const node of graph.getAllNodes()) {
|
||||||
if (node.type !== 'INSTANCE' || !node.componentId) continue
|
if (node.type !== 'INSTANCE' || !node.componentId) continue
|
||||||
const remapped = guidToNodeId.get(node.componentId)
|
const remapped = guidToNodeId.get(node.componentId)
|
||||||
|
|
@ -290,6 +289,7 @@ export function importNodeChanges(
|
||||||
|
|
||||||
const { changeMap, parentMap, childrenMap } = buildChangeMaps(nodeChanges)
|
const { changeMap, parentMap, childrenMap } = buildChangeMaps(nodeChanges)
|
||||||
|
|
||||||
|
const canvasIdToPageId = new Map<string, string>()
|
||||||
const created = new Set<string>()
|
const created = new Set<string>()
|
||||||
const guidToNodeId = new Map<string, string>()
|
const guidToNodeId = new Map<string, string>()
|
||||||
const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? []
|
const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? []
|
||||||
|
|
@ -304,7 +304,8 @@ export function importNodeChanges(
|
||||||
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
|
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
|
||||||
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return
|
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return
|
||||||
|
|
||||||
const node = graph.createNode(nodeType, graphParentId, props)
|
const parentId = canvasIdToPageId.get(graphParentId) ?? graphParentId
|
||||||
|
const node = graph.createNode(nodeType, parentId, props)
|
||||||
guidToNodeId.set(ncId, node.id)
|
guidToNodeId.set(ncId, node.id)
|
||||||
|
|
||||||
for (const childId of getChildren(ncId)) {
|
for (const childId of getChildren(ncId)) {
|
||||||
|
|
@ -312,7 +313,7 @@ export function importNodeChanges(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
importPages(graph, changeMap, parentMap, childrenMap, created, createSceneNode)
|
importPages(graph, changeMap, parentMap, childrenMap, created, canvasIdToPageId, createSceneNode)
|
||||||
|
|
||||||
importCollections(changeMap, graph)
|
importCollections(changeMap, graph)
|
||||||
importVariableEntries(changeMap, parentMap, graph)
|
importVariableEntries(changeMap, parentMap, graph)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { SceneGraph, SceneNode } from '../../scene-graph'
|
|
||||||
import { copyFills, copyStrokes, copyEffects, copyStyleRuns } from '../../copy'
|
import { copyFills, copyStrokes, copyEffects, copyStyleRuns } from '../../copy'
|
||||||
|
|
||||||
|
import type { SceneGraph, SceneNode } from '../../scene-graph'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy appearance props from source to target (text, visibility, fills, etc.).
|
* Copy appearance props from source to target (text, visibility, fills, etc.).
|
||||||
* Only writes properties that actually differ.
|
* Only writes properties that actually differ.
|
||||||
|
|
@ -15,7 +16,8 @@ export function syncNodeProps(graph: SceneGraph, source: SceneNode, target: Scen
|
||||||
if (source.effects !== target.effects) updates.effects = copyEffects(source.effects)
|
if (source.effects !== target.effects) updates.effects = copyEffects(source.effects)
|
||||||
if (source.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns)
|
if (source.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns)
|
||||||
if (source.layoutGrow !== target.layoutGrow) updates.layoutGrow = source.layoutGrow
|
if (source.layoutGrow !== target.layoutGrow) updates.layoutGrow = source.layoutGrow
|
||||||
if (source.textAutoResize !== target.textAutoResize) updates.textAutoResize = source.textAutoResize
|
if (source.textAutoResize !== target.textAutoResize)
|
||||||
|
updates.textAutoResize = source.textAutoResize
|
||||||
if (source.locked !== target.locked) updates.locked = source.locked
|
if (source.locked !== target.locked) updates.locked = source.locked
|
||||||
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
|
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +65,11 @@ export function syncChildrenDeep(
|
||||||
const tgtNode = graph.getNode(tgt.childIds[i])
|
const tgtNode = graph.getNode(tgt.childIds[i])
|
||||||
if (!srcNode || !tgtNode || srcNode.type !== tgtNode.type) continue
|
if (!srcNode || !tgtNode || srcNode.type !== tgtNode.type) continue
|
||||||
|
|
||||||
if (srcNode.type === 'INSTANCE' && swappedInstances.has(src.childIds[i]) && srcNode.componentId !== tgtNode.componentId) {
|
if (
|
||||||
|
srcNode.type === 'INSTANCE' &&
|
||||||
|
swappedInstances.has(src.childIds[i]) &&
|
||||||
|
srcNode.componentId !== tgtNode.componentId
|
||||||
|
) {
|
||||||
recloneChildren(graph, src.childIds[i], tgtNode, swappedInstances)
|
recloneChildren(graph, src.childIds[i], tgtNode, swappedInstances)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +112,10 @@ function expandSeedsToParents(graph: SceneGraph, seeds: Set<string>): Set<string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** BFS from expanded seeds through clone chains to find all nodes needing sync. */
|
/** BFS from expanded seeds through clone chains to find all nodes needing sync. */
|
||||||
function buildNeedsSyncSet(expandedSeeds: Set<string>, clonesOf: Map<string, string[]>): Set<string> {
|
function buildNeedsSyncSet(
|
||||||
|
expandedSeeds: Set<string>,
|
||||||
|
clonesOf: Map<string, string[]>
|
||||||
|
): Set<string> {
|
||||||
const needsSync = new Set<string>()
|
const needsSync = new Set<string>()
|
||||||
const queue = [...expandedSeeds]
|
const queue = [...expandedSeeds]
|
||||||
for (let id = queue.pop(); id !== undefined; id = queue.pop()) {
|
for (let id = queue.pop(); id !== undefined; id = queue.pop()) {
|
||||||
|
|
@ -142,9 +151,7 @@ export function propagateOverridesTransitively(
|
||||||
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
|
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
|
||||||
|
|
||||||
// Merge seeds + protect into a single skip set for syncChildrenDeep
|
// Merge seeds + protect into a single skip set for syncChildrenDeep
|
||||||
const skip = protect && protect.size > 0
|
const skip = protect && protect.size > 0 ? new Set([...seeds, ...protect]) : seeds
|
||||||
? new Set([...seeds, ...protect])
|
|
||||||
: seeds
|
|
||||||
|
|
||||||
const visited = new Set<string>()
|
const visited = new Set<string>()
|
||||||
const syncQueue = [...expandedSeeds]
|
const syncQueue = [...expandedSeeds]
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,86 @@ import {
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
IS_BROWSER
|
IS_BROWSER
|
||||||
} from '../constants'
|
} from '../constants'
|
||||||
|
import { computeAbsoluteBounds } from '../geometry'
|
||||||
import { RenderProfiler } from '../profiler'
|
import { RenderProfiler } from '../profiler'
|
||||||
|
import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays'
|
||||||
|
import {
|
||||||
|
getCachedDropShadow as getCachedDropShadowFn,
|
||||||
|
getCachedBlur as getCachedBlurFn,
|
||||||
|
getCachedDecalBlur as getCachedDecalBlurFn,
|
||||||
|
getCachedMaskBlur as getCachedMaskBlurFn,
|
||||||
|
applyClippedBlur as applyClippedBlurFn
|
||||||
|
} from './effects'
|
||||||
|
import {
|
||||||
|
drawNodeFill as drawNodeFillFn,
|
||||||
|
applyFill as applyFillFn,
|
||||||
|
applyGradientFill as applyGradientFillFn,
|
||||||
|
applyImageFill as applyImageFillFn,
|
||||||
|
drawArc as drawArcFn
|
||||||
|
} from './fills'
|
||||||
|
import { LabelCache } from './label-cache'
|
||||||
|
import {
|
||||||
|
drawSectionTitles as drawSectionTitlesFn,
|
||||||
|
drawComponentLabels as drawComponentLabelsFn
|
||||||
|
} from './labels'
|
||||||
|
import {
|
||||||
|
drawHoverHighlight as drawHoverHighlightFn,
|
||||||
|
drawEnteredContainer as drawEnteredContainerFn,
|
||||||
|
drawSelection as drawSelectionFn,
|
||||||
|
drawNodeSelection as drawNodeSelectionFn,
|
||||||
|
drawSelectionLabels as drawSelectionLabelsFn,
|
||||||
|
drawParentFrameOutlines as drawParentFrameOutlinesFn,
|
||||||
|
drawNodeOutline as drawNodeOutlineFn,
|
||||||
|
drawGroupBounds as drawGroupBoundsFn,
|
||||||
|
getRotatedCorners as getRotatedCornersFn,
|
||||||
|
drawHandle as drawHandleFn,
|
||||||
|
drawSnapGuides as drawSnapGuidesFn,
|
||||||
|
drawMarquee as drawMarqueeFn,
|
||||||
|
drawFlashes as drawFlashesFn,
|
||||||
|
drawLayoutInsertIndicator as drawLayoutInsertIndicatorFn,
|
||||||
|
drawTextEditOverlay as drawTextEditOverlayFn
|
||||||
|
} from './overlays'
|
||||||
|
import {
|
||||||
|
drawPenOverlay as drawPenOverlayFn,
|
||||||
|
drawRemoteCursors as drawRemoteCursorsFn
|
||||||
|
} from './pen-overlay'
|
||||||
|
import { drawRulers as drawRulersFn } from './rulers'
|
||||||
|
import {
|
||||||
|
renderNode as renderNodeFn,
|
||||||
|
renderSection as renderSectionFn,
|
||||||
|
renderComponentSet as renderComponentSetFn,
|
||||||
|
renderShape as renderShapeFn,
|
||||||
|
renderShapeUncached as renderShapeUncachedFn,
|
||||||
|
renderEffects as renderEffectsFn,
|
||||||
|
renderText as renderTextFn
|
||||||
|
} from './scene'
|
||||||
|
import {
|
||||||
|
makeNodeShapePath as makeNodeShapePathFn,
|
||||||
|
makePolygonPath as makePolygonPathFn,
|
||||||
|
makeRRect as makeRRectFn,
|
||||||
|
makeRRectWithSpread as makeRRectWithSpreadFn,
|
||||||
|
makeRRectWithOffset as makeRRectWithOffsetFn,
|
||||||
|
clipNodeShape as clipNodeShapeFn,
|
||||||
|
getVectorPaths as getVectorPathsFn,
|
||||||
|
getFillGeometry as getFillGeometryFn,
|
||||||
|
getStrokeGeometry as getStrokeGeometryFn
|
||||||
|
} from './shapes'
|
||||||
|
import {
|
||||||
|
drawNodeStroke as drawNodeStrokeFn,
|
||||||
|
drawStrokeWithAlign as drawStrokeWithAlignFn,
|
||||||
|
drawRRectStrokeWithAlign as drawRRectStrokeWithAlignFn,
|
||||||
|
drawIndividualSideStrokes as drawIndividualSideStrokesFn,
|
||||||
|
strokeNodeShape as strokeNodeShapeFn
|
||||||
|
} from './strokes'
|
||||||
|
import {
|
||||||
|
measureTextNode as measureTextNodeFn,
|
||||||
|
isNodeFontLoaded as isNodeFontLoadedFn,
|
||||||
|
buildTextPicture as buildTextPictureFn,
|
||||||
|
buildParagraph as buildParagraphFn
|
||||||
|
} from './text'
|
||||||
|
|
||||||
import type { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph'
|
|
||||||
import type { EditorState } from '../editor/types'
|
import type { EditorState } from '../editor/types'
|
||||||
|
import type { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph'
|
||||||
import type { SnapGuide } from '../snap'
|
import type { SnapGuide } from '../snap'
|
||||||
import type { TextEditor } from '../text-editor'
|
import type { TextEditor } from '../text-editor'
|
||||||
import type { Color, Rect, Vector } from '../types'
|
import type { Color, Rect, Vector } from '../types'
|
||||||
|
|
@ -52,82 +127,6 @@ import type {
|
||||||
Paragraph
|
Paragraph
|
||||||
} from 'canvaskit-wasm'
|
} from 'canvaskit-wasm'
|
||||||
|
|
||||||
import {
|
|
||||||
drawHoverHighlight as drawHoverHighlightFn,
|
|
||||||
drawEnteredContainer as drawEnteredContainerFn,
|
|
||||||
drawSelection as drawSelectionFn,
|
|
||||||
drawNodeSelection as drawNodeSelectionFn,
|
|
||||||
drawSelectionLabels as drawSelectionLabelsFn,
|
|
||||||
drawParentFrameOutlines as drawParentFrameOutlinesFn,
|
|
||||||
drawNodeOutline as drawNodeOutlineFn,
|
|
||||||
drawGroupBounds as drawGroupBoundsFn,
|
|
||||||
getRotatedCorners as getRotatedCornersFn,
|
|
||||||
drawHandle as drawHandleFn,
|
|
||||||
drawSnapGuides as drawSnapGuidesFn,
|
|
||||||
drawMarquee as drawMarqueeFn,
|
|
||||||
drawFlashes as drawFlashesFn,
|
|
||||||
drawLayoutInsertIndicator as drawLayoutInsertIndicatorFn,
|
|
||||||
drawTextEditOverlay as drawTextEditOverlayFn,
|
|
||||||
} from './overlays'
|
|
||||||
import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays'
|
|
||||||
import {
|
|
||||||
drawPenOverlay as drawPenOverlayFn,
|
|
||||||
drawRemoteCursors as drawRemoteCursorsFn
|
|
||||||
} from './pen-overlay'
|
|
||||||
import { drawRulers as drawRulersFn } from './rulers'
|
|
||||||
import {
|
|
||||||
drawSectionTitles as drawSectionTitlesFn,
|
|
||||||
drawComponentLabels as drawComponentLabelsFn
|
|
||||||
} from './labels'
|
|
||||||
import { LabelCache } from './label-cache'
|
|
||||||
import {
|
|
||||||
renderNode as renderNodeFn,
|
|
||||||
renderSection as renderSectionFn,
|
|
||||||
renderComponentSet as renderComponentSetFn,
|
|
||||||
renderShape as renderShapeFn,
|
|
||||||
renderShapeUncached as renderShapeUncachedFn,
|
|
||||||
renderEffects as renderEffectsFn,
|
|
||||||
renderText as renderTextFn
|
|
||||||
} from './scene'
|
|
||||||
import {
|
|
||||||
drawNodeFill as drawNodeFillFn,
|
|
||||||
applyFill as applyFillFn,
|
|
||||||
applyGradientFill as applyGradientFillFn,
|
|
||||||
applyImageFill as applyImageFillFn,
|
|
||||||
drawArc as drawArcFn
|
|
||||||
} from './fills'
|
|
||||||
import {
|
|
||||||
drawNodeStroke as drawNodeStrokeFn,
|
|
||||||
drawStrokeWithAlign as drawStrokeWithAlignFn,
|
|
||||||
drawRRectStrokeWithAlign as drawRRectStrokeWithAlignFn,
|
|
||||||
drawIndividualSideStrokes as drawIndividualSideStrokesFn,
|
|
||||||
strokeNodeShape as strokeNodeShapeFn
|
|
||||||
} from './strokes'
|
|
||||||
import {
|
|
||||||
makeNodeShapePath as makeNodeShapePathFn,
|
|
||||||
makePolygonPath as makePolygonPathFn,
|
|
||||||
makeRRect as makeRRectFn,
|
|
||||||
makeRRectWithSpread as makeRRectWithSpreadFn,
|
|
||||||
makeRRectWithOffset as makeRRectWithOffsetFn,
|
|
||||||
clipNodeShape as clipNodeShapeFn,
|
|
||||||
getVectorPaths as getVectorPathsFn,
|
|
||||||
getFillGeometry as getFillGeometryFn,
|
|
||||||
getStrokeGeometry as getStrokeGeometryFn
|
|
||||||
} from './shapes'
|
|
||||||
import {
|
|
||||||
getCachedDropShadow as getCachedDropShadowFn,
|
|
||||||
getCachedBlur as getCachedBlurFn,
|
|
||||||
getCachedDecalBlur as getCachedDecalBlurFn,
|
|
||||||
getCachedMaskBlur as getCachedMaskBlurFn,
|
|
||||||
applyClippedBlur as applyClippedBlurFn
|
|
||||||
} from './effects'
|
|
||||||
import {
|
|
||||||
measureTextNode as measureTextNodeFn,
|
|
||||||
isNodeFontLoaded as isNodeFontLoadedFn,
|
|
||||||
buildTextPicture as buildTextPictureFn,
|
|
||||||
buildParagraph as buildParagraphFn
|
|
||||||
} from './text'
|
|
||||||
|
|
||||||
export interface RenderOverlays {
|
export interface RenderOverlays {
|
||||||
hoveredNodeId?: string | null
|
hoveredNodeId?: string | null
|
||||||
enteredContainerId?: string | null
|
enteredContainerId?: string | null
|
||||||
|
|
@ -501,7 +500,9 @@ export class SkiaRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasActiveFlashes(): boolean {
|
get hasActiveFlashes(): boolean {
|
||||||
return this._flashes.length > 0 || this._aiActiveNodes.size > 0 || this._aiDoneFlashes.length > 0
|
return (
|
||||||
|
this._flashes.length > 0 || this._aiActiveNodes.size > 0 || this._aiDoneFlashes.length > 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hitTestSectionTitle(graph: SceneGraph, canvasX: number, canvasY: number): SceneNode | null {
|
hitTestSectionTitle(graph: SceneGraph, canvasX: number, canvasY: number): SceneNode | null {
|
||||||
|
|
@ -673,7 +674,14 @@ export class SkiaRenderer {
|
||||||
this.worldViewport = prevViewport
|
this.worldViewport = prevViewport
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFromEditorState(state: EditorState, graph: SceneGraph, textEditor: unknown, viewportWidth: number, viewportHeight: number, showRulers = true): void {
|
renderFromEditorState(
|
||||||
|
state: EditorState,
|
||||||
|
graph: SceneGraph,
|
||||||
|
textEditor: unknown,
|
||||||
|
viewportWidth: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
showRulers = true
|
||||||
|
): void {
|
||||||
this.dpr = IS_BROWSER ? window.devicePixelRatio || 1 : 1
|
this.dpr = IS_BROWSER ? window.devicePixelRatio || 1 : 1
|
||||||
this.panX = state.panX
|
this.panX = state.panX
|
||||||
this.panY = state.panY
|
this.panY = state.panY
|
||||||
|
|
@ -683,21 +691,30 @@ export class SkiaRenderer {
|
||||||
this.showRulers = showRulers
|
this.showRulers = showRulers
|
||||||
this.pageColor = state.pageColor
|
this.pageColor = state.pageColor
|
||||||
this.pageId = state.currentPageId
|
this.pageId = state.currentPageId
|
||||||
this.render(graph, state.selectedIds, {
|
this.render(
|
||||||
hoveredNodeId: state.hoveredNodeId,
|
graph,
|
||||||
enteredContainerId: state.enteredContainerId,
|
state.selectedIds,
|
||||||
editingTextId: state.editingTextId,
|
{
|
||||||
textEditor: textEditor as RenderOverlays['textEditor'],
|
hoveredNodeId: state.hoveredNodeId,
|
||||||
marquee: state.marquee,
|
enteredContainerId: state.enteredContainerId,
|
||||||
snapGuides: state.snapGuides,
|
editingTextId: state.editingTextId,
|
||||||
rotationPreview: state.rotationPreview,
|
textEditor: textEditor as RenderOverlays['textEditor'],
|
||||||
dropTargetId: state.dropTargetId,
|
marquee: state.marquee,
|
||||||
layoutInsertIndicator: state.layoutInsertIndicator,
|
snapGuides: state.snapGuides,
|
||||||
penState: state.penState
|
rotationPreview: state.rotationPreview,
|
||||||
? { ...state.penState, cursorX: state.penCursorX ?? undefined, cursorY: state.penCursorY ?? undefined } as RenderOverlays['penState']
|
dropTargetId: state.dropTargetId,
|
||||||
: null,
|
layoutInsertIndicator: state.layoutInsertIndicator,
|
||||||
remoteCursors: state.remoteCursors
|
penState: state.penState
|
||||||
}, state.sceneVersion)
|
? ({
|
||||||
|
...state.penState,
|
||||||
|
cursorX: state.penCursorX ?? undefined,
|
||||||
|
cursorY: state.penCursorY ?? undefined
|
||||||
|
} as RenderOverlays['penState'])
|
||||||
|
: null,
|
||||||
|
remoteCursors: state.remoteCursors
|
||||||
|
},
|
||||||
|
state.sceneVersion
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|
@ -812,9 +829,24 @@ export class SkiaRenderer {
|
||||||
const prevViewport = this.worldViewport
|
const prevViewport = this.worldViewport
|
||||||
this.worldViewport = { x: -1e6, y: -1e6, w: 2e6, h: 2e6 }
|
this.worldViewport = { x: -1e6, y: -1e6, w: 2e6, h: 2e6 }
|
||||||
const recorder = new this.ck.PictureRecorder()
|
const recorder = new this.ck.PictureRecorder()
|
||||||
const bounds = this.ck.LTRBRect(-1e6, -1e6, 1e6, 1e6)
|
|
||||||
const recCanvas = recorder.beginRecording(bounds)
|
|
||||||
const pageNode = graph.getNode(this.pageId ?? graph.rootId)
|
const pageNode = graph.getNode(this.pageId ?? graph.rootId)
|
||||||
|
const sceneNodes = pageNode
|
||||||
|
? pageNode.childIds
|
||||||
|
.map((childId) => graph.getNode(childId))
|
||||||
|
.filter((node): node is SceneNode => node != null)
|
||||||
|
: []
|
||||||
|
const sceneBounds =
|
||||||
|
sceneNodes.length > 0
|
||||||
|
? computeAbsoluteBounds(sceneNodes, (id) => graph.getAbsolutePosition(id))
|
||||||
|
: { x: 0, y: 0, width: 1, height: 1 }
|
||||||
|
const padding = 1024
|
||||||
|
const bounds = this.ck.LTRBRect(
|
||||||
|
sceneBounds.x - padding,
|
||||||
|
sceneBounds.y - padding,
|
||||||
|
sceneBounds.x + sceneBounds.width + padding,
|
||||||
|
sceneBounds.y + sceneBounds.height + padding
|
||||||
|
)
|
||||||
|
const recCanvas = recorder.beginRecording(bounds)
|
||||||
if (pageNode) {
|
if (pageNode) {
|
||||||
for (const childId of pageNode.childIds) {
|
for (const childId of pageNode.childIds) {
|
||||||
this.renderNode(recCanvas, graph, childId, {}, 0, 0)
|
this.renderNode(recCanvas, graph, childId, {}, 0, 0)
|
||||||
|
|
@ -944,15 +976,28 @@ export class SkiaRenderer {
|
||||||
|
|
||||||
// --- Delegation methods ---
|
// --- Delegation methods ---
|
||||||
|
|
||||||
private drawHoverHighlight(canvas: Canvas, graph: SceneGraph, hoveredNodeId?: string | null): void {
|
private drawHoverHighlight(
|
||||||
|
canvas: Canvas,
|
||||||
|
graph: SceneGraph,
|
||||||
|
hoveredNodeId?: string | null
|
||||||
|
): void {
|
||||||
drawHoverHighlightFn(this, canvas, graph, hoveredNodeId)
|
drawHoverHighlightFn(this, canvas, graph, hoveredNodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawEnteredContainer(canvas: Canvas, graph: SceneGraph, enteredContainerId?: string | null): void {
|
private drawEnteredContainer(
|
||||||
|
canvas: Canvas,
|
||||||
|
graph: SceneGraph,
|
||||||
|
enteredContainerId?: string | null
|
||||||
|
): void {
|
||||||
drawEnteredContainerFn(this, canvas, graph, enteredContainerId)
|
drawEnteredContainerFn(this, canvas, graph, enteredContainerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawSelection(canvas: Canvas, graph: SceneGraph, selectedIds: Set<string>, overlays: RenderOverlays): void {
|
private drawSelection(
|
||||||
|
canvas: Canvas,
|
||||||
|
graph: SceneGraph,
|
||||||
|
selectedIds: Set<string>,
|
||||||
|
overlays: RenderOverlays
|
||||||
|
): void {
|
||||||
drawSelectionFn(this, canvas, graph, selectedIds, overlays)
|
drawSelectionFn(this, canvas, graph, selectedIds, overlays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -997,7 +1042,10 @@ export class SkiaRenderer {
|
||||||
drawAiOverlaysFn(this, canvas, graph)
|
drawAiOverlaysFn(this, canvas, graph)
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawLayoutInsertIndicator(canvas: Canvas, indicator?: RenderOverlays['layoutInsertIndicator']): void {
|
private drawLayoutInsertIndicator(
|
||||||
|
canvas: Canvas,
|
||||||
|
indicator?: RenderOverlays['layoutInsertIndicator']
|
||||||
|
): void {
|
||||||
drawLayoutInsertIndicatorFn(this, canvas, indicator)
|
drawLayoutInsertIndicatorFn(this, canvas, indicator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1009,7 +1057,11 @@ export class SkiaRenderer {
|
||||||
drawPenOverlayFn(this, canvas, penState)
|
drawPenOverlayFn(this, canvas, penState)
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawRemoteCursors(canvas: Canvas, graph: SceneGraph, cursors?: RenderOverlays['remoteCursors']): void {
|
private drawRemoteCursors(
|
||||||
|
canvas: Canvas,
|
||||||
|
graph: SceneGraph,
|
||||||
|
cursors?: RenderOverlays['remoteCursors']
|
||||||
|
): void {
|
||||||
drawRemoteCursorsFn(this, canvas, graph, cursors)
|
drawRemoteCursorsFn(this, canvas, graph, cursors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1025,7 +1077,14 @@ export class SkiaRenderer {
|
||||||
drawComponentLabelsFn(this, canvas, graph)
|
drawComponentLabelsFn(this, canvas, graph)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderNode(canvas: Canvas, graph: SceneGraph, nodeId: string, overlays: RenderOverlays, parentAbsX = 0, parentAbsY = 0): void {
|
renderNode(
|
||||||
|
canvas: Canvas,
|
||||||
|
graph: SceneGraph,
|
||||||
|
nodeId: string,
|
||||||
|
overlays: RenderOverlays,
|
||||||
|
parentAbsX = 0,
|
||||||
|
parentAbsY = 0
|
||||||
|
): void {
|
||||||
renderNodeFn(this, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY)
|
renderNodeFn(this, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1045,7 +1104,14 @@ export class SkiaRenderer {
|
||||||
renderShapeUncachedFn(this, canvas, node, graph)
|
renderShapeUncachedFn(this, canvas, node, graph)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderEffects(canvas: Canvas, node: SceneNode, rect: Float32Array, hasRadius: boolean, pass: 'behind' | 'front', shadowShapeChild?: SceneNode | null): void {
|
renderEffects(
|
||||||
|
canvas: Canvas,
|
||||||
|
node: SceneNode,
|
||||||
|
rect: Float32Array,
|
||||||
|
hasRadius: boolean,
|
||||||
|
pass: 'behind' | 'front',
|
||||||
|
shadowShapeChild?: SceneNode | null
|
||||||
|
): void {
|
||||||
renderEffectsFn(this, canvas, node, rect, hasRadius, pass, shadowShapeChild)
|
renderEffectsFn(this, canvas, node, rect, hasRadius, pass, shadowShapeChild)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1077,15 +1143,30 @@ export class SkiaRenderer {
|
||||||
drawNodeStrokeFn(this, canvas, node, rect, hasRadius)
|
drawNodeStrokeFn(this, canvas, node, rect, hasRadius)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawStrokeWithAlign(canvas: Canvas, node: SceneNode, rect: Float32Array, hasRadius: boolean, align: 'INSIDE' | 'CENTER' | 'OUTSIDE'): void {
|
drawStrokeWithAlign(
|
||||||
|
canvas: Canvas,
|
||||||
|
node: SceneNode,
|
||||||
|
rect: Float32Array,
|
||||||
|
hasRadius: boolean,
|
||||||
|
align: 'INSIDE' | 'CENTER' | 'OUTSIDE'
|
||||||
|
): void {
|
||||||
drawStrokeWithAlignFn(this, canvas, node, rect, hasRadius, align)
|
drawStrokeWithAlignFn(this, canvas, node, rect, hasRadius, align)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawRRectStrokeWithAlign(canvas: Canvas, rrect: Float32Array, node: SceneNode, stroke: Stroke): void {
|
drawRRectStrokeWithAlign(
|
||||||
|
canvas: Canvas,
|
||||||
|
rrect: Float32Array,
|
||||||
|
node: SceneNode,
|
||||||
|
stroke: Stroke
|
||||||
|
): void {
|
||||||
drawRRectStrokeWithAlignFn(this, canvas, rrect, node, stroke)
|
drawRRectStrokeWithAlignFn(this, canvas, rrect, node, stroke)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawIndividualSideStrokes(canvas: Canvas, node: SceneNode, align: 'INSIDE' | 'CENTER' | 'OUTSIDE'): void {
|
drawIndividualSideStrokes(
|
||||||
|
canvas: Canvas,
|
||||||
|
node: SceneNode,
|
||||||
|
align: 'INSIDE' | 'CENTER' | 'OUTSIDE'
|
||||||
|
): void {
|
||||||
drawIndividualSideStrokesFn(this, canvas, node, align)
|
drawIndividualSideStrokesFn(this, canvas, node, align)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1145,7 +1226,13 @@ export class SkiaRenderer {
|
||||||
return getCachedMaskBlurFn(this, sigma)
|
return getCachedMaskBlurFn(this, sigma)
|
||||||
}
|
}
|
||||||
|
|
||||||
applyClippedBlur(canvas: Canvas, node: SceneNode, rect: Float32Array, hasRadius: boolean, sigma: number): void {
|
applyClippedBlur(
|
||||||
|
canvas: Canvas,
|
||||||
|
node: SceneNode,
|
||||||
|
rect: Float32Array,
|
||||||
|
hasRadius: boolean,
|
||||||
|
sigma: number
|
||||||
|
): void {
|
||||||
applyClippedBlurFn(this, canvas, node, rect, hasRadius, sigma)
|
applyClippedBlurFn(this, canvas, node, rect, hasRadius, sigma)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '../constants'
|
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '../constants'
|
||||||
|
|
||||||
import type { SceneNode, SceneGraph } from '../scene-graph'
|
import type { SceneNode, SceneGraph } from '../scene-graph'
|
||||||
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
|
|
||||||
import type { Color } from '../types'
|
import type { Color } from '../types'
|
||||||
import type { SkiaRenderer, RenderOverlays } from './renderer'
|
import type { SkiaRenderer, RenderOverlays } from './renderer'
|
||||||
|
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
|
||||||
|
|
||||||
function isCulled(
|
function isCulled(r: SkiaRenderer, node: SceneNode, absX: number, absY: number): boolean {
|
||||||
r: SkiaRenderer,
|
|
||||||
node: SceneNode,
|
|
||||||
absX: number,
|
|
||||||
absY: number
|
|
||||||
): boolean {
|
|
||||||
const canCull =
|
const canCull =
|
||||||
node.childIds.length === 0 ||
|
node.childIds.length === 0 ||
|
||||||
((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
|
((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
|
||||||
|
|
@ -47,10 +43,7 @@ function applyNodeTransforms(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.flipX || node.flipY) {
|
if (node.flipX || node.flipY) {
|
||||||
canvas.translate(
|
canvas.translate(node.flipX ? node.width : 0, node.flipY ? node.height : 0)
|
||||||
node.flipX ? node.width : 0,
|
|
||||||
node.flipY ? node.height : 0
|
|
||||||
)
|
|
||||||
canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1)
|
canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,8 +88,13 @@ function renderChildren(
|
||||||
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
|
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
|
||||||
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
|
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
|
||||||
canvas.save()
|
canvas.save()
|
||||||
const hasRadius = node.cornerRadius > 0 || (node.independentCorners &&
|
const hasRadius =
|
||||||
(node.topLeftRadius > 0 || node.topRightRadius > 0 || node.bottomRightRadius > 0 || node.bottomLeftRadius > 0))
|
node.cornerRadius > 0 ||
|
||||||
|
(node.independentCorners &&
|
||||||
|
(node.topLeftRadius > 0 ||
|
||||||
|
node.topRightRadius > 0 ||
|
||||||
|
node.bottomRightRadius > 0 ||
|
||||||
|
node.bottomLeftRadius > 0))
|
||||||
if (hasRadius) {
|
if (hasRadius) {
|
||||||
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
|
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -219,10 +217,7 @@ export function renderComponentSet(
|
||||||
r.auxStroke.setStrokeWidth(r.COMPONENT_SET_BORDER_WIDTH / r.zoom)
|
r.auxStroke.setStrokeWidth(r.COMPONENT_SET_BORDER_WIDTH / r.zoom)
|
||||||
r.auxStroke.setColor(r.compColor())
|
r.auxStroke.setColor(r.compColor())
|
||||||
r.auxStroke.setPathEffect(
|
r.auxStroke.setPathEffect(
|
||||||
r.ck.PathEffect.MakeDash(
|
r.ck.PathEffect.MakeDash([r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom], 0)
|
||||||
[r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom],
|
|
||||||
0
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
canvas.drawRRect(rrect, r.auxStroke)
|
canvas.drawRRect(rrect, r.auxStroke)
|
||||||
r.auxStroke.setPathEffect(null)
|
r.auxStroke.setPathEffect(null)
|
||||||
|
|
@ -283,17 +278,23 @@ function getShadowShapeChild(node: SceneNode, graph: SceneGraph): SceneNode | nu
|
||||||
|
|
||||||
function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
|
function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
|
||||||
switch (cap) {
|
switch (cap) {
|
||||||
case 'ROUND': return r.ck.StrokeCap.Round
|
case 'ROUND':
|
||||||
case 'SQUARE': return r.ck.StrokeCap.Square
|
return r.ck.StrokeCap.Round
|
||||||
default: return r.ck.StrokeCap.Butt
|
case 'SQUARE':
|
||||||
|
return r.ck.StrokeCap.Square
|
||||||
|
default:
|
||||||
|
return r.ck.StrokeCap.Butt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity {
|
function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity {
|
||||||
switch (join) {
|
switch (join) {
|
||||||
case 'ROUND': return r.ck.StrokeJoin.Round
|
case 'ROUND':
|
||||||
case 'BEVEL': return r.ck.StrokeJoin.Bevel
|
return r.ck.StrokeJoin.Round
|
||||||
default: return r.ck.StrokeJoin.Miter
|
case 'BEVEL':
|
||||||
|
return r.ck.StrokeJoin.Bevel
|
||||||
|
default:
|
||||||
|
return r.ck.StrokeJoin.Miter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -530,7 +531,9 @@ export function renderEffects(
|
||||||
innerPath.delete()
|
innerPath.delete()
|
||||||
} else if (hasRadius) {
|
} else if (hasRadius) {
|
||||||
const innerPath = new r.ck.Path()
|
const innerPath = new r.ck.Path()
|
||||||
innerPath.addRRect(r.makeRRectWithOffset(node, effect.offset.x + sp, effect.offset.y + sp, -sp))
|
innerPath.addRRect(
|
||||||
|
r.makeRRectWithOffset(node, effect.offset.x + sp, effect.offset.y + sp, -sp)
|
||||||
|
)
|
||||||
bigPath.op(innerPath, r.ck.PathOp.Difference)
|
bigPath.op(innerPath, r.ck.PathOp.Difference)
|
||||||
innerPath.delete()
|
innerPath.delete()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -558,22 +561,25 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): vo
|
||||||
const text = node.text
|
const text = node.text
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
|
||||||
|
canvas.save()
|
||||||
|
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
|
||||||
|
|
||||||
if (node.textPicture) {
|
if (node.textPicture) {
|
||||||
const pic = r.ck.MakePicture(node.textPicture)
|
const pic = r.ck.MakePicture(node.textPicture)
|
||||||
if (pic) {
|
if (pic) {
|
||||||
canvas.drawPicture(pic)
|
canvas.drawPicture(pic)
|
||||||
pic.delete()
|
pic.delete()
|
||||||
|
canvas.restore()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (r.fontsLoaded && r.fontProvider) {
|
if (r.fontsLoaded && r.fontProvider) {
|
||||||
const paragraph = r.buildParagraph(node, r.fillPaint.getColor(), { halfLeading: true })
|
const paragraph = r.buildParagraph(node, r.fillPaint.getColor())
|
||||||
canvas.drawParagraph(paragraph, 0, 0)
|
canvas.drawParagraph(paragraph, 0, 0)
|
||||||
paragraph.delete()
|
paragraph.delete()
|
||||||
} else if (r.textFont) {
|
} else if (r.textFont) {
|
||||||
canvas.save()
|
|
||||||
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
|
|
||||||
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)
|
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)
|
||||||
canvas.restore()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canvas.restore()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,11 @@ export function measureTextNode(
|
||||||
node: SceneNode,
|
node: SceneNode,
|
||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
): { width: number; height: number } | null {
|
): { width: number; height: number } | null {
|
||||||
if (!r.fontsLoaded || !r.fontProvider) return null
|
if (!r.fontsLoaded || !r.fontProvider || !isNodeFontLoaded(r, node)) return null
|
||||||
if (node.type !== 'TEXT' || !node.text) return null
|
if (node.type !== 'TEXT' || !node.text) return null
|
||||||
|
|
||||||
const paragraph = buildParagraph(r, node)
|
const paragraph = buildParagraph(r, node)
|
||||||
let layoutWidth = node.width || 1e6
|
paragraph.layout(resolveParagraphLayoutWidth(node, maxWidth))
|
||||||
if (maxWidth !== undefined) layoutWidth = maxWidth
|
|
||||||
else if (node.textAutoResize === 'WIDTH_AND_HEIGHT') layoutWidth = 1e6
|
|
||||||
paragraph.layout(layoutWidth)
|
|
||||||
const width = paragraph.getLongestLine()
|
const width = paragraph.getLongestLine()
|
||||||
const height = paragraph.getHeight()
|
const height = paragraph.getHeight()
|
||||||
paragraph.delete()
|
paragraph.delete()
|
||||||
|
|
@ -39,7 +36,7 @@ export function measureTextNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array | null {
|
export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array | null {
|
||||||
if (!r.fontsLoaded || !r.fontProvider) return null
|
if (!r.fontsLoaded || !r.fontProvider || !isNodeFontLoaded(r, node)) return null
|
||||||
if (node.type !== 'TEXT' || !node.text) return null
|
if (node.type !== 'TEXT' || !node.text) return null
|
||||||
|
|
||||||
const ck = r.ck
|
const ck = r.ck
|
||||||
|
|
@ -47,7 +44,7 @@ export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array |
|
||||||
const bounds = ck.LTRBRect(0, 0, node.width || 1e6, node.height || 1e6)
|
const bounds = ck.LTRBRect(0, 0, node.width || 1e6, node.height || 1e6)
|
||||||
const recCanvas = recorder.beginRecording(bounds)
|
const recCanvas = recorder.beginRecording(bounds)
|
||||||
|
|
||||||
const paragraph = buildParagraph(r, node, undefined, { halfLeading: true })
|
const paragraph = buildParagraph(r, node)
|
||||||
recCanvas.drawParagraph(paragraph, 0, 0)
|
recCanvas.drawParagraph(paragraph, 0, 0)
|
||||||
paragraph.delete()
|
paragraph.delete()
|
||||||
|
|
||||||
|
|
@ -59,6 +56,12 @@ export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array |
|
||||||
return bytes ?? null
|
return bytes ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveParagraphLayoutWidth(node: SceneNode, maxWidth?: number): number {
|
||||||
|
if (maxWidth !== undefined) return maxWidth
|
||||||
|
if (node.textAutoResize === 'WIDTH_AND_HEIGHT') return 1e6
|
||||||
|
return node.width || 1e6
|
||||||
|
}
|
||||||
|
|
||||||
function buildTruncateOpts(
|
function buildTruncateOpts(
|
||||||
node: SceneNode,
|
node: SceneNode,
|
||||||
baseFontSize: number
|
baseFontSize: number
|
||||||
|
|
@ -202,7 +205,12 @@ export function buildParagraph(
|
||||||
}
|
}
|
||||||
|
|
||||||
const paragraph = builder.build()
|
const paragraph = builder.build()
|
||||||
paragraph.layout(node.width || 1e6)
|
if (node.textAutoResize === 'WIDTH_AND_HEIGHT') {
|
||||||
|
paragraph.layout(1e6)
|
||||||
|
paragraph.layout(Math.max(node.width || 1, Math.ceil(paragraph.getLongestLine())))
|
||||||
|
} else {
|
||||||
|
paragraph.layout(resolveParagraphLayoutWidth(node))
|
||||||
|
}
|
||||||
builder.delete()
|
builder.delete()
|
||||||
return paragraph
|
return paragraph
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,11 @@ function copyProp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneChildrenWithMapping(graph: SceneGraph, sourceParentId: string, destParentId: string): void {
|
function cloneChildrenWithMapping(
|
||||||
|
graph: SceneGraph,
|
||||||
|
sourceParentId: string,
|
||||||
|
destParentId: string
|
||||||
|
): void {
|
||||||
const sourceParent = graph.nodes.get(sourceParentId)
|
const sourceParent = graph.nodes.get(sourceParentId)
|
||||||
if (!sourceParent) return
|
if (!sourceParent) return
|
||||||
|
|
||||||
|
|
@ -164,7 +168,11 @@ export function createInstance(
|
||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
export function populateInstanceChildren(graph: SceneGraph, instanceId: string, componentId: string): void {
|
export function populateInstanceChildren(
|
||||||
|
graph: SceneGraph,
|
||||||
|
instanceId: string,
|
||||||
|
componentId: string
|
||||||
|
): void {
|
||||||
const instance = graph.nodes.get(instanceId)
|
const instance = graph.nodes.get(instanceId)
|
||||||
const component = graph.nodes.get(componentId)
|
const component = graph.nodes.get(componentId)
|
||||||
if (!instance || !component || instance.type !== 'INSTANCE') return
|
if (!instance || !component || instance.type !== 'INSTANCE') return
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAppearance } from '../shared/useAppearance'
|
import { useAppearance } from '../controls/useAppearance'
|
||||||
|
|
||||||
const ctx = useAppearance()
|
const ctx = useAppearance()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLayout } from '../shared/useLayout'
|
import { useLayout } from '../controls/useLayout'
|
||||||
|
|
||||||
const ctx = useLayout()
|
const ctx = useLayout()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { MIXED, useNodeProps } from '../shared/useNodeProps'
|
import { MIXED, useNodeProps } from '../controls/useNodeProps'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
updateProp,
|
updateProp,
|
||||||
|
|
@ -14,8 +14,12 @@ const {
|
||||||
store
|
store
|
||||||
} = useNodeProps()
|
} = useNodeProps()
|
||||||
|
|
||||||
const xValue = computed(() => (isMulti.value ? multiProp('x').value : Math.round(node.value?.x ?? 0)))
|
const xValue = computed(() =>
|
||||||
const yValue = computed(() => (isMulti.value ? multiProp('y').value : Math.round(node.value?.y ?? 0)))
|
isMulti.value ? multiProp('x').value : Math.round(node.value?.x ?? 0)
|
||||||
|
)
|
||||||
|
const yValue = computed(() =>
|
||||||
|
isMulti.value ? multiProp('y').value : Math.round(node.value?.y ?? 0)
|
||||||
|
)
|
||||||
const wValue = multiProp('width')
|
const wValue = multiProp('width')
|
||||||
const hValue = multiProp('height')
|
const hValue = multiProp('height')
|
||||||
const rotationValue = computed(() =>
|
const rotationValue = computed(() =>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { useEditor } from '../shared/editorContext'
|
import { useEditor } from '../context/editorContext'
|
||||||
import { providePropertyList } from './context'
|
import { providePropertyList } from './context'
|
||||||
|
|
||||||
import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core'
|
import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core'
|
||||||
|
|
@ -25,7 +25,9 @@ const emit = defineEmits<{
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
|
||||||
const selectedNodes = computed(() => editor.getSelectedNodes())
|
const selectedNodes = computed(() => editor.getSelectedNodes())
|
||||||
const activeNode = computed<SceneNode | null>(() => editor.getSelectedNode() ?? selectedNodes.value[0] ?? null)
|
const activeNode = computed<SceneNode | null>(
|
||||||
|
() => editor.getSelectedNode() ?? selectedNodes.value[0] ?? null
|
||||||
|
)
|
||||||
const isMulti = computed(() => selectedNodes.value.length > 1)
|
const isMulti = computed(() => selectedNodes.value.length > 1)
|
||||||
const active = computed(() => selectedNodes.value.length > 0)
|
const active = computed(() => selectedNodes.value.length > 0)
|
||||||
|
|
||||||
|
|
@ -55,7 +57,11 @@ function add(defaults: ArrayItemType) {
|
||||||
emit('add', defaults)
|
emit('add', defaults)
|
||||||
for (const n of targetNodes()) {
|
for (const n of targetNodes()) {
|
||||||
const arr = isMulti.value ? [defaults] : [...n[propKey], defaults]
|
const arr = isMulti.value ? [defaults] : [...n[propKey], defaults]
|
||||||
editor.updateNodeWithUndo(n.id, { [propKey]: arr } as Partial<SceneNode>, isMulti.value ? `Set ${propKey}` : `Add ${propKey}`)
|
editor.updateNodeWithUndo(
|
||||||
|
n.id,
|
||||||
|
{ [propKey]: arr } as Partial<SceneNode>,
|
||||||
|
isMulti.value ? `Set ${propKey}` : `Add ${propKey}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,7 +70,9 @@ function remove(index: number) {
|
||||||
for (const n of targetNodes()) {
|
for (const n of targetNodes()) {
|
||||||
editor.updateNodeWithUndo(
|
editor.updateNodeWithUndo(
|
||||||
n.id,
|
n.id,
|
||||||
{ [propKey]: (n[propKey] as ArrayItemType[]).filter((_, i) => i !== index) } as Partial<SceneNode>,
|
{
|
||||||
|
[propKey]: (n[propKey] as ArrayItemType[]).filter((_, i) => i !== index)
|
||||||
|
} as Partial<SceneNode>,
|
||||||
`Remove ${propKey}`
|
`Remove ${propKey}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +103,11 @@ function toggleVisibility(index: number) {
|
||||||
if (!arr[index]) continue
|
if (!arr[index]) continue
|
||||||
const newArr = [...n[propKey]] as Array<{ visible: boolean }>
|
const newArr = [...n[propKey]] as Array<{ visible: boolean }>
|
||||||
newArr[index] = { ...newArr[index], visible: !arr[index].visible }
|
newArr[index] = { ...newArr[index], visible: !arr[index].visible }
|
||||||
editor.updateNodeWithUndo(n.id, { [propKey]: newArr } as Partial<SceneNode>, `Toggle ${propKey} visibility`)
|
editor.updateNodeWithUndo(
|
||||||
|
n.id,
|
||||||
|
{ [propKey]: newArr } as Partial<SceneNode>,
|
||||||
|
`Toggle ${propKey} visibility`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { EDITOR_TOOLS } from '@open-pencil/core/editor'
|
import { EDITOR_TOOLS } from '@open-pencil/core/editor'
|
||||||
|
|
||||||
import { useEditor } from '../shared/editorContext'
|
import { useEditor } from '../context/editorContext'
|
||||||
import { provideToolbar } from './context'
|
import { provideToolbar } from './context'
|
||||||
|
|
||||||
import type { EditorToolDef, Tool } from '@open-pencil/core/editor'
|
import type { EditorToolDef, Tool } from '@open-pencil/core/editor'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTypography } from '../shared/useTypography'
|
import { useTypography } from '../controls/useTypography'
|
||||||
|
|
||||||
import type { AcceptableValue } from 'reka-ui'
|
import type { AcceptableValue } from 'reka-ui'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { useEditor } from '../shared/editorContext'
|
import { useEditor } from '../context/editorContext'
|
||||||
import { usePageList } from '../shared/usePageList'
|
import { usePageList } from '../PageList/usePageList'
|
||||||
import { useSelectionCapabilities } from '../selection/useSelectionCapabilities'
|
import { useSelectionCapabilities } from '../selection/useSelectionCapabilities'
|
||||||
import { useSelectionState } from '../shared/useSelectionState'
|
import { useSelectionState } from '../selection/useSelectionState'
|
||||||
|
|
||||||
import type { Component, ComputedRef } from 'vue'
|
import type { Component, ComputedRef } from 'vue'
|
||||||
|
|
||||||
|
|
@ -191,7 +191,9 @@ export function useEditorCommands() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const otherPages = computed(() => pages.value.filter((page) => page.id !== editor.state.currentPageId))
|
const otherPages = computed(() =>
|
||||||
|
pages.value.filter((page) => page.id !== editor.state.currentPageId)
|
||||||
|
)
|
||||||
|
|
||||||
function getCommand(id: EditorCommandId) {
|
function getCommand(id: EditorCommandId) {
|
||||||
return commands[id]
|
return commands[id]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useEditor } from '../context/editorContext'
|
||||||
|
import { useSelectionState } from '../selection/useSelectionState'
|
||||||
import { useEditorCommands } from './useEditorCommands'
|
import { useEditorCommands } from './useEditorCommands'
|
||||||
import { useSelectionState } from '../shared/useSelectionState'
|
|
||||||
import { useEditor } from '../shared/editorContext'
|
|
||||||
|
|
||||||
export interface MenuActionNode {
|
export interface MenuActionNode {
|
||||||
separator?: false
|
separator?: false
|
||||||
|
|
@ -85,7 +85,9 @@ export function useMenuModel() {
|
||||||
...(hasSelection.value ? [commandMenuItem('selection.wrapInAutoLayout', '⇧A')] : []),
|
...(hasSelection.value ? [commandMenuItem('selection.wrapInAutoLayout', '⇧A')] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
commandMenuItem('selection.createComponent', '⌥⌘K'),
|
commandMenuItem('selection.createComponent', '⌥⌘K'),
|
||||||
...(canCreateComponentSet.value ? [commandMenuItem('selection.createComponentSet', '⇧⌘K')] : []),
|
...(canCreateComponentSet.value
|
||||||
|
? [commandMenuItem('selection.createComponentSet', '⇧⌘K')]
|
||||||
|
: []),
|
||||||
...(isComponent.value && selectedNode.value
|
...(isComponent.value && selectedNode.value
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
@ -108,8 +110,8 @@ export function useMenuModel() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectionLabelMenu = computed(() => ({
|
const selectionLabelMenu = computed(() => ({
|
||||||
visibility: editor.getSelectedNode()?.visible ?? true ? 'Hide' : 'Show',
|
visibility: (editor.getSelectedNode()?.visible ?? true) ? 'Hide' : 'Show',
|
||||||
lock: editor.getSelectedNode()?.locked ?? false ? 'Unlock' : 'Lock'
|
lock: (editor.getSelectedNode()?.locked ?? false) ? 'Unlock' : 'Lock'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { computed, type ComputedRef } from 'vue'
|
import { computed, type ComputedRef } from 'vue'
|
||||||
|
|
||||||
import { useEditor } from '../context/editorContext'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a computed ref that re-evaluates when the scene graph changes.
|
* Creates a computed ref that re-evaluates when the scene graph changes.
|
||||||
*
|
*
|
||||||
|
|
@ -10,10 +8,11 @@ import { useEditor } from '../context/editorContext'
|
||||||
* requestRender(). Vue tracks the read automatically, so the computed
|
* requestRender(). Vue tracks the read automatically, so the computed
|
||||||
* re-evaluates in the same tick as the change. Zero latency.
|
* re-evaluates in the same tick as the change. Zero latency.
|
||||||
*/
|
*/
|
||||||
export function useSceneComputed<T>(fn: () => T): ComputedRef<T> {
|
export function useSceneComputed<T>(fn: () => T, sceneVersion?: () => number): ComputedRef<T> {
|
||||||
const editor = useEditor()
|
|
||||||
return computed(() => {
|
return computed(() => {
|
||||||
void editor.state.sceneVersion
|
if (sceneVersion) {
|
||||||
|
void sceneVersion()
|
||||||
|
}
|
||||||
return fn()
|
return fn()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,16 @@ import { onMounted } from 'vue'
|
||||||
import { useHead } from '@unhead/vue'
|
import { useHead } from '@unhead/vue'
|
||||||
import { TooltipProvider } from 'reka-ui'
|
import { TooltipProvider } from 'reka-ui'
|
||||||
|
|
||||||
|
import { provideEditor } from '@open-pencil/vue'
|
||||||
import AppToast from '@/components/AppToast.vue'
|
import AppToast from '@/components/AppToast.vue'
|
||||||
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { toast } from '@/utils/toast'
|
import { toast } from '@/utils/toast'
|
||||||
|
|
||||||
useHead({ titleTemplate: (title) => (title ? `${title} — OpenPencil` : 'OpenPencil') })
|
useHead({ titleTemplate: (title) => (title ? `${title} — OpenPencil` : 'OpenPencil') })
|
||||||
|
|
||||||
|
const store = useEditorStore()
|
||||||
|
provideEditor(store)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
toast.setupGlobalErrorHandler()
|
toast.setupGlobalErrorHandler()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { ToastProvider, ToastRoot, ToastDescription, ToastViewport, ToastClose }
|
||||||
|
|
||||||
import { useClipboard } from '@vueuse/core'
|
import { useClipboard } from '@vueuse/core'
|
||||||
|
|
||||||
import Tip from '@/components/Tip.vue'
|
import Tip from '@/components/ui/Tip.vue'
|
||||||
import { toast } from '@/utils/toast'
|
import { toast } from '@/utils/toast'
|
||||||
import { toastRoot } from '@/components/ui/toast'
|
import { toastRoot } from '@/components/ui/toast'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import { useClipboard } from '@vueuse/core'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { selectionToJSX } from '@open-pencil/core'
|
import { selectionToJSX } from '@open-pencil/core'
|
||||||
import { useEditor, useSceneComputed } from '@open-pencil/vue'
|
import { useSceneComputed } from '@open-pencil/vue'
|
||||||
|
|
||||||
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
import type { JSXFormat } from '@open-pencil/core'
|
import type { JSXFormat } from '@open-pencil/core'
|
||||||
|
|
||||||
const store = useEditor()
|
const store = useEditorStore()
|
||||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||||
const jsxFormat = ref<JSXFormat>('openpencil')
|
const jsxFormat = ref<JSXFormat>('openpencil')
|
||||||
|
|
||||||
|
|
@ -18,11 +20,14 @@ function toggleFormat() {
|
||||||
jsxFormat.value = jsxFormat.value === 'openpencil' ? 'tailwind' : 'openpencil'
|
jsxFormat.value = jsxFormat.value === 'openpencil' ? 'tailwind' : 'openpencil'
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsxCode = useSceneComputed(() => {
|
const jsxCode = useSceneComputed(
|
||||||
const ids = [...store.state.selectedIds]
|
() => {
|
||||||
if (ids.length === 0) return ''
|
const ids = [...store.state.selectedIds]
|
||||||
return selectionToJSX(ids, store.graph, jsxFormat.value)
|
if (ids.length === 0) return ''
|
||||||
})
|
return selectionToJSX(ids, store.graph, jsxFormat.value)
|
||||||
|
},
|
||||||
|
() => store.state.sceneVersion
|
||||||
|
)
|
||||||
|
|
||||||
const highlightedLines = computed(() => {
|
const highlightedLines = computed(() => {
|
||||||
if (!jsxCode.value) return []
|
if (!jsxCode.value) return []
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppSelect from './AppSelect.vue'
|
import AppSelect from './ui/AppSelect.vue'
|
||||||
import Tip from './Tip.vue'
|
import Tip from './ui/Tip.vue'
|
||||||
import HsvColorArea from './HsvColorArea.vue'
|
import HsvColorArea from './HsvColorArea.vue'
|
||||||
import ScrubInput from './ScrubInput.vue'
|
import ScrubInput from './ScrubInput.vue'
|
||||||
import { colorToCSS } from '@open-pencil/core'
|
import { colorToCSS } from '@open-pencil/core'
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
import { computed, shallowRef, watch } from 'vue'
|
import { computed, shallowRef, watch } from 'vue'
|
||||||
import { useFileDialog, useObjectUrl } from '@vueuse/core'
|
import { useFileDialog, useObjectUrl } from '@vueuse/core'
|
||||||
|
|
||||||
import AppSelect from './AppSelect.vue'
|
import AppSelect from './ui/AppSelect.vue'
|
||||||
import { useEditor } from '@open-pencil/vue'
|
|
||||||
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
import type { Fill, ImageScaleMode } from '@open-pencil/core'
|
import type { Fill, ImageScaleMode } from '@open-pencil/core'
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@ const IMAGE_SCALE_MODES: { value: ImageScaleMode; label: string }[] = [
|
||||||
const { fill } = defineProps<{ fill: Fill }>()
|
const { fill } = defineProps<{ fill: Fill }>()
|
||||||
const emit = defineEmits<{ update: [fill: Fill] }>()
|
const emit = defineEmits<{ update: [fill: Fill] }>()
|
||||||
|
|
||||||
const store = useEditor()
|
const store = useEditorStore()
|
||||||
|
|
||||||
const imageBlob = shallowRef<Blob | null>(null)
|
const imageBlob = shallowRef<Blob | null>(null)
|
||||||
const imagePreviewUrl = useObjectUrl(imageBlob)
|
const imagePreviewUrl = useObjectUrl(imageBlob)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
|
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
|
||||||
|
|
||||||
import {
|
import { LayerTreeRoot, LayerTreeItem, useInlineRename, useLayerDrag } from '@open-pencil/vue'
|
||||||
LayerTreeRoot,
|
import { useEditorStore } from '@/stores/editor'
|
||||||
LayerTreeItem,
|
|
||||||
useInlineRename,
|
|
||||||
useLayerDrag,
|
|
||||||
useEditor
|
|
||||||
} from '@open-pencil/vue'
|
|
||||||
import { nodeIcon, COMPONENT_TYPES } from '@/utils/layer-icons'
|
import { nodeIcon, COMPONENT_TYPES } from '@/utils/layer-icons'
|
||||||
import CanvasMenu from './CanvasMenu.vue'
|
import CanvasMenu from './CanvasMenu.vue'
|
||||||
import Tip from './Tip.vue'
|
import Tip from './ui/Tip.vue'
|
||||||
|
|
||||||
const INDENT = 16
|
const INDENT = 16
|
||||||
const store = useEditor()
|
const store = useEditorStore()
|
||||||
const rename = useInlineRename((id, name) => store.renameNode(id, name))
|
const rename = useInlineRename((id, name) => store.renameNode(id, name))
|
||||||
const { draggingId, instruction, instructionTargetId } = useLayerDrag(store, INDENT)
|
const { draggingId, instruction, instructionTargetId } = useLayerDrag(store, INDENT)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ const pageActions = ref<{
|
||||||
renamePage: (pageId: string, name: string) => void
|
renamePage: (pageId: string, name: string) => void
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
function setPageActions(renamePage: (pageId: string, name: string) => void) {
|
||||||
|
pageActions.value = { renamePage }
|
||||||
|
}
|
||||||
|
|
||||||
function setPageInputRef(pageId: string, el: HTMLInputElement | null) {
|
function setPageInputRef(pageId: string, el: HTMLInputElement | null) {
|
||||||
if (el) pageInputRefs.set(pageId, el)
|
if (el) pageInputRefs.set(pageId, el)
|
||||||
else pageInputRefs.delete(pageId)
|
else pageInputRefs.delete(pageId)
|
||||||
|
|
@ -27,6 +31,14 @@ function startRename(pg: { id: string; name: string }) {
|
||||||
rename.start(pg.id, pg.name)
|
rename.start(pg.id, pg.name)
|
||||||
activeRenameId.value = pg.id
|
activeRenameId.value = pg.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePageDblClick(
|
||||||
|
pg: { id: string; name: string },
|
||||||
|
renamePage: (pageId: string, name: string) => void
|
||||||
|
) {
|
||||||
|
setPageActions(renamePage)
|
||||||
|
startRename(pg)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -62,7 +74,11 @@ function startRename(pg: { id: string; name: string }) {
|
||||||
@keydown="rename.onKeydown"
|
@keydown="rename.onKeydown"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="isDivider(pg)" class="my-1 flex items-center px-2" @dblclick="startRename(pg)">
|
<div
|
||||||
|
v-else-if="isDivider(pg)"
|
||||||
|
class="my-1 flex items-center px-2"
|
||||||
|
@dblclick="startRename(pg)"
|
||||||
|
>
|
||||||
<div class="h-px flex-1 bg-border" />
|
<div class="h-px flex-1 bg-border" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -75,10 +91,7 @@ function startRename(pg: { id: string; name: string }) {
|
||||||
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
|
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
|
||||||
"
|
"
|
||||||
@click="switchPage(pg.id)"
|
@click="switchPage(pg.id)"
|
||||||
@dblclick="
|
@dblclick="handlePageDblClick(pg, renamePage)"
|
||||||
pageActions = { renamePage }
|
|
||||||
startRename(pg)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<icon-lucide-file class="size-3 shrink-0" />
|
<icon-lucide-file class="size-3 shrink-0" />
|
||||||
<span class="truncate">{{ pg.name }}</span>
|
<span class="truncate">{{ pg.name }}</span>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||||
|
|
||||||
import { useAIChat } from '@/composables/use-chat'
|
import { useAIChat } from '@/composables/use-chat'
|
||||||
import { useEditor } from '@open-pencil/vue'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
import ChatPanel from './ChatPanel.vue'
|
import ChatPanel from './ChatPanel.vue'
|
||||||
import CodePanel from './CodePanel.vue'
|
import CodePanel from './CodePanel.vue'
|
||||||
import DesignPanel from './DesignPanel.vue'
|
import DesignPanel from './DesignPanel.vue'
|
||||||
|
|
||||||
const store = useEditor()
|
const store = useEditorStore()
|
||||||
const { activeTab } = useAIChat()
|
const { activeTab } = useAIChat()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
import { TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||||
|
|
||||||
import Tip from '@/components/Tip.vue'
|
import Tip from '@/components/ui/Tip.vue'
|
||||||
import { useTabsStore, createTab } from '@/stores/tabs'
|
import { useTabsStore, createTab } from '@/stores/tabs'
|
||||||
|
|
||||||
const { tabs, activeTabId, switchTab, closeTab } = useTabsStore()
|
const { tabs, activeTabId, switchTab, closeTab } = useTabsStore()
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import { computed } from 'vue'
|
||||||
|
|
||||||
import ColorInput from '@/components/ColorInput.vue'
|
import ColorInput from '@/components/ColorInput.vue'
|
||||||
import { sectionWrapper } from '@/components/ui/section'
|
import { sectionWrapper } from '@/components/ui/section'
|
||||||
import { useEditor } from '@open-pencil/vue'
|
|
||||||
|
|
||||||
const editor = useEditor()
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
|
const editor = useEditorStore()
|
||||||
const pageColor = computed(() => editor.state.pageColor)
|
const pageColor = computed(() => editor.state.pageColor)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import Tip from '@/components/Tip.vue'
|
import Tip from '@/components/ui/Tip.vue'
|
||||||
import { sectionWrapper } from '@/components/ui/section'
|
import { sectionWrapper } from '@/components/ui/section'
|
||||||
import { useEditor, useSceneComputed } from '@open-pencil/vue'
|
import { useSceneComputed } from '@open-pencil/vue'
|
||||||
|
|
||||||
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
const emit = defineEmits<{ openDialog: [] }>()
|
const emit = defineEmits<{ openDialog: [] }>()
|
||||||
|
|
||||||
const editor = useEditor()
|
const editor = useEditorStore()
|
||||||
const collectionCount = useSceneComputed(() => editor.getCollectionCount())
|
const collectionCount = useSceneComputed(
|
||||||
const variableCount = useSceneComputed(() => editor.getVariableCount())
|
() => editor.getCollectionCount(),
|
||||||
|
() => editor.state.sceneVersion
|
||||||
|
)
|
||||||
|
const variableCount = useSceneComputed(
|
||||||
|
() => editor.getVariableCount(),
|
||||||
|
() => editor.state.sceneVersion
|
||||||
|
)
|
||||||
const hasVariables = computed(() => variableCount.value > 0)
|
const hasVariables = computed(() => variableCount.value > 0)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { openFileDialog } from '@/composables/use-menu'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { useEditorCommands, useMenuModel } from '@open-pencil/vue'
|
import { useEditorCommands, useMenuModel } from '@open-pencil/vue'
|
||||||
|
|
||||||
|
|
@ -20,14 +21,15 @@ export function useAppMenu(mod: string) {
|
||||||
shortcut: `${mod}N`,
|
shortcut: `${mod}N`,
|
||||||
action: () => import('@/stores/tabs').then((m) => m.createTab())
|
action: () => import('@/stores/tabs').then((m) => m.createTab())
|
||||||
},
|
},
|
||||||
{ label: 'Open…', shortcut: `${mod}O` },
|
{ label: 'Open…', shortcut: `${mod}O`, action: () => void openFileDialog() },
|
||||||
{ separator: true as const },
|
{ separator: true as const },
|
||||||
{ label: 'Save', shortcut: `${mod}S` },
|
{ label: 'Save', shortcut: `${mod}S`, action: () => void store.saveFigFile() },
|
||||||
{ label: 'Save as…', shortcut: `${mod}⇧S` },
|
{ label: 'Save as…', shortcut: `${mod}⇧S`, action: () => void store.saveFigFileAs() },
|
||||||
{ separator: true as const },
|
{ separator: true as const },
|
||||||
{
|
{
|
||||||
label: 'Export selection…',
|
label: 'Export selection…',
|
||||||
shortcut: `${mod}⇧E`,
|
shortcut: `${mod}⇧E`,
|
||||||
|
action: () => void store.exportSelection(1, 'PNG'),
|
||||||
disabled: store.state.selectedIds.size === 0
|
disabled: store.state.selectedIds.size === 0
|
||||||
},
|
},
|
||||||
{ separator: true as const },
|
{ separator: true as const },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useFileDialog } from '@vueuse/core'
|
import { useFileDialog } from '@vueuse/core'
|
||||||
import { onUnmounted } from 'vue'
|
import { onUnmounted } from 'vue'
|
||||||
|
|
||||||
import { IS_TAURI } from '@/constants'
|
import { IS_BROWSER, IS_TAURI } from '@/constants'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs'
|
import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs'
|
||||||
|
|
||||||
|
|
@ -11,6 +11,18 @@ fileDialog.onChange((files) => {
|
||||||
if (file) void openFileInNewTab(file)
|
if (file) void openFileInNewTab(file)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (IS_BROWSER) {
|
||||||
|
;(
|
||||||
|
window as Window & { __OPEN_PENCIL_OPEN_FILE__?: (path: string) => Promise<void> }
|
||||||
|
).__OPEN_PENCIL_OPEN_FILE__ = async (path: string) => {
|
||||||
|
const response = await fetch(path)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const name = path.split('/').pop() ?? 'file.fig'
|
||||||
|
const file = new File([blob], name, { type: 'application/octet-stream' })
|
||||||
|
await openFileInNewTab(file, undefined, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function openFileDialog() {
|
export async function openFileDialog() {
|
||||||
if (IS_TAURI) {
|
if (IS_TAURI) {
|
||||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
import { shallowReactive, shallowRef, computed, watch } from 'vue'
|
import { shallowReactive, shallowRef, computed, watch, triggerRef } from 'vue'
|
||||||
|
|
||||||
import { IS_TAURI, CANVAS_BG_COLOR } from '@/constants'
|
import { IS_TAURI } from '@/constants'
|
||||||
import { loadFont } from '@/engine/fonts'
|
import { loadFont } from '@/engine/fonts'
|
||||||
import { toast } from '@/utils/toast'
|
import { toast } from '@/utils/toast'
|
||||||
import {
|
import {
|
||||||
computeAllLayouts,
|
|
||||||
createDefaultEditorState,
|
createDefaultEditorState,
|
||||||
createEditor,
|
createEditor,
|
||||||
exportFigFile,
|
exportFigFile,
|
||||||
|
|
@ -21,8 +20,8 @@ export type { Tool } from '@open-pencil/core'
|
||||||
export type { EditorToolDef as ToolDef } from '@open-pencil/core'
|
export type { EditorToolDef as ToolDef } from '@open-pencil/core'
|
||||||
export { EDITOR_TOOLS as TOOLS, TOOL_SHORTCUTS } from '@open-pencil/core'
|
export { EDITOR_TOOLS as TOOLS, TOOL_SHORTCUTS } from '@open-pencil/core'
|
||||||
|
|
||||||
export function createEditorStore() {
|
export function createEditorStore(initialGraph?: SceneGraph) {
|
||||||
const graph = new SceneGraph()
|
const graph = initialGraph ?? new SceneGraph()
|
||||||
|
|
||||||
const state = shallowReactive<
|
const state = shallowReactive<
|
||||||
EditorState & {
|
EditorState & {
|
||||||
|
|
@ -49,7 +48,11 @@ export function createEditorStore() {
|
||||||
cursorCanvasY: null
|
cursorCanvasY: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const editor = createEditor({ graph, state, loadFont })
|
const editor = createEditor({ graph, state, loadFont, skipInitialGraphSetup: !!initialGraph })
|
||||||
|
|
||||||
|
if (initialGraph) {
|
||||||
|
editor.subscribeToGraph()
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Vue computed refs ────────────────────────────────────────
|
// ─── Vue computed refs ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -167,7 +170,6 @@ export function createEditorStore() {
|
||||||
const imported = await readFigFile(file)
|
const imported = await readFigFile(file)
|
||||||
await yieldToUI()
|
await yieldToUI()
|
||||||
editor.replaceGraph(imported)
|
editor.replaceGraph(imported)
|
||||||
computeAllLayouts(editor.graph)
|
|
||||||
editor.undo.clear()
|
editor.undo.clear()
|
||||||
fileHandle = handle ?? null
|
fileHandle = handle ?? null
|
||||||
filePath = path ?? null
|
filePath = path ?? null
|
||||||
|
|
@ -176,12 +178,7 @@ export function createEditorStore() {
|
||||||
state.selectedIds = new Set()
|
state.selectedIds = new Set()
|
||||||
const firstPage = editor.graph.getPages()[0] as SceneNode | undefined
|
const firstPage = editor.graph.getPages()[0] as SceneNode | undefined
|
||||||
const pageId = firstPage?.id ?? editor.graph.rootId
|
const pageId = firstPage?.id ?? editor.graph.rootId
|
||||||
state.currentPageId = pageId
|
await editor.switchPage(pageId)
|
||||||
state.panX = 0
|
|
||||||
state.panY = 0
|
|
||||||
state.zoom = 1
|
|
||||||
state.pageColor = { ...CANVAS_BG_COLOR }
|
|
||||||
await editor.loadFontsForNodes(editor.graph.getChildren(pageId).map((n) => n.id))
|
|
||||||
editor.requestRender()
|
editor.requestRender()
|
||||||
void startWatchingFile()
|
void startWatchingFile()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -286,12 +283,10 @@ export function createEditorStore() {
|
||||||
const file = new File([blob], state.documentName + '.fig')
|
const file = new File([blob], state.documentName + '.fig')
|
||||||
const imported = await readFigFile(file)
|
const imported = await readFigFile(file)
|
||||||
editor.replaceGraph(imported)
|
editor.replaceGraph(imported)
|
||||||
computeAllLayouts(editor.graph)
|
|
||||||
} else if (fileHandle) {
|
} else if (fileHandle) {
|
||||||
const file = await fileHandle.getFile()
|
const file = await fileHandle.getFile()
|
||||||
const imported = await readFigFile(file)
|
const imported = await readFigFile(file)
|
||||||
editor.replaceGraph(imported)
|
editor.replaceGraph(imported)
|
||||||
computeAllLayouts(editor.graph)
|
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -500,7 +495,7 @@ export function createEditorStore() {
|
||||||
// ─── Public API ───────────────────────────────────────────────
|
// ─── Public API ───────────────────────────────────────────────
|
||||||
// Spread all core Editor methods, then override getters and add app-specific.
|
// Spread all core Editor methods, then override getters and add app-specific.
|
||||||
|
|
||||||
return {
|
const store = {
|
||||||
...editor,
|
...editor,
|
||||||
state,
|
state,
|
||||||
selectedNodes,
|
selectedNodes,
|
||||||
|
|
@ -523,6 +518,23 @@ export function createEditorStore() {
|
||||||
mobileCut,
|
mobileCut,
|
||||||
mobilePaste
|
mobilePaste
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object.defineProperties(store, {
|
||||||
|
graph: {
|
||||||
|
enumerable: true,
|
||||||
|
get: () => editor.graph
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
enumerable: true,
|
||||||
|
get: () => editor.renderer
|
||||||
|
},
|
||||||
|
textEditor: {
|
||||||
|
enumerable: true,
|
||||||
|
get: () => editor.textEditor
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EditorStore = ReturnType<typeof createEditorStore>
|
export type EditorStore = ReturnType<typeof createEditorStore>
|
||||||
|
|
@ -531,6 +543,7 @@ const storeRef = shallowRef<EditorStore>()
|
||||||
|
|
||||||
export function setActiveEditorStore(store: EditorStore) {
|
export function setActiveEditorStore(store: EditorStore) {
|
||||||
storeRef.value = store
|
storeRef.value = store
|
||||||
|
triggerRef(storeRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveEditorStore(): EditorStore {
|
export function getActiveEditorStore(): EditorStore {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { shallowRef, computed } from 'vue'
|
import { shallowRef, computed, triggerRef } from 'vue'
|
||||||
|
|
||||||
import { createEditorStore, setActiveEditorStore } from './editor'
|
import { createEditorStore, setActiveEditorStore } from './editor'
|
||||||
|
|
||||||
import type { EditorStore } from './editor'
|
import type { EditorStore } from './editor'
|
||||||
|
import type { SceneGraph } from '@open-pencil/core'
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -34,8 +35,8 @@ export function getActiveStore(): EditorStore {
|
||||||
return tab.store
|
return tab.store
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTab(store?: EditorStore): Tab {
|
export function createTab(store?: EditorStore, initialGraph?: SceneGraph): Tab {
|
||||||
const s = store ?? createEditorStore()
|
const s = store ?? createEditorStore(initialGraph)
|
||||||
const tab: Tab = { id: generateTabId(), store: s }
|
const tab: Tab = { id: generateTabId(), store: s }
|
||||||
tabsRef.value = [...tabsRef.value, tab]
|
tabsRef.value = [...tabsRef.value, tab]
|
||||||
activateTab(tab)
|
activateTab(tab)
|
||||||
|
|
@ -45,6 +46,7 @@ export function createTab(store?: EditorStore): Tab {
|
||||||
function activateTab(tab: Tab) {
|
function activateTab(tab: Tab) {
|
||||||
activeTabId.value = tab.id
|
activeTabId.value = tab.id
|
||||||
setActiveEditorStore(tab.store)
|
setActiveEditorStore(tab.store)
|
||||||
|
triggerRef(tabsRef)
|
||||||
window.__OPEN_PENCIL_STORE__ = tab.store
|
window.__OPEN_PENCIL_STORE__ = tab.store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,19 +76,32 @@ export function closeTab(tabId: string) {
|
||||||
|
|
||||||
export async function openFileInNewTab(
|
export async function openFileInNewTab(
|
||||||
file: File,
|
file: File,
|
||||||
handle?: FileSystemFileHandle,
|
_handle?: FileSystemFileHandle,
|
||||||
path?: string
|
_path?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const current = activeTab.value
|
const current = activeTab.value
|
||||||
const isUntouched =
|
const isUntouched =
|
||||||
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
|
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
|
||||||
|
|
||||||
if (isUntouched) {
|
if (isUntouched) {
|
||||||
await current.store.openFigFile(file, handle, path)
|
const { readFigFile } = await import('@open-pencil/core')
|
||||||
|
const imported = await readFigFile(file)
|
||||||
|
current.store.replaceGraph(imported)
|
||||||
|
current.store.undo.clear()
|
||||||
|
current.store.state.documentName = file.name.replace(/\.fig$/i, '')
|
||||||
|
current.store.state.selectedIds = new Set()
|
||||||
|
const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId
|
||||||
|
await current.store.switchPage(pageId)
|
||||||
} else {
|
} else {
|
||||||
const store = createEditorStore()
|
const { readFigFile } = await import('@open-pencil/core')
|
||||||
|
const imported = await readFigFile(file)
|
||||||
|
const store = createEditorStore(imported)
|
||||||
createTab(store)
|
createTab(store)
|
||||||
await store.openFigFile(file, handle, path)
|
store.undo.clear()
|
||||||
|
store.state.documentName = file.name.replace(/\.fig$/i, '')
|
||||||
|
store.state.selectedIds = new Set()
|
||||||
|
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
|
||||||
|
await store.switchPage(pageId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useRoute } from 'vue-router'
|
||||||
import { useHead } from '@unhead/vue'
|
import { useHead } from '@unhead/vue'
|
||||||
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
||||||
|
|
||||||
import { provideEditor, useViewportKind } from '@open-pencil/vue'
|
import { useViewportKind } from '@open-pencil/vue'
|
||||||
import { useKeyboard } from '@/composables/use-keyboard'
|
import { useKeyboard } from '@/composables/use-keyboard'
|
||||||
import { useMenu } from '@/composables/use-menu'
|
import { useMenu } from '@/composables/use-menu'
|
||||||
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
|
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
|
||||||
|
|
@ -32,7 +32,6 @@ const showChrome = !('no-chrome' in params)
|
||||||
|
|
||||||
const firstTab = createTab()
|
const firstTab = createTab()
|
||||||
const store = useEditorStore()
|
const store = useEditorStore()
|
||||||
provideEditor(store)
|
|
||||||
const { isMobile } = useViewportKind()
|
const { isMobile } = useViewportKind()
|
||||||
|
|
||||||
if (route.meta.demo && !('test' in params)) {
|
if (route.meta.demo && !('test' in params)) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
|
||||||
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, setTextMeasurer, FigmaAPI } from '@open-pencil/core'
|
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, setTextMeasurer, FigmaAPI, readFigFile } from '@open-pencil/core'
|
||||||
|
|
||||||
|
import { createEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
function pageId(graph: SceneGraph) {
|
function pageId(graph: SceneGraph) {
|
||||||
return graph.getPages()[0].id
|
return graph.getPages()[0].id
|
||||||
|
|
@ -36,6 +38,13 @@ function rect(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFixtureGraph(name: string) {
|
||||||
|
const path = new URL(`../fixtures/${name}`, import.meta.url)
|
||||||
|
const buffer = await Bun.file(path).arrayBuffer()
|
||||||
|
const file = new File([buffer], name, { type: 'application/octet-stream' })
|
||||||
|
return readFigFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
describe('Auto Layout', () => {
|
describe('Auto Layout', () => {
|
||||||
describe('horizontal basic', () => {
|
describe('horizontal basic', () => {
|
||||||
test('positions children left-to-right', () => {
|
test('positions children left-to-right', () => {
|
||||||
|
|
@ -970,6 +979,75 @@ describe('Auto Layout', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('text measurement', () => {
|
describe('text measurement', () => {
|
||||||
|
test('opening imported fig keeps stored text bounds before CanvasKit measurement', async () => {
|
||||||
|
const graph = await loadFixtureGraph('gold-preview.fig')
|
||||||
|
const store = createEditorStore(graph)
|
||||||
|
const title = [...store.graph.getAllNodes()].find(
|
||||||
|
(node) => node.type === 'TEXT' && node.text === "World's largest"
|
||||||
|
)
|
||||||
|
const subtitle = [...store.graph.getAllNodes()].find(
|
||||||
|
(node) =>
|
||||||
|
node.type === 'TEXT' && node.text === 'Preline UI Figma - crafted with Tailwind CSS styles'
|
||||||
|
)
|
||||||
|
const description = [...store.graph.getAllNodes()].find(
|
||||||
|
(node) =>
|
||||||
|
node.type === 'TEXT' &&
|
||||||
|
node.text.startsWith('Preline UI Figma is the largest free design system for Figma')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!title || !subtitle || !description) {
|
||||||
|
throw new Error('Expected imported text nodes in gold-preview.fig')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(title.width).toBe(444)
|
||||||
|
expect(title.height).toBe(73)
|
||||||
|
expect(subtitle.width).toBe(439)
|
||||||
|
expect(subtitle.height).toBe(22)
|
||||||
|
expect(description.width).toBe(878)
|
||||||
|
expect(description.height).toBe(60)
|
||||||
|
|
||||||
|
await store.switchPage(store.graph.getPages()[0].id)
|
||||||
|
|
||||||
|
expect(store.graph.getNode(title.id)?.width).toBe(444)
|
||||||
|
expect(store.graph.getNode(title.id)?.height).toBe(73)
|
||||||
|
expect(store.graph.getNode(subtitle.id)?.width).toBe(439)
|
||||||
|
expect(store.graph.getNode(subtitle.id)?.height).toBe(22)
|
||||||
|
expect(store.graph.getNode(description.id)?.width).toBe(878)
|
||||||
|
expect(store.graph.getNode(description.id)?.height).toBe(60)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('imported nested instance layout recomputes hidden sibling offsets', async () => {
|
||||||
|
const graph = await loadFixtureGraph('gold-preview.fig')
|
||||||
|
const previewRoot = graph.getChildren(graph.getPages()[0].id)[0]
|
||||||
|
const wysiwygEditor = graph.getChildren(previewRoot.id).find((node) => node.name === '_WYSIWYG-editor')
|
||||||
|
const toolbarVariant = wysiwygEditor
|
||||||
|
? graph.getChildren(wysiwygEditor.id).find((node) => node.name === '_on-text-WYSIWYG-toolbar')
|
||||||
|
: undefined
|
||||||
|
const toolbarRow = toolbarVariant
|
||||||
|
? graph.getChildren(toolbarVariant.id).find((node) => node.name === 'Toolbar')
|
||||||
|
: undefined
|
||||||
|
const hiddenInput = toolbarRow
|
||||||
|
? graph.getChildren(toolbarRow.id).find((node) => node.name === 'Input')
|
||||||
|
: undefined
|
||||||
|
const visibleToolbar = toolbarRow
|
||||||
|
? graph.getChildren(toolbarRow.id).find(
|
||||||
|
(node) => node.name === 'Toolbar' && node.id !== toolbarRow.id
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (!toolbarRow || !hiddenInput || !visibleToolbar) {
|
||||||
|
throw new Error('Expected imported WYSIWYG toolbar nodes in gold-preview.fig')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hiddenInput.visible).toBe(false)
|
||||||
|
expect(visibleToolbar.x).toBe(298)
|
||||||
|
|
||||||
|
computeAllLayouts(graph, graph.getPages()[0].id)
|
||||||
|
|
||||||
|
expect(graph.getNode(visibleToolbar.id)?.x).toBe(8)
|
||||||
|
expect(graph.getNode(visibleToolbar.id)?.y).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => {
|
test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => {
|
||||||
const graph = new SceneGraph()
|
const graph = new SceneGraph()
|
||||||
const pid = pageId(graph)
|
const pid = pageId(graph)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue