fix(editor): address guide authoring review

- Dispose CanvasKit dash effects and render nested frame guides
- Validate imported guide GUIDs and invalidate stale raw guide metadata
- Resolve frame owners through nested hit ancestry and require primary-button ruler drags
- Share guide preview types through a neutral editor module
This commit is contained in:
Danila Poyarkov 2026-08-19 20:13:57 +03:00
parent adaf6dacb9
commit c141663291
12 changed files with 100 additions and 28 deletions

View file

@ -34,10 +34,12 @@ function drawOwnedGuide(
canvas.drawLine(sx1, sy1, sx2, sy2, r.auxStroke)
if (!preview || owner.type === 'CANVAS') return
r.auxStroke.setPathEffect(r.ck.PathEffect.MakeDash(GUIDE_DASH, 0))
const dash = r.ck.PathEffect.MakeDash(GUIDE_DASH, 0)
r.auxStroke.setPathEffect(dash)
if (axis === 'x') canvas.drawLine(sx1, 0, sx1, r.viewportHeight, r.auxStroke)
else canvas.drawLine(0, sy1, r.viewportWidth, sy1, r.auxStroke)
r.auxStroke.setPathEffect(null)
dash.delete()
}
export function drawPageGuides(
@ -62,13 +64,18 @@ export function drawPageGuides(
}
}
for (const childId of page.childIds) {
const node = graph.getNode(childId)
if (!node) continue
if (node.id === page.id || node.guides.length === 0) continue
const visit = (node: SceneNode) => {
for (const guide of node.guides) {
drawOwnedGuide(r, canvas, node, graph, guide.axis, guide.position, false)
}
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (child) visit(child)
}
}
for (const childId of page.childIds) {
const node = graph.getNode(childId)
if (node) visit(node)
}
if (preview) {

View file

@ -2,6 +2,7 @@ import type { VectorRegion, VectorVertex } from '@open-pencil/scene-graph'
import type { Color, Rect, Vector } from '@open-pencil/scene-graph/primitives'
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
import type { GuidePreview } from '#core/editor/guide-preview'
import type { TextEditor } from '#core/text/editor'
export interface RulerTheme {
@ -21,7 +22,7 @@ export interface RenderOverlays {
textEditor?: TextEditor | null
marquee?: Rect | null
snapGuides?: SnapGuide[]
guidePreview?: { ownerId: string; axis: 'x' | 'y'; position: number } | null
guidePreview?: GuidePreview | null
rotationPreview?: { nodeId: string; angle: number } | null
dropTargetId?: string | null
layoutInsertIndicator?: {

View file

@ -0,0 +1,5 @@
export interface GuidePreview {
ownerId: string
axis: 'x' | 'y'
position: number
}

View file

@ -10,7 +10,10 @@ function owner(ctx: EditorContext, ownerId: string) {
}
function replaceGuides(ctx: EditorContext, ownerId: string, guides: CanvasGuide[]): void {
const node = ctx.graph.getNode(ownerId)
if (!node) return
ctx.graph.updateNode(ownerId, { guides: structuredClone(guides) })
node.source.editedFields = [...new Set([...node.source.editedFields, 'guides'])]
ctx.emitEditorEvent('guides:changed', ownerId, structuredClone(guides))
ctx.requestRender()
}

View file

@ -1,7 +1,8 @@
import type { Rect } from '@open-pencil/scene-graph/primitives'
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
import type { EditorContext, GuidePreview } from '#core/editor/types'
import type { GuidePreview } from '#core/editor/guide-preview'
import type { EditorContext } from '#core/editor/types'
export function createSelectionOverlayActions(ctx: EditorContext) {
function setMarquee(rect: Rect | null) {

View file

@ -14,6 +14,7 @@ import type { UndoManager } from '@open-pencil/scene-graph/undo'
import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer'
import type { MeasurementMode, RenderOverlays } from '#core/canvas/renderer/types'
import type { GuidePreview } from '#core/editor/guide-preview'
import type { SnappingPreferences } from '#core/editor/preferences'
import type { TextEditor } from '#core/text/editor'
import type { FontResolutionEvent, FontResolutionSnapshot } from '#core/text/resolver'
@ -47,12 +48,6 @@ export interface EditorSharedState {
loading: boolean
}
export interface GuidePreview {
ownerId: string
axis: 'x' | 'y'
position: number
}
export interface EditorViewState {
currentPageId: string
selectedIds: Set<string>

View file

@ -4,7 +4,13 @@ import type { GUID } from '@open-pencil/scene-graph/primitives'
interface FigmaCanvasGuide {
axis?: string
offset?: number
guid?: GUID
guid?: unknown
}
function isGuid(value: unknown): value is GUID {
if (!value || typeof value !== 'object') return false
const candidate = value as { sessionID?: unknown; localID?: unknown }
return Number.isFinite(candidate.sessionID) && Number.isFinite(candidate.localID)
}
function guideId(guid: GUID | undefined, index: number): string {
@ -18,19 +24,20 @@ export function importCanvasGuides(value: unknown): CanvasGuide[] {
if (!raw || typeof raw !== 'object') continue
const guide = raw as FigmaCanvasGuide
if (typeof guide.offset !== 'number' || !Number.isFinite(guide.offset)) continue
const figGuid = isGuid(guide.guid) ? guide.guid : undefined
if (guide.axis === 'X') {
guides.push({
id: guideId(guide.guid, index),
id: guideId(figGuid, index),
axis: 'x',
position: guide.offset,
figGuid: guide.guid
...(figGuid ? { figGuid } : {})
})
} else if (guide.axis === 'Y') {
guides.push({
id: guideId(guide.guid, index),
id: guideId(figGuid, index),
axis: 'y',
position: guide.offset,
figGuid: guide.guid
...(figGuid ? { figGuid } : {})
})
}
}

View file

@ -26,6 +26,7 @@ const EDITED_RAW_FIELDS: Partial<Record<string, readonly string[]>> = {
strokes: ['strokePaints'],
effects: ['effects'],
layoutGrids: ['layoutGrids'],
guides: ['guides'],
exportSettings: ['exportSettings'],
cornerRadius: ['cornerRadius'],
independentCorners: ['rectangleCornerRadiiIndependent'],

View file

@ -11,6 +11,12 @@ describe('Figma canvas guide conversion', () => {
])
})
test('rejects malformed Figma guide GUIDs', () => {
expect(
importCanvasGuides([{ axis: 'X', offset: 10, guid: { sessionID: 'bad', localID: null } }])
).toEqual([{ id: 'guide:0', axis: 'x', position: 10 }])
})
test('exports preserved GUID and allocates no format-specific fallback', () => {
expect(
exportCanvasGuides([

View file

@ -199,10 +199,14 @@ export function useCanvasInput(
}
function guideOwner(cx: number, cy: number): { id: string; position: number } {
const hit = editor.graph.hitTestDeep(cx, cy, editor.state.currentPageId)
const owner = hit && ['FRAME', 'COMPONENT'].includes(hit.type) ? hit : null
if (!owner) return { id: editor.state.currentPageId, position: 0 }
return { id: owner.id, position: 0 }
let node = editor.graph.hitTestDeep(cx, cy, editor.state.currentPageId)
while (node) {
if (node.type === 'FRAME' || node.type === 'COMPONENT') {
return { id: node.id, position: 0 }
}
node = node.parentId ? (editor.graph.getNode(node.parentId) ?? null) : null
}
return { id: editor.state.currentPageId, position: 0 }
}
function startGuideDrag(sx: number, sy: number, cx: number, cy: number): boolean {
@ -237,7 +241,7 @@ export function useCanvasInput(
if (!editor.state.editingTextId) canvasRef.value?.focus()
editor.setHoveredNode(null)
const { sx, sy, cx, cy } = getCoords(e)
if (startGuideDrag(sx, sy, cx, cy)) {
if (e.button === 0 && startGuideDrag(sx, sy, cx, cy)) {
e.preventDefault()
return
}

View file

@ -44,8 +44,8 @@ describe('fig roundtrip source metadata', () => {
}
]
page.guides = [
{ axis: 'x', position: 42 },
{ axis: 'y', position: 84 }
{ id: 'x', axis: 'x', position: 42 },
{ id: 'y', axis: 'y', position: 84 }
]
page.source.fig.rawNodeFields.strokeJoin = 'BEVEL'
page.source.fig.rawNodeFields.strokeWeight = 0

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from 'bun:test'
import { describe, expect, mock, test } from 'bun:test'
import type { Canvas } from 'canvaskit-wasm'
@ -32,8 +32,8 @@ describe('page guide rendering', () => {
})
const canvas = createMockCanvas()
const graph = graphWithGuides([
{ axis: 'x', position: 42 },
{ axis: 'y', position: 84 }
{ id: 'x', axis: 'x', position: 42 },
{ id: 'y', axis: 'y', position: 84 }
])
drawPageGuides(r, canvas as Canvas, graph)
@ -45,6 +45,48 @@ describe('page guide rendering', () => {
])
})
test('renders nested frame guides', () => {
const r = createMockRenderer({ pageId: 'page', zoom: 1, panX: 0, panY: 0 })
const canvas = createMockCanvas()
canvas.drawLine = mock(() => undefined)
const nested = {
id: 'nested',
type: 'FRAME',
parentId: 'frame',
childIds: [],
x: 20,
y: 30,
width: 100,
height: 80,
rotation: 0,
flipX: false,
flipY: false,
guides: [{ id: 'nested-guide', axis: 'x', position: 10 }]
} as SceneNode
const frame = {
...nested,
id: 'frame',
parentId: 'page',
childIds: ['nested'],
x: 100,
y: 100,
guides: []
} as SceneNode
const page = { id: 'page', parentId: null, childIds: ['frame'], guides: [] } as SceneNode
const nodes = new Map([
['page', page],
['frame', frame],
['nested', nested]
])
const graph = new SceneGraph()
graph.rootId = 'root'
graph.nodes = nodes
drawPageGuides(r, canvas as Canvas, graph)
expect(canvas.drawLine).toHaveBeenCalled()
})
test('ignores pages without guides', () => {
const r = createMockRenderer({ pageId: 'page' })
const canvas = createMockCanvas()