diff --git a/CHANGELOG.md b/CHANGELOG.md index 3335feb92..b97550ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index e176ded44..b26de891a 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -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, diff --git a/packages/core/src/editor/pages.ts b/packages/core/src/editor/pages.ts index a8959819f..1078bb9e8 100644 --- a/packages/core/src/editor/pages.ts +++ b/packages/core/src/editor/pages.ts @@ -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() } diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 06a82b44b..74ca14957 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -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 getViewportSize?: () => { width: number; height: number } + skipInitialGraphSetup?: boolean } export interface EditorContext { diff --git a/packages/core/src/kiwi/fig-import.ts b/packages/core/src/kiwi/fig-import.ts index 6589a72af..c46dc9722 100644 --- a/packages/core/src/kiwi/fig-import.ts +++ b/packages/core/src/kiwi/fig-import.ts @@ -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): (guid: GUID) => Color | null { +function buildVariableColorResolver( + changeMap: Map +): (guid: GUID) => Color | null { // Collect variable data: GUID → entries const varEntries = new Map() const varSetId = new Map() @@ -44,7 +45,7 @@ function buildVariableColorResolver(changeMap: Map): (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, - graph: SceneGraph -): void { +function importCollections(changeMap: Map, 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, childrenMap: Map, created: Set, + canvasIdToPageId: Map, 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 -): void { +function remapComponentIds(graph: SceneGraph, guidToNodeId: Map): 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() const created = new Set() const guidToNodeId = new Map() 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) diff --git a/packages/core/src/kiwi/instance-overrides/sync.ts b/packages/core/src/kiwi/instance-overrides/sync.ts index a0c13087f..56c799ab3 100644 --- a/packages/core/src/kiwi/instance-overrides/sync.ts +++ b/packages/core/src/kiwi/instance-overrides/sync.ts @@ -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): Set, clonesOf: Map): Set { +function buildNeedsSyncSet( + expandedSeeds: Set, + clonesOf: Map +): Set { const needsSync = new Set() 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() const syncQueue = [...expandedSeeds] diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index 38cc9bc26..9f4936fe4 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -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, overlays: RenderOverlays): void { + private drawSelection( + canvas: Canvas, + graph: SceneGraph, + selectedIds: Set, + 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) } } diff --git a/packages/core/src/renderer/scene.ts b/packages/core/src/renderer/scene.ts index 6610255fa..db6d13a7c 100644 --- a/packages/core/src/renderer/scene.ts +++ b/packages/core/src/renderer/scene.ts @@ -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() } diff --git a/packages/core/src/renderer/text.ts b/packages/core/src/renderer/text.ts index 14afc1689..19ffa20ee 100644 --- a/packages/core/src/renderer/text.ts +++ b/packages/core/src/renderer/text.ts @@ -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 } diff --git a/packages/core/src/scene-graph-instances.ts b/packages/core/src/scene-graph-instances.ts index 33a959e75..bd78a9ea3 100644 --- a/packages/core/src/scene-graph-instances.ts +++ b/packages/core/src/scene-graph-instances.ts @@ -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 diff --git a/packages/vue/src/AppearanceControls/AppearanceControlsRoot.vue b/packages/vue/src/AppearanceControls/AppearanceControlsRoot.vue index 073f4ce0b..929076384 100644 --- a/packages/vue/src/AppearanceControls/AppearanceControlsRoot.vue +++ b/packages/vue/src/AppearanceControls/AppearanceControlsRoot.vue @@ -1,5 +1,5 @@ diff --git a/packages/vue/src/LayoutControls/LayoutControlsRoot.vue b/packages/vue/src/LayoutControls/LayoutControlsRoot.vue index 23cf2e5c5..6ff706413 100644 --- a/packages/vue/src/LayoutControls/LayoutControlsRoot.vue +++ b/packages/vue/src/LayoutControls/LayoutControlsRoot.vue @@ -1,5 +1,5 @@ diff --git a/packages/vue/src/PositionControls/PositionControlsRoot.vue b/packages/vue/src/PositionControls/PositionControlsRoot.vue index a2e087476..34515d7e6 100644 --- a/packages/vue/src/PositionControls/PositionControlsRoot.vue +++ b/packages/vue/src/PositionControls/PositionControlsRoot.vue @@ -1,7 +1,7 @@