Merge pull request #562 from open-pencil/guide-authoring
feat(editor): author canvas and frame guides from rulers
This commit is contained in:
commit
9f7ce64bf6
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
### Added
|
### 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.
|
- 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.
|
- 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.
|
- Open multiple selected design files in separate tabs.
|
||||||
|
|
|
||||||
118
packages/core/src/canvas/guides/draw.ts
Normal file
118
packages/core/src/canvas/guides/draw.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
64
packages/core/src/canvas/guides/geometry.ts
Normal file
64
packages/core/src/canvas/guides/geometry.ts
Normal 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))
|
||||||
|
}
|
||||||
51
packages/core/src/canvas/guides/hit-test.ts
Normal file
51
packages/core/src/canvas/guides/hit-test.ts
Normal 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
|
||||||
|
}
|
||||||
21
packages/core/src/canvas/guides/types.ts
Normal file
21
packages/core/src/canvas/guides/types.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -4,4 +4,12 @@ export {
|
||||||
hasVisibleStrokeSourceNode,
|
hasVisibleStrokeSourceNode,
|
||||||
nodeHasVisibleStroke
|
nodeHasVisibleStroke
|
||||||
} from './boolean'
|
} 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'
|
export { SkiaRenderer, type RenderOverlays, type RulerTheme } from './renderer'
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
68
packages/core/src/canvas/renderer/overlay-pass.ts
Normal file
68
packages/core/src/canvas/renderer/overlay-pass.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,10 @@ import type { Canvas } from 'canvaskit-wasm'
|
||||||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||||
import { computeDescendantVisualBounds } from '@open-pencil/scene-graph/geometry'
|
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 { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer'
|
||||||
import type { EditorState } from '#core/editor/types'
|
import type { EditorState } from '#core/editor/types'
|
||||||
|
|
||||||
|
import { drawChromePass, drawLabelPass, drawOverlayPass } from './overlay-pass'
|
||||||
import { renderSceneBacking, updateSceneBackingPreviewState } from './retained-backing'
|
import { renderSceneBacking, updateSceneBackingPreviewState } from './retained-backing'
|
||||||
|
|
||||||
export function renderSceneToCanvas(
|
export function renderSceneToCanvas(
|
||||||
|
|
@ -61,6 +61,7 @@ export function renderFromEditorState(
|
||||||
textEditor: textEditor as RenderOverlays['textEditor'],
|
textEditor: textEditor as RenderOverlays['textEditor'],
|
||||||
marquee: state.marquee,
|
marquee: state.marquee,
|
||||||
snapGuides: state.snapGuides,
|
snapGuides: state.snapGuides,
|
||||||
|
guides: state.guides,
|
||||||
rotationPreview: state.rotationPreview,
|
rotationPreview: state.rotationPreview,
|
||||||
dropTargetId: state.dropTargetId,
|
dropTargetId: state.dropTargetId,
|
||||||
layoutInsertIndicator: state.layoutInsertIndicator,
|
layoutInsertIndicator: state.layoutInsertIndicator,
|
||||||
|
|
@ -80,7 +81,7 @@ export function renderFromEditorState(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasVolatileOverlay(overlays: RenderOverlays): boolean {
|
function sceneContentDependsOnOverlay(overlays: RenderOverlays): boolean {
|
||||||
return (
|
return (
|
||||||
overlays.dropTargetId != null ||
|
overlays.dropTargetId != null ||
|
||||||
overlays.rotationPreview != null ||
|
overlays.rotationPreview != null ||
|
||||||
|
|
@ -97,7 +98,7 @@ function scenePictureMissReason(
|
||||||
hasPositionPreview: boolean
|
hasPositionPreview: boolean
|
||||||
): string {
|
): string {
|
||||||
if (hasPositionPreview) return 'position-preview'
|
if (hasPositionPreview) return 'position-preview'
|
||||||
if (hasVolatileOverlay(overlays)) return 'volatile-overlay'
|
if (sceneContentDependsOnOverlay(overlays)) return 'volatile-overlay'
|
||||||
if (!r.scenePicture) return 'missing-picture'
|
if (!r.scenePicture) return 'missing-picture'
|
||||||
if (graph.positionPreviewVersion !== r.scenePicturePositionPreviewVersion)
|
if (graph.positionPreviewVersion !== r.scenePicturePositionPreviewVersion)
|
||||||
return 'position-preview-version'
|
return 'position-preview-version'
|
||||||
|
|
@ -111,10 +112,10 @@ function canUseScenePicture(
|
||||||
r: SkiaRenderer,
|
r: SkiaRenderer,
|
||||||
graph: SceneGraph,
|
graph: SceneGraph,
|
||||||
sceneVersion: number,
|
sceneVersion: number,
|
||||||
hasVolatileOverlays: boolean
|
requiresUncachedSceneRender: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
!hasVolatileOverlays &&
|
!requiresUncachedSceneRender &&
|
||||||
!!r.scenePicture &&
|
!!r.scenePicture &&
|
||||||
graph.positionPreviewVersion === r.scenePicturePositionPreviewVersion &&
|
graph.positionPreviewVersion === r.scenePicturePositionPreviewVersion &&
|
||||||
sceneVersion === r.scenePictureVersion &&
|
sceneVersion === r.scenePictureVersion &&
|
||||||
|
|
@ -131,36 +132,6 @@ function measure<T>(fn: () => T): { value: T; duration: number } {
|
||||||
return { value, duration: now() - start }
|
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(
|
export function render(
|
||||||
r: SkiaRenderer,
|
r: SkiaRenderer,
|
||||||
graph: SceneGraph,
|
graph: SceneGraph,
|
||||||
|
|
@ -196,9 +167,9 @@ export function render(
|
||||||
const hasPositionPreview =
|
const hasPositionPreview =
|
||||||
graph.positionPreviewVersion !== r.scenePicturePositionPreviewVersion &&
|
graph.positionPreviewVersion !== r.scenePicturePositionPreviewVersion &&
|
||||||
sceneVersion === r.scenePictureVersion
|
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(
|
const cacheMissReason = scenePictureMissReason(
|
||||||
r,
|
r,
|
||||||
graph,
|
graph,
|
||||||
|
|
@ -214,7 +185,7 @@ export function render(
|
||||||
p.beginPhase('render:scene')
|
p.beginPhase('render:scene')
|
||||||
if (
|
if (
|
||||||
layer === 'scene' &&
|
layer === 'scene' &&
|
||||||
!hasVolatileOverlays &&
|
!requiresUncachedSceneRender &&
|
||||||
renderSceneBacking(r, canvas, graph, sceneVersion)
|
renderSceneBacking(r, canvas, graph, sceneVersion)
|
||||||
) {
|
) {
|
||||||
p.setScenePictureMode('hit', 'backing')
|
p.setScenePictureMode('hit', 'backing')
|
||||||
|
|
@ -229,7 +200,7 @@ export function render(
|
||||||
sceneVersion,
|
sceneVersion,
|
||||||
canUsePicture,
|
canUsePicture,
|
||||||
cacheMissReason,
|
cacheMissReason,
|
||||||
hasVolatileOverlays
|
requiresUncachedSceneRender
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
p.endPhase('render:scene')
|
p.endPhase('render:scene')
|
||||||
|
|
@ -241,34 +212,14 @@ export function render(
|
||||||
canvas.save()
|
canvas.save()
|
||||||
canvas.scale(r.dpr, r.dpr)
|
canvas.scale(r.dpr, r.dpr)
|
||||||
r.labelCache.update(graph, r.pageId, sceneVersion, graph.positionPreviewVersion)
|
r.labelCache.update(graph, r.pageId, sceneVersion, graph.positionPreviewVersion)
|
||||||
p.beginPhase('render:sectionTitles')
|
drawLabelPass(r, canvas, graph)
|
||||||
r.drawSectionTitles(canvas, graph)
|
|
||||||
p.endPhase('render:sectionTitles')
|
|
||||||
p.beginPhase('render:componentLabels')
|
|
||||||
r.drawComponentLabels(canvas, graph)
|
|
||||||
p.endPhase('render:componentLabels')
|
|
||||||
canvas.restore()
|
canvas.restore()
|
||||||
|
|
||||||
canvas.save()
|
canvas.save()
|
||||||
canvas.scale(r.dpr, r.dpr)
|
canvas.scale(r.dpr, r.dpr)
|
||||||
|
|
||||||
drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays)
|
drawOverlayPass(r, canvas, graph, selectedIds, overlays)
|
||||||
r.drawFlashes(canvas, graph)
|
drawChromePass(r, canvas, graph, selectedIds)
|
||||||
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)
|
|
||||||
|
|
||||||
canvas.restore()
|
canvas.restore()
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +241,7 @@ function renderSceneContent(
|
||||||
sceneVersion: number,
|
sceneVersion: number,
|
||||||
canUsePicture: boolean,
|
canUsePicture: boolean,
|
||||||
cacheMissReason: string,
|
cacheMissReason: string,
|
||||||
hasVolatileOverlays: boolean
|
requiresUncachedSceneRender: boolean
|
||||||
): void {
|
): void {
|
||||||
const p = r.profiler
|
const p = r.profiler
|
||||||
if (canUsePicture) {
|
if (canUsePicture) {
|
||||||
|
|
@ -302,7 +253,7 @@ function renderSceneContent(
|
||||||
p.setScenePictureDrawTime(duration)
|
p.setScenePictureDrawTime(duration)
|
||||||
}
|
}
|
||||||
p.endPhase('render:drawPicture')
|
p.endPhase('render:drawPicture')
|
||||||
} else if (hasVolatileOverlays) {
|
} else if (requiresUncachedSceneRender) {
|
||||||
p.setScenePictureMode('volatile', cacheMissReason)
|
p.setScenePictureMode('volatile', cacheMissReason)
|
||||||
r._nodeCount = 0
|
r._nodeCount = 0
|
||||||
r._culledCount = 0
|
r._culledCount = 0
|
||||||
|
|
|
||||||
|
|
@ -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 { Color, Rect, Vector } from '@open-pencil/scene-graph/primitives'
|
||||||
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
|
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
|
||||||
|
|
||||||
|
import type { GuideOverlayState } from '#core/canvas/guides/types'
|
||||||
import type { TextEditor } from '#core/text/editor'
|
import type { TextEditor } from '#core/text/editor'
|
||||||
|
|
||||||
export interface RulerTheme {
|
export interface RulerTheme {
|
||||||
|
|
@ -21,6 +22,7 @@ export interface RenderOverlays {
|
||||||
textEditor?: TextEditor | null
|
textEditor?: TextEditor | null
|
||||||
marquee?: Rect | null
|
marquee?: Rect | null
|
||||||
snapGuides?: SnapGuide[]
|
snapGuides?: SnapGuide[]
|
||||||
|
guides?: GuideOverlayState
|
||||||
rotationPreview?: { nodeId: string; angle: number } | null
|
rotationPreview?: { nodeId: string; angle: number } | null
|
||||||
dropTargetId?: string | null
|
dropTargetId?: string | null
|
||||||
layoutInsertIndicator?: {
|
layoutInsertIndicator?: {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { createComponentSyncScheduler } from './component-sync'
|
||||||
import { createComponentActions } from './components'
|
import { createComponentActions } from './components'
|
||||||
import { createGraphEventSubscription } from './graph-events'
|
import { createGraphEventSubscription } from './graph-events'
|
||||||
import { createGraphReadActions } from './graph-reads'
|
import { createGraphReadActions } from './graph-reads'
|
||||||
|
import { createGuideActions } from './guides'
|
||||||
import { createLayoutRunner } from './layout-runner'
|
import { createLayoutRunner } from './layout-runner'
|
||||||
import { createNodeActions } from './nodes'
|
import { createNodeActions } from './nodes'
|
||||||
import { createPageActions } from './pages'
|
import { createPageActions } from './pages'
|
||||||
|
|
@ -163,6 +164,7 @@ export function createEditor(options?: EditorOptions) {
|
||||||
const viewport = createViewportActions(ctx)
|
const viewport = createViewportActions(ctx)
|
||||||
const selection = createSelectionActions(ctx)
|
const selection = createSelectionActions(ctx)
|
||||||
const pages = createPageActions(ctx)
|
const pages = createPageActions(ctx)
|
||||||
|
const guides = createGuideActions(ctx)
|
||||||
const shapes = createShapeActions(ctx)
|
const shapes = createShapeActions(ctx)
|
||||||
const structure = createStructureActions(ctx)
|
const structure = createStructureActions(ctx)
|
||||||
const components = createComponentActions(ctx)
|
const components = createComponentActions(ctx)
|
||||||
|
|
@ -207,6 +209,7 @@ export function createEditor(options?: EditorOptions) {
|
||||||
state.hoveredNodeId = null
|
state.hoveredNodeId = null
|
||||||
state.measurementMode = 'off'
|
state.measurementMode = 'off'
|
||||||
state.snapGuides = []
|
state.snapGuides = []
|
||||||
|
state.guides = { preview: null, hovered: null, selected: null }
|
||||||
state.layoutInsertIndicator = null
|
state.layoutInsertIndicator = null
|
||||||
state.dropTargetId = null
|
state.dropTargetId = null
|
||||||
pages.clearPageViewports()
|
pages.clearPageViewports()
|
||||||
|
|
@ -252,6 +255,9 @@ export function createEditor(options?: EditorOptions) {
|
||||||
// Pages
|
// Pages
|
||||||
...pages,
|
...pages,
|
||||||
|
|
||||||
|
// Canvas and frame guides
|
||||||
|
...guides,
|
||||||
|
|
||||||
// Shapes & tools
|
// Shapes & tools
|
||||||
...shapes,
|
...shapes,
|
||||||
|
|
||||||
|
|
|
||||||
101
packages/core/src/editor/guides.ts
Normal file
101
packages/core/src/editor/guides.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ export {
|
||||||
export { createDefaultEditorState, createEditor } from './create'
|
export { createDefaultEditorState, createEditor } from './create'
|
||||||
export type { Editor } from './create'
|
export type { Editor } from './create'
|
||||||
export { reapplyInstanceComponentProperties } from './components/properties'
|
export { reapplyInstanceComponentProperties } from './components/properties'
|
||||||
|
export { createGuideActions } from './guides'
|
||||||
export { createTextActions } from './text'
|
export { createTextActions } from './text'
|
||||||
export { opacityFromBuffer } from './nodes'
|
export { opacityFromBuffer } from './nodes'
|
||||||
export { EDITOR_TOOLS, TOOL_SHORTCUTS } from './tool-registry'
|
export { EDITOR_TOOLS, TOOL_SHORTCUTS } from './tool-registry'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Rect } from '@open-pencil/scene-graph/primitives'
|
import type { Rect } from '@open-pencil/scene-graph/primitives'
|
||||||
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
|
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
|
||||||
|
|
||||||
|
import type { GuidePreview } from '#core/canvas/guides/types'
|
||||||
import type { EditorContext } from '#core/editor/types'
|
import type { EditorContext } from '#core/editor/types'
|
||||||
|
|
||||||
export function createSelectionOverlayActions(ctx: EditorContext) {
|
export function createSelectionOverlayActions(ctx: EditorContext) {
|
||||||
|
|
@ -14,6 +15,24 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
|
||||||
ctx.requestRepaint()
|
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) {
|
function setRotationPreview(preview: { nodeId: string; angle: number } | null) {
|
||||||
ctx.state.rotationPreview = preview
|
ctx.state.rotationPreview = preview
|
||||||
ctx.requestRepaint()
|
ctx.requestRepaint()
|
||||||
|
|
@ -60,6 +79,9 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
|
||||||
return {
|
return {
|
||||||
setMarquee,
|
setMarquee,
|
||||||
setSnapGuides,
|
setSnapGuides,
|
||||||
|
setGuidePreview,
|
||||||
|
setHoveredGuide,
|
||||||
|
setSelectedGuide,
|
||||||
setRotationPreview,
|
setRotationPreview,
|
||||||
setHoveredNode,
|
setHoveredNode,
|
||||||
setMeasurementMode,
|
setMeasurementMode,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { createGuideOverlayState } from '#core/canvas/guides/types'
|
||||||
import { CANVAS_BG_COLOR } from '#core/constants'
|
import { CANVAS_BG_COLOR } from '#core/constants'
|
||||||
import type { EditorState, EditorViewState } from '#core/editor/types'
|
import type { EditorState, EditorViewState } from '#core/editor/types'
|
||||||
|
|
||||||
|
|
@ -7,6 +8,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState {
|
||||||
selectedIds: new Set<string>(),
|
selectedIds: new Set<string>(),
|
||||||
marquee: null,
|
marquee: null,
|
||||||
snapGuides: [],
|
snapGuides: [],
|
||||||
|
guides: createGuideOverlayState(),
|
||||||
rotationPreview: null,
|
rotationPreview: null,
|
||||||
dropTargetId: null,
|
dropTargetId: null,
|
||||||
layoutInsertIndicator: null,
|
layoutInsertIndicator: null,
|
||||||
|
|
@ -35,6 +37,7 @@ export function copyEditorViewState(source: EditorViewState): EditorViewState {
|
||||||
selectedIds: new Set(source.selectedIds),
|
selectedIds: new Set(source.selectedIds),
|
||||||
marquee: structuredClone(source.marquee),
|
marquee: structuredClone(source.marquee),
|
||||||
snapGuides: structuredClone(source.snapGuides),
|
snapGuides: structuredClone(source.snapGuides),
|
||||||
|
guides: structuredClone(source.guides),
|
||||||
rotationPreview: structuredClone(source.rotationPreview),
|
rotationPreview: structuredClone(source.rotationPreview),
|
||||||
layoutInsertIndicator: structuredClone(source.layoutInsertIndicator),
|
layoutInsertIndicator: structuredClone(source.layoutInsertIndicator),
|
||||||
penState: structuredClone(source.penState),
|
penState: structuredClone(source.penState),
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ import type {
|
||||||
VectorSegment,
|
VectorSegment,
|
||||||
VectorVertex
|
VectorVertex
|
||||||
} from '@open-pencil/scene-graph'
|
} 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 { Color, Rect, Vector } from '@open-pencil/scene-graph/primitives'
|
||||||
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
|
import type { SnapGuide } from '@open-pencil/scene-graph/snap'
|
||||||
import type { UndoManager } from '@open-pencil/scene-graph/undo'
|
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 { RulerTheme, SkiaRenderer } from '#core/canvas/renderer'
|
||||||
import type { MeasurementMode, RenderOverlays } from '#core/canvas/renderer/types'
|
import type { MeasurementMode, RenderOverlays } from '#core/canvas/renderer/types'
|
||||||
import type { SnappingPreferences } from '#core/editor/preferences'
|
import type { SnappingPreferences } from '#core/editor/preferences'
|
||||||
|
|
@ -51,6 +53,7 @@ export interface EditorViewState {
|
||||||
selectedIds: Set<string>
|
selectedIds: Set<string>
|
||||||
marquee: Rect | null
|
marquee: Rect | null
|
||||||
snapGuides: SnapGuide[]
|
snapGuides: SnapGuide[]
|
||||||
|
guides: GuideOverlayState
|
||||||
rotationPreview: { nodeId: string; angle: number } | null
|
rotationPreview: { nodeId: string; angle: number } | null
|
||||||
dropTargetId: string | null
|
dropTargetId: string | null
|
||||||
layoutInsertIndicator: {
|
layoutInsertIndicator: {
|
||||||
|
|
@ -114,6 +117,7 @@ export interface EditorEvents extends SceneGraphEvents {
|
||||||
'selection:changed': (selectedIds: string[], previousIds: string[]) => void
|
'selection:changed': (selectedIds: string[], previousIds: string[]) => void
|
||||||
'tool:changed': (tool: Tool, previousTool: Tool) => void
|
'tool:changed': (tool: Tool, previousTool: Tool) => void
|
||||||
'page:changed': (pageId: string, previousPageId: string) => void
|
'page:changed': (pageId: string, previousPageId: string) => void
|
||||||
|
'guides:changed': (ownerId: string, guides: readonly CanvasGuide[]) => void
|
||||||
'clipboard:images-missing': (resolution: ClipboardImageResolution) => void
|
'clipboard:images-missing': (resolution: ClipboardImageResolution) => void
|
||||||
'font:resolution-changed': (event: FontResolutionEvent, snapshot: FontResolutionSnapshot) => void
|
'font:resolution-changed': (event: FontResolutionEvent, snapshot: FontResolutionSnapshot) => void
|
||||||
'viewport:changed': (
|
'viewport:changed': (
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,45 @@
|
||||||
import type { CanvasGuide } from '@open-pencil/scene-graph/guides'
|
import type { CanvasGuide } from '@open-pencil/scene-graph/guides'
|
||||||
|
import type { GUID } from '@open-pencil/scene-graph/primitives'
|
||||||
|
|
||||||
interface FigmaCanvasGuide {
|
interface FigmaCanvasGuide {
|
||||||
axis?: string
|
axis?: string
|
||||||
offset?: number
|
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[] {
|
export function importCanvasGuides(value: unknown): CanvasGuide[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
const guides: CanvasGuide[] = []
|
const guides: CanvasGuide[] = []
|
||||||
for (const raw of value) {
|
for (const [index, raw] of value.entries()) {
|
||||||
if (!raw || typeof raw !== 'object') continue
|
if (!raw || typeof raw !== 'object') continue
|
||||||
const guide = raw as FigmaCanvasGuide
|
const guide = raw as FigmaCanvasGuide
|
||||||
if (typeof guide.offset !== 'number' || !Number.isFinite(guide.offset)) continue
|
if (typeof guide.offset !== 'number' || !Number.isFinite(guide.offset)) continue
|
||||||
if (guide.axis === 'X') guides.push({ axis: 'x', position: guide.offset })
|
const figGuid = isGuid(guide.guid) ? guide.guid : undefined
|
||||||
else if (guide.axis === 'Y') guides.push({ axis: 'y', position: guide.offset })
|
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
|
return guides
|
||||||
}
|
}
|
||||||
|
|
@ -21,6 +47,7 @@ export function importCanvasGuides(value: unknown): CanvasGuide[] {
|
||||||
export function exportCanvasGuides(guides: readonly CanvasGuide[]): FigmaCanvasGuide[] {
|
export function exportCanvasGuides(guides: readonly CanvasGuide[]): FigmaCanvasGuide[] {
|
||||||
return guides.map((guide) => ({
|
return guides.map((guide) => ({
|
||||||
axis: guide.axis === 'x' ? 'X' : 'Y',
|
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'
|
import { parseVariantName } from '@open-pencil/scene-graph/variant-name'
|
||||||
/* eslint-disable max-lines -- kiwi↔scene conversion helpers are tightly coupled */
|
/* eslint-disable max-lines -- kiwi↔scene conversion helpers are tightly coupled */
|
||||||
|
|
||||||
|
import { importCanvasGuides } from './canvas-guides'
|
||||||
import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs'
|
import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs'
|
||||||
import { convertFontFeatures } from './font/features'
|
import { convertFontFeatures } from './font/features'
|
||||||
import { convertFontVariations } from './font/variations'
|
import { convertFontVariations } from './font/variations'
|
||||||
|
|
@ -640,6 +641,7 @@ export function nodeChangeToProps(
|
||||||
),
|
),
|
||||||
effects: convertEffects(nc.effects),
|
effects: convertEffects(nc.effects),
|
||||||
layoutGrids: convertLayoutGrids(nc.layoutGrids),
|
layoutGrids: convertLayoutGrids(nc.layoutGrids),
|
||||||
|
guides: importCanvasGuides(nc.guides),
|
||||||
fillStyleId: styleRefId(nc.styleIdForFill),
|
fillStyleId: styleRefId(nc.styleIdForFill),
|
||||||
strokeStyleId: styleRefId(nc.styleIdForStrokeFill),
|
strokeStyleId: styleRefId(nc.styleIdForStrokeFill),
|
||||||
textStyleId: styleRefId(nc.styleIdForText),
|
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'
|
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '../source-metadata'
|
||||||
/* eslint-disable max-lines */
|
/* eslint-disable max-lines */
|
||||||
import { bytesToHex } from './bytes'
|
import { bytesToHex } from './bytes'
|
||||||
|
import { exportCanvasGuides } from './canvas-guides'
|
||||||
import {
|
import {
|
||||||
applyExportSettingsPluginData,
|
applyExportSettingsPluginData,
|
||||||
applyLibrarySourcePluginData,
|
applyLibrarySourcePluginData,
|
||||||
|
|
@ -839,6 +840,7 @@ function applySharedStyleProps(node: SceneNode, nc: KiwiNodeChange): void {
|
||||||
if (node.effectStyleId) nc.styleIdForEffect = { guid: stringToGuid(node.effectStyleId) }
|
if (node.effectStyleId) nc.styleIdForEffect = { guid: stringToGuid(node.effectStyleId) }
|
||||||
if (node.gridStyleId) nc.styleIdForGrid = { guid: stringToGuid(node.gridStyleId) }
|
if (node.gridStyleId) nc.styleIdForGrid = { guid: stringToGuid(node.gridStyleId) }
|
||||||
if (node.layoutGrids.length > 0) nc.layoutGrids = structuredClone(node.layoutGrids)
|
if (node.layoutGrids.length > 0) nc.layoutGrids = structuredClone(node.layoutGrids)
|
||||||
|
if (node.guides.length > 0) nc.guides = exportCanvasGuides(node.guides)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyNodeVisualProps(
|
function applyNodeVisualProps(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const EDITED_RAW_FIELDS: Partial<Record<string, readonly string[]>> = {
|
||||||
strokes: ['strokePaints'],
|
strokes: ['strokePaints'],
|
||||||
effects: ['effects'],
|
effects: ['effects'],
|
||||||
layoutGrids: ['layoutGrids'],
|
layoutGrids: ['layoutGrids'],
|
||||||
|
guides: ['guides'],
|
||||||
exportSettings: ['exportSettings'],
|
exportSettings: ['exportSettings'],
|
||||||
cornerRadius: ['cornerRadius'],
|
cornerRadius: ['cornerRadius'],
|
||||||
independentCorners: ['rectangleCornerRadiiIndependent'],
|
independentCorners: ['rectangleCornerRadiiIndependent'],
|
||||||
|
|
|
||||||
31
packages/fig/tests/canvas-guides.test.ts
Normal file
31
packages/fig/tests/canvas-guides.test.ts
Normal 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 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
|
import type { GUID } from './primitives'
|
||||||
|
|
||||||
export interface CanvasGuide {
|
export interface CanvasGuide {
|
||||||
|
id: string
|
||||||
axis: 'x' | 'y'
|
axis: 'x' | 'y'
|
||||||
position: number
|
position: number
|
||||||
|
figGuid?: GUID
|
||||||
}
|
}
|
||||||
|
|
|
||||||
182
packages/vue/src/canvas/guides/input.ts
Normal file
182
packages/vue/src/canvas/guides/input.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
handleBendHandleMove,
|
handleBendHandleMove,
|
||||||
resolveBendTargetHandle,
|
resolveBendTargetHandle,
|
||||||
type CanvasNodeEditMethods
|
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 { hitTestEditHandle, isEndpoint, NODE_HIT_THRESHOLD } from '#vue/shared/input/node-edit'
|
||||||
import type { DragState } from '#vue/shared/input/types'
|
import type { DragState } from '#vue/shared/input/types'
|
||||||
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Ref } from 'vue'
|
||||||
import { PEN_CLOSE_THRESHOLD } from '@open-pencil/core/constants'
|
import { PEN_CLOSE_THRESHOLD } from '@open-pencil/core/constants'
|
||||||
import type { Editor } from '@open-pencil/core/editor'
|
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 { handlePenNodeEditDown } from '#vue/shared/input/node-edit'
|
||||||
import type { DragState } from '#vue/shared/input/types'
|
import type { DragState } from '#vue/shared/input/types'
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { handleToolMouseDown, startPanDrag } from './use'
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { Ref } from 'vue'
|
||||||
|
|
||||||
import type { Editor } from '@open-pencil/core/editor'
|
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 { startShapeDraw, startTextDraw } from '#vue/shared/input/draw'
|
||||||
import { startPanDrag } from '#vue/shared/input/pan'
|
import { startPanDrag } from '#vue/shared/input/pan'
|
||||||
import { handleSelectDown } from '#vue/shared/input/select'
|
import { handleSelectDown } from '#vue/shared/input/select'
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import type { Editor } from '@open-pencil/core/editor'
|
import type { Editor } from '@open-pencil/core/editor'
|
||||||
|
|
||||||
import { handleMarqueeMove as handleMarqueeMoveAction } from '#vue/canvas/transform-input/marquee'
|
import { handleMarqueeMove as handleMarqueeMoveAction } from '#vue/canvas/transform/marquee'
|
||||||
import { handlePanMove as handlePanMoveAction } from '#vue/canvas/transform-input/pan'
|
import { handlePanMove as handlePanMoveAction } from '#vue/canvas/transform/pan'
|
||||||
import {
|
import {
|
||||||
handleRotateMove as handleRotateMoveAction,
|
handleRotateMove as handleRotateMoveAction,
|
||||||
tryStartRotation as tryStartRotationAction
|
tryStartRotation as tryStartRotationAction
|
||||||
} from '#vue/canvas/transform-input/rotation'
|
} from '#vue/canvas/transform/rotation'
|
||||||
import { handleTextSelectMove as handleTextSelectMoveAction } from '#vue/canvas/transform-input/text-selection'
|
import { handleTextSelectMove as handleTextSelectMoveAction } from '#vue/canvas/transform/text-selection'
|
||||||
import type { DragMarquee, DragPan, DragRotate, DragState } from '#vue/shared/input/types'
|
import type { DragMarquee, DragPan, DragRotate, DragState } from '#vue/shared/input/types'
|
||||||
|
|
||||||
type CanvasToLocal = (cx: number, cy: number, scopeId: string) => { lx: number; ly: number }
|
type CanvasToLocal = (cx: number, cy: number, scopeId: string) => { lx: number; ly: number }
|
||||||
|
|
@ -1 +1 @@
|
||||||
export { createTransformInputActions as createCanvasTransformInput } from '#vue/canvas/transform-input/actions'
|
export { createTransformInputActions as createCanvasTransformInput } from '#vue/canvas/transform/actions'
|
||||||
|
|
@ -4,16 +4,17 @@ import { onScopeDispose, ref, type Ref } from 'vue'
|
||||||
import type { Editor } from '@open-pencil/core/editor'
|
import type { Editor } from '@open-pencil/core/editor'
|
||||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||||
|
|
||||||
|
import { createGuideInput } from '#vue/canvas/guides/input'
|
||||||
import {
|
import {
|
||||||
handleBendHandleMove,
|
handleBendHandleMove,
|
||||||
handleNodeEditMouseUp,
|
handleNodeEditMouseUp,
|
||||||
updateNodeEditHover
|
updateNodeEditHover
|
||||||
} from '#vue/canvas/node-edit-input/use'
|
} from '#vue/canvas/node-edit/input'
|
||||||
import { handlePenDragMove, updatePenHover } from '#vue/canvas/pen-input/use'
|
import { handlePenDragMove, updatePenHover } from '#vue/canvas/pen/input'
|
||||||
import { createCanvasPointer } from '#vue/canvas/pointer/use'
|
import { createCanvasPointer } from '#vue/canvas/pointer/use'
|
||||||
import { createTextEditInput } from '#vue/canvas/text-edit/input'
|
import { createTextEditInput } from '#vue/canvas/text-edit/input'
|
||||||
import { handleToolMouseDown } from '#vue/canvas/tool-input/use'
|
import { handleToolMouseDown } from '#vue/canvas/tools/input'
|
||||||
import { createCanvasTransformInput } from '#vue/canvas/transform-input/use'
|
import { createCanvasTransformInput } from '#vue/canvas/transform/input'
|
||||||
import { resolveAutoLayoutHover } from '#vue/shared/input/auto-layout-hover'
|
import { resolveAutoLayoutHover } from '#vue/shared/input/auto-layout-hover'
|
||||||
import { createClickCounter } from '#vue/shared/input/click-count'
|
import { createClickCounter } from '#vue/shared/input/click-count'
|
||||||
import { handleDrawMove, handleDrawUp } from '#vue/shared/input/draw'
|
import { handleDrawMove, handleDrawUp } from '#vue/shared/input/draw'
|
||||||
|
|
@ -119,6 +120,16 @@ export function useCanvasInput(
|
||||||
drag.value = d
|
drag.value = d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const guideInput = createGuideInput({
|
||||||
|
canvasRef,
|
||||||
|
editor,
|
||||||
|
canvasToLocal,
|
||||||
|
setDrag,
|
||||||
|
setCursor: (cursor) => {
|
||||||
|
cursorOverride.value = cursor
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const { handleTextEditClick, onDblClick: onTextDblClick } = createTextEditInput({
|
const { handleTextEditClick, onDblClick: onTextDblClick } = createTextEditInput({
|
||||||
editor,
|
editor,
|
||||||
getCoords,
|
getCoords,
|
||||||
|
|
@ -213,6 +224,15 @@ export function useCanvasInput(
|
||||||
if (!editor.state.editingTextId) canvasRef.value?.focus()
|
if (!editor.state.editingTextId) canvasRef.value?.focus()
|
||||||
editor.setHoveredNode(null)
|
editor.setHoveredNode(null)
|
||||||
const { sx, sy, cx, cy } = getCoords(e)
|
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 selectedIdsBeforeMouseDown = new Set(editor.state.selectedIds)
|
||||||
const clickCount = recordClick(sx, sy)
|
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) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
if (!isEnabled()) return
|
if (!isEnabled()) return
|
||||||
pointerInside.value = true
|
pointerInside.value = true
|
||||||
|
|
@ -252,14 +274,11 @@ export function useCanvasInput(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!drag.value && editor.state.activeTool === 'SELECT') {
|
if (!drag.value && editor.state.activeTool === 'SELECT') {
|
||||||
const { cx, cy } = coords
|
const { sx, sy, cx, cy } = coords
|
||||||
cursorOverride.value = updateHoverCursor(
|
const guideCursor = guideInput.updateHover(sx, sy)
|
||||||
cx,
|
cursorOverride.value =
|
||||||
cy,
|
guideCursor ??
|
||||||
editor,
|
updateHoverCursor(cx, cy, editor, hitFns, editor.state.measurementMode === 'deep')
|
||||||
hitFns,
|
|
||||||
editor.state.measurementMode === 'deep'
|
|
||||||
)
|
|
||||||
editor.setAutoLayoutHover(
|
editor.setAutoLayoutHover(
|
||||||
editor.state.measurementMode === 'off' ? resolveAutoLayoutHover(cx, cy, editor) : null
|
editor.state.measurementMode === 'off' ? resolveAutoLayoutHover(cx, cy, editor) : null
|
||||||
)
|
)
|
||||||
|
|
@ -275,6 +294,11 @@ export function useCanvasInput(
|
||||||
|
|
||||||
const { sx, sy, cx, cy } = getCoords(e)
|
const { sx, sy, cx, cy } = getCoords(e)
|
||||||
|
|
||||||
|
if (d.type === 'guide') {
|
||||||
|
guideInput.handleMove(d, sx, sy, cx, cy)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (d.type === 'rotate') {
|
if (d.type === 'rotate') {
|
||||||
handleRotateMove(d, cx, cy, e.shiftKey)
|
handleRotateMove(d, cx, cy, e.shiftKey)
|
||||||
return
|
return
|
||||||
|
|
@ -322,7 +346,9 @@ export function useCanvasInput(
|
||||||
|
|
||||||
if (handleNodeEditMouseUp(drag, editor)) return
|
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') {
|
else if (d.type === 'text-select') {
|
||||||
drag.value = null
|
drag.value = null
|
||||||
return
|
return
|
||||||
|
|
@ -357,6 +383,7 @@ export function useCanvasInput(
|
||||||
editor.setSnapGuides([])
|
editor.setSnapGuides([])
|
||||||
editor.setLayoutInsertIndicator(null)
|
editor.setLayoutInsertIndicator(null)
|
||||||
editor.setDropTarget(null)
|
editor.setDropTarget(null)
|
||||||
|
guideInput.clearHoverAndPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelPointerInteraction() {
|
function cancelPointerInteraction() {
|
||||||
|
|
@ -369,7 +396,9 @@ export function useCanvasInput(
|
||||||
useEventListener(canvasRef, 'mousedown', onMouseDown)
|
useEventListener(canvasRef, 'mousedown', onMouseDown)
|
||||||
useEventListener(canvasRef, 'mousemove', onMouseMove)
|
useEventListener(canvasRef, 'mousemove', onMouseMove)
|
||||||
useEventListener(canvasRef, 'mouseup', onMouseUp)
|
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, 'keyup', (event) => updateModifier(event.code, false))
|
||||||
useEventListener(window, 'blur', () => {
|
useEventListener(window, 'blur', () => {
|
||||||
resetMeasurementModifiers()
|
resetMeasurementModifiers()
|
||||||
|
|
@ -381,6 +410,7 @@ export function useCanvasInput(
|
||||||
editor.setMeasurementMode('off')
|
editor.setMeasurementMode('off')
|
||||||
if (!drag.value) {
|
if (!drag.value) {
|
||||||
editor.setHoveredNode(null)
|
editor.setHoveredNode(null)
|
||||||
|
editor.setHoveredGuide(null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
useEventListener(
|
useEventListener(
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,21 @@ export interface DragBendHandle {
|
||||||
targetTangentField: 'tangentStart' | 'tangentEnd' | null
|
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 =
|
export type DragState =
|
||||||
| DragDraw
|
| DragDraw
|
||||||
| DragMove
|
| DragMove
|
||||||
|
|
@ -140,6 +155,7 @@ export type DragState =
|
||||||
| DragEditNode
|
| DragEditNode
|
||||||
| DragEditHandle
|
| DragEditHandle
|
||||||
| DragBendHandle
|
| DragBendHandle
|
||||||
|
| DragGuide
|
||||||
|
|
||||||
export const TOOL_TO_NODE: Partial<Record<Tool, NodeType>> = {
|
export const TOOL_TO_NODE: Partial<Record<Tool, NodeType>> = {
|
||||||
FRAME: 'FRAME',
|
FRAME: 'FRAME',
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export function cloneCanvasPaneState(id: string, source: CanvasPaneState): Canva
|
||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
marquee: null,
|
marquee: null,
|
||||||
snapGuides: [],
|
snapGuides: [],
|
||||||
|
guides: { preview: null, hovered: null, selected: null },
|
||||||
rotationPreview: null,
|
rotationPreview: null,
|
||||||
dropTargetId: null,
|
dropTargetId: null,
|
||||||
layoutInsertIndicator: 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -44,8 +44,8 @@ describe('fig roundtrip source metadata', () => {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
page.guides = [
|
page.guides = [
|
||||||
{ axis: 'x', position: 42 },
|
{ id: 'x', axis: 'x', position: 42 },
|
||||||
{ axis: 'y', position: 84 }
|
{ id: 'y', axis: 'y', position: 84 }
|
||||||
]
|
]
|
||||||
page.source.fig.rawNodeFields.strokeJoin = 'BEVEL'
|
page.source.fig.rawNodeFields.strokeJoin = 'BEVEL'
|
||||||
page.source.fig.rawNodeFields.strokeWeight = 0
|
page.source.fig.rawNodeFields.strokeWeight = 0
|
||||||
|
|
|
||||||
101
tests/engine/render/canvas/guides/draw.test.ts
Normal file
101
tests/engine/render/canvas/guides/draw.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
53
tests/engine/render/canvas/guides/geometry.test.ts
Normal file
53
tests/engine/render/canvas/guides/geometry.test.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
59
tests/engine/vue/input/guides.test.ts
Normal file
59
tests/engine/vue/input/guides.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue