feat(editor): add ruler guide authoring foundation

- Preserve page and frame guide GUIDs through .fig conversion
- Add undoable guide add, move, transfer, and remove actions
- Start page/frame guide previews from CanvasKit rulers
- Render warm guide previews with frame-scoped solid lines and dotted continuation
This commit is contained in:
Danila Poyarkov 2026-08-19 18:08:48 +03:00
parent 83a5ea1b42
commit f892cb583c
18 changed files with 348 additions and 13 deletions

View file

@ -1,17 +1,56 @@
import type { Canvas } from 'canvaskit-wasm'
import type { SceneGraph } from '@open-pencil/scene-graph'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import { SELECTION_COLOR } from '#core/constants'
import type { RenderOverlays, SkiaRenderer } from './renderer'
import type { SkiaRenderer } from './renderer'
const GUIDE_COLOR = { r: 0.85, g: 0.29, b: 0.2, a: 0.78 }
const GUIDE_DASH = [3, 4]
export function drawPageGuides(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
function drawOwnedGuide(
r: SkiaRenderer,
canvas: Canvas,
owner: SceneNode,
graph: SceneGraph,
axis: 'x' | 'y',
position: number,
preview: boolean
): void {
const matrix = getWorldMatrix(owner, graph)
const start = Matrix.mapPoint(
matrix,
axis === 'x' ? { x: position, y: 0 } : { x: 0, y: position }
)
const end = Matrix.mapPoint(
matrix,
axis === 'x' ? { x: position, y: owner.height } : { x: owner.width, y: position }
)
const sx1 = start.x * r.zoom + r.panX
const sy1 = start.y * r.zoom + r.panY
const sx2 = end.x * r.zoom + r.panX
const sy2 = end.y * r.zoom + r.panY
canvas.drawLine(sx1, sy1, sx2, sy2, r.auxStroke)
if (!preview || owner.type === 'CANVAS') return
r.auxStroke.setPathEffect(r.ck.PathEffect.MakeDash(GUIDE_DASH, 0))
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)
}
export function drawPageGuides(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
preview?: RenderOverlays['guidePreview']
): void {
const page = graph.getNode(r.pageId ?? graph.rootId)
if (!page || page.guides.length === 0) return
if (!page) return
r.auxStroke.setStrokeWidth(1)
r.auxStroke.setColor(r.ck.Color4f(SELECTION_COLOR.r, SELECTION_COLOR.g, SELECTION_COLOR.b, 0.65))
r.auxStroke.setColor(r.ck.Color4f(GUIDE_COLOR.r, GUIDE_COLOR.g, GUIDE_COLOR.b, GUIDE_COLOR.a))
for (const guide of page.guides) {
if (guide.axis === 'x') {
@ -22,4 +61,18 @@ export function drawPageGuides(r: SkiaRenderer, canvas: Canvas, graph: SceneGrap
canvas.drawRect(r.ck.LTRBRect(0, y, r.viewportWidth, y + 1), r.auxStroke)
}
}
for (const childId of page.childIds ?? []) {
const node = graph.getNode(childId)
if (!node) continue
if (node.id === page.id || node.guides.length === 0) continue
for (const guide of node.guides) {
drawOwnedGuide(r, canvas, node, graph, guide.axis, guide.position, false)
}
}
if (preview) {
const owner = graph.getNode(preview.ownerId)
if (owner) drawOwnedGuide(r, canvas, owner, graph, preview.axis, preview.position, true)
}
}

View file

@ -61,6 +61,7 @@ export function renderFromEditorState(
textEditor: textEditor as RenderOverlays['textEditor'],
marquee: state.marquee,
snapGuides: state.snapGuides,
guidePreview: state.guidePreview,
rotationPreview: state.rotationPreview,
dropTargetId: state.dropTargetId,
layoutInsertIndicator: state.layoutInsertIndicator,
@ -254,7 +255,7 @@ export function render(
drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays)
r.drawFlashes(canvas, graph)
drawPageGuides(r, canvas, graph)
drawPageGuides(r, canvas, graph, overlays.guidePreview)
r.drawSnapGuides(canvas, overlays.snapGuides)
r.drawMarquee(canvas, overlays.marquee)
r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator)

View file

@ -21,6 +21,7 @@ export interface RenderOverlays {
textEditor?: TextEditor | null
marquee?: Rect | null
snapGuides?: SnapGuide[]
guidePreview?: { ownerId: string; axis: 'x' | 'y'; position: number } | null
rotationPreview?: { nodeId: string; angle: number } | null
dropTargetId?: string | null
layoutInsertIndicator?: {

View file

@ -24,6 +24,7 @@ import { createComponentSyncScheduler } from './component-sync'
import { createComponentActions } from './components'
import { createGraphEventSubscription } from './graph-events'
import { createGraphReadActions } from './graph-reads'
import { createGuideActions } from './guides'
import { createLayoutRunner } from './layout-runner'
import { createNodeActions } from './nodes'
import { createPageActions } from './pages'
@ -163,6 +164,7 @@ export function createEditor(options?: EditorOptions) {
const viewport = createViewportActions(ctx)
const selection = createSelectionActions(ctx)
const pages = createPageActions(ctx)
const guides = createGuideActions(ctx)
const shapes = createShapeActions(ctx)
const structure = createStructureActions(ctx)
const components = createComponentActions(ctx)
@ -207,6 +209,7 @@ export function createEditor(options?: EditorOptions) {
state.hoveredNodeId = null
state.measurementMode = 'off'
state.snapGuides = []
state.guidePreview = null
state.layoutInsertIndicator = null
state.dropTargetId = null
pages.clearPageViewports()
@ -252,6 +255,9 @@ export function createEditor(options?: EditorOptions) {
// Pages
...pages,
// Canvas and frame guides
...guides,
// Shapes & tools
...shapes,

View file

@ -0,0 +1,98 @@
import type { CanvasGuide } from '@open-pencil/scene-graph/guides'
import type { EditorContext } from './types'
function owner(ctx: EditorContext, ownerId: string) {
const node = ctx.graph.getNode(ownerId)
return node?.type === 'CANVAS' || node?.type === 'FRAME' || node?.type === 'COMPONENT'
? node
: null
}
function replaceGuides(ctx: EditorContext, ownerId: string, guides: CanvasGuide[]): void {
ctx.graph.updateNode(ownerId, { guides: structuredClone(guides) })
ctx.emitEditorEvent('guides:changed', ownerId, structuredClone(guides))
ctx.requestRender()
}
function newGuideId(): string {
return `guide:${crypto.randomUUID()}`
}
export function createGuideActions(ctx: EditorContext) {
function addGuide(ownerId: string, axis: CanvasGuide['axis'], position: number): string | null {
const node = owner(ctx, ownerId)
if (!node || !Number.isFinite(position)) return null
const guide: CanvasGuide = { id: newGuideId(), axis, position }
const before = structuredClone(node.guides)
const after = [...before, guide]
replaceGuides(ctx, ownerId, after)
ctx.undo.push({
label: 'Add guide',
forward: () => replaceGuides(ctx, ownerId, after),
inverse: () => replaceGuides(ctx, ownerId, before)
})
return guide.id
}
function moveGuide(ownerId: string, guideId: string, position: number): boolean {
const node = owner(ctx, ownerId)
if (!node || !Number.isFinite(position)) return false
const index = node.guides.findIndex((guide) => guide.id === guideId)
if (index === -1 || node.guides[index].position === position) return false
const before = structuredClone(node.guides)
const after = structuredClone(node.guides)
after[index].position = position
replaceGuides(ctx, ownerId, after)
ctx.undo.push({
label: 'Move guide',
forward: () => replaceGuides(ctx, ownerId, after),
inverse: () => replaceGuides(ctx, ownerId, before)
})
return true
}
function removeGuide(ownerId: string, guideId: string): boolean {
const node = owner(ctx, ownerId)
if (!node) return false
const before = structuredClone(node.guides)
const after = before.filter((guide) => guide.id !== guideId)
if (after.length === before.length) return false
replaceGuides(ctx, ownerId, after)
ctx.undo.push({
label: 'Remove guide',
forward: () => replaceGuides(ctx, ownerId, after),
inverse: () => replaceGuides(ctx, ownerId, before)
})
return true
}
function transferGuide(
fromOwnerId: string,
toOwnerId: string,
guideId: string,
position: number
): boolean {
const from = owner(ctx, fromOwnerId)
const to = owner(ctx, toOwnerId)
const guide = from?.guides.find((candidate) => candidate.id === guideId)
if (!from || !to || !guide || !Number.isFinite(position)) return false
const fromBefore = structuredClone(from.guides)
const toBefore = structuredClone(to.guides)
const fromAfter = fromBefore.filter((candidate) => candidate.id !== guideId)
const toAfter = [...toBefore, { ...guide, position }]
const apply = (fromGuides: CanvasGuide[], toGuides: CanvasGuide[]) => {
replaceGuides(ctx, fromOwnerId, fromGuides)
replaceGuides(ctx, toOwnerId, toGuides)
}
apply(fromAfter, toAfter)
ctx.undo.push({
label: 'Move guide to frame',
forward: () => apply(fromAfter, toAfter),
inverse: () => apply(fromBefore, toBefore)
})
return true
}
return { addGuide, moveGuide, removeGuide, transferGuide }
}

View file

@ -15,6 +15,7 @@ export {
export { createDefaultEditorState, createEditor } from './create'
export type { Editor } from './create'
export { reapplyInstanceComponentProperties } from './components/properties'
export { createGuideActions } from './guides'
export { createTextActions } from './text'
export { opacityFromBuffer } from './nodes'
export { EDITOR_TOOLS, TOOL_SHORTCUTS } from './tool-registry'

View file

@ -1,7 +1,7 @@
import type { Rect } from '@open-pencil/scene-graph/primitives'
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
import type { EditorContext } from '#core/editor/types'
import type { EditorContext, GuidePreview } from '#core/editor/types'
export function createSelectionOverlayActions(ctx: EditorContext) {
function setMarquee(rect: Rect | null) {
@ -14,6 +14,11 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
ctx.requestRepaint()
}
function setGuidePreview(preview: GuidePreview | null) {
ctx.state.guidePreview = preview
ctx.requestRepaint()
}
function setRotationPreview(preview: { nodeId: string; angle: number } | null) {
ctx.state.rotationPreview = preview
ctx.requestRepaint()
@ -60,6 +65,7 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
return {
setMarquee,
setSnapGuides,
setGuidePreview,
setRotationPreview,
setHoveredNode,
setMeasurementMode,

View file

@ -7,6 +7,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState {
selectedIds: new Set<string>(),
marquee: null,
snapGuides: [],
guidePreview: null,
rotationPreview: null,
dropTargetId: null,
layoutInsertIndicator: null,
@ -35,6 +36,7 @@ export function copyEditorViewState(source: EditorViewState): EditorViewState {
selectedIds: new Set(source.selectedIds),
marquee: structuredClone(source.marquee),
snapGuides: structuredClone(source.snapGuides),
guidePreview: structuredClone(source.guidePreview),
rotationPreview: structuredClone(source.rotationPreview),
layoutInsertIndicator: structuredClone(source.layoutInsertIndicator),
penState: structuredClone(source.penState),

View file

@ -7,6 +7,7 @@ import type {
VectorSegment,
VectorVertex
} from '@open-pencil/scene-graph'
import type { CanvasGuide } from '@open-pencil/scene-graph/guides'
import type { Color, Rect, Vector } from '@open-pencil/scene-graph/primitives'
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
import type { UndoManager } from '@open-pencil/scene-graph/undo'
@ -46,11 +47,18 @@ export interface EditorSharedState {
loading: boolean
}
export interface GuidePreview {
ownerId: string
axis: 'x' | 'y'
position: number
}
export interface EditorViewState {
currentPageId: string
selectedIds: Set<string>
marquee: Rect | null
snapGuides: SnapGuide[]
guidePreview: GuidePreview | null
rotationPreview: { nodeId: string; angle: number } | null
dropTargetId: string | null
layoutInsertIndicator: {
@ -114,6 +122,7 @@ export interface EditorEvents extends SceneGraphEvents {
'selection:changed': (selectedIds: string[], previousIds: string[]) => void
'tool:changed': (tool: Tool, previousTool: Tool) => void
'page:changed': (pageId: string, previousPageId: string) => void
'guides:changed': (ownerId: string, guides: readonly CanvasGuide[]) => void
'clipboard:images-missing': (resolution: ClipboardImageResolution) => void
'font:resolution-changed': (event: FontResolutionEvent, snapshot: FontResolutionSnapshot) => void
'viewport:changed': (

View file

@ -1,19 +1,38 @@
import type { CanvasGuide } from '@open-pencil/scene-graph/guides'
import type { GUID } from '@open-pencil/scene-graph/primitives'
interface FigmaCanvasGuide {
axis?: string
offset?: number
guid?: GUID
}
function guideId(guid: GUID | undefined, index: number): string {
return guid ? `fig-guide:${guid.sessionID}:${guid.localID}` : `guide:${index}`
}
export function importCanvasGuides(value: unknown): CanvasGuide[] {
if (!Array.isArray(value)) return []
const guides: CanvasGuide[] = []
for (const raw of value) {
for (const [index, raw] of value.entries()) {
if (!raw || typeof raw !== 'object') continue
const guide = raw as FigmaCanvasGuide
if (typeof guide.offset !== 'number' || !Number.isFinite(guide.offset)) continue
if (guide.axis === 'X') guides.push({ axis: 'x', position: guide.offset })
else if (guide.axis === 'Y') guides.push({ axis: 'y', position: guide.offset })
if (guide.axis === 'X') {
guides.push({
id: guideId(guide.guid, index),
axis: 'x',
position: guide.offset,
figGuid: guide.guid
})
} else if (guide.axis === 'Y') {
guides.push({
id: guideId(guide.guid, index),
axis: 'y',
position: guide.offset,
figGuid: guide.guid
})
}
}
return guides
}
@ -21,6 +40,7 @@ export function importCanvasGuides(value: unknown): CanvasGuide[] {
export function exportCanvasGuides(guides: readonly CanvasGuide[]): FigmaCanvasGuide[] {
return guides.map((guide) => ({
axis: guide.axis === 'x' ? 'X' : 'Y',
offset: guide.position
offset: guide.position,
...(guide.figGuid ? { guid: guide.figGuid } : {})
}))
}

View file

@ -7,6 +7,7 @@ import {
import { parseVariantName } from '@open-pencil/scene-graph/variant-name'
/* eslint-disable max-lines -- kiwi↔scene conversion helpers are tightly coupled */
import { importCanvasGuides } from './canvas-guides'
import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs'
import { convertFontFeatures } from './font/features'
import { convertFontVariations } from './font/variations'
@ -640,6 +641,7 @@ export function nodeChangeToProps(
),
effects: convertEffects(nc.effects),
layoutGrids: convertLayoutGrids(nc.layoutGrids),
guides: importCanvasGuides(nc.guides),
fillStyleId: styleRefId(nc.styleIdForFill),
strokeStyleId: styleRefId(nc.styleIdForStrokeFill),
textStyleId: styleRefId(nc.styleIdForText),

View file

@ -12,6 +12,7 @@ import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primi
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '../source-metadata'
/* eslint-disable max-lines */
import { bytesToHex } from './bytes'
import { exportCanvasGuides } from './canvas-guides'
import {
applyExportSettingsPluginData,
applyLibrarySourcePluginData,
@ -810,6 +811,7 @@ function applySharedStyleProps(node: SceneNode, nc: KiwiNodeChange): void {
if (node.effectStyleId) nc.styleIdForEffect = { guid: stringToGuid(node.effectStyleId) }
if (node.gridStyleId) nc.styleIdForGrid = { guid: stringToGuid(node.gridStyleId) }
if (node.layoutGrids.length > 0) nc.layoutGrids = structuredClone(node.layoutGrids)
if (node.guides.length > 0) nc.guides = exportCanvasGuides(node.guides)
}
function applyNodeVisualProps(

View file

@ -0,0 +1,25 @@
import { describe, expect, test } from 'bun:test'
import { exportCanvasGuides, importCanvasGuides } from '@open-pencil/fig/node-change'
const guid = { sessionID: 123, localID: 456 }
describe('Figma canvas guide conversion', () => {
test('imports axis, owner-local offset, and binary GUID', () => {
expect(importCanvasGuides([{ axis: 'X', offset: 42, guid }])).toEqual([
{ id: 'fig-guide:123:456', axis: 'x', position: 42, figGuid: guid }
])
})
test('exports preserved GUID and allocates no format-specific fallback', () => {
expect(
exportCanvasGuides([
{ id: 'fig-guide:123:456', axis: 'y', position: 84, figGuid: guid },
{ id: 'guide:new', axis: 'x', position: 12 }
])
).toEqual([
{ axis: 'Y', offset: 84, guid },
{ axis: 'X', offset: 12 }
])
})
})

View file

@ -1,4 +1,8 @@
import type { GUID } from './primitives'
export interface CanvasGuide {
id: string
axis: 'x' | 'y'
position: number
figGuid?: GUID
}

View file

@ -1,6 +1,7 @@
import { useEventListener } from '@vueuse/core'
import { onScopeDispose, ref, type Ref } from 'vue'
import { RULER_SIZE } from '@open-pencil/core/constants'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
@ -197,6 +198,27 @@ export function useCanvasInput(
autoLayoutPaddingEdit.value = null
}
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 }
}
function startGuideDrag(sx: number, sy: number, cx: number, cy: number): boolean {
if (!('showRulers' in editor.state) || editor.state.showRulers !== true) return false
if (sx < RULER_SIZE && sy < RULER_SIZE) return false
const axis = sy < RULER_SIZE ? 'y' : sx < RULER_SIZE ? 'x' : null
if (!axis) return false
const target = guideOwner(cx, cy)
const owner = editor.graph.getNode(target.id)
const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null
const position = axis === 'x' ? (local?.lx ?? cx) : (local?.ly ?? cy)
editor.setGuidePreview({ ownerId: target.id, axis, position })
setDrag({ type: 'guide', axis, ownerId: target.id, position })
return true
}
function onDblClick(e: MouseEvent) {
if (startAutoLayoutPaddingEdit(e)) return
onTextDblClick(e)
@ -213,6 +235,10 @@ 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)) {
e.preventDefault()
return
}
const selectedIdsBeforeMouseDown = new Set(editor.state.selectedIds)
const clickCount = recordClick(sx, sy)
@ -275,6 +301,16 @@ export function useCanvasInput(
const { sx, sy, cx, cy } = getCoords(e)
if (d.type === 'guide') {
const target = guideOwner(cx, cy)
const owner = editor.graph.getNode(target.id)
const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null
d.ownerId = target.id
d.position = d.axis === 'x' ? (local?.lx ?? cx) : (local?.ly ?? cy)
editor.setGuidePreview({ ownerId: d.ownerId, axis: d.axis, position: d.position })
return
}
if (d.type === 'rotate') {
handleRotateMove(d, cx, cy, e.shiftKey)
return
@ -322,7 +358,10 @@ export function useCanvasInput(
if (handleNodeEditMouseUp(drag, editor)) return
if (d.type === 'move') handleMoveUp(d, editor)
if (d.type === 'guide') {
editor.addGuide(d.ownerId, d.axis, d.position)
editor.setGuidePreview(null)
} else if (d.type === 'move') handleMoveUp(d, editor)
else if (d.type === 'text-select') {
drag.value = null
return
@ -357,6 +396,7 @@ export function useCanvasInput(
editor.setSnapGuides([])
editor.setLayoutInsertIndicator(null)
editor.setDropTarget(null)
editor.setGuidePreview(null)
}
function cancelPointerInteraction() {

View file

@ -128,6 +128,13 @@ export interface DragBendHandle {
targetTangentField: 'tangentStart' | 'tangentEnd' | null
}
export interface DragGuide {
type: 'guide'
axis: 'x' | 'y'
ownerId: string
position: number
}
export type DragState =
| DragDraw
| DragMove
@ -140,6 +147,7 @@ export type DragState =
| DragEditNode
| DragEditHandle
| DragBendHandle
| DragGuide
export const TOOL_TO_NODE: Partial<Record<Tool, NodeType>> = {
FRAME: 'FRAME',

View file

@ -33,6 +33,7 @@ export function cloneCanvasPaneState(id: string, source: CanvasPaneState): Canva
editingTextId: null,
marquee: null,
snapGuides: [],
guidePreview: null,
rotationPreview: null,
dropTargetId: null,
layoutInsertIndicator: null,

View file

@ -0,0 +1,56 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
function setup() {
const editor = createEditor()
const pageId = editor.state.currentPageId
const frame = editor.graph.createNode('FRAME', pageId, {
x: 100,
y: 100,
width: 300,
height: 200
})
return { editor, pageId, frameId: frame.id }
}
describe('guide editor actions', () => {
test('adds, moves, removes, and undoes a page guide', () => {
const { editor, pageId } = setup()
const id = editor.addGuide(pageId, 'x', 42)
expect(id).not.toBeNull()
expect(editor.graph.getNode(pageId)?.guides).toEqual([{ id, axis: 'x', position: 42 }])
expect(editor.moveGuide(pageId, id ?? '', 84)).toBe(true)
expect(editor.graph.getNode(pageId)?.guides[0]?.position).toBe(84)
editor.undoAction()
expect(editor.graph.getNode(pageId)?.guides[0]?.position).toBe(42)
expect(editor.removeGuide(pageId, id ?? '')).toBe(true)
expect(editor.graph.getNode(pageId)?.guides).toEqual([])
editor.undoAction()
expect(editor.graph.getNode(pageId)?.guides[0]?.position).toBe(42)
})
test('transfers a guide between page and frame in one undo step', () => {
const { editor, pageId, frameId } = setup()
const id = editor.addGuide(pageId, 'y', 120)
editor.undo.clear()
expect(editor.transferGuide(pageId, frameId, id ?? '', 20)).toBe(true)
expect(editor.graph.getNode(pageId)?.guides).toEqual([])
expect(editor.graph.getNode(frameId)?.guides).toEqual([{ id, axis: 'y', position: 20 }])
editor.undoAction()
expect(editor.graph.getNode(pageId)?.guides).toEqual([{ id, axis: 'y', position: 120 }])
expect(editor.graph.getNode(frameId)?.guides).toEqual([])
})
test('rejects unsupported owners and no-op movement', () => {
const { editor, pageId } = setup()
const rect = editor.graph.createNode('RECTANGLE', pageId)
expect(editor.addGuide(rect.id, 'x', 10)).toBeNull()
const id = editor.addGuide(pageId, 'x', 10)
expect(editor.moveGuide(pageId, id ?? '', 10)).toBe(false)
})
})