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:
parent
83a5ea1b42
commit
f892cb583c
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
98
packages/core/src/editor/guides.ts
Normal file
98
packages/core/src/editor/guides.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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': (
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
25
packages/fig/tests/canvas-guides.test.ts
Normal file
25
packages/fig/tests/canvas-guides.test.ts
Normal 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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import type { GUID } from './primitives'
|
||||
|
||||
export interface CanvasGuide {
|
||||
id: string
|
||||
axis: 'x' | 'y'
|
||||
position: number
|
||||
figGuid?: GUID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
56
tests/engine/editor/guides.test.ts
Normal file
56
tests/engine/editor/guides.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue