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:
parent
0b563ed621
commit
94c4635907
371
src/components/ColorPicker.vue
Normal file
371
src/components/ColorPicker.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
132
src/engine/snap.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue