Merge pull request #562 from open-pencil/guide-authoring

feat(editor): author canvas and frame guides from rulers
This commit is contained in:
Danila Poyarkov 2026-08-20 09:39:19 +03:00 committed by GitHub
commit 9f7ce64bf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1078 additions and 174 deletions

View file

@ -8,6 +8,7 @@
### Added
- Create, select, move, transfer, and delete canvas and frame guides directly from rulers, with undoable edits and `.fig` round-trip fidelity.
- Snap vector points, moved layers, and resized edges to nearby geometry, sibling layer bounds, canvas and frame layout guides, and whole-pixel coordinates with visible alignment guides, fractional-coordinate preservation when pixel snapping is off, and persistent geometry, object, and pixel-grid controls in General settings and the Preferences menu.
- Run Pi through AI SDK HarnessAgent as a configurable desktop provider with multiple saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings.
- Open multiple selected design files in separate tabs.

View file

@ -0,0 +1,118 @@
import type { Canvas } from 'canvaskit-wasm'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer'
import { getGuideScreenSegment } from './geometry'
const GUIDE_COLOR = { r: 0.85, g: 0.29, b: 0.2, a: 0.78 }
const HOVERED_GUIDE_COLOR = { r: 0.96, g: 0.4, b: 0.26, a: 1 }
const SELECTED_GUIDE_COLOR = { r: 0.1, g: 0.45, b: 0.95, a: 1 }
const GUIDE_DASH = [3, 4]
type GuideVisualState = 'idle' | 'hovered' | 'selected'
function guideColor(state: GuideVisualState) {
if (state === 'selected') return SELECTED_GUIDE_COLOR
if (state === 'hovered') return HOVERED_GUIDE_COLOR
return GUIDE_COLOR
}
function guideState(selected: boolean, hovered: boolean): GuideVisualState {
if (selected) return 'selected'
if (hovered) return 'hovered'
return 'idle'
}
function drawGuide(
r: SkiaRenderer,
canvas: Canvas,
owner: SceneNode,
graph: SceneGraph,
axis: 'x' | 'y',
position: number,
preview: boolean,
state: GuideVisualState = 'idle'
): void {
const color = guideColor(state)
r.auxStroke.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a))
const segment = getGuideScreenSegment(
graph,
owner,
{ axis, position },
{
panX: r.panX,
panY: r.panY,
zoom: r.zoom,
width: r.viewportWidth,
height: r.viewportHeight
}
)
if (owner.type === 'CANVAS') {
if (axis === 'x') {
canvas.drawRect(
r.ck.LTRBRect(segment.x1, segment.y1, segment.x1 + 1, segment.y2),
r.auxStroke
)
} else {
canvas.drawRect(
r.ck.LTRBRect(segment.x1, segment.y1, segment.x2, segment.y1 + 1),
r.auxStroke
)
}
} else {
canvas.drawLine(segment.x1, segment.y1, segment.x2, segment.y2, r.auxStroke)
}
if (!preview || owner.type === 'CANVAS') return
const dash = r.ck.PathEffect.MakeDash(GUIDE_DASH, 0)
r.auxStroke.setPathEffect(dash)
if (axis === 'x') canvas.drawLine(segment.x1, 0, segment.x1, r.viewportHeight, r.auxStroke)
else canvas.drawLine(0, segment.y1, r.viewportWidth, segment.y1, r.auxStroke)
r.auxStroke.setPathEffect(null)
dash.delete()
}
export function drawGuides(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
guides: RenderOverlays['guides']
): void {
const page = graph.getNode(r.pageId ?? graph.rootId)
if (!page) return
const preview = guides?.preview
const hovered = guides?.hovered
const selected = guides?.selected
r.auxStroke.setStrokeWidth(1)
const visit = (owner: SceneNode) => {
for (const guide of owner.guides) {
if (preview?.source?.ownerId === owner.id && preview.source.guideId === guide.id) continue
drawGuide(
r,
canvas,
owner,
graph,
guide.axis,
guide.position,
false,
guideState(
selected?.ownerId === owner.id && selected.guideId === guide.id,
hovered?.ownerId === owner.id && hovered.guideId === guide.id
)
)
}
for (const childId of owner.childIds) {
const child = graph.getNode(childId)
if (child) visit(child)
}
}
visit(page)
if (preview) {
const owner = graph.getNode(preview.ownerId)
if (owner) drawGuide(r, canvas, owner, graph, preview.axis, preview.position, true, 'selected')
}
}

View file

@ -0,0 +1,64 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import type { CanvasGuide } from '@open-pencil/scene-graph/guides'
import Matrix from '@open-pencil/scene-graph/matrix'
export interface GuideViewport {
panX: number
panY: number
zoom: number
width: number
height: number
}
export interface GuideScreenSegment {
x1: number
y1: number
x2: number
y2: number
}
export function getGuideScreenSegment(
graph: SceneGraph,
owner: SceneNode,
guide: Pick<CanvasGuide, 'axis' | 'position'>,
viewport: GuideViewport
): GuideScreenSegment {
if (owner.type === 'CANVAS') {
if (guide.axis === 'x') {
const x = guide.position * viewport.zoom + viewport.panX
return { x1: x, y1: 0, x2: x, y2: viewport.height }
}
const y = guide.position * viewport.zoom + viewport.panY
return { x1: 0, y1: y, x2: viewport.width, y2: y }
}
const matrix = getWorldMatrix(owner, graph)
const start = Matrix.mapPoint(
matrix,
guide.axis === 'x' ? { x: guide.position, y: 0 } : { x: 0, y: guide.position }
)
const end = Matrix.mapPoint(
matrix,
guide.axis === 'x'
? { x: guide.position, y: owner.height }
: { x: owner.width, y: guide.position }
)
return {
x1: start.x * viewport.zoom + viewport.panX,
y1: start.y * viewport.zoom + viewport.panY,
x2: end.x * viewport.zoom + viewport.panX,
y2: end.y * viewport.zoom + viewport.panY
}
}
export function distanceToGuideSegment(x: number, y: number, segment: GuideScreenSegment): number {
const dx = segment.x2 - segment.x1
const dy = segment.y2 - segment.y1
const lengthSquared = dx * dx + dy * dy
const t =
lengthSquared === 0
? 0
: Math.max(0, Math.min(1, ((x - segment.x1) * dx + (y - segment.y1) * dy) / lengthSquared))
return Math.hypot(x - (segment.x1 + t * dx), y - (segment.y1 + t * dy))
}

View file

@ -0,0 +1,51 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { CanvasGuide } from '@open-pencil/scene-graph/guides'
import { distanceToGuideSegment, getGuideScreenSegment, type GuideViewport } from './geometry'
export interface GuideHit {
ownerId: string
guideId: string
axis: CanvasGuide['axis']
position: number
distance: number
}
export function hitTestGuides(
graph: SceneGraph,
pageId: string,
viewport: GuideViewport,
x: number,
y: number,
tolerance = 5
): GuideHit | null {
const page = graph.getNode(pageId)
if (!page) return null
let closest: GuideHit | null = null
const visit = (owner: SceneNode) => {
for (const guide of owner.guides) {
const distance = distanceToGuideSegment(
x,
y,
getGuideScreenSegment(graph, owner, guide, viewport)
)
if (distance <= tolerance && (!closest || distance < closest.distance)) {
closest = {
ownerId: owner.id,
guideId: guide.id,
axis: guide.axis,
position: guide.position,
distance
}
}
}
for (const childId of owner.childIds) {
const child = graph.getNode(childId)
if (child) visit(child)
}
}
visit(page)
return closest
}

View file

@ -0,0 +1,21 @@
export interface GuideSelection {
ownerId: string
guideId: string
}
export interface GuidePreview {
ownerId: string
axis: 'x' | 'y'
position: number
source?: GuideSelection
}
export interface GuideOverlayState {
preview: GuidePreview | null
hovered: GuideSelection | null
selected: GuideSelection | null
}
export function createGuideOverlayState(): GuideOverlayState {
return { preview: null, hovered: null, selected: null }
}

View file

@ -4,4 +4,12 @@ export {
hasVisibleStrokeSourceNode,
nodeHasVisibleStroke
} from './boolean'
export {
distanceToGuideSegment,
getGuideScreenSegment,
type GuideScreenSegment,
type GuideViewport
} from './guides/geometry'
export { hitTestGuides, type GuideHit } from './guides/hit-test'
export type { GuideOverlayState, GuidePreview, GuideSelection } from './guides/types'
export { SkiaRenderer, type RenderOverlays, type RulerTheme } from './renderer'

View file

@ -1,25 +0,0 @@
import type { Canvas } from 'canvaskit-wasm'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { SELECTION_COLOR } from '#core/constants'
import type { SkiaRenderer } from './renderer'
export function drawPageGuides(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
const page = graph.getNode(r.pageId ?? graph.rootId)
if (!page || page.guides.length === 0) return
r.auxStroke.setStrokeWidth(1)
r.auxStroke.setColor(r.ck.Color4f(SELECTION_COLOR.r, SELECTION_COLOR.g, SELECTION_COLOR.b, 0.65))
for (const guide of page.guides) {
if (guide.axis === 'x') {
const x = guide.position * r.zoom + r.panX
canvas.drawRect(r.ck.LTRBRect(x, 0, x + 1, r.viewportHeight), r.auxStroke)
} else {
const y = guide.position * r.zoom + r.panY
canvas.drawRect(r.ck.LTRBRect(0, y, r.viewportWidth, y + 1), r.auxStroke)
}
}
}

View file

@ -0,0 +1,68 @@
import type { Canvas } from 'canvaskit-wasm'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { drawGuides } from '#core/canvas/guides/draw'
import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer'
function measurementVisible(overlays: RenderOverlays): boolean {
return (
overlays.measurementMode !== undefined &&
overlays.measurementMode !== 'off' &&
!overlays.editingTextId &&
!overlays.nodeEditState &&
!overlays.penState
)
}
export function drawLabelPass(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
const profiler = r.profiler
profiler.beginPhase('render:sectionTitles')
r.drawSectionTitles(canvas, graph)
profiler.endPhase('render:sectionTitles')
profiler.beginPhase('render:componentLabels')
r.drawComponentLabels(canvas, graph)
profiler.endPhase('render:componentLabels')
}
export function drawOverlayPass(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>,
overlays: RenderOverlays
): void {
const measuring = measurementVisible(overlays)
const hoveredNodeId =
measuring || overlays.hoveredNodeId === overlays.nodeEditState?.nodeId
? null
: overlays.hoveredNodeId
r.drawHoverHighlight(canvas, graph, hoveredNodeId)
r.drawEnteredContainer(canvas, graph, overlays.enteredContainerId)
r.profiler.beginPhase('render:selection')
r.drawSelection(canvas, graph, selectedIds, overlays)
if (measuring) r.drawMeasurements(canvas, graph, selectedIds, overlays.hoveredNodeId)
r.profiler.endPhase('render:selection')
r.drawFlashes(canvas, graph)
drawGuides(r, canvas, graph, overlays.guides)
r.drawSnapGuides(canvas, overlays.snapGuides)
r.drawMarquee(canvas, overlays.marquee)
r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator)
if (!measuring) r.drawAutoLayoutHover(canvas, graph, overlays.autoLayoutHover)
r.drawNodeEditOverlay(canvas, graph, overlays.nodeEditState)
r.drawPenOverlay(canvas, overlays.penState)
r.drawRemoteCursors(canvas, graph, overlays.remoteCursors)
}
export function drawChromePass(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>
): void {
r.profiler.beginPhase('render:rulers')
if (r.showRulers) r.drawRulers(canvas, graph, selectedIds)
r.profiler.endPhase('render:rulers')
r.profiler.drawHUD(canvas, r.showRulers)
}

View file

@ -3,10 +3,10 @@ import type { Canvas } from 'canvaskit-wasm'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { computeDescendantVisualBounds } from '@open-pencil/scene-graph/geometry'
import { drawPageGuides } from '#core/canvas/page-guides'
import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer'
import type { EditorState } from '#core/editor/types'
import { drawChromePass, drawLabelPass, drawOverlayPass } from './overlay-pass'
import { renderSceneBacking, updateSceneBackingPreviewState } from './retained-backing'
export function renderSceneToCanvas(
@ -61,6 +61,7 @@ export function renderFromEditorState(
textEditor: textEditor as RenderOverlays['textEditor'],
marquee: state.marquee,
snapGuides: state.snapGuides,
guides: state.guides,
rotationPreview: state.rotationPreview,
dropTargetId: state.dropTargetId,
layoutInsertIndicator: state.layoutInsertIndicator,
@ -80,7 +81,7 @@ export function renderFromEditorState(
)
}
function hasVolatileOverlay(overlays: RenderOverlays): boolean {
function sceneContentDependsOnOverlay(overlays: RenderOverlays): boolean {
return (
overlays.dropTargetId != null ||
overlays.rotationPreview != null ||
@ -97,7 +98,7 @@ function scenePictureMissReason(
hasPositionPreview: boolean
): string {
if (hasPositionPreview) return 'position-preview'
if (hasVolatileOverlay(overlays)) return 'volatile-overlay'
if (sceneContentDependsOnOverlay(overlays)) return 'volatile-overlay'
if (!r.scenePicture) return 'missing-picture'
if (graph.positionPreviewVersion !== r.scenePicturePositionPreviewVersion)
return 'position-preview-version'
@ -111,10 +112,10 @@ function canUseScenePicture(
r: SkiaRenderer,
graph: SceneGraph,
sceneVersion: number,
hasVolatileOverlays: boolean
requiresUncachedSceneRender: boolean
): boolean {
return (
!hasVolatileOverlays &&
!requiresUncachedSceneRender &&
!!r.scenePicture &&
graph.positionPreviewVersion === r.scenePicturePositionPreviewVersion &&
sceneVersion === r.scenePictureVersion &&
@ -131,36 +132,6 @@ function measure<T>(fn: () => T): { value: T; duration: number } {
return { value, duration: now() - start }
}
function measurementVisible(overlays: RenderOverlays): boolean {
return (
overlays.measurementMode !== undefined &&
overlays.measurementMode !== 'off' &&
!overlays.editingTextId &&
!overlays.nodeEditState &&
!overlays.penState
)
}
function drawInteractiveOverlays(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>,
overlays: RenderOverlays
) {
const measuring = measurementVisible(overlays)
const hoveredNodeId =
measuring || overlays.hoveredNodeId === overlays.nodeEditState?.nodeId
? null
: overlays.hoveredNodeId
r.drawHoverHighlight(canvas, graph, hoveredNodeId)
r.drawEnteredContainer(canvas, graph, overlays.enteredContainerId)
r.profiler.beginPhase('render:selection')
r.drawSelection(canvas, graph, selectedIds, overlays)
if (measuring) r.drawMeasurements(canvas, graph, selectedIds, overlays.hoveredNodeId)
r.profiler.endPhase('render:selection')
}
export function render(
r: SkiaRenderer,
graph: SceneGraph,
@ -196,9 +167,9 @@ export function render(
const hasPositionPreview =
graph.positionPreviewVersion !== r.scenePicturePositionPreviewVersion &&
sceneVersion === r.scenePictureVersion
const hasVolatileOverlays = hasPositionPreview || hasVolatileOverlay(overlays)
const requiresUncachedSceneRender = hasPositionPreview || sceneContentDependsOnOverlay(overlays)
const canUsePicture = canUseScenePicture(r, graph, sceneVersion, hasVolatileOverlays)
const canUsePicture = canUseScenePicture(r, graph, sceneVersion, requiresUncachedSceneRender)
const cacheMissReason = scenePictureMissReason(
r,
graph,
@ -214,7 +185,7 @@ export function render(
p.beginPhase('render:scene')
if (
layer === 'scene' &&
!hasVolatileOverlays &&
!requiresUncachedSceneRender &&
renderSceneBacking(r, canvas, graph, sceneVersion)
) {
p.setScenePictureMode('hit', 'backing')
@ -229,7 +200,7 @@ export function render(
sceneVersion,
canUsePicture,
cacheMissReason,
hasVolatileOverlays
requiresUncachedSceneRender
)
}
p.endPhase('render:scene')
@ -241,34 +212,14 @@ export function render(
canvas.save()
canvas.scale(r.dpr, r.dpr)
r.labelCache.update(graph, r.pageId, sceneVersion, graph.positionPreviewVersion)
p.beginPhase('render:sectionTitles')
r.drawSectionTitles(canvas, graph)
p.endPhase('render:sectionTitles')
p.beginPhase('render:componentLabels')
r.drawComponentLabels(canvas, graph)
p.endPhase('render:componentLabels')
drawLabelPass(r, canvas, graph)
canvas.restore()
canvas.save()
canvas.scale(r.dpr, r.dpr)
drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays)
r.drawFlashes(canvas, graph)
drawPageGuides(r, canvas, graph)
r.drawSnapGuides(canvas, overlays.snapGuides)
r.drawMarquee(canvas, overlays.marquee)
r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator)
if (!measurementVisible(overlays)) {
r.drawAutoLayoutHover(canvas, graph, overlays.autoLayoutHover)
}
r.drawNodeEditOverlay(canvas, graph, overlays.nodeEditState)
r.drawPenOverlay(canvas, overlays.penState)
r.drawRemoteCursors(canvas, graph, overlays.remoteCursors)
p.beginPhase('render:rulers')
if (r.showRulers) r.drawRulers(canvas, graph, selectedIds)
p.endPhase('render:rulers')
p.drawHUD(canvas, r.showRulers)
drawOverlayPass(r, canvas, graph, selectedIds, overlays)
drawChromePass(r, canvas, graph, selectedIds)
canvas.restore()
}
@ -290,7 +241,7 @@ function renderSceneContent(
sceneVersion: number,
canUsePicture: boolean,
cacheMissReason: string,
hasVolatileOverlays: boolean
requiresUncachedSceneRender: boolean
): void {
const p = r.profiler
if (canUsePicture) {
@ -302,7 +253,7 @@ function renderSceneContent(
p.setScenePictureDrawTime(duration)
}
p.endPhase('render:drawPicture')
} else if (hasVolatileOverlays) {
} else if (requiresUncachedSceneRender) {
p.setScenePictureMode('volatile', cacheMissReason)
r._nodeCount = 0
r._culledCount = 0

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 { GuideOverlayState } from '#core/canvas/guides/types'
import type { TextEditor } from '#core/text/editor'
export interface RulerTheme {
@ -21,6 +22,7 @@ export interface RenderOverlays {
textEditor?: TextEditor | null
marquee?: Rect | null
snapGuides?: SnapGuide[]
guides?: GuideOverlayState
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.guides = { preview: null, hovered: null, selected: 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,101 @@
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 {
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()
}
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,6 +1,7 @@
import type { Rect } from '@open-pencil/scene-graph/primitives'
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
import type { GuidePreview } from '#core/canvas/guides/types'
import type { EditorContext } from '#core/editor/types'
export function createSelectionOverlayActions(ctx: EditorContext) {
@ -14,6 +15,24 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
ctx.requestRepaint()
}
function setGuidePreview(preview: GuidePreview | null) {
ctx.state.guides.preview = preview
ctx.requestRepaint()
}
function setHoveredGuide(selection: typeof ctx.state.guides.hovered) {
const current = ctx.state.guides.hovered
if (current?.ownerId === selection?.ownerId && current?.guideId === selection?.guideId) return
ctx.state.guides.hovered = selection
ctx.requestRepaint()
}
function setSelectedGuide(selection: typeof ctx.state.guides.selected) {
ctx.state.guides.selected = selection
if (selection) ctx.setSelectedIds(new Set())
ctx.requestRepaint()
}
function setRotationPreview(preview: { nodeId: string; angle: number } | null) {
ctx.state.rotationPreview = preview
ctx.requestRepaint()
@ -60,6 +79,9 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
return {
setMarquee,
setSnapGuides,
setGuidePreview,
setHoveredGuide,
setSelectedGuide,
setRotationPreview,
setHoveredNode,
setMeasurementMode,

View file

@ -1,3 +1,4 @@
import { createGuideOverlayState } from '#core/canvas/guides/types'
import { CANVAS_BG_COLOR } from '#core/constants'
import type { EditorState, EditorViewState } from '#core/editor/types'
@ -7,6 +8,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState {
selectedIds: new Set<string>(),
marquee: null,
snapGuides: [],
guides: createGuideOverlayState(),
rotationPreview: null,
dropTargetId: null,
layoutInsertIndicator: null,
@ -35,6 +37,7 @@ export function copyEditorViewState(source: EditorViewState): EditorViewState {
selectedIds: new Set(source.selectedIds),
marquee: structuredClone(source.marquee),
snapGuides: structuredClone(source.snapGuides),
guides: structuredClone(source.guides),
rotationPreview: structuredClone(source.rotationPreview),
layoutInsertIndicator: structuredClone(source.layoutInsertIndicator),
penState: structuredClone(source.penState),

View file

@ -7,10 +7,12 @@ 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'
import type { GuideOverlayState } from '#core/canvas/guides/types'
import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer'
import type { MeasurementMode, RenderOverlays } from '#core/canvas/renderer/types'
import type { SnappingPreferences } from '#core/editor/preferences'
@ -51,6 +53,7 @@ export interface EditorViewState {
selectedIds: Set<string>
marquee: Rect | null
snapGuides: SnapGuide[]
guides: GuideOverlayState
rotationPreview: { nodeId: string; angle: number } | null
dropTargetId: string | null
layoutInsertIndicator: {
@ -114,6 +117,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,45 @@
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?: 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 {
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 })
const figGuid = isGuid(guide.guid) ? guide.guid : undefined
if (guide.axis === 'X') {
guides.push({
id: guideId(figGuid, index),
axis: 'x',
position: guide.offset,
...(figGuid ? { figGuid } : {})
})
} else if (guide.axis === 'Y') {
guides.push({
id: guideId(figGuid, index),
axis: 'y',
position: guide.offset,
...(figGuid ? { figGuid } : {})
})
}
}
return guides
}
@ -21,6 +47,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,
@ -839,6 +840,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

@ -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

@ -0,0 +1,31 @@
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('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([
{ 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

@ -0,0 +1,182 @@
import type { Ref } from 'vue'
import { hitTestGuides } from '@open-pencil/core/canvas'
import { RULER_SIZE } from '@open-pencil/core/constants'
import type { Editor } from '@open-pencil/core/editor'
import type { DragGuide, DragState } from '#vue/shared/input/types'
interface GuideInputOptions {
canvasRef: Ref<HTMLCanvasElement | null>
editor: Editor
canvasToLocal: (cx: number, cy: number, scopeId: string) => { lx: number; ly: number }
setDrag: (drag: DragState) => void
setCursor: (cursor: string | null) => void
}
export function createGuideInput({
canvasRef,
editor,
canvasToLocal,
setDrag,
setCursor
}: GuideInputOptions) {
function viewport() {
const canvas = canvasRef.value
return {
panX: editor.state.panX,
panY: editor.state.panY,
zoom: editor.state.zoom,
width: canvas?.clientWidth ?? 0,
height: canvas?.clientHeight ?? 0
}
}
function hitTest(sx: number, sy: number) {
return hitTestGuides(editor.graph, editor.state.currentPageId, viewport(), sx, sy)
}
function ownerAt(cx: number, cy: number): string {
let node = editor.graph.hitTestDeep(cx, cy, editor.state.currentPageId)
while (node) {
if (node.type === 'FRAME' || node.type === 'COMPONENT') return node.id
node = node.parentId ? (editor.graph.getNode(node.parentId) ?? null) : null
}
return editor.state.currentPageId
}
function positionFor(ownerId: string, axis: DragGuide['axis'], cx: number, cy: number) {
const owner = editor.graph.getNode(ownerId)
const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null
return axis === 'x' ? (local?.lx ?? cx) : (local?.ly ?? cy)
}
function cursor(axis: DragGuide['axis']) {
return axis === 'x' ? 'ew-resize' : 'ns-resize'
}
function rulerAxis(sx: number, sy: number): DragGuide['axis'] | null {
if (sx < RULER_SIZE && sy < RULER_SIZE) return null
if (sy < RULER_SIZE) return 'y'
if (sx < RULER_SIZE) return 'x'
return null
}
function updateHover(sx: number, sy: number): string | null {
const axis = rulerAxis(sx, sy)
if (axis) {
editor.setHoveredGuide(null)
return cursor(axis)
}
const hit = hitTest(sx, sy)
editor.setHoveredGuide(hit ? { ownerId: hit.ownerId, guideId: hit.guideId } : null)
return hit ? cursor(hit.axis) : null
}
function tryStartExisting(sx: number, sy: number): boolean {
if (rulerAxis(sx, sy)) return false
const hit = hitTest(sx, sy)
if (!hit) return false
editor.setSelectedGuide({ ownerId: hit.ownerId, guideId: hit.guideId })
editor.setHoveredGuide(null)
setCursor(cursor(hit.axis))
setDrag({
type: 'guide',
axis: hit.axis,
ownerId: hit.ownerId,
position: hit.position,
startScreenX: sx,
startScreenY: sy,
currentScreenX: sx,
currentScreenY: sy,
dragStarted: false,
guideId: hit.guideId,
originalOwnerId: hit.ownerId,
originalPosition: hit.position
})
return true
}
function tryStartFromRuler(sx: number, sy: number, cx: number, cy: number): boolean {
if (!('showRulers' in editor.state) || editor.state.showRulers !== true) return false
const axis = rulerAxis(sx, sy)
if (!axis) return false
const ownerId = ownerAt(cx, cy)
setDrag({
type: 'guide',
axis,
ownerId,
position: positionFor(ownerId, axis, cx, cy),
startScreenX: sx,
startScreenY: sy,
currentScreenX: sx,
currentScreenY: sy,
dragStarted: false
})
return true
}
function handleMove(drag: DragGuide, sx: number, sy: number, cx: number, cy: number): void {
drag.currentScreenX = sx
drag.currentScreenY = sy
if (!drag.dragStarted && Math.hypot(sx - drag.startScreenX, sy - drag.startScreenY) < 3) return
drag.dragStarted = true
setCursor(cursor(drag.axis))
drag.ownerId = ownerAt(cx, cy)
drag.position = positionFor(drag.ownerId, drag.axis, cx, cy)
editor.setGuidePreview({
ownerId: drag.ownerId,
axis: drag.axis,
position: drag.position,
source:
drag.guideId && drag.originalOwnerId
? { ownerId: drag.originalOwnerId, guideId: drag.guideId }
: undefined
})
}
function finish(drag: DragGuide): void {
if (drag.dragStarted) {
if (drag.currentScreenX < RULER_SIZE || drag.currentScreenY < RULER_SIZE) {
if (drag.guideId && drag.originalOwnerId) {
editor.removeGuide(drag.originalOwnerId, drag.guideId)
editor.setSelectedGuide(null)
}
} else if (drag.guideId && drag.originalOwnerId) {
if (drag.ownerId === drag.originalOwnerId)
editor.moveGuide(drag.ownerId, drag.guideId, drag.position)
else editor.transferGuide(drag.originalOwnerId, drag.ownerId, drag.guideId, drag.position)
editor.setSelectedGuide({ ownerId: drag.ownerId, guideId: drag.guideId })
} else {
const guideId = editor.addGuide(drag.ownerId, drag.axis, drag.position)
if (guideId) editor.setSelectedGuide({ ownerId: drag.ownerId, guideId })
}
}
clearHoverAndPreview()
}
function deleteSelected(event: KeyboardEvent): boolean {
if (event.code !== 'Delete' && event.code !== 'Backspace') return false
const selected = editor.state.guides.selected
if (!selected || editor.state.editingTextId) return false
if (!editor.removeGuide(selected.ownerId, selected.guideId)) return false
editor.setSelectedGuide(null)
event.preventDefault()
return true
}
function clearHoverAndPreview(): void {
editor.setGuidePreview(null)
editor.setHoveredGuide(null)
}
return {
tryStartExisting,
tryStartFromRuler,
updateHover,
handleMove,
finish,
deleteSelected,
clearHoverAndPreview
}
}

View file

@ -7,7 +7,7 @@ import {
handleBendHandleMove,
resolveBendTargetHandle,
type CanvasNodeEditMethods
} from '#vue/canvas/node-edit-input/bend'
} from '#vue/canvas/node-edit/bend'
import { hitTestEditHandle, isEndpoint, NODE_HIT_THRESHOLD } from '#vue/shared/input/node-edit'
import type { DragState } from '#vue/shared/input/types'

View file

@ -3,7 +3,7 @@ import type { Ref } from 'vue'
import { PEN_CLOSE_THRESHOLD } from '@open-pencil/core/constants'
import type { Editor } from '@open-pencil/core/editor'
import { createPenDrag, handlePenDragMove } from '#vue/canvas/pen-input/drag'
import { createPenDrag, handlePenDragMove } from '#vue/canvas/pen/drag'
import { handlePenNodeEditDown } from '#vue/shared/input/node-edit'
import type { DragState } from '#vue/shared/input/types'

View file

@ -1 +0,0 @@
export { handleToolMouseDown, startPanDrag } from './use'

View file

@ -2,7 +2,7 @@ import type { Ref } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import { startPenInput } from '#vue/canvas/pen-input/use'
import { startPenInput } from '#vue/canvas/pen/input'
import { startShapeDraw, startTextDraw } from '#vue/shared/input/draw'
import { startPanDrag } from '#vue/shared/input/pan'
import { handleSelectDown } from '#vue/shared/input/select'

View file

@ -1,12 +1,12 @@
import type { Editor } from '@open-pencil/core/editor'
import { handleMarqueeMove as handleMarqueeMoveAction } from '#vue/canvas/transform-input/marquee'
import { handlePanMove as handlePanMoveAction } from '#vue/canvas/transform-input/pan'
import { handleMarqueeMove as handleMarqueeMoveAction } from '#vue/canvas/transform/marquee'
import { handlePanMove as handlePanMoveAction } from '#vue/canvas/transform/pan'
import {
handleRotateMove as handleRotateMoveAction,
tryStartRotation as tryStartRotationAction
} from '#vue/canvas/transform-input/rotation'
import { handleTextSelectMove as handleTextSelectMoveAction } from '#vue/canvas/transform-input/text-selection'
} from '#vue/canvas/transform/rotation'
import { handleTextSelectMove as handleTextSelectMoveAction } from '#vue/canvas/transform/text-selection'
import type { DragMarquee, DragPan, DragRotate, DragState } from '#vue/shared/input/types'
type CanvasToLocal = (cx: number, cy: number, scopeId: string) => { lx: number; ly: number }

View file

@ -1 +1 @@
export { createTransformInputActions as createCanvasTransformInput } from '#vue/canvas/transform-input/actions'
export { createTransformInputActions as createCanvasTransformInput } from '#vue/canvas/transform/actions'

View file

@ -4,16 +4,17 @@ import { onScopeDispose, ref, type Ref } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
import { createGuideInput } from '#vue/canvas/guides/input'
import {
handleBendHandleMove,
handleNodeEditMouseUp,
updateNodeEditHover
} from '#vue/canvas/node-edit-input/use'
import { handlePenDragMove, updatePenHover } from '#vue/canvas/pen-input/use'
} from '#vue/canvas/node-edit/input'
import { handlePenDragMove, updatePenHover } from '#vue/canvas/pen/input'
import { createCanvasPointer } from '#vue/canvas/pointer/use'
import { createTextEditInput } from '#vue/canvas/text-edit/input'
import { handleToolMouseDown } from '#vue/canvas/tool-input/use'
import { createCanvasTransformInput } from '#vue/canvas/transform-input/use'
import { handleToolMouseDown } from '#vue/canvas/tools/input'
import { createCanvasTransformInput } from '#vue/canvas/transform/input'
import { resolveAutoLayoutHover } from '#vue/shared/input/auto-layout-hover'
import { createClickCounter } from '#vue/shared/input/click-count'
import { handleDrawMove, handleDrawUp } from '#vue/shared/input/draw'
@ -119,6 +120,16 @@ export function useCanvasInput(
drag.value = d
}
const guideInput = createGuideInput({
canvasRef,
editor,
canvasToLocal,
setDrag,
setCursor: (cursor) => {
cursorOverride.value = cursor
}
})
const { handleTextEditClick, onDblClick: onTextDblClick } = createTextEditInput({
editor,
getCoords,
@ -213,6 +224,15 @@ export function useCanvasInput(
if (!editor.state.editingTextId) canvasRef.value?.focus()
editor.setHoveredNode(null)
const { sx, sy, cx, cy } = getCoords(e)
if (e.button === 0 && guideInput.tryStartExisting(sx, sy)) {
e.preventDefault()
return
}
if (e.button === 0 && guideInput.tryStartFromRuler(sx, sy, cx, cy)) {
e.preventDefault()
return
}
editor.setSelectedGuide(null)
const selectedIdsBeforeMouseDown = new Set(editor.state.selectedIds)
const clickCount = recordClick(sx, sy)
@ -232,6 +252,8 @@ export function useCanvasInput(
})
}
// Dispatching the full drag union is intentionally centralized here.
// eslint-disable-next-line complexity
function onMouseMove(e: MouseEvent) {
if (!isEnabled()) return
pointerInside.value = true
@ -252,14 +274,11 @@ export function useCanvasInput(
}
if (!drag.value && editor.state.activeTool === 'SELECT') {
const { cx, cy } = coords
cursorOverride.value = updateHoverCursor(
cx,
cy,
editor,
hitFns,
editor.state.measurementMode === 'deep'
)
const { sx, sy, cx, cy } = coords
const guideCursor = guideInput.updateHover(sx, sy)
cursorOverride.value =
guideCursor ??
updateHoverCursor(cx, cy, editor, hitFns, editor.state.measurementMode === 'deep')
editor.setAutoLayoutHover(
editor.state.measurementMode === 'off' ? resolveAutoLayoutHover(cx, cy, editor) : null
)
@ -275,6 +294,11 @@ export function useCanvasInput(
const { sx, sy, cx, cy } = getCoords(e)
if (d.type === 'guide') {
guideInput.handleMove(d, sx, sy, cx, cy)
return
}
if (d.type === 'rotate') {
handleRotateMove(d, cx, cy, e.shiftKey)
return
@ -322,7 +346,9 @@ export function useCanvasInput(
if (handleNodeEditMouseUp(drag, editor)) return
if (d.type === 'move') handleMoveUp(d, editor)
if (d.type === 'guide') {
guideInput.finish(d)
} else if (d.type === 'move') handleMoveUp(d, editor)
else if (d.type === 'text-select') {
drag.value = null
return
@ -357,6 +383,7 @@ export function useCanvasInput(
editor.setSnapGuides([])
editor.setLayoutInsertIndicator(null)
editor.setDropTarget(null)
guideInput.clearHoverAndPreview()
}
function cancelPointerInteraction() {
@ -369,7 +396,9 @@ export function useCanvasInput(
useEventListener(canvasRef, 'mousedown', onMouseDown)
useEventListener(canvasRef, 'mousemove', onMouseMove)
useEventListener(canvasRef, 'mouseup', onMouseUp)
useEventListener(window, 'keydown', (event) => updateModifier(event.code, true))
useEventListener(window, 'keydown', (event) => {
if (!guideInput.deleteSelected(event)) updateModifier(event.code, true)
})
useEventListener(window, 'keyup', (event) => updateModifier(event.code, false))
useEventListener(window, 'blur', () => {
resetMeasurementModifiers()
@ -381,6 +410,7 @@ export function useCanvasInput(
editor.setMeasurementMode('off')
if (!drag.value) {
editor.setHoveredNode(null)
editor.setHoveredGuide(null)
}
})
useEventListener(

View file

@ -128,6 +128,21 @@ export interface DragBendHandle {
targetTangentField: 'tangentStart' | 'tangentEnd' | null
}
export interface DragGuide {
type: 'guide'
axis: 'x' | 'y'
ownerId: string
position: number
startScreenX: number
startScreenY: number
currentScreenX: number
currentScreenY: number
dragStarted: boolean
guideId?: string
originalOwnerId?: string
originalPosition?: number
}
export type DragState =
| DragDraw
| DragMove
@ -140,6 +155,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: [],
guides: { preview: null, hovered: null, selected: 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)
})
})

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

@ -0,0 +1,101 @@
import { describe, expect, mock, test } from 'bun:test'
import type { Canvas } from 'canvaskit-wasm'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
import { drawGuides } from '#core/canvas/guides/draw'
import { createMockCanvas, createMockRenderer, mockCalls } from '../effects/helpers'
function graphWithGuides(guides: SceneNode['guides']): SceneGraph {
const page = {
id: 'page',
type: 'CANVAS',
childIds: [],
guides
} as SceneNode
return {
rootId: 'root',
getNode: (id: string) => (id === 'page' ? page : null)
} as SceneGraph
}
describe('page guide rendering', () => {
test('renders imported Figma page guides in screen space', () => {
const r = createMockRenderer({
pageId: 'page',
panX: 10,
panY: 20,
zoom: 2,
viewportWidth: 300,
viewportHeight: 200
})
const canvas = createMockCanvas()
const graph = graphWithGuides([
{ id: 'x', axis: 'x', position: 42 },
{ id: 'y', axis: 'y', position: 84 }
])
drawGuides(r, canvas as Canvas, graph)
expect(mockCalls(canvas.drawRect)).toHaveLength(2)
expect(mockCalls(r.ck.LTRBRect)).toEqual([
[94, 0, 95, 200],
[0, 188, 300, 189]
])
})
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
drawGuides(r, canvas as Canvas, graph)
expect(canvas.drawLine).toHaveBeenCalled()
})
test('ignores pages without guides', () => {
const r = createMockRenderer({ pageId: 'page' })
const canvas = createMockCanvas()
const graph = graphWithGuides([])
drawGuides(r, canvas as Canvas, graph)
expect(canvas.drawRect).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
import { distanceToGuideSegment, getGuideScreenSegment } from '#core/canvas/guides/geometry'
import { hitTestGuides } from '#core/canvas/guides/hit-test'
function pageWithGuide(): { graph: SceneGraph; page: SceneNode } {
const page = {
id: 'page',
type: 'CANVAS',
parentId: null,
childIds: [],
guides: [{ id: 'guide', axis: 'x', position: 20 }]
} as SceneNode
const graph = new SceneGraph()
graph.nodes = new Map([['page', page]])
graph.rootId = 'page'
return { graph, page }
}
describe('guide screen geometry', () => {
test('spans page guides across the viewport', () => {
const { graph, page } = pageWithGuide()
expect(
getGuideScreenSegment(graph, page, page.guides[0], {
panX: 10,
panY: 0,
zoom: 2,
width: 300,
height: 200
})
).toEqual({ x1: 50, y1: 0, x2: 50, y2: 200 })
})
test('measures distance to the bounded segment', () => {
expect(distanceToGuideSegment(20, 5, { x1: 10, y1: 0, x2: 10, y2: 20 })).toBe(10)
expect(distanceToGuideSegment(10, 30, { x1: 10, y1: 0, x2: 10, y2: 20 })).toBe(10)
})
test('hit tests page guides using screen coordinates', () => {
const { graph } = pageWithGuide()
const hit = hitTestGuides(
graph,
'page',
{ panX: 10, panY: 0, zoom: 2, width: 300, height: 200 },
52,
100
)
expect(hit).toMatchObject({ ownerId: 'page', guideId: 'guide', axis: 'x', position: 20 })
})
})

View file

@ -1,56 +0,0 @@
import { describe, expect, test } from 'bun:test'
import type { Canvas } from 'canvaskit-wasm'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { drawPageGuides } from '#core/canvas/page-guides'
import { createMockCanvas, createMockRenderer, mockCalls } from './effects/helpers'
function graphWithGuides(guides: SceneNode['guides']): SceneGraph {
const page = {
id: 'page',
guides
} as SceneNode
return {
rootId: 'root',
getNode: (id: string) => (id === 'page' ? page : null)
} as SceneGraph
}
describe('page guide rendering', () => {
test('renders imported Figma page guides in screen space', () => {
const r = createMockRenderer({
pageId: 'page',
panX: 10,
panY: 20,
zoom: 2,
viewportWidth: 300,
viewportHeight: 200
})
const canvas = createMockCanvas()
const graph = graphWithGuides([
{ axis: 'x', position: 42 },
{ axis: 'y', position: 84 }
])
drawPageGuides(r, canvas as Canvas, graph)
expect(mockCalls(canvas.drawRect)).toHaveLength(2)
expect(mockCalls(r.ck.LTRBRect)).toEqual([
[94, 0, 95, 200],
[0, 188, 300, 189]
])
})
test('ignores pages without guides', () => {
const r = createMockRenderer({ pageId: 'page' })
const canvas = createMockCanvas()
const graph = graphWithGuides([])
drawPageGuides(r, canvas as Canvas, graph)
expect(canvas.drawRect).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test'
import { ref } from 'vue'
import { createEditor } from '@open-pencil/core/editor'
import { createGuideInput } from '#vue/canvas/guides/input'
import type { DragState } from '#vue/shared/input/types'
function setup() {
const editor = createEditor()
Object.assign(editor.state, { showRulers: true })
let drag: DragState | null = null
const input = createGuideInput({
canvasRef: ref<HTMLCanvasElement | null>(null),
editor,
canvasToLocal: (cx, cy) => ({ lx: cx, ly: cy }),
setDrag: (next) => {
drag = next
},
setCursor: () => undefined
})
return { editor, input, getDrag: () => drag }
}
describe('guide canvas input', () => {
test('does not create a guide from a ruler click without movement', () => {
const { editor, input, getDrag } = setup()
expect(input.tryStartFromRuler(100, 5, 100, 5)).toBe(true)
const drag = getDrag()
expect(drag?.type).toBe('guide')
if (drag?.type === 'guide') input.finish(drag)
expect(editor.graph.getNode(editor.state.currentPageId)?.guides).toEqual([])
})
test('publishes live preview after the drag threshold and commits on release', () => {
const { editor, input, getDrag } = setup()
input.tryStartFromRuler(100, 5, 100, 5)
const drag = getDrag()
if (drag?.type !== 'guide') throw new Error('Expected guide drag')
input.handleMove(drag, 100, 40, 100, 40)
expect(editor.state.guides.preview).toMatchObject({ axis: 'y', position: 40 })
expect(editor.graph.getNode(editor.state.currentPageId)?.guides).toEqual([])
input.finish(drag)
expect(editor.graph.getNode(editor.state.currentPageId)?.guides[0]).toMatchObject({
axis: 'y',
position: 40
})
})
test('ruler hover takes precedence over an intersecting existing guide', () => {
const { editor, input } = setup()
editor.addGuide(editor.state.currentPageId, 'x', 100)
expect(input.updateHover(100, 5)).toBe('ns-resize')
expect(editor.state.guides.hovered).toBeNull()
})
})