Fix imported fig rendering and file open regressions
This commit is contained in:
parent
0a770248a2
commit
07af72ab24
|
|
@ -12,6 +12,9 @@
|
|||
|
||||
### 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 save crash when COLOR variable is missing alpha channel
|
||||
- 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 { CANVAS_BG_COLOR, IS_BROWSER } from '../constants'
|
||||
import { loadFont as defaultLoadFont } from '../fonts'
|
||||
import { computeLayout, setTextMeasurer } from '../layout'
|
||||
import { SceneGraph } from '../scene-graph'
|
||||
import { TextEditor } from '../text-editor'
|
||||
import { UndoManager } from '../undo'
|
||||
|
||||
import { createAlignmentActions } from './alignment'
|
||||
import { createClipboardActions } from './clipboard'
|
||||
import { createComponentActions } from './components'
|
||||
|
|
@ -19,10 +18,10 @@ import { createUndoActions } from './undo'
|
|||
import { createVariableActions } from './variables'
|
||||
import { createViewportActions } from './viewport'
|
||||
|
||||
import type { SceneNode } from '../scene-graph'
|
||||
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 { CanvasKit } from 'canvaskit-wasm'
|
||||
|
||||
export function createDefaultEditorState(pageId: string): EditorState {
|
||||
return {
|
||||
|
|
@ -54,12 +53,15 @@ export function createDefaultEditorState(pageId: string): EditorState {
|
|||
|
||||
export function createEditor(options?: EditorOptions) {
|
||||
let _graph = options?.graph ?? new SceneGraph()
|
||||
const skipInitialGraphSetup = options?.skipInitialGraphSetup ?? false
|
||||
const undo = new UndoManager()
|
||||
const _loadFont = options?.loadFont ?? defaultLoadFont
|
||||
const _getViewportSize = options?.getViewportSize ?? (() => {
|
||||
if (IS_BROWSER) return { width: window.innerWidth, height: window.innerHeight }
|
||||
return { width: 800, height: 600 }
|
||||
})
|
||||
const _getViewportSize =
|
||||
options?.getViewportSize ??
|
||||
(() => {
|
||||
if (IS_BROWSER) return { width: window.innerWidth, height: window.innerHeight }
|
||||
return { width: 800, height: 600 }
|
||||
})
|
||||
let _ck: CanvasKit | null = null
|
||||
let _renderer: SkiaRenderer | null = null
|
||||
let _textEditor: TextEditor | null = null
|
||||
|
|
@ -153,12 +155,18 @@ export function createEditor(options?: EditorOptions) {
|
|||
]
|
||||
}
|
||||
|
||||
subscribeToGraph()
|
||||
if (!skipInitialGraphSetup) {
|
||||
subscribeToGraph()
|
||||
}
|
||||
|
||||
// Build the shared context
|
||||
const ctx: EditorContext = {
|
||||
get graph() { return _graph },
|
||||
set graph(g) { _graph = g },
|
||||
get graph() {
|
||||
return _graph
|
||||
},
|
||||
set graph(g) {
|
||||
_graph = g
|
||||
},
|
||||
undo,
|
||||
state,
|
||||
loadFont: _loadFont,
|
||||
|
|
@ -204,9 +212,15 @@ export function createEditor(options?: EditorOptions) {
|
|||
}
|
||||
|
||||
return {
|
||||
get graph() { return _graph },
|
||||
get renderer() { return _renderer },
|
||||
get textEditor() { return _textEditor },
|
||||
get graph() {
|
||||
return _graph
|
||||
},
|
||||
get renderer() {
|
||||
return _renderer
|
||||
},
|
||||
get textEditor() {
|
||||
return _textEditor
|
||||
},
|
||||
undo,
|
||||
state,
|
||||
|
||||
|
|
@ -221,6 +235,7 @@ export function createEditor(options?: EditorOptions) {
|
|||
requestRepaint,
|
||||
setCanvasKit,
|
||||
replaceGraph,
|
||||
subscribeToGraph,
|
||||
|
||||
// Selection
|
||||
...selection,
|
||||
|
|
@ -263,7 +278,8 @@ export function createEditor(options?: EditorOptions) {
|
|||
|
||||
// Clipboard — bridge functions that need selectedNodes
|
||||
duplicateSelected: () => clipboard.duplicateSelected(selection.getSelectedNodes()),
|
||||
writeCopyData: (data: DataTransfer) => clipboard.writeCopyData(data, selection.getSelectedNodes()),
|
||||
writeCopyData: (data: DataTransfer) =>
|
||||
clipboard.writeCopyData(data, selection.getSelectedNodes()),
|
||||
pasteFromHTML: clipboard.pasteFromHTML,
|
||||
deleteSelected: clipboard.deleteSelected,
|
||||
storeImage: clipboard.storeImage,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CANVAS_BG_COLOR } from '../constants'
|
||||
import { collectFontKeys } from '../fonts'
|
||||
import { computeAllLayouts } from '../layout'
|
||||
|
||||
import type { Color } from '../types'
|
||||
import type { EditorContext } from './types'
|
||||
|
|
@ -42,10 +43,16 @@ export function createPageActions(ctx: EditorContext) {
|
|||
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) {
|
||||
await Promise.all(toLoad.map(([family, style]) => ctx.loadFont(family, style)))
|
||||
}
|
||||
if (ctx.getRenderer()) {
|
||||
computeAllLayouts(ctx.graph, pageId)
|
||||
}
|
||||
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 { SnapGuide } from '../snap'
|
||||
import type { SkiaRenderer } from '../renderer/renderer'
|
||||
import type { UndoManager } from '../undo'
|
||||
import type { TextEditor } from '../text-editor'
|
||||
import type { Color, Rect, Vector } from '../types'
|
||||
import type { UndoManager } from '../undo'
|
||||
import type { CanvasKit } from 'canvaskit-wasm'
|
||||
|
||||
export type Tool =
|
||||
|
|
@ -101,6 +101,7 @@ export interface EditorOptions {
|
|||
state?: EditorState
|
||||
loadFont?: (family: string, style: string) => Promise<ArrayBuffer | null>
|
||||
getViewportSize?: () => { width: number; height: number }
|
||||
skipInitialGraphSetup?: boolean
|
||||
}
|
||||
|
||||
export interface EditorContext {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { VariableType, VariableValue } from '../scene-graph'
|
||||
import { SceneGraph } from '../scene-graph'
|
||||
|
||||
import { populateAndApplyOverrides } from './instance-overrides'
|
||||
import {
|
||||
guidToString,
|
||||
nodeChangeToProps,
|
||||
|
|
@ -8,12 +7,14 @@ import {
|
|||
setVariableColorResolver,
|
||||
VARIABLE_BINDING_FIELDS_INVERSE
|
||||
} 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 { NodeChange, VariableDataValuesEntry, Color, GUID } from './codec'
|
||||
|
||||
function buildVariableColorResolver(changeMap: Map<string, NodeChange>): (guid: GUID) => Color | null {
|
||||
function buildVariableColorResolver(
|
||||
changeMap: Map<string, NodeChange>
|
||||
): (guid: GUID) => Color | null {
|
||||
// Collect variable data: GUID → entries
|
||||
const varEntries = new Map<string, VariableDataValuesEntry[]>()
|
||||
const varSetId = new Map<string, string>()
|
||||
|
|
@ -44,7 +45,7 @@ function buildVariableColorResolver(changeMap: Map<string, NodeChange>): (guid:
|
|||
const setId = varSetId.get(id)
|
||||
const defaultMode = setId ? defaultModes.get(setId) : undefined
|
||||
let entry = defaultMode
|
||||
? entries.find(e => guidToString(e.modeID) === defaultMode)
|
||||
? entries.find((e) => guidToString(e.modeID) === defaultMode)
|
||||
: undefined
|
||||
if (!entry) entry = entries[0]
|
||||
|
||||
|
|
@ -124,10 +125,7 @@ function resolveDefaultValue(type: VariableType): VariableValue {
|
|||
return 0
|
||||
}
|
||||
|
||||
function importCollections(
|
||||
changeMap: Map<string, NodeChange>,
|
||||
graph: SceneGraph
|
||||
): void {
|
||||
function importCollections(changeMap: Map<string, NodeChange>, graph: SceneGraph): void {
|
||||
for (const [id, nc] of changeMap) {
|
||||
if (nc.type !== 'VARIABLE_SET') continue
|
||||
|
||||
|
|
@ -155,7 +153,9 @@ function importVariableEntries(
|
|||
for (const [id, nc] of changeMap) {
|
||||
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)) {
|
||||
const parentNc = changeMap.get(collectionId)
|
||||
|
|
@ -204,6 +204,7 @@ function importPages(
|
|||
parentMap: Map<string, string>,
|
||||
childrenMap: Map<string, string[]>,
|
||||
created: Set<string>,
|
||||
canvasIdToPageId: Map<string, string>,
|
||||
createSceneNode: (ncId: string, graphParentId: string) => void
|
||||
): void {
|
||||
let docId: string | null = null
|
||||
|
|
@ -220,6 +221,7 @@ function importPages(
|
|||
if (!canvasNc) continue
|
||||
if (canvasNc.type === 'CANVAS') {
|
||||
const page = graph.addPage(canvasNc.name ?? 'Page')
|
||||
canvasIdToPageId.set(canvasId, page.id)
|
||||
if (canvasNc.internalOnly) page.internalOnly = true
|
||||
created.add(canvasId)
|
||||
for (const childId of childrenMap.get(canvasId) ?? []) {
|
||||
|
|
@ -260,10 +262,7 @@ function importVariableBindings(
|
|||
}
|
||||
}
|
||||
|
||||
function remapComponentIds(
|
||||
graph: SceneGraph,
|
||||
guidToNodeId: Map<string, string>
|
||||
): void {
|
||||
function remapComponentIds(graph: SceneGraph, guidToNodeId: Map<string, string>): void {
|
||||
for (const node of graph.getAllNodes()) {
|
||||
if (node.type !== 'INSTANCE' || !node.componentId) continue
|
||||
const remapped = guidToNodeId.get(node.componentId)
|
||||
|
|
@ -290,6 +289,7 @@ export function importNodeChanges(
|
|||
|
||||
const { changeMap, parentMap, childrenMap } = buildChangeMaps(nodeChanges)
|
||||
|
||||
const canvasIdToPageId = new Map<string, string>()
|
||||
const created = new Set<string>()
|
||||
const guidToNodeId = new Map<string, string>()
|
||||
const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? []
|
||||
|
|
@ -304,7 +304,8 @@ export function importNodeChanges(
|
|||
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
|
||||
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)
|
||||
|
||||
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)
|
||||
importVariableEntries(changeMap, parentMap, graph)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { SceneGraph, SceneNode } from '../../scene-graph'
|
||||
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.).
|
||||
* 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.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns)
|
||||
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 (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
|
||||
}
|
||||
|
|
@ -63,7 +65,11 @@ export function syncChildrenDeep(
|
|||
const tgtNode = graph.getNode(tgt.childIds[i])
|
||||
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)
|
||||
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. */
|
||||
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 queue = [...expandedSeeds]
|
||||
for (let id = queue.pop(); id !== undefined; id = queue.pop()) {
|
||||
|
|
@ -142,9 +151,7 @@ export function propagateOverridesTransitively(
|
|||
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
|
||||
|
||||
// Merge seeds + protect into a single skip set for syncChildrenDeep
|
||||
const skip = protect && protect.size > 0
|
||||
? new Set([...seeds, ...protect])
|
||||
: seeds
|
||||
const skip = protect && protect.size > 0 ? new Set([...seeds, ...protect]) : seeds
|
||||
|
||||
const visited = new Set<string>()
|
||||
const syncQueue = [...expandedSeeds]
|
||||
|
|
|
|||
|
|
@ -28,11 +28,86 @@ import {
|
|||
DEFAULT_FONT_FAMILY,
|
||||
IS_BROWSER
|
||||
} from '../constants'
|
||||
|
||||
import { computeAbsoluteBounds } from '../geometry'
|
||||
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 { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph'
|
||||
import type { SnapGuide } from '../snap'
|
||||
import type { TextEditor } from '../text-editor'
|
||||
import type { Color, Rect, Vector } from '../types'
|
||||
|
|
@ -52,82 +127,6 @@ import type {
|
|||
Paragraph
|
||||
} 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 {
|
||||
hoveredNodeId?: string | null
|
||||
enteredContainerId?: string | null
|
||||
|
|
@ -501,7 +500,9 @@ export class SkiaRenderer {
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -673,7 +674,14 @@ export class SkiaRenderer {
|
|||
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.panX = state.panX
|
||||
this.panY = state.panY
|
||||
|
|
@ -683,21 +691,30 @@ export class SkiaRenderer {
|
|||
this.showRulers = showRulers
|
||||
this.pageColor = state.pageColor
|
||||
this.pageId = state.currentPageId
|
||||
this.render(graph, state.selectedIds, {
|
||||
hoveredNodeId: state.hoveredNodeId,
|
||||
enteredContainerId: state.enteredContainerId,
|
||||
editingTextId: state.editingTextId,
|
||||
textEditor: textEditor as RenderOverlays['textEditor'],
|
||||
marquee: state.marquee,
|
||||
snapGuides: state.snapGuides,
|
||||
rotationPreview: state.rotationPreview,
|
||||
dropTargetId: state.dropTargetId,
|
||||
layoutInsertIndicator: state.layoutInsertIndicator,
|
||||
penState: state.penState
|
||||
? { ...state.penState, cursorX: state.penCursorX ?? undefined, cursorY: state.penCursorY ?? undefined } as RenderOverlays['penState']
|
||||
: null,
|
||||
remoteCursors: state.remoteCursors
|
||||
}, state.sceneVersion)
|
||||
this.render(
|
||||
graph,
|
||||
state.selectedIds,
|
||||
{
|
||||
hoveredNodeId: state.hoveredNodeId,
|
||||
enteredContainerId: state.enteredContainerId,
|
||||
editingTextId: state.editingTextId,
|
||||
textEditor: textEditor as RenderOverlays['textEditor'],
|
||||
marquee: state.marquee,
|
||||
snapGuides: state.snapGuides,
|
||||
rotationPreview: state.rotationPreview,
|
||||
dropTargetId: state.dropTargetId,
|
||||
layoutInsertIndicator: state.layoutInsertIndicator,
|
||||
penState: state.penState
|
||||
? ({
|
||||
...state.penState,
|
||||
cursorX: state.penCursorX ?? undefined,
|
||||
cursorY: state.penCursorY ?? undefined
|
||||
} as RenderOverlays['penState'])
|
||||
: null,
|
||||
remoteCursors: state.remoteCursors
|
||||
},
|
||||
state.sceneVersion
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
|
|
@ -812,9 +829,24 @@ export class SkiaRenderer {
|
|||
const prevViewport = this.worldViewport
|
||||
this.worldViewport = { x: -1e6, y: -1e6, w: 2e6, h: 2e6 }
|
||||
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 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) {
|
||||
for (const childId of pageNode.childIds) {
|
||||
this.renderNode(recCanvas, graph, childId, {}, 0, 0)
|
||||
|
|
@ -944,15 +976,28 @@ export class SkiaRenderer {
|
|||
|
||||
// --- 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -997,7 +1042,10 @@ export class SkiaRenderer {
|
|||
drawAiOverlaysFn(this, canvas, graph)
|
||||
}
|
||||
|
||||
private drawLayoutInsertIndicator(canvas: Canvas, indicator?: RenderOverlays['layoutInsertIndicator']): void {
|
||||
private drawLayoutInsertIndicator(
|
||||
canvas: Canvas,
|
||||
indicator?: RenderOverlays['layoutInsertIndicator']
|
||||
): void {
|
||||
drawLayoutInsertIndicatorFn(this, canvas, indicator)
|
||||
}
|
||||
|
||||
|
|
@ -1009,7 +1057,11 @@ export class SkiaRenderer {
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
@ -1025,7 +1077,14 @@ export class SkiaRenderer {
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
@ -1045,7 +1104,14 @@ export class SkiaRenderer {
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
@ -1077,15 +1143,30 @@ export class SkiaRenderer {
|
|||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -1145,7 +1226,13 @@ export class SkiaRenderer {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '../constants'
|
||||
|
||||
import type { SceneNode, SceneGraph } from '../scene-graph'
|
||||
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
|
||||
import type { Color } from '../types'
|
||||
import type { SkiaRenderer, RenderOverlays } from './renderer'
|
||||
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
|
||||
|
||||
function isCulled(
|
||||
r: SkiaRenderer,
|
||||
node: SceneNode,
|
||||
absX: number,
|
||||
absY: number
|
||||
): boolean {
|
||||
function isCulled(r: SkiaRenderer, node: SceneNode, absX: number, absY: number): boolean {
|
||||
const canCull =
|
||||
node.childIds.length === 0 ||
|
||||
((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
|
||||
|
|
@ -47,10 +43,7 @@ function applyNodeTransforms(
|
|||
}
|
||||
|
||||
if (node.flipX || node.flipY) {
|
||||
canvas.translate(
|
||||
node.flipX ? node.width : 0,
|
||||
node.flipY ? node.height : 0
|
||||
)
|
||||
canvas.translate(node.flipX ? node.width : 0, node.flipY ? node.height : 0)
|
||||
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'
|
||||
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
|
||||
canvas.save()
|
||||
const hasRadius = node.cornerRadius > 0 || (node.independentCorners &&
|
||||
(node.topLeftRadius > 0 || node.topRightRadius > 0 || node.bottomRightRadius > 0 || node.bottomLeftRadius > 0))
|
||||
const hasRadius =
|
||||
node.cornerRadius > 0 ||
|
||||
(node.independentCorners &&
|
||||
(node.topLeftRadius > 0 ||
|
||||
node.topRightRadius > 0 ||
|
||||
node.bottomRightRadius > 0 ||
|
||||
node.bottomLeftRadius > 0))
|
||||
if (hasRadius) {
|
||||
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
|
||||
} else {
|
||||
|
|
@ -219,10 +217,7 @@ export function renderComponentSet(
|
|||
r.auxStroke.setStrokeWidth(r.COMPONENT_SET_BORDER_WIDTH / r.zoom)
|
||||
r.auxStroke.setColor(r.compColor())
|
||||
r.auxStroke.setPathEffect(
|
||||
r.ck.PathEffect.MakeDash(
|
||||
[r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom],
|
||||
0
|
||||
)
|
||||
r.ck.PathEffect.MakeDash([r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom], 0)
|
||||
)
|
||||
canvas.drawRRect(rrect, r.auxStroke)
|
||||
r.auxStroke.setPathEffect(null)
|
||||
|
|
@ -283,17 +278,23 @@ function getShadowShapeChild(node: SceneNode, graph: SceneGraph): SceneNode | nu
|
|||
|
||||
function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
|
||||
switch (cap) {
|
||||
case 'ROUND': return r.ck.StrokeCap.Round
|
||||
case 'SQUARE': return r.ck.StrokeCap.Square
|
||||
default: return r.ck.StrokeCap.Butt
|
||||
case 'ROUND':
|
||||
return r.ck.StrokeCap.Round
|
||||
case 'SQUARE':
|
||||
return r.ck.StrokeCap.Square
|
||||
default:
|
||||
return r.ck.StrokeCap.Butt
|
||||
}
|
||||
}
|
||||
|
||||
function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity {
|
||||
switch (join) {
|
||||
case 'ROUND': return r.ck.StrokeJoin.Round
|
||||
case 'BEVEL': return r.ck.StrokeJoin.Bevel
|
||||
default: return r.ck.StrokeJoin.Miter
|
||||
case 'ROUND':
|
||||
return r.ck.StrokeJoin.Round
|
||||
case 'BEVEL':
|
||||
return r.ck.StrokeJoin.Bevel
|
||||
default:
|
||||
return r.ck.StrokeJoin.Miter
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -530,7 +531,9 @@ export function renderEffects(
|
|||
innerPath.delete()
|
||||
} else if (hasRadius) {
|
||||
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)
|
||||
innerPath.delete()
|
||||
} else {
|
||||
|
|
@ -558,22 +561,25 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): vo
|
|||
const text = node.text
|
||||
if (!text) return
|
||||
|
||||
canvas.save()
|
||||
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
|
||||
|
||||
if (node.textPicture) {
|
||||
const pic = r.ck.MakePicture(node.textPicture)
|
||||
if (pic) {
|
||||
canvas.drawPicture(pic)
|
||||
pic.delete()
|
||||
canvas.restore()
|
||||
return
|
||||
}
|
||||
}
|
||||
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)
|
||||
paragraph.delete()
|
||||
} 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.restore()
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,14 +24,11 @@ export function measureTextNode(
|
|||
node: SceneNode,
|
||||
maxWidth?: number
|
||||
): { 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
|
||||
|
||||
const paragraph = buildParagraph(r, node)
|
||||
let layoutWidth = node.width || 1e6
|
||||
if (maxWidth !== undefined) layoutWidth = maxWidth
|
||||
else if (node.textAutoResize === 'WIDTH_AND_HEIGHT') layoutWidth = 1e6
|
||||
paragraph.layout(layoutWidth)
|
||||
paragraph.layout(resolveParagraphLayoutWidth(node, maxWidth))
|
||||
const width = paragraph.getLongestLine()
|
||||
const height = paragraph.getHeight()
|
||||
paragraph.delete()
|
||||
|
|
@ -39,7 +36,7 @@ export function measureTextNode(
|
|||
}
|
||||
|
||||
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
|
||||
|
||||
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 recCanvas = recorder.beginRecording(bounds)
|
||||
|
||||
const paragraph = buildParagraph(r, node, undefined, { halfLeading: true })
|
||||
const paragraph = buildParagraph(r, node)
|
||||
recCanvas.drawParagraph(paragraph, 0, 0)
|
||||
paragraph.delete()
|
||||
|
||||
|
|
@ -59,6 +56,12 @@ export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array |
|
|||
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(
|
||||
node: SceneNode,
|
||||
baseFontSize: number
|
||||
|
|
@ -202,7 +205,12 @@ export function buildParagraph(
|
|||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
if (!sourceParent) return
|
||||
|
||||
|
|
@ -164,7 +168,11 @@ export function createInstance(
|
|||
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 component = graph.nodes.get(componentId)
|
||||
if (!instance || !component || instance.type !== 'INSTANCE') return
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useAppearance } from '../shared/useAppearance'
|
||||
import { useAppearance } from '../controls/useAppearance'
|
||||
|
||||
const ctx = useAppearance()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useLayout } from '../shared/useLayout'
|
||||
import { useLayout } from '../controls/useLayout'
|
||||
|
||||
const ctx = useLayout()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MIXED, useNodeProps } from '../shared/useNodeProps'
|
||||
import { MIXED, useNodeProps } from '../controls/useNodeProps'
|
||||
|
||||
const {
|
||||
updateProp,
|
||||
|
|
@ -14,8 +14,12 @@ const {
|
|||
store
|
||||
} = useNodeProps()
|
||||
|
||||
const xValue = computed(() => (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 xValue = computed(() =>
|
||||
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 hValue = multiProp('height')
|
||||
const rotationValue = computed(() =>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useEditor } from '../shared/editorContext'
|
||||
import { useEditor } from '../context/editorContext'
|
||||
import { providePropertyList } from './context'
|
||||
|
||||
import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core'
|
||||
|
|
@ -25,7 +25,9 @@ const emit = defineEmits<{
|
|||
const editor = useEditor()
|
||||
|
||||
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 active = computed(() => selectedNodes.value.length > 0)
|
||||
|
||||
|
|
@ -55,7 +57,11 @@ function add(defaults: ArrayItemType) {
|
|||
emit('add', defaults)
|
||||
for (const n of targetNodes()) {
|
||||
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()) {
|
||||
editor.updateNodeWithUndo(
|
||||
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}`
|
||||
)
|
||||
}
|
||||
|
|
@ -95,7 +103,11 @@ function toggleVisibility(index: number) {
|
|||
if (!arr[index]) continue
|
||||
const newArr = [...n[propKey]] as Array<{ visible: boolean }>
|
||||
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 { EDITOR_TOOLS } from '@open-pencil/core/editor'
|
||||
|
||||
import { useEditor } from '../shared/editorContext'
|
||||
import { useEditor } from '../context/editorContext'
|
||||
import { provideToolbar } from './context'
|
||||
|
||||
import type { EditorToolDef, Tool } from '@open-pencil/core/editor'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useTypography } from '../shared/useTypography'
|
||||
import { useTypography } from '../controls/useTypography'
|
||||
|
||||
import type { AcceptableValue } from 'reka-ui'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { computed } from 'vue'
|
||||
|
||||
import { useEditor } from '../shared/editorContext'
|
||||
import { usePageList } from '../shared/usePageList'
|
||||
import { useEditor } from '../context/editorContext'
|
||||
import { usePageList } from '../PageList/usePageList'
|
||||
import { useSelectionCapabilities } from '../selection/useSelectionCapabilities'
|
||||
import { useSelectionState } from '../shared/useSelectionState'
|
||||
import { useSelectionState } from '../selection/useSelectionState'
|
||||
|
||||
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) {
|
||||
return commands[id]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { computed } from 'vue'
|
||||
|
||||
import { useEditor } from '../context/editorContext'
|
||||
import { useSelectionState } from '../selection/useSelectionState'
|
||||
import { useEditorCommands } from './useEditorCommands'
|
||||
import { useSelectionState } from '../shared/useSelectionState'
|
||||
import { useEditor } from '../shared/editorContext'
|
||||
|
||||
export interface MenuActionNode {
|
||||
separator?: false
|
||||
|
|
@ -85,7 +85,9 @@ export function useMenuModel() {
|
|||
...(hasSelection.value ? [commandMenuItem('selection.wrapInAutoLayout', '⇧A')] : []),
|
||||
{ separator: true },
|
||||
commandMenuItem('selection.createComponent', '⌥⌘K'),
|
||||
...(canCreateComponentSet.value ? [commandMenuItem('selection.createComponentSet', '⇧⌘K')] : []),
|
||||
...(canCreateComponentSet.value
|
||||
? [commandMenuItem('selection.createComponentSet', '⇧⌘K')]
|
||||
: []),
|
||||
...(isComponent.value && selectedNode.value
|
||||
? [
|
||||
{
|
||||
|
|
@ -108,8 +110,8 @@ export function useMenuModel() {
|
|||
})
|
||||
|
||||
const selectionLabelMenu = computed(() => ({
|
||||
visibility: editor.getSelectedNode()?.visible ?? true ? 'Hide' : 'Show',
|
||||
lock: editor.getSelectedNode()?.locked ?? false ? 'Unlock' : 'Lock'
|
||||
visibility: (editor.getSelectedNode()?.visible ?? true) ? 'Hide' : 'Show',
|
||||
lock: (editor.getSelectedNode()?.locked ?? false) ? 'Unlock' : 'Lock'
|
||||
}))
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
import { useEditor } from '../context/editorContext'
|
||||
|
||||
/**
|
||||
* 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
|
||||
* re-evaluates in the same tick as the change. Zero latency.
|
||||
*/
|
||||
export function useSceneComputed<T>(fn: () => T): ComputedRef<T> {
|
||||
const editor = useEditor()
|
||||
export function useSceneComputed<T>(fn: () => T, sceneVersion?: () => number): ComputedRef<T> {
|
||||
return computed(() => {
|
||||
void editor.state.sceneVersion
|
||||
if (sceneVersion) {
|
||||
void sceneVersion()
|
||||
}
|
||||
return fn()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@ import { onMounted } from 'vue'
|
|||
import { useHead } from '@unhead/vue'
|
||||
import { TooltipProvider } from 'reka-ui'
|
||||
|
||||
import { provideEditor } from '@open-pencil/vue'
|
||||
import AppToast from '@/components/AppToast.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
useHead({ titleTemplate: (title) => (title ? `${title} — OpenPencil` : 'OpenPencil') })
|
||||
|
||||
const store = useEditorStore()
|
||||
provideEditor(store)
|
||||
|
||||
onMounted(() => {
|
||||
toast.setupGlobalErrorHandler()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ToastProvider, ToastRoot, ToastDescription, ToastViewport, ToastClose }
|
|||
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
import Tip from '@/components/Tip.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { toastRoot } from '@/components/ui/toast'
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import { useClipboard } from '@vueuse/core'
|
|||
import { computed, ref } from 'vue'
|
||||
|
||||
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'
|
||||
|
||||
const store = useEditor()
|
||||
const store = useEditorStore()
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
const jsxFormat = ref<JSXFormat>('openpencil')
|
||||
|
||||
|
|
@ -18,11 +20,14 @@ function toggleFormat() {
|
|||
jsxFormat.value = jsxFormat.value === 'openpencil' ? 'tailwind' : 'openpencil'
|
||||
}
|
||||
|
||||
const jsxCode = useSceneComputed(() => {
|
||||
const ids = [...store.state.selectedIds]
|
||||
if (ids.length === 0) return ''
|
||||
return selectionToJSX(ids, store.graph, jsxFormat.value)
|
||||
})
|
||||
const jsxCode = useSceneComputed(
|
||||
() => {
|
||||
const ids = [...store.state.selectedIds]
|
||||
if (ids.length === 0) return ''
|
||||
return selectionToJSX(ids, store.graph, jsxFormat.value)
|
||||
},
|
||||
() => store.state.sceneVersion
|
||||
)
|
||||
|
||||
const highlightedLines = computed(() => {
|
||||
if (!jsxCode.value) return []
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import AppSelect from './AppSelect.vue'
|
||||
import Tip from './Tip.vue'
|
||||
import AppSelect from './ui/AppSelect.vue'
|
||||
import Tip from './ui/Tip.vue'
|
||||
import HsvColorArea from './HsvColorArea.vue'
|
||||
import ScrubInput from './ScrubInput.vue'
|
||||
import { colorToCSS } from '@open-pencil/core'
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { useFileDialog, useObjectUrl } from '@vueuse/core'
|
||||
|
||||
import AppSelect from './AppSelect.vue'
|
||||
import { useEditor } from '@open-pencil/vue'
|
||||
import AppSelect from './ui/AppSelect.vue'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
|
||||
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 emit = defineEmits<{ update: [fill: Fill] }>()
|
||||
|
||||
const store = useEditor()
|
||||
const store = useEditorStore()
|
||||
|
||||
const imageBlob = shallowRef<Blob | null>(null)
|
||||
const imagePreviewUrl = useObjectUrl(imageBlob)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
|
||||
|
||||
import {
|
||||
LayerTreeRoot,
|
||||
LayerTreeItem,
|
||||
useInlineRename,
|
||||
useLayerDrag,
|
||||
useEditor
|
||||
} from '@open-pencil/vue'
|
||||
import { LayerTreeRoot, LayerTreeItem, useInlineRename, useLayerDrag } from '@open-pencil/vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { nodeIcon, COMPONENT_TYPES } from '@/utils/layer-icons'
|
||||
import CanvasMenu from './CanvasMenu.vue'
|
||||
import Tip from './Tip.vue'
|
||||
import Tip from './ui/Tip.vue'
|
||||
|
||||
const INDENT = 16
|
||||
const store = useEditor()
|
||||
const store = useEditorStore()
|
||||
const rename = useInlineRename((id, name) => store.renameNode(id, name))
|
||||
const { draggingId, instruction, instructionTargetId } = useLayerDrag(store, INDENT)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ const pageActions = ref<{
|
|||
renamePage: (pageId: string, name: string) => void
|
||||
} | null>(null)
|
||||
|
||||
function setPageActions(renamePage: (pageId: string, name: string) => void) {
|
||||
pageActions.value = { renamePage }
|
||||
}
|
||||
|
||||
function setPageInputRef(pageId: string, el: HTMLInputElement | null) {
|
||||
if (el) pageInputRefs.set(pageId, el)
|
||||
else pageInputRefs.delete(pageId)
|
||||
|
|
@ -27,6 +31,14 @@ function startRename(pg: { id: string; name: string }) {
|
|||
rename.start(pg.id, pg.name)
|
||||
activeRenameId.value = pg.id
|
||||
}
|
||||
|
||||
function handlePageDblClick(
|
||||
pg: { id: string; name: string },
|
||||
renamePage: (pageId: string, name: string) => void
|
||||
) {
|
||||
setPageActions(renamePage)
|
||||
startRename(pg)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -62,7 +74,11 @@ function startRename(pg: { id: string; name: string }) {
|
|||
@keydown="rename.onKeydown"
|
||||
/>
|
||||
</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>
|
||||
<button
|
||||
|
|
@ -75,10 +91,7 @@ function startRename(pg: { id: string; name: string }) {
|
|||
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
|
||||
"
|
||||
@click="switchPage(pg.id)"
|
||||
@dblclick="
|
||||
pageActions = { renamePage }
|
||||
startRename(pg)
|
||||
"
|
||||
@dblclick="handlePageDblClick(pg, renamePage)"
|
||||
>
|
||||
<icon-lucide-file class="size-3 shrink-0" />
|
||||
<span class="truncate">{{ pg.name }}</span>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import { useAIChat } from '@/composables/use-chat'
|
||||
import { useEditor } from '@open-pencil/vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
|
||||
import ChatPanel from './ChatPanel.vue'
|
||||
import CodePanel from './CodePanel.vue'
|
||||
import DesignPanel from './DesignPanel.vue'
|
||||
|
||||
const store = useEditor()
|
||||
const store = useEditorStore()
|
||||
const { activeTab } = useAIChat()
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { computed } from 'vue'
|
||||
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'
|
||||
|
||||
const { tabs, activeTabId, switchTab, closeTab } = useTabsStore()
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { computed } from 'vue'
|
|||
|
||||
import ColorInput from '@/components/ColorInput.vue'
|
||||
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)
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Tip from '@/components/Tip.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
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 editor = useEditor()
|
||||
const collectionCount = useSceneComputed(() => editor.getCollectionCount())
|
||||
const variableCount = useSceneComputed(() => editor.getVariableCount())
|
||||
const editor = useEditorStore()
|
||||
const collectionCount = useSceneComputed(
|
||||
() => editor.getCollectionCount(),
|
||||
() => editor.state.sceneVersion
|
||||
)
|
||||
const variableCount = useSceneComputed(
|
||||
() => editor.getVariableCount(),
|
||||
() => editor.state.sceneVersion
|
||||
)
|
||||
const hasVariables = computed(() => variableCount.value > 0)
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { computed } from 'vue'
|
||||
|
||||
import { openFileDialog } from '@/composables/use-menu'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useEditorCommands, useMenuModel } from '@open-pencil/vue'
|
||||
|
||||
|
|
@ -20,14 +21,15 @@ export function useAppMenu(mod: string) {
|
|||
shortcut: `${mod}N`,
|
||||
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 },
|
||||
{ label: 'Save', shortcut: `${mod}S` },
|
||||
{ label: 'Save as…', shortcut: `${mod}⇧S` },
|
||||
{ label: 'Save', shortcut: `${mod}S`, action: () => void store.saveFigFile() },
|
||||
{ label: 'Save as…', shortcut: `${mod}⇧S`, action: () => void store.saveFigFileAs() },
|
||||
{ separator: true as const },
|
||||
{
|
||||
label: 'Export selection…',
|
||||
shortcut: `${mod}⇧E`,
|
||||
action: () => void store.exportSelection(1, 'PNG'),
|
||||
disabled: store.state.selectedIds.size === 0
|
||||
},
|
||||
{ separator: true as const },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useFileDialog } from '@vueuse/core'
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
import { IS_TAURI } from '@/constants'
|
||||
import { IS_BROWSER, IS_TAURI } from '@/constants'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs'
|
||||
|
||||
|
|
@ -11,6 +11,18 @@ fileDialog.onChange((files) => {
|
|||
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() {
|
||||
if (IS_TAURI) {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
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 { toast } from '@/utils/toast'
|
||||
import {
|
||||
computeAllLayouts,
|
||||
createDefaultEditorState,
|
||||
createEditor,
|
||||
exportFigFile,
|
||||
|
|
@ -21,8 +20,8 @@ export type { Tool } 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 function createEditorStore() {
|
||||
const graph = new SceneGraph()
|
||||
export function createEditorStore(initialGraph?: SceneGraph) {
|
||||
const graph = initialGraph ?? new SceneGraph()
|
||||
|
||||
const state = shallowReactive<
|
||||
EditorState & {
|
||||
|
|
@ -49,7 +48,11 @@ export function createEditorStore() {
|
|||
cursorCanvasY: null
|
||||
})
|
||||
|
||||
const editor = createEditor({ graph, state, loadFont })
|
||||
const editor = createEditor({ graph, state, loadFont, skipInitialGraphSetup: !!initialGraph })
|
||||
|
||||
if (initialGraph) {
|
||||
editor.subscribeToGraph()
|
||||
}
|
||||
|
||||
// ─── Vue computed refs ────────────────────────────────────────
|
||||
|
||||
|
|
@ -167,7 +170,6 @@ export function createEditorStore() {
|
|||
const imported = await readFigFile(file)
|
||||
await yieldToUI()
|
||||
editor.replaceGraph(imported)
|
||||
computeAllLayouts(editor.graph)
|
||||
editor.undo.clear()
|
||||
fileHandle = handle ?? null
|
||||
filePath = path ?? null
|
||||
|
|
@ -176,12 +178,7 @@ export function createEditorStore() {
|
|||
state.selectedIds = new Set()
|
||||
const firstPage = editor.graph.getPages()[0] as SceneNode | undefined
|
||||
const pageId = firstPage?.id ?? editor.graph.rootId
|
||||
state.currentPageId = 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))
|
||||
await editor.switchPage(pageId)
|
||||
editor.requestRender()
|
||||
void startWatchingFile()
|
||||
} catch (e) {
|
||||
|
|
@ -286,12 +283,10 @@ export function createEditorStore() {
|
|||
const file = new File([blob], state.documentName + '.fig')
|
||||
const imported = await readFigFile(file)
|
||||
editor.replaceGraph(imported)
|
||||
computeAllLayouts(editor.graph)
|
||||
} else if (fileHandle) {
|
||||
const file = await fileHandle.getFile()
|
||||
const imported = await readFigFile(file)
|
||||
editor.replaceGraph(imported)
|
||||
computeAllLayouts(editor.graph)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
|
@ -500,7 +495,7 @@ export function createEditorStore() {
|
|||
// ─── Public API ───────────────────────────────────────────────
|
||||
// Spread all core Editor methods, then override getters and add app-specific.
|
||||
|
||||
return {
|
||||
const store = {
|
||||
...editor,
|
||||
state,
|
||||
selectedNodes,
|
||||
|
|
@ -523,6 +518,23 @@ export function createEditorStore() {
|
|||
mobileCut,
|
||||
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>
|
||||
|
|
@ -531,6 +543,7 @@ const storeRef = shallowRef<EditorStore>()
|
|||
|
||||
export function setActiveEditorStore(store: EditorStore) {
|
||||
storeRef.value = store
|
||||
triggerRef(storeRef)
|
||||
}
|
||||
|
||||
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 type { EditorStore } from './editor'
|
||||
import type { SceneGraph } from '@open-pencil/core'
|
||||
|
||||
export interface Tab {
|
||||
id: string
|
||||
|
|
@ -34,8 +35,8 @@ export function getActiveStore(): EditorStore {
|
|||
return tab.store
|
||||
}
|
||||
|
||||
export function createTab(store?: EditorStore): Tab {
|
||||
const s = store ?? createEditorStore()
|
||||
export function createTab(store?: EditorStore, initialGraph?: SceneGraph): Tab {
|
||||
const s = store ?? createEditorStore(initialGraph)
|
||||
const tab: Tab = { id: generateTabId(), store: s }
|
||||
tabsRef.value = [...tabsRef.value, tab]
|
||||
activateTab(tab)
|
||||
|
|
@ -45,6 +46,7 @@ export function createTab(store?: EditorStore): Tab {
|
|||
function activateTab(tab: Tab) {
|
||||
activeTabId.value = tab.id
|
||||
setActiveEditorStore(tab.store)
|
||||
triggerRef(tabsRef)
|
||||
window.__OPEN_PENCIL_STORE__ = tab.store
|
||||
}
|
||||
|
||||
|
|
@ -74,19 +76,32 @@ export function closeTab(tabId: string) {
|
|||
|
||||
export async function openFileInNewTab(
|
||||
file: File,
|
||||
handle?: FileSystemFileHandle,
|
||||
path?: string
|
||||
_handle?: FileSystemFileHandle,
|
||||
_path?: string
|
||||
): Promise<void> {
|
||||
const current = activeTab.value
|
||||
const isUntouched =
|
||||
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
|
||||
|
||||
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 {
|
||||
const store = createEditorStore()
|
||||
const { readFigFile } = await import('@open-pencil/core')
|
||||
const imported = await readFigFile(file)
|
||||
const store = createEditorStore(imported)
|
||||
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 { 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 { useMenu } from '@/composables/use-menu'
|
||||
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
|
||||
|
|
@ -32,7 +32,6 @@ const showChrome = !('no-chrome' in params)
|
|||
|
||||
const firstTab = createTab()
|
||||
const store = useEditorStore()
|
||||
provideEditor(store)
|
||||
const { isMobile } = useViewportKind()
|
||||
|
||||
if (route.meta.demo && !('test' in params)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
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) {
|
||||
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('horizontal basic', () => {
|
||||
test('positions children left-to-right', () => {
|
||||
|
|
@ -970,6 +979,75 @@ describe('Auto Layout', () => {
|
|||
})
|
||||
|
||||
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', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pid = pageId(graph)
|
||||
|
|
|
|||
Loading…
Reference in a new issue