-
-
-
+
+
+
+
{{ colorHex(fill.color) }}
+
-
+
+
+
+ {{ colorHex(stroke.color) }}
+
+
+
@@ -222,6 +340,12 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
border-bottom: 1px solid var(--border);
}
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
.section-label {
display: block;
font-size: 11px;
@@ -229,6 +353,22 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
margin-bottom: 6px;
}
+.section-add {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 0 4px;
+ line-height: 1;
+ border-radius: 4px;
+}
+
+.section-add:hover {
+ background: var(--hover);
+ color: var(--text);
+}
+
.node-header {
display: flex;
align-items: center;
@@ -296,20 +436,64 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
.fill-row {
display: flex;
align-items: center;
- gap: 8px;
- padding: 2px 0;
+ gap: 6px;
+ padding: 3px 0;
}
-.color-swatch {
- width: 20px;
- height: 20px;
- border-radius: 4px;
- border: 1px solid var(--border);
- flex-shrink: 0;
+.visibility-toggle {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 0;
+ width: 16px;
+ text-align: center;
+}
+
+.visibility-toggle.hidden {
+ opacity: 0.4;
}
.color-hex {
font-size: 12px;
font-family: monospace;
+ flex: 1;
+}
+
+.remove-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 14px;
+ padding: 0 2px;
+ line-height: 1;
+ opacity: 0;
+ transition: opacity 0.1s;
+}
+
+.fill-row:hover .remove-btn {
+ opacity: 1;
+}
+
+.remove-btn:hover {
+ color: var(--text);
+}
+
+.stroke-weight {
+ width: 36px;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ padding: 2px 4px;
+ font: inherit;
+ font-size: 11px;
+ text-align: center;
+}
+
+.stroke-weight::-webkit-inner-spin-button {
+ display: none;
}
diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts
index e0f9715ed..b9299340c 100644
--- a/src/composables/use-canvas-input.ts
+++ b/src/composables/use-canvas-input.ts
@@ -1,6 +1,8 @@
import { useEventListener } from '@vueuse/core'
import { ref, type Ref } from 'vue'
+import { computeSelectionBounds, computeSnap } from '../engine/snap'
+
import type { NodeType, SceneNode } from '../engine/scene-graph'
import type { EditorStore, Tool } from '../stores/editor'
@@ -44,16 +46,27 @@ interface DragMarquee {
startY: number
}
-type DragState = DragDraw | DragMove | DragPan | DragResize | DragMarquee
+interface DragRotate {
+ type: 'rotate'
+ nodeId: string
+ centerX: number
+ centerY: number
+ startAngle: number
+ origRotation: number
+}
+
+type DragState = DragDraw | DragMove | DragPan | DragResize | DragMarquee | DragRotate
const TOOL_TO_NODE: Partial
> = {
FRAME: 'FRAME',
RECTANGLE: 'RECTANGLE',
ELLIPSE: 'ELLIPSE',
- LINE: 'LINE'
+ LINE: 'LINE',
+ TEXT: 'TEXT'
}
const HANDLE_HIT_RADIUS = 6
+const ROTATION_HIT_RADIUS = 8
const HANDLE_CURSORS: Record = {
nw: 'nwse-resize',
@@ -66,11 +79,17 @@ const HANDLE_CURSORS: Record = {
w: 'ew-resize'
}
+function getScreenRect(node: SceneNode, zoom: number, panX: number, panY: number) {
+ return {
+ x1: node.x * zoom + panX,
+ y1: node.y * zoom + panY,
+ x2: (node.x + node.width) * zoom + panX,
+ y2: (node.y + node.height) * zoom + panY
+ }
+}
+
function getHandlePositions(node: SceneNode, zoom: number, panX: number, panY: number) {
- const x1 = node.x * zoom + panX
- const y1 = node.y * zoom + panY
- const x2 = (node.x + node.width) * zoom + panX
- const y2 = (node.y + node.height) * zoom + panY
+ const { x1, y1, x2, y2 } = getScreenRect(node, zoom, panX, panY)
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2
@@ -103,6 +122,20 @@ function hitTestHandle(
return null
}
+function hitTestRotationHandle(
+ sx: number,
+ sy: number,
+ node: SceneNode,
+ zoom: number,
+ panX: number,
+ panY: number
+): boolean {
+ const { x1, x2, y1 } = getScreenRect(node, zoom, panX, panY)
+ const mx = (x1 + x2) / 2
+ const rotY = y1 - 24
+ return Math.abs(sx - mx) < ROTATION_HIT_RADIUS && Math.abs(sy - rotY) < ROTATION_HIT_RADIUS
+}
+
export function useCanvasInput(canvasRef: Ref, store: EditorStore) {
const drag = ref(null)
const cursorOverride = ref(null)
@@ -121,7 +154,6 @@ export function useCanvasInput(canvasRef: Ref, store:
const { sx, sy, cx, cy } = getCoords(e)
const tool = store.state.activeTool
- // Middle mouse or Hand tool → pan
if (e.button === 1 || tool === 'HAND') {
drag.value = {
type: 'pan',
@@ -133,7 +165,6 @@ export function useCanvasInput(canvasRef: Ref, store:
return
}
- // Alt+click with SELECT → pan
if (tool === 'SELECT' && e.altKey && !store.state.selectedIds.size) {
drag.value = {
type: 'pan',
@@ -146,7 +177,30 @@ export function useCanvasInput(canvasRef: Ref, store:
}
if (tool === 'SELECT') {
- // Check resize handles first
+ // Check rotation handle (single selection only)
+ if (store.state.selectedIds.size === 1) {
+ const id = [...store.state.selectedIds][0]
+ const node = store.graph.getNode(id)
+ if (
+ node &&
+ hitTestRotationHandle(sx, sy, node, store.state.zoom, store.state.panX, store.state.panY)
+ ) {
+ const screenCx = (node.x + node.width / 2) * store.state.zoom + store.state.panX
+ const screenCy = (node.y + node.height / 2) * store.state.zoom + store.state.panY
+ const startAngle = Math.atan2(sy - screenCy, sx - screenCx) * (180 / Math.PI)
+ drag.value = {
+ type: 'rotate',
+ nodeId: id,
+ centerX: screenCx,
+ centerY: screenCy,
+ startAngle,
+ origRotation: node.rotation
+ }
+ return
+ }
+ }
+
+ // Check resize handles
for (const id of store.state.selectedIds) {
const node = store.graph.getNode(id)
if (!node) continue
@@ -186,7 +240,7 @@ export function useCanvasInput(canvasRef: Ref, store:
if (n) originals.set(id, { x: n.x, y: n.y })
}
- // Alt+drag selected → duplicate
+ // Alt+drag → duplicate
if (e.altKey && store.state.selectedIds.size > 0) {
const newIds: string[] = []
const newOriginals = new Map()
@@ -220,13 +274,22 @@ export function useCanvasInput(canvasRef: Ref, store:
drag.value = { type: 'move', startX: cx, startY: cy, originals }
} else {
- // Marquee selection
store.clearSelection()
drag.value = { type: 'marquee', startX: cx, startY: cy }
}
return
}
+ // Text tool: click to create text node
+ if (tool === 'TEXT') {
+ const nodeId = store.createShape('TEXT', cx, cy, 200, 24)
+ store.graph.updateNode(nodeId, { text: 'Text' })
+ store.select([nodeId])
+ store.setTool('SELECT')
+ store.requestRender()
+ return
+ }
+
// Shape creation
const nodeType = TOOL_TO_NODE[tool]
if (!nodeType) return
@@ -238,24 +301,40 @@ export function useCanvasInput(canvasRef: Ref, store:
}
function onMouseMove(e: MouseEvent) {
- // Cursor changes on hover (when not dragging)
+ // Cursor on hover
if (!drag.value && store.state.activeTool === 'SELECT') {
const { sx, sy } = getCoords(e)
let cursor: string | null = null
- for (const id of store.state.selectedIds) {
+
+ // Rotation handle cursor
+ if (store.state.selectedIds.size === 1) {
+ const id = [...store.state.selectedIds][0]
const node = store.graph.getNode(id)
- if (!node) continue
- const handle = hitTestHandle(
- sx,
- sy,
- node,
- store.state.zoom,
- store.state.panX,
- store.state.panY
- )
- if (handle) {
- cursor = HANDLE_CURSORS[handle]
- break
+ if (
+ node &&
+ hitTestRotationHandle(sx, sy, node, store.state.zoom, store.state.panX, store.state.panY)
+ ) {
+ cursor =
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Cpath d='M21 2v6h-6'/%3E%3Cpath d='M21 13a9 9 0 1 1-3-7.7L21 8'/%3E%3C/svg%3E\") 12 12, pointer"
+ }
+ }
+
+ if (!cursor) {
+ for (const id of store.state.selectedIds) {
+ const node = store.graph.getNode(id)
+ if (!node) continue
+ const handle = hitTestHandle(
+ sx,
+ sy,
+ node,
+ store.state.zoom,
+ store.state.panX,
+ store.state.panY
+ )
+ if (handle) {
+ cursor = HANDLE_CURSORS[handle]
+ break
+ }
}
}
cursorOverride.value = cursor
@@ -273,11 +352,50 @@ export function useCanvasInput(canvasRef: Ref, store:
return
}
- const { cx, cy } = getCoords(e)
+ const { cx, cy, sx, sy } = getCoords(e)
+
+ if (d.type === 'rotate') {
+ const currentAngle = Math.atan2(sy - d.centerY, sx - d.centerX) * (180 / Math.PI)
+ let rotation = d.origRotation + (currentAngle - d.startAngle)
+
+ // Shift → snap to 15° increments
+ if (e.shiftKey) {
+ rotation = Math.round(rotation / 15) * 15
+ }
+
+ // Normalize to -180..180
+ rotation = ((((rotation + 180) % 360) + 360) % 360) - 180
+
+ store.setRotationPreview({ nodeId: d.nodeId, angle: rotation })
+ return
+ }
if (d.type === 'move') {
- const dx = cx - d.startX
- const dy = cy - d.startY
+ let dx = cx - d.startX
+ let dy = cy - d.startY
+
+ // Compute snap
+ const selectedNodes: SceneNode[] = []
+ for (const [id, orig] of d.originals) {
+ const n = store.graph.getNode(id)
+ if (n) {
+ selectedNodes.push({
+ ...n,
+ x: orig.x + dx,
+ y: orig.y + dy
+ })
+ }
+ }
+
+ const bounds = computeSelectionBounds(selectedNodes)
+ if (bounds) {
+ const allNodes = store.graph.getChildren(store.graph.rootId)
+ const snap = computeSnap(store.state.selectedIds, bounds, allNodes)
+ dx += snap.dx
+ dy += snap.dy
+ store.setSnapGuides(snap.guides)
+ }
+
for (const [id, orig] of d.originals) {
store.updateNode(id, { x: Math.round(orig.x + dx), y: Math.round(orig.y + dy) })
}
@@ -293,7 +411,6 @@ export function useCanvasInput(canvasRef: Ref, store:
let w = cx - d.startX
let h = cy - d.startY
- // Shift → constrain to square
if (e.shiftKey) {
const size = Math.max(Math.abs(w), Math.abs(h))
w = Math.sign(w) * size
@@ -337,7 +454,6 @@ export function useCanvasInput(canvasRef: Ref, store:
const dx = cx - d.startX
const dy = cy - d.startY
- // Which edges move
const moveLeft = handle.includes('w')
const moveRight = handle.includes('e')
const moveTop = handle === 'nw' || handle === 'n' || handle === 'ne'
@@ -354,7 +470,6 @@ export function useCanvasInput(canvasRef: Ref, store:
height = origRect.height - dy
}
- // Shift → maintain aspect ratio
if (constrain && origRect.width > 0 && origRect.height > 0) {
const aspect = origRect.width / origRect.height
if (handle === 'n' || handle === 's') {
@@ -374,7 +489,6 @@ export function useCanvasInput(canvasRef: Ref, store:
}
}
- // Prevent negative sizes — flip
if (width < 0) {
x = x + width
width = -width
@@ -398,10 +512,15 @@ export function useCanvasInput(canvasRef: Ref, store:
if (d.type === 'move') {
store.commitMove(d.originals)
+ store.setSnapGuides([])
}
- if (d.type === 'resize') {
- // TODO: commit resize to undo stack
+ if (d.type === 'rotate') {
+ const preview = store.state.rotationPreview
+ if (preview) {
+ store.updateNode(d.nodeId, { rotation: preview.angle })
+ }
+ store.setRotationPreview(null)
}
if (d.type === 'draw') {
diff --git a/src/composables/use-canvas.ts b/src/composables/use-canvas.ts
index cf966d448..3c52fccfd 100644
--- a/src/composables/use-canvas.ts
+++ b/src/composables/use-canvas.ts
@@ -51,7 +51,11 @@ export function useCanvas(canvasRef: Ref, store: Edito
renderer.panX = store.state.panX
renderer.panY = store.state.panY
renderer.zoom = store.state.zoom
- renderer.render(store.graph, store.state.selectedIds, store.state.marquee)
+ renderer.render(store.graph, store.state.selectedIds, {
+ marquee: store.state.marquee,
+ snapGuides: store.state.snapGuides,
+ rotationPreview: store.state.rotationPreview
+ })
}
onMounted(() => {
diff --git a/src/engine/renderer.ts b/src/engine/renderer.ts
index 1d0fe30e9..66cde84c1 100644
--- a/src/engine/renderer.ts
+++ b/src/engine/renderer.ts
@@ -1,5 +1,12 @@
import type { SceneNode, SceneGraph, Fill } from './scene-graph'
-import type { CanvasKit, Surface, Canvas, Paint } from 'canvaskit-wasm'
+import type { SnapGuide } from './snap'
+import type { CanvasKit, Surface, Canvas, Paint, Font } from 'canvaskit-wasm'
+
+export interface RenderOverlays {
+ marquee?: { x: number; y: number; width: number; height: number } | null
+ snapGuides?: SnapGuide[]
+ rotationPreview?: { nodeId: string; angle: number } | null
+}
export class SkiaRenderer {
private ck: CanvasKit
@@ -7,8 +14,9 @@ export class SkiaRenderer {
private fillPaint: Paint
private strokePaint: Paint
private selectionPaint: Paint
+ private snapPaint: Paint
+ private textFont: Font | null = null
- // Viewport (in CSS pixels — renderer multiplies by dpr)
panX = 0
panY = 0
zoom = 1
@@ -31,80 +39,221 @@ export class SkiaRenderer {
this.selectionPaint.setStrokeWidth(1)
this.selectionPaint.setColor(ck.Color4f(0.23, 0.51, 0.96, 1.0))
this.selectionPaint.setAntiAlias(true)
+
+ this.snapPaint = new ck.Paint()
+ this.snapPaint.setStyle(ck.PaintStyle.Stroke)
+ this.snapPaint.setStrokeWidth(1)
+ this.snapPaint.setColor(ck.Color4f(1.0, 0.0, 0.56, 1.0))
+ this.snapPaint.setAntiAlias(true)
+
+ this.textFont = new ck.Font(null, 14)
}
- render(
- graph: SceneGraph,
- selectedIds: Set,
- marquee?: { x: number; y: number; width: number; height: number } | null
- ): void {
+ render(graph: SceneGraph, selectedIds: Set, overlays: RenderOverlays = {}): void {
const canvas = this.surface.getCanvas()
canvas.clear(this.ck.Color4f(0.96, 0.96, 0.96, 1.0))
+ // Scene layer (world coordinates)
canvas.save()
canvas.scale(this.dpr, this.dpr)
canvas.translate(this.panX, this.panY)
canvas.scale(this.zoom, this.zoom)
- // Render all visible nodes
const root = graph.getNode(graph.rootId)
if (root) {
for (const childId of root.childIds) {
- this.renderNode(canvas, graph, childId)
+ this.renderNode(canvas, graph, childId, overlays.rotationPreview)
}
}
canvas.restore()
- // Selection outlines in screen space (after dpr, before zoom — constant 1px CSS)
+ // UI overlay layer (screen coordinates, zoom-independent)
canvas.save()
canvas.scale(this.dpr, this.dpr)
- this.selectionPaint.setStrokeWidth(1)
- for (const id of selectedIds) {
- const node = graph.getNode(id)
- if (!node) continue
-
- const x1 = node.x * this.zoom + this.panX
- const y1 = node.y * this.zoom + this.panY
- const x2 = (node.x + node.width) * this.zoom + this.panX
- const y2 = (node.y + node.height) * this.zoom + this.panY
-
- const rect = this.ck.LTRBRect(x1, y1, x2, y2)
- canvas.drawRect(rect, this.selectionPaint)
-
- const mx = (x1 + x2) / 2
- const my = (y1 + y2) / 2
- this.drawHandle(canvas, x1, y1)
- this.drawHandle(canvas, x2, y1)
- this.drawHandle(canvas, x1, y2)
- this.drawHandle(canvas, x2, y2)
- this.drawHandle(canvas, mx, y1)
- this.drawHandle(canvas, mx, y2)
- this.drawHandle(canvas, x1, my)
- this.drawHandle(canvas, x2, my)
- }
-
- // Marquee selection rectangle
- if (marquee && marquee.width > 0 && marquee.height > 0) {
- const mx1 = marquee.x * this.zoom + this.panX
- const my1 = marquee.y * this.zoom + this.panY
- const mx2 = (marquee.x + marquee.width) * this.zoom + this.panX
- const my2 = (marquee.y + marquee.height) * this.zoom + this.panY
- const mRect = this.ck.LTRBRect(mx1, my1, mx2, my2)
-
- const marqueeFill = new this.ck.Paint()
- marqueeFill.setStyle(this.ck.PaintStyle.Fill)
- marqueeFill.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.08))
- canvas.drawRect(mRect, marqueeFill)
- canvas.drawRect(mRect, this.selectionPaint)
- marqueeFill.delete()
- }
+ this.drawSelection(canvas, graph, selectedIds, overlays.rotationPreview)
+ this.drawSnapGuides(canvas, overlays.snapGuides)
+ this.drawMarquee(canvas, overlays.marquee)
canvas.restore()
this.surface.flush()
}
+ // --- Selection UI ---
+
+ private drawSelection(
+ canvas: Canvas,
+ graph: SceneGraph,
+ selectedIds: Set,
+ rotationPreview?: { nodeId: string; angle: number } | null
+ ): void {
+ if (selectedIds.size === 0) return
+
+ this.selectionPaint.setStrokeWidth(1)
+
+ if (selectedIds.size === 1) {
+ const id = [...selectedIds][0]
+ const node = graph.getNode(id)
+ if (!node) return
+
+ const rotation = rotationPreview?.nodeId === id ? rotationPreview.angle : node.rotation
+ this.drawNodeSelection(canvas, node, rotation)
+ return
+ }
+
+ // Multi-select: individual outlines + unified bounding box
+ for (const id of selectedIds) {
+ const node = graph.getNode(id)
+ if (!node) continue
+ const rotation = rotationPreview?.nodeId === id ? rotationPreview.angle : node.rotation
+ this.drawNodeOutline(canvas, node, rotation)
+ }
+
+ // Unified bounding box
+ const nodes = [...selectedIds]
+ .map((id) => graph.getNode(id))
+ .filter((n): n is SceneNode => n !== undefined)
+ this.drawGroupBounds(canvas, nodes)
+ }
+
+ private drawNodeSelection(canvas: Canvas, node: SceneNode, rotation: number): void {
+ const cx = (node.x + node.width / 2) * this.zoom + this.panX
+ const cy = (node.y + node.height / 2) * this.zoom + this.panY
+ const hw = (node.width / 2) * this.zoom
+ const hh = (node.height / 2) * this.zoom
+
+ canvas.save()
+ if (rotation !== 0) {
+ canvas.rotate(rotation, cx, cy)
+ }
+
+ const x1 = cx - hw
+ const y1 = cy - hh
+ const x2 = cx + hw
+ const y2 = cy + hh
+
+ canvas.drawRect(this.ck.LTRBRect(x1, y1, x2, y2), this.selectionPaint)
+
+ // Corner handles
+ this.drawHandle(canvas, x1, y1)
+ this.drawHandle(canvas, x2, y1)
+ this.drawHandle(canvas, x1, y2)
+ this.drawHandle(canvas, x2, y2)
+
+ // Edge handles
+ const mx = (x1 + x2) / 2
+ const my = (y1 + y2) / 2
+ this.drawHandle(canvas, mx, y1)
+ this.drawHandle(canvas, mx, y2)
+ this.drawHandle(canvas, x1, my)
+ this.drawHandle(canvas, x2, my)
+
+ // Rotation handle (line extending above + circle)
+ const rotHandleY = y1 - 24
+ const rotLinePaint = new this.ck.Paint()
+ rotLinePaint.setStyle(this.ck.PaintStyle.Stroke)
+ rotLinePaint.setStrokeWidth(1)
+ rotLinePaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
+ rotLinePaint.setAntiAlias(true)
+ canvas.drawLine(mx, y1, mx, rotHandleY, rotLinePaint)
+
+ const rotFill = new this.ck.Paint()
+ rotFill.setStyle(this.ck.PaintStyle.Fill)
+ rotFill.setColor(this.ck.WHITE)
+ rotFill.setAntiAlias(true)
+ canvas.drawCircle(mx, rotHandleY, 4, rotFill)
+ canvas.drawCircle(mx, rotHandleY, 4, rotLinePaint)
+ rotLinePaint.delete()
+ rotFill.delete()
+
+ canvas.restore()
+ }
+
+ private drawNodeOutline(canvas: Canvas, node: SceneNode, rotation: number): void {
+ const cx = (node.x + node.width / 2) * this.zoom + this.panX
+ const cy = (node.y + node.height / 2) * this.zoom + this.panY
+ const hw = (node.width / 2) * this.zoom
+ const hh = (node.height / 2) * this.zoom
+
+ canvas.save()
+ if (rotation !== 0) {
+ canvas.rotate(rotation, cx, cy)
+ }
+
+ canvas.drawRect(this.ck.LTRBRect(cx - hw, cy - hh, cx + hw, cy + hh), this.selectionPaint)
+ canvas.restore()
+ }
+
+ private drawGroupBounds(canvas: Canvas, nodes: SceneNode[]): void {
+ let minX = Infinity
+ let minY = Infinity
+ let maxX = -Infinity
+ let maxY = -Infinity
+
+ for (const n of nodes) {
+ // For rotated nodes, use AABB of the rotated corners
+ if (n.rotation !== 0) {
+ const corners = this.getRotatedCorners(n)
+ for (const c of corners) {
+ minX = Math.min(minX, c.x)
+ minY = Math.min(minY, c.y)
+ maxX = Math.max(maxX, c.x)
+ maxY = Math.max(maxY, c.y)
+ }
+ } else {
+ const x1 = n.x * this.zoom + this.panX
+ const y1 = n.y * this.zoom + this.panY
+ const x2 = (n.x + n.width) * this.zoom + this.panX
+ const y2 = (n.y + n.height) * this.zoom + this.panY
+ minX = Math.min(minX, x1)
+ minY = Math.min(minY, y1)
+ maxX = Math.max(maxX, x2)
+ maxY = Math.max(maxY, y2)
+ }
+ }
+
+ // Dashed bounding box
+ const dashPaint = new this.ck.Paint()
+ dashPaint.setStyle(this.ck.PaintStyle.Stroke)
+ dashPaint.setStrokeWidth(1)
+ dashPaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.6))
+ dashPaint.setAntiAlias(true)
+
+ canvas.drawRect(this.ck.LTRBRect(minX, minY, maxX, maxY), dashPaint)
+
+ // Group resize handles
+ this.drawHandle(canvas, minX, minY)
+ this.drawHandle(canvas, maxX, minY)
+ this.drawHandle(canvas, minX, maxY)
+ this.drawHandle(canvas, maxX, maxY)
+ const gmx = (minX + maxX) / 2
+ const gmy = (minY + maxY) / 2
+ this.drawHandle(canvas, gmx, minY)
+ this.drawHandle(canvas, gmx, maxY)
+ this.drawHandle(canvas, minX, gmy)
+ this.drawHandle(canvas, maxX, gmy)
+
+ dashPaint.delete()
+ }
+
+ private getRotatedCorners(n: SceneNode) {
+ const cx = (n.x + n.width / 2) * this.zoom + this.panX
+ const cy = (n.y + n.height / 2) * this.zoom + this.panY
+ const hw = (n.width / 2) * this.zoom
+ const hh = (n.height / 2) * this.zoom
+ const rad = (n.rotation * Math.PI) / 180
+ const cos = Math.cos(rad)
+ const sin = Math.sin(rad)
+
+ return [
+ { x: cx + -hw * cos - -hh * sin, y: cy + -hw * sin + -hh * cos },
+ { x: cx + hw * cos - -hh * sin, y: cy + hw * sin + -hh * cos },
+ { x: cx + hw * cos - hh * sin, y: cy + hw * sin + hh * cos },
+ { x: cx + -hw * cos - hh * sin, y: cy + -hw * sin + hh * cos }
+ ]
+ }
+
private drawHandle(canvas: Canvas, x: number, y: number): void {
const S = 3
const handleFill = new this.ck.Paint()
@@ -117,7 +266,56 @@ export class SkiaRenderer {
handleFill.delete()
}
- private renderNode(canvas: Canvas, graph: SceneGraph, nodeId: string): void {
+ // --- Snap guides ---
+
+ private drawSnapGuides(canvas: Canvas, guides?: SnapGuide[]): void {
+ if (!guides || guides.length === 0) return
+
+ for (const guide of guides) {
+ if (guide.axis === 'x') {
+ const x = guide.position * this.zoom + this.panX
+ const y1 = guide.from * this.zoom + this.panY
+ const y2 = guide.to * this.zoom + this.panY
+ canvas.drawLine(x, y1, x, y2, this.snapPaint)
+ } else {
+ const y = guide.position * this.zoom + this.panY
+ const x1 = guide.from * this.zoom + this.panX
+ const x2 = guide.to * this.zoom + this.panX
+ canvas.drawLine(x1, y, x2, y, this.snapPaint)
+ }
+ }
+ }
+
+ // --- Marquee ---
+
+ private drawMarquee(
+ canvas: Canvas,
+ marquee?: { x: number; y: number; width: number; height: number } | null
+ ): void {
+ if (!marquee || marquee.width <= 0 || marquee.height <= 0) return
+
+ const x1 = marquee.x * this.zoom + this.panX
+ const y1 = marquee.y * this.zoom + this.panY
+ const x2 = (marquee.x + marquee.width) * this.zoom + this.panX
+ const y2 = (marquee.y + marquee.height) * this.zoom + this.panY
+ const rect = this.ck.LTRBRect(x1, y1, x2, y2)
+
+ const fill = new this.ck.Paint()
+ fill.setStyle(this.ck.PaintStyle.Fill)
+ fill.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.08))
+ canvas.drawRect(rect, fill)
+ canvas.drawRect(rect, this.selectionPaint)
+ fill.delete()
+ }
+
+ // --- Scene rendering ---
+
+ private renderNode(
+ canvas: Canvas,
+ graph: SceneGraph,
+ nodeId: string,
+ rotationPreview?: { nodeId: string; angle: number } | null
+ ): void {
const node = graph.getNode(nodeId)
if (!node || !node.visible) return
@@ -130,15 +328,27 @@ export class SkiaRenderer {
layerPaint.delete()
}
- if (node.rotation !== 0) {
- canvas.rotate(node.rotation, node.x + node.width / 2, node.y + node.height / 2)
+ const rotation = rotationPreview?.nodeId === nodeId ? rotationPreview.angle : node.rotation
+
+ if (rotation !== 0) {
+ canvas.rotate(rotation, node.x + node.width / 2, node.y + node.height / 2)
}
- this.renderShape(canvas, node)
-
- // Render children
- for (const childId of node.childIds) {
- this.renderNode(canvas, graph, childId)
+ // Clip children for frames
+ if (node.type === 'FRAME') {
+ const clipRect = this.ck.LTRBRect(node.x, node.y, node.x + node.width, node.y + node.height)
+ this.renderShape(canvas, node)
+ canvas.save()
+ canvas.clipRect(clipRect, this.ck.ClipOp.Intersect, true)
+ for (const childId of node.childIds) {
+ this.renderNode(canvas, graph, childId, rotationPreview)
+ }
+ canvas.restore()
+ } else {
+ this.renderShape(canvas, node)
+ for (const childId of node.childIds) {
+ this.renderNode(canvas, graph, childId, rotationPreview)
+ }
}
if (node.opacity < 1) {
@@ -168,15 +378,20 @@ export class SkiaRenderer {
case 'ELLIPSE':
canvas.drawOval(rect, this.fillPaint)
break
- case 'RECTANGLE':
- case 'FRAME':
- case 'GROUP':
- case 'SECTION':
+ case 'TEXT':
+ this.renderText(canvas, node)
+ break
+ case 'LINE':
+ canvas.drawLine(node.x, node.y, node.x + node.width, node.y + node.height, this.fillPaint)
+ break
+ default:
if (hasRadius) {
if (node.independentCorners) {
- const rrect = this.ck.RRectXY(rect, node.cornerRadius, node.cornerRadius)
- // For independent corners, build a proper RRect
- const radii = [
+ const rrect = new Float32Array([
+ node.x,
+ node.y,
+ node.x + node.width,
+ node.y + node.height,
node.topLeftRadius,
node.topLeftRadius,
node.topRightRadius,
@@ -185,16 +400,8 @@ export class SkiaRenderer {
node.bottomRightRadius,
node.bottomLeftRadius,
node.bottomLeftRadius
- ]
- const rrectIndep = new Float32Array([
- node.x,
- node.y,
- node.x + node.width,
- node.y + node.height,
- ...radii
])
- canvas.drawRRect(rrectIndep, this.fillPaint)
- void rrect
+ canvas.drawRRect(rrect, this.fillPaint)
} else {
const rrect = this.ck.RRectXY(rect, node.cornerRadius, node.cornerRadius)
canvas.drawRRect(rrect, this.fillPaint)
@@ -202,12 +409,6 @@ export class SkiaRenderer {
} else {
canvas.drawRect(rect, this.fillPaint)
}
- break
- case 'LINE':
- canvas.drawLine(node.x, node.y, node.x + node.width, node.y + node.height, this.fillPaint)
- break
- default:
- canvas.drawRect(rect, this.fillPaint)
}
}
@@ -233,9 +434,14 @@ export class SkiaRenderer {
}
}
}
+ }
- // Effects (drop shadows — simplified, drawn behind)
- // Full implementation would use saveLayer + blur filters
+ private renderText(canvas: Canvas, node: SceneNode): void {
+ if (!this.textFont || !('text' in node)) return
+ const text = (node as SceneNode & { text?: string }).text ?? ''
+ if (!text) return
+
+ canvas.drawText(text, node.x, node.y + 14, this.fillPaint, this.textFont)
}
private applyFill(fill: Fill): void {
@@ -257,6 +463,8 @@ export class SkiaRenderer {
this.fillPaint.delete()
this.strokePaint.delete()
this.selectionPaint.delete()
+ this.snapPaint.delete()
+ this.textFont?.delete()
this.surface.delete()
}
}
diff --git a/src/engine/scene-graph.ts b/src/engine/scene-graph.ts
index dc67b0b1d..c8f5a0b41 100644
--- a/src/engine/scene-graph.ts
+++ b/src/engine/scene-graph.ts
@@ -74,6 +74,15 @@ export interface SceneNode {
visible: boolean
locked: boolean
+
+ // Text-specific
+ text: string
+ fontSize: number
+ fontFamily: string
+ fontWeight: number
+ textAlignHorizontal: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
+ lineHeight: number | null
+ letterSpacing: number
}
let nextLocalID = 1
@@ -107,6 +116,13 @@ function createDefaultNode(type: NodeType, overrides: Partial = {}):
cornerSmoothing: 0,
visible: true,
locked: false,
+ text: '',
+ fontSize: 14,
+ fontFamily: 'Inter',
+ fontWeight: 400,
+ textAlignHorizontal: 'LEFT',
+ lineHeight: null,
+ letterSpacing: 0,
...overrides
}
}
diff --git a/src/engine/snap.ts b/src/engine/snap.ts
new file mode 100644
index 000000000..22ea37091
--- /dev/null
+++ b/src/engine/snap.ts
@@ -0,0 +1,132 @@
+import type { SceneNode } from './scene-graph'
+
+const SNAP_THRESHOLD = 5
+
+export interface SnapGuide {
+ axis: 'x' | 'y'
+ position: number
+ from: number
+ to: number
+}
+
+export interface SnapResult {
+ dx: number
+ dy: number
+ guides: SnapGuide[]
+}
+
+function getEdges(node: SceneNode) {
+ return {
+ left: node.x,
+ right: node.x + node.width,
+ centerX: node.x + node.width / 2,
+ top: node.y,
+ bottom: node.y + node.height,
+ centerY: node.y + node.height / 2
+ }
+}
+
+export function computeSnap(
+ movingIds: Set,
+ movingBounds: { x: number; y: number; width: number; height: number },
+ allNodes: SceneNode[]
+): SnapResult {
+ const targets = allNodes.filter((n) => !movingIds.has(n.id))
+ if (targets.length === 0) return { dx: 0, dy: 0, guides: [] }
+
+ const m = {
+ left: movingBounds.x,
+ right: movingBounds.x + movingBounds.width,
+ centerX: movingBounds.x + movingBounds.width / 2,
+ top: movingBounds.y,
+ bottom: movingBounds.y + movingBounds.height,
+ centerY: movingBounds.y + movingBounds.height / 2
+ }
+
+ let bestDx = Infinity
+ let bestDy = Infinity
+ const guides: SnapGuide[] = []
+
+ for (const target of targets) {
+ const t = getEdges(target)
+
+ // X-axis snapping: left-to-left, left-to-right, right-to-left, right-to-right, center-to-center
+ const xPairs: [number, number][] = [
+ [m.left, t.left],
+ [m.left, t.right],
+ [m.right, t.left],
+ [m.right, t.right],
+ [m.centerX, t.centerX]
+ ]
+
+ for (const [mVal, tVal] of xPairs) {
+ const d = tVal - mVal
+ if (Math.abs(d) < SNAP_THRESHOLD && Math.abs(d) <= Math.abs(bestDx)) {
+ if (Math.abs(d) < Math.abs(bestDx)) {
+ bestDx = d
+ guides.length = guides.filter((g) => g.axis === 'y').length
+ ? guides.length
+ : guides.length
+ // Remove old x guides if we found a closer snap
+ for (let i = guides.length - 1; i >= 0; i--) {
+ if (guides[i].axis === 'x') guides.splice(i, 1)
+ }
+ }
+ if (Math.abs(d) === Math.abs(bestDx)) {
+ const minY = Math.min(m.top, t.top)
+ const maxY = Math.max(m.bottom, t.bottom)
+ guides.push({ axis: 'x', position: tVal, from: minY, to: maxY })
+ }
+ }
+ }
+
+ // Y-axis snapping
+ const yPairs: [number, number][] = [
+ [m.top, t.top],
+ [m.top, t.bottom],
+ [m.bottom, t.top],
+ [m.bottom, t.bottom],
+ [m.centerY, t.centerY]
+ ]
+
+ for (const [mVal, tVal] of yPairs) {
+ const d = tVal - mVal
+ if (Math.abs(d) < SNAP_THRESHOLD && Math.abs(d) <= Math.abs(bestDy)) {
+ if (Math.abs(d) < Math.abs(bestDy)) {
+ for (let i = guides.length - 1; i >= 0; i--) {
+ if (guides[i].axis === 'y') guides.splice(i, 1)
+ }
+ bestDy = d
+ }
+ if (Math.abs(d) === Math.abs(bestDy)) {
+ const minX = Math.min(m.left, t.left)
+ const maxX = Math.max(m.right, t.right)
+ guides.push({ axis: 'y', position: tVal, from: minX, to: maxX })
+ }
+ }
+ }
+ }
+
+ return {
+ dx: Math.abs(bestDx) <= SNAP_THRESHOLD ? bestDx : 0,
+ dy: Math.abs(bestDy) <= SNAP_THRESHOLD ? bestDy : 0,
+ guides
+ }
+}
+
+export function computeSelectionBounds(
+ nodes: SceneNode[]
+): { x: number; y: number; width: number; height: number } | null {
+ if (nodes.length === 0) return null
+ let minX = Infinity
+ let minY = Infinity
+ let maxX = -Infinity
+ let maxY = -Infinity
+ for (const n of nodes) {
+ minX = Math.min(minX, n.x)
+ minY = Math.min(minY, n.y)
+ maxX = Math.max(maxX, n.x + n.width)
+ maxY = Math.max(maxY, n.y + n.height)
+ }
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
+}
diff --git a/src/stores/editor.ts b/src/stores/editor.ts
index 22d6658e5..7cef5252f 100644
--- a/src/stores/editor.ts
+++ b/src/stores/editor.ts
@@ -4,6 +4,7 @@ import { SceneGraph } from '../engine/scene-graph'
import { UndoManager } from '../engine/undo'
import type { SceneNode, NodeType, Fill } from '../engine/scene-graph'
+import type { SnapGuide } from '../engine/snap'
export type Tool = 'SELECT' | 'FRAME' | 'RECTANGLE' | 'ELLIPSE' | 'LINE' | 'TEXT' | 'PEN' | 'HAND'
@@ -55,7 +56,8 @@ const DEFAULT_FILLS: Record = {
opacity: 1,
visible: true
},
- LINE: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
+ LINE: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true },
+ TEXT: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
}
export function createEditorStore() {
@@ -66,6 +68,8 @@ export function createEditorStore() {
activeTool: 'SELECT' as Tool,
selectedIds: new Set(),
marquee: null as { x: number; y: number; width: number; height: number } | null,
+ snapGuides: [] as SnapGuide[],
+ rotationPreview: null as { nodeId: string; angle: number } | null,
panX: 0,
panY: 0,
zoom: 1,
@@ -120,6 +124,16 @@ export function createEditorStore() {
requestRender()
}
+ function setSnapGuides(guides: SnapGuide[]) {
+ state.snapGuides = guides
+ requestRender()
+ }
+
+ function setRotationPreview(preview: { nodeId: string; angle: number } | null) {
+ state.rotationPreview = preview
+ requestRender()
+ }
+
function updateNode(id: string, changes: Partial) {
graph.updateNode(id, changes)
requestRender()
@@ -274,6 +288,8 @@ export function createEditorStore() {
clearSelection,
selectAll,
setMarquee,
+ setSnapGuides,
+ setRotationPreview,
updateNode,
createShape,
duplicateSelected,