Add rotation, multi-select bbox, snap guides, text tool, color picker

Rotation:
- Rotation handle above selection (line + circle)
- Drag to rotate, Shift snaps to 15° increments
- Live preview during drag, applied on release
- Rotated nodes render correctly with canvas.rotate()

Multi-select bounding box:
- Individual outlines per node + unified group bbox
- Group resize handles on the unified bounds
- Handles rotated AABB for rotated nodes

Snap guides:
- Magenta guide lines when moving nodes near edges/centers
- Snaps to left/right/top/bottom/center of other nodes
- 5px threshold, guides clear on mouse up

Text tool:
- T shortcut or toolbar to activate
- Click to place text node (200×24, default 'Text')
- CanvasKit drawText with default font (Inter to come)

Color picker (Reka UI Popover):
- HSV saturation/brightness area with pointer drag
- Hue slider (rainbow gradient)
- Alpha slider
- Hex input + alpha percentage
- Live color updates to fill/stroke

Properties panel:
- Add/remove fills and strokes with +/× buttons
- Toggle fill visibility
- Stroke weight editing
- Multi-select shows layer count + shared opacity
- ColorPicker integrated for fill and stroke colors

Frame clipping:
- Frame children now clip to parent bounds
This commit is contained in:
Danila Poyarkov 2026-02-27 22:15:35 +03:00
parent 0b563ed621
commit 94c4635907
8 changed files with 1204 additions and 154 deletions

View file

@ -0,0 +1,371 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent } from 'reka-ui'
import type { Color } from '../engine/scene-graph'
const props = defineProps<{
color: Color
}>()
const emit = defineEmits<{
update: [color: Color]
}>()
const hue = ref(0)
const saturation = ref(100)
const brightness = ref(100)
const alpha = ref(1)
function rgbToHsv(r: number, g: number, b: number) {
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let h = 0
const s = max === 0 ? 0 : d / max
const v = max
if (d !== 0) {
if (max === r) h = ((g - b) / d + 6) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
}
return { h, s: s * 100, v: v * 100 }
}
function hsvToRgb(h: number, s: number, v: number) {
s /= 100
v /= 100
const c = v * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = v - c
let r = 0
let g = 0
let b = 0
if (h < 60) [r, g, b] = [c, x, 0]
else if (h < 120) [r, g, b] = [x, c, 0]
else if (h < 180) [r, g, b] = [0, c, x]
else if (h < 240) [r, g, b] = [0, x, c]
else if (h < 300) [r, g, b] = [x, 0, c]
else [r, g, b] = [c, 0, x]
return { r: r + m, g: g + m, b: b + m }
}
watch(
() => props.color,
(c) => {
const hsv = rgbToHsv(c.r, c.g, c.b)
hue.value = hsv.h
saturation.value = hsv.s
brightness.value = hsv.v
alpha.value = c.a
},
{ immediate: true }
)
function emitColor() {
const rgb = hsvToRgb(hue.value, saturation.value, brightness.value)
emit('update', { r: rgb.r, g: rgb.g, b: rgb.b, a: alpha.value })
}
const hexValue = computed(() => {
const hex = (v: number) =>
Math.round(v * 255)
.toString(16)
.padStart(2, '0')
return `${hex(props.color.r)}${hex(props.color.g)}${hex(props.color.b)}`
})
function onHexInput(e: Event) {
const input = (e.target as HTMLInputElement).value.replace('#', '')
if (input.length !== 6) return
const r = parseInt(input.slice(0, 2), 16) / 255
const g = parseInt(input.slice(2, 4), 16) / 255
const b = parseInt(input.slice(4, 6), 16) / 255
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return
emit('update', { r, g, b, a: alpha.value })
}
const svAreaRef = ref<HTMLDivElement | null>(null)
function onSvPointerDown(e: PointerEvent) {
const el = svAreaRef.value
if (!el) return
el.setPointerCapture(e.pointerId)
updateSv(e)
}
function onSvPointerMove(e: PointerEvent) {
const el = svAreaRef.value
if (!el || !el.hasPointerCapture(e.pointerId)) return
updateSv(e)
}
function updateSv(e: PointerEvent) {
const el = svAreaRef.value
if (!el) return
const rect = el.getBoundingClientRect()
saturation.value = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
brightness.value = Math.max(0, Math.min(100, 100 - ((e.clientY - rect.top) / rect.height) * 100))
emitColor()
}
function onHueInput(e: Event) {
hue.value = +(e.target as HTMLInputElement).value
emitColor()
}
function onAlphaSliderInput(e: Event) {
alpha.value = +(e.target as HTMLInputElement).value / 100
emitColor()
}
function onAlphaNumberInput(e: Event) {
alpha.value = Math.max(0, Math.min(1, +(e.target as HTMLInputElement).value / 100))
emitColor()
}
const hueColor = computed(() => {
const rgb = hsvToRgb(hue.value, 100, 100)
return `rgb(${Math.round(rgb.r * 255)}, ${Math.round(rgb.g * 255)}, ${Math.round(rgb.b * 255)})`
})
const swatchColor = computed(() => {
const c = props.color
return `rgba(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}, ${c.a})`
})
</script>
<template>
<PopoverRoot>
<PopoverTrigger as-child>
<button class="color-trigger" :style="{ background: swatchColor }" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent class="color-popover" :side-offset="4" side="left">
<!-- SV area -->
<div
ref="svAreaRef"
class="sv-area"
:style="{ background: hueColor }"
@pointerdown="onSvPointerDown"
@pointermove="onSvPointerMove"
>
<div class="sv-white" />
<div class="sv-black" />
<div
class="sv-cursor"
:style="{
left: `${saturation}%`,
top: `${100 - brightness}%`
}"
/>
</div>
<!-- Hue slider -->
<div class="slider-row">
<input
type="range"
class="hue-slider"
:value="hue"
min="0"
max="360"
@input="onHueInput"
/>
</div>
<!-- Alpha slider -->
<div class="slider-row">
<input
type="range"
class="alpha-slider"
:value="alpha * 100"
min="0"
max="100"
@input="onAlphaSliderInput"
/>
</div>
<!-- Hex input -->
<div class="hex-row">
<span class="hash">#</span>
<input
type="text"
class="hex-input"
:value="hexValue"
maxlength="6"
@change="onHexInput"
/>
<input
type="number"
class="alpha-input"
:value="Math.round(alpha * 100)"
min="0"
max="100"
@change="onAlphaNumberInput"
/>
<span class="percent">%</span>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<style scoped>
.color-trigger {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid var(--border);
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.color-popover {
width: 224px;
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.sv-area {
position: relative;
width: 100%;
height: 140px;
border-radius: 4px;
cursor: crosshair;
overflow: hidden;
}
.sv-white {
position: absolute;
inset: 0;
background: linear-gradient(to right, white, transparent);
}
.sv-black {
position: absolute;
inset: 0;
background: linear-gradient(to top, black, transparent);
}
.sv-cursor {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
transform: translate(-50%, -50%);
pointer-events: none;
}
.slider-row {
margin-top: 8px;
}
.hue-slider {
width: 100%;
height: 12px;
-webkit-appearance: none;
appearance: none;
border-radius: 6px;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
outline: none;
}
.hue-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
border: 2px solid white;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
cursor: pointer;
}
.alpha-slider {
width: 100%;
height: 12px;
-webkit-appearance: none;
appearance: none;
border-radius: 6px;
background: linear-gradient(to right, transparent, v-bind(hueColor));
outline: none;
}
.alpha-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
border: 2px solid white;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
cursor: pointer;
}
.hex-row {
display: flex;
align-items: center;
gap: 4px;
margin-top: 8px;
}
.hash {
font-size: 11px;
color: var(--text-muted);
}
.hex-input {
flex: 1;
min-width: 0;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 3px 6px;
font: inherit;
font-size: 12px;
font-family: monospace;
}
.alpha-input {
width: 40px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 3px 4px;
font: inherit;
font-size: 12px;
text-align: right;
}
.alpha-input::-webkit-inner-spin-button {
display: none;
}
.percent {
font-size: 11px;
color: var(--text-muted);
}
</style>

View file

@ -1,41 +1,135 @@
<script setup lang="ts">
import { computed } from 'vue'
import ColorPicker from './ColorPicker.vue'
import { useEditorStore } from '../stores/editor'
import type { Color, Fill, Stroke } from '../engine/scene-graph'
const store = useEditorStore()
function updateProp(key: string, value: number) {
const node = store.selectedNode.value
if (!node) return
store.updateNode(node.id, { [key]: value })
const node = computed(() => store.selectedNode.value)
const multiCount = computed(() => store.selectedNodes.value.length)
function updateProp(key: string, value: number | string) {
if (multiCount.value > 1) {
for (const n of store.selectedNodes.value) {
store.updateNode(n.id, { [key]: value })
}
} else if (node.value) {
store.updateNode(node.value.id, { [key]: value })
}
}
function colorHex(c: { r: number; g: number; b: number }) {
function updateFillColor(index: number, color: Color) {
if (!node.value) return
const fills = [...node.value.fills]
fills[index] = { ...fills[index], color }
store.updateNode(node.value.id, { fills })
}
function addFill() {
if (!node.value) return
const fill: Fill = {
type: 'SOLID',
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
opacity: 1,
visible: true
}
store.updateNode(node.value.id, { fills: [...node.value.fills, fill] })
}
function removeFill(index: number) {
if (!node.value) return
const fills = node.value.fills.filter((_, i) => i !== index)
store.updateNode(node.value.id, { fills })
}
function toggleFillVisibility(index: number) {
if (!node.value) return
const fills = [...node.value.fills]
fills[index] = { ...fills[index], visible: !fills[index].visible }
store.updateNode(node.value.id, { fills })
}
function addStroke() {
if (!node.value) return
const stroke: Stroke = {
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'CENTER'
}
store.updateNode(node.value.id, { strokes: [...node.value.strokes, stroke] })
}
function updateStrokeColor(index: number, color: Color) {
if (!node.value) return
const strokes = [...node.value.strokes]
strokes[index] = { ...strokes[index], color }
store.updateNode(node.value.id, { strokes })
}
function updateStrokeWeight(index: number, weight: number) {
if (!node.value) return
const strokes = [...node.value.strokes]
strokes[index] = { ...strokes[index], weight }
store.updateNode(node.value.id, { strokes })
}
function removeStroke(index: number) {
if (!node.value) return
const strokes = node.value.strokes.filter((_, i) => i !== index)
store.updateNode(node.value.id, { strokes })
}
function colorHex(c: Color) {
const hex = (v: number) =>
Math.round(v * 255)
.toString(16)
.padStart(2, '0')
return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`
}
function colorRgba(c: { r: number; g: number; b: number; a: number }) {
return `rgba(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}, ${c.a})`
}
</script>
<template>
<aside class="properties-panel">
<!-- Tabs -->
<div class="panel-tabs">
<button class="tab active">Design</button>
<button class="tab">Prototype</button>
<span class="zoom-display">{{ Math.round(store.state.zoom * 100) }}%</span>
</div>
<div v-if="store.selectedNode.value" class="panel-scroll">
<!-- Node header -->
<!-- Multi-select summary -->
<div v-if="multiCount > 1" class="panel-scroll">
<div class="section node-header">
<span class="node-type">{{ store.selectedNode.value.type }}</span>
<span class="node-name">{{ store.selectedNode.value.name }}</span>
<span class="node-type">Mixed</span>
<span class="node-name">{{ multiCount }} layers</span>
</div>
<div class="section">
<label class="section-label">Appearance</label>
<div class="input-row">
<label class="prop-input full">
<span class="prop-label">Opacity</span>
<input
type="range"
min="0"
max="100"
:value="store.selectedNodes.value[0]?.opacity * 100"
@input="updateProp('opacity', +($event.target as HTMLInputElement).value / 100)"
/>
</label>
</div>
</div>
</div>
<!-- Single selection -->
<div v-else-if="node" class="panel-scroll">
<div class="section node-header">
<span class="node-type">{{ node.type }}</span>
<span class="node-name">{{ node.name }}</span>
</div>
<!-- Position -->
@ -46,7 +140,7 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
<span class="prop-label">X</span>
<input
type="number"
:value="Math.round(store.selectedNode.value.x)"
:value="Math.round(node.x)"
@change="updateProp('x', +($event.target as HTMLInputElement).value)"
/>
</label>
@ -54,7 +148,7 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
<span class="prop-label">Y</span>
<input
type="number"
:value="Math.round(store.selectedNode.value.y)"
:value="Math.round(node.y)"
@change="updateProp('y', +($event.target as HTMLInputElement).value)"
/>
</label>
@ -68,7 +162,7 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
<span class="prop-label">R</span>
<input
type="number"
:value="Math.round(store.selectedNode.value.rotation)"
:value="Math.round(node.rotation)"
@change="updateProp('rotation', +($event.target as HTMLInputElement).value)"
/>
</label>
@ -83,7 +177,7 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
<span class="prop-label">W</span>
<input
type="number"
:value="Math.round(store.selectedNode.value.width)"
:value="Math.round(node.width)"
@change="updateProp('width', +($event.target as HTMLInputElement).value)"
/>
</label>
@ -91,7 +185,7 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
<span class="prop-label">H</span>
<input
type="number"
:value="Math.round(store.selectedNode.value.height)"
:value="Math.round(node.height)"
@change="updateProp('height', +($event.target as HTMLInputElement).value)"
/>
</label>
@ -101,7 +195,7 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
<span class="prop-label"></span>
<input
type="number"
:value="store.selectedNode.value.cornerRadius"
:value="node.cornerRadius"
@change="updateProp('cornerRadius', +($event.target as HTMLInputElement).value)"
/>
</label>
@ -118,28 +212,52 @@ function colorRgba(c: { r: number; g: number; b: number; a: number }) {
type="range"
min="0"
max="100"
:value="store.selectedNode.value.opacity * 100"
:value="node.opacity * 100"
@input="updateProp('opacity', +($event.target as HTMLInputElement).value / 100)"
/>
<span class="prop-value"
>{{ Math.round(store.selectedNode.value.opacity * 100) }}%</span
>
<span class="prop-value">{{ Math.round(node.opacity * 100) }}%</span>
</label>
</div>
</div>
<!-- Fill -->
<div class="section">
<label class="section-label">Fill</label>
<div v-for="(fill, i) in store.selectedNode.value.fills" :key="i" class="fill-row">
<div class="color-swatch" :style="{ background: colorRgba(fill.color) }" />
<div class="section-header">
<label class="section-label">Fill</label>
<button class="section-add" @click="addFill">+</button>
</div>
<div v-for="(fill, i) in node.fills" :key="i" class="fill-row">
<button
class="visibility-toggle"
:class="{ hidden: !fill.visible }"
@click="toggleFillVisibility(i)"
>
{{ fill.visible ? '◉' : '○' }}
</button>
<ColorPicker :color="fill.color" @update="updateFillColor(i, $event)" />
<span class="color-hex">{{ colorHex(fill.color) }}</span>
<button class="remove-btn" @click="removeFill(i)">×</button>
</div>
</div>
<!-- Stroke -->
<div class="section">
<label class="section-label">Stroke</label>
<div class="section-header">
<label class="section-label">Stroke</label>
<button class="section-add" @click="addStroke">+</button>
</div>
<div v-for="(stroke, i) in node.strokes" :key="i" class="fill-row">
<ColorPicker :color="stroke.color" @update="updateStrokeColor(i, $event)" />
<span class="color-hex">{{ colorHex(stroke.color) }}</span>
<input
type="number"
class="stroke-weight"
:value="stroke.weight"
min="0"
@change="updateStrokeWeight(i, +($event.target as HTMLInputElement).value)"
/>
<button class="remove-btn" @click="removeStroke(i)">×</button>
</div>
</div>
<!-- Effects -->
@ -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;
}
</style>

View file

@ -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<Record<Tool, NodeType>> = {
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<HandlePosition, string> = {
nw: 'nwse-resize',
@ -66,11 +79,17 @@ const HANDLE_CURSORS: Record<HandlePosition, string> = {
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<HTMLCanvasElement | null>, store: EditorStore) {
const drag = ref<DragState | null>(null)
const cursorOverride = ref<string | null>(null)
@ -121,7 +154,6 @@ export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<string, { x: number; y: number }>()
@ -220,13 +274,22 @@ export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, store:
}
}
// Prevent negative sizes — flip
if (width < 0) {
x = x + width
width = -width
@ -398,10 +512,15 @@ export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, 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') {

View file

@ -51,7 +51,11 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, 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(() => {

View file

@ -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<string>,
marquee?: { x: number; y: number; width: number; height: number } | null
): void {
render(graph: SceneGraph, selectedIds: Set<string>, 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<string>,
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()
}
}

View file

@ -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<SceneNode> = {}):
cornerSmoothing: 0,
visible: true,
locked: false,
text: '',
fontSize: 14,
fontFamily: 'Inter',
fontWeight: 400,
textAlignHorizontal: 'LEFT',
lineHeight: null,
letterSpacing: 0,
...overrides
}
}

132
src/engine/snap.ts Normal file
View file

@ -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<string>,
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 }
}

View file

@ -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<string, Fill> = {
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<string>(),
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<SceneNode>) {
graph.updateNode(id, changes)
requestRender()
@ -274,6 +288,8 @@ export function createEditorStore() {
clearSelection,
selectAll,
setMarquee,
setSnapGuides,
setRotationPreview,
updateNode,
createShape,
duplicateSelected,