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:
parent
adaf6dacb9
commit
c141663291
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
5
packages/core/src/editor/guide-preview.ts
Normal file
5
packages/core/src/editor/guide-preview.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface GuidePreview {
|
||||
ownerId: string
|
||||
axis: 'x' | 'y'
|
||||
position: number
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue