Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'refactor(diagrams): decompose main.ts into focused modules' (#286) from refactor/diagrams-decompose into main

scott b691da1f 7a3eda9f

+1836 -1469
+3
CHANGELOG.md
··· 270 270 - Fix E2E test flakiness: replace page reload with addInitScript, add waitForURL before waitForSelector (#305) 271 271 272 272 ### Changed 273 + - Decompose diagrams/main.ts into focused modules (#463) 274 + - Phase 5: extract toolbar, keyboard, cell-editing, grid-rendering from sheets main.ts (#462) 275 + - Phase 4: extract formula-bar, keyboard-shortcuts, clipboard-selection from sheets main.ts (#461) 273 276 - Aggressively decompose sheets/main.ts - extract all major UI blocks (#459) 274 277 - Decompose sheets/main.ts monolith into focused modules (#458) 275 278 - Polish task list checkbox alignment and spacing (#457)
+724
src/diagrams/canvas-events.ts
··· 1 + // @ts-nocheck 2 + /** 3 + * Canvas Event Handlers — mouse, touch, and wheel interactions for the diagram canvas. 4 + * 5 + * Extracted from main.ts to isolate pointer interaction logic from rendering and state. 6 + */ 7 + 8 + import { 9 + shapeAtPoint, shapesInRect, nearestEdgeAnchor, snapPoint, 10 + hitTestResizeHandle, applyResize, setShapeRotation, addShape, 11 + removeShape, removeShapes, addArrow, setShapeStyle, duplicateShapes, 12 + setZoom, pointsToCatmullRomPath, getGroupMembers, 13 + } from './whiteboard.js'; 14 + import type { 15 + WhiteboardState, Shape, ShapeKind, ArrowEndpoint, Point, ResizeHandle, 16 + } from './whiteboard.js'; 17 + import { 18 + renderCreationPreview, removeCreationPreview, 19 + renderArrowPreview, removeArrowPreview, 20 + renderLinePreview, 21 + } from './shape-renderers.js'; 22 + import type { ShapeRendererDeps } from './shape-renderers.js'; 23 + 24 + // --------------------------------------------------------------------------- 25 + // Deps interface 26 + // --------------------------------------------------------------------------- 27 + 28 + export interface CanvasEventDeps { 29 + canvas: SVGSVGElement; 30 + layer: SVGGElement; 31 + getState: () => WhiteboardState; 32 + setState: (wb: WhiteboardState) => void; 33 + getActiveTool: () => string; 34 + setActiveTool: (tool: string) => void; 35 + getSelectedShapeIds: () => Set<string>; 36 + setSelectedShapeIds: (ids: Set<string>) => void; 37 + getEditingShapeId: () => string | null; 38 + render: () => void; 39 + syncToYjs: () => void; 40 + pushHistory: () => void; 41 + updateToolbar: () => void; 42 + screenToCanvas: (sx: number, sy: number) => Point; 43 + startTextEditing: (shapeId: string) => void; 44 + finishLine: () => void; 45 + computeSnapGuides: (draggedIds: Set<string>) => Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }>; 46 + renderSnapGuides: (guides: Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }>) => void; 47 + clearSnapGuides: () => void; 48 + startEdgeScroll: (clientX: number, clientY: number) => void; 49 + stopEdgeScroll: () => void; 50 + } 51 + 52 + // --------------------------------------------------------------------------- 53 + // Internal state for pointer interactions 54 + // --------------------------------------------------------------------------- 55 + 56 + let isDragging = false; 57 + let dragStart: Point = { x: 0, y: 0 }; 58 + let dragShapesStart: Map<string, Point> = new Map(); 59 + 60 + let isPanning = false; 61 + let panStart: Point = { x: 0, y: 0 }; 62 + let panWbStart: Point = { x: 0, y: 0 }; 63 + let spaceHeld = false; 64 + 65 + let isMarqueeSelecting = false; 66 + let marqueeStart: Point = { x: 0, y: 0 }; 67 + let marqueeEnd: Point = { x: 0, y: 0 }; 68 + 69 + let isCreatingShape = false; 70 + let createShapeKind: ShapeKind | null = null; 71 + let createStart: Point = { x: 0, y: 0 }; 72 + 73 + let isResizing = false; 74 + let resizeHandle: ResizeHandle | null = null; 75 + let resizeShapeId: string | null = null; 76 + let resizeStart: Point = { x: 0, y: 0 }; 77 + let resizeShapeOriginal: { x: number; y: number; width: number; height: number } | null = null; 78 + 79 + let isRotating = false; 80 + let rotateShapeId: string | null = null; 81 + let rotateStartAngle = 0; 82 + let rotateShapeStartRotation = 0; 83 + 84 + let altDragDuplicated = false; 85 + 86 + let isDrawingArrow = false; 87 + let arrowFromShape: string | null = null; 88 + let arrowFromAnchor: { anchor: string; x: number; y: number } | null = null; 89 + 90 + let freehandPoints: Point[] = []; 91 + let isDrawingFreehand = false; 92 + 93 + let arrowHoverTargetId: string | null = null; 94 + 95 + let isDrawingLine = false; 96 + let linePoints: Point[] = []; 97 + 98 + let isErasing = false; 99 + 100 + // Touch state 101 + let lastTouchDist = 0; 102 + let lastTouchCenter: Point = { x: 0, y: 0 }; 103 + let touchPanning = false; 104 + let touchPanStart: Point = { x: 0, y: 0 }; 105 + let touchPanWbStart: Point = { x: 0, y: 0 }; 106 + 107 + // --------------------------------------------------------------------------- 108 + // Public getters for state needed by other modules 109 + // --------------------------------------------------------------------------- 110 + 111 + export function getSpaceHeld(): boolean { return spaceHeld; } 112 + export function setSpaceHeld(v: boolean) { spaceHeld = v; } 113 + export function getArrowHoverTargetId(): string | null { return arrowHoverTargetId; } 114 + export function getIsMarqueeSelecting(): boolean { return isMarqueeSelecting; } 115 + export function getMarqueeStart(): Point { return marqueeStart; } 116 + export function getMarqueeEnd(): Point { return marqueeEnd; } 117 + export function getIsDrawingLine(): boolean { return isDrawingLine; } 118 + export function getLinePoints(): Point[] { return linePoints; } 119 + 120 + // --------------------------------------------------------------------------- 121 + // Cursor management 122 + // --------------------------------------------------------------------------- 123 + 124 + const HANDLE_CURSORS: Record<ResizeHandle, string> = { 125 + nw: 'nwse-resize', se: 'nwse-resize', 126 + ne: 'nesw-resize', sw: 'nesw-resize', 127 + n: 'ns-resize', s: 'ns-resize', 128 + e: 'ew-resize', w: 'ew-resize', 129 + }; 130 + 131 + function updateCursor(deps: CanvasEventDeps, e: MouseEvent) { 132 + const canvasArea = document.getElementById('canvas-area')!; 133 + const activeTool = deps.getActiveTool(); 134 + if (activeTool === 'eraser') { 135 + canvasArea.style.cursor = 'crosshair'; 136 + return; 137 + } 138 + const selectedShapeIds = deps.getSelectedShapeIds(); 139 + if (activeTool !== 'select' || selectedShapeIds.size !== 1) { 140 + canvasArea.style.cursor = activeTool === 'select' ? '' : 'crosshair'; 141 + return; 142 + } 143 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 144 + const wb = deps.getState(); 145 + const selShape = wb.shapes.get([...selectedShapeIds][0]!); 146 + if (selShape) { 147 + // Check rotation handle 148 + const rotX = selShape.x + selShape.width / 2; 149 + const rotY = selShape.y - 25; 150 + if (Math.abs(pt.x - rotX) <= 8 && Math.abs(pt.y - rotY) <= 8) { 151 + canvasArea.style.cursor = 'grab'; 152 + return; 153 + } 154 + const handle = hitTestResizeHandle(selShape, pt.x, pt.y); 155 + if (handle) { 156 + canvasArea.style.cursor = HANDLE_CURSORS[handle]; 157 + return; 158 + } 159 + } 160 + canvasArea.style.cursor = ''; 161 + } 162 + 163 + // --------------------------------------------------------------------------- 164 + // Wire up all canvas events 165 + // --------------------------------------------------------------------------- 166 + 167 + export function wireCanvasEvents(deps: CanvasEventDeps) { 168 + const { canvas } = deps; 169 + const rendererDeps: ShapeRendererDeps = { layer: deps.layer }; 170 + 171 + // --- mousedown --- 172 + canvas.addEventListener('mousedown', (e) => { 173 + if (deps.getEditingShapeId()) return; 174 + 175 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 176 + let wb = deps.getState(); 177 + const activeTool = deps.getActiveTool(); 178 + let selectedShapeIds = deps.getSelectedShapeIds(); 179 + 180 + // Middle-click or Space+drag or Hand tool = pan 181 + if (e.button === 1 || spaceHeld || activeTool === 'hand') { 182 + isPanning = true; 183 + panStart = { x: e.clientX, y: e.clientY }; 184 + panWbStart = { x: wb.panX, y: wb.panY }; 185 + e.preventDefault(); 186 + return; 187 + } 188 + 189 + if (activeTool === 'eraser') { 190 + isErasing = true; 191 + const hit = shapeAtPoint(wb, pt.x, pt.y); 192 + if (hit) { 193 + deps.pushHistory(); 194 + wb = removeShape(wb, hit.id); 195 + selectedShapeIds = new Set(selectedShapeIds); 196 + selectedShapeIds.delete(hit.id); 197 + deps.setState(wb); 198 + deps.setSelectedShapeIds(selectedShapeIds); 199 + deps.syncToYjs(); 200 + deps.render(); 201 + } 202 + return; 203 + } 204 + 205 + if (activeTool === 'select') { 206 + // Check rotation handle first (single selection) 207 + if (selectedShapeIds.size === 1) { 208 + const selId = [...selectedShapeIds][0]!; 209 + const selShape = wb.shapes.get(selId); 210 + if (selShape) { 211 + const rotX = selShape.x + selShape.width / 2; 212 + const rotY = selShape.y - 25; 213 + if (Math.abs(pt.x - rotX) <= 8 && Math.abs(pt.y - rotY) <= 8) { 214 + isRotating = true; 215 + rotateShapeId = selId; 216 + rotateShapeStartRotation = selShape.rotation; 217 + const cx = selShape.x + selShape.width / 2; 218 + const cy = selShape.y + selShape.height / 2; 219 + rotateStartAngle = Math.atan2(pt.y - cy, pt.x - cx) * 180 / Math.PI; 220 + return; 221 + } 222 + 223 + // Check resize handles 224 + const handle = hitTestResizeHandle(selShape, pt.x, pt.y); 225 + if (handle) { 226 + isResizing = true; 227 + resizeHandle = handle; 228 + resizeShapeId = selId; 229 + resizeStart = { x: e.clientX, y: e.clientY }; 230 + resizeShapeOriginal = { x: selShape.x, y: selShape.y, width: selShape.width, height: selShape.height }; 231 + return; 232 + } 233 + } 234 + } 235 + 236 + const hit = shapeAtPoint(wb, pt.x, pt.y); 237 + if (hit) { 238 + // If hit shape is in a group, select the whole group 239 + if (hit.groupId && !e.shiftKey) { 240 + const members = getGroupMembers(wb, hit.groupId); 241 + selectedShapeIds = new Set(members); 242 + } else if (e.shiftKey) { 243 + const newSet = new Set(selectedShapeIds); 244 + if (newSet.has(hit.id)) newSet.delete(hit.id); 245 + else newSet.add(hit.id); 246 + selectedShapeIds = newSet; 247 + } else if (!selectedShapeIds.has(hit.id)) { 248 + selectedShapeIds = new Set([hit.id]); 249 + } 250 + // Start dragging all selected shapes 251 + isDragging = true; 252 + altDragDuplicated = false; 253 + dragStart = { x: e.clientX, y: e.clientY }; 254 + dragShapesStart = new Map(); 255 + // Alt+drag = duplicate first, then drag the copies 256 + if (e.altKey && selectedShapeIds.size > 0) { 257 + deps.pushHistory(); 258 + const result = duplicateShapes(wb, selectedShapeIds); 259 + wb = result.state; 260 + selectedShapeIds = new Set(result.idMap.values()); 261 + altDragDuplicated = true; 262 + deps.setState(wb); 263 + deps.syncToYjs(); 264 + } 265 + for (const id of selectedShapeIds) { 266 + const s = wb.shapes.get(id); 267 + if (s) dragShapesStart.set(id, { x: s.x, y: s.y }); 268 + } 269 + } else { 270 + // Empty canvas click -> start marquee selection 271 + if (!e.shiftKey) selectedShapeIds = new Set(); 272 + isMarqueeSelecting = true; 273 + marqueeStart = pt; 274 + marqueeEnd = pt; 275 + } 276 + deps.setSelectedShapeIds(selectedShapeIds); 277 + deps.render(); 278 + 279 + } else if (activeTool === 'line') { 280 + if (!isDrawingLine) { 281 + isDrawingLine = true; 282 + linePoints = [pt]; 283 + } else { 284 + linePoints.push(pt); 285 + } 286 + renderLinePreview(rendererDeps, linePoints); 287 + return; 288 + 289 + } else if (activeTool === 'arrow') { 290 + isDrawingArrow = true; 291 + const hit = shapeAtPoint(wb, pt.x, pt.y); 292 + if (hit) { 293 + arrowFromShape = hit.id; 294 + arrowFromAnchor = nearestEdgeAnchor(hit, pt.x, pt.y); 295 + } else { 296 + arrowFromShape = null; 297 + arrowFromAnchor = { anchor: 'center', x: pt.x, y: pt.y }; 298 + } 299 + 300 + } else if (activeTool === 'freehand' || activeTool === 'highlighter') { 301 + isDrawingFreehand = true; 302 + freehandPoints = [pt]; 303 + 304 + } else { 305 + // Shape creation tools 306 + const kind = activeTool as ShapeKind; 307 + const creatableShapes: ShapeKind[] = ['rectangle', 'ellipse', 'diamond', 'text', 'triangle', 'star', 'hexagon', 'cloud', 'cylinder', 'parallelogram', 'note']; 308 + if (creatableShapes.includes(kind)) { 309 + isCreatingShape = true; 310 + createShapeKind = kind; 311 + createStart = pt; 312 + } 313 + } 314 + }); 315 + 316 + // --- mousemove --- 317 + canvas.addEventListener('mousemove', (e) => { 318 + updateCursor(deps, e); 319 + let wb = deps.getState(); 320 + const activeTool = deps.getActiveTool(); 321 + const selectedShapeIds = deps.getSelectedShapeIds(); 322 + 323 + if (isErasing && activeTool === 'eraser') { 324 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 325 + const hit = shapeAtPoint(wb, pt.x, pt.y); 326 + if (hit) { 327 + deps.pushHistory(); 328 + wb = removeShape(wb, hit.id); 329 + const newSel = new Set(selectedShapeIds); 330 + newSel.delete(hit.id); 331 + deps.setState(wb); 332 + deps.setSelectedShapeIds(newSel); 333 + deps.syncToYjs(); 334 + deps.render(); 335 + } 336 + return; 337 + } 338 + 339 + if (isRotating && rotateShapeId) { 340 + const shape = wb.shapes.get(rotateShapeId); 341 + if (shape) { 342 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 343 + const cx = shape.x + shape.width / 2; 344 + const cy = shape.y + shape.height / 2; 345 + const currentAngle = Math.atan2(pt.y - cy, pt.x - cx) * 180 / Math.PI; 346 + let newRotation = rotateShapeStartRotation + (currentAngle - rotateStartAngle); 347 + if (e.shiftKey) newRotation = Math.round(newRotation / 15) * 15; 348 + wb = setShapeRotation(wb, rotateShapeId, newRotation); 349 + deps.setState(wb); 350 + deps.render(); 351 + } 352 + return; 353 + } 354 + 355 + if (isDragging && selectedShapeIds.size > 0) { 356 + const dx = (e.clientX - dragStart.x) / wb.zoom; 357 + const dy = (e.clientY - dragStart.y) / wb.zoom; 358 + const shapes = new Map(wb.shapes); 359 + for (const [id, startPos] of dragShapesStart) { 360 + const shape = shapes.get(id); 361 + if (!shape) continue; 362 + const nx = startPos.x + dx; 363 + const ny = startPos.y + dy; 364 + const snapped = wb.snapToGrid ? snapPoint(nx, ny, wb.gridSize) : { x: nx, y: ny }; 365 + shapes.set(id, { ...shape, x: snapped.x, y: snapped.y }); 366 + } 367 + wb = { ...wb, shapes }; 368 + deps.setState(wb); 369 + deps.render(); 370 + const guides = deps.computeSnapGuides(selectedShapeIds); 371 + deps.renderSnapGuides(guides); 372 + deps.startEdgeScroll(e.clientX, e.clientY); 373 + 374 + } else if (isPanning) { 375 + const dx = e.clientX - panStart.x; 376 + const dy = e.clientY - panStart.y; 377 + wb = { ...wb, panX: panWbStart.x + dx, panY: panWbStart.y + dy }; 378 + deps.setState(wb); 379 + deps.render(); 380 + 381 + } else if (isMarqueeSelecting) { 382 + marqueeEnd = deps.screenToCanvas(e.clientX, e.clientY); 383 + const mx = Math.min(marqueeStart.x, marqueeEnd.x); 384 + const my = Math.min(marqueeStart.y, marqueeEnd.y); 385 + const mw = Math.abs(marqueeEnd.x - marqueeStart.x); 386 + const mh = Math.abs(marqueeEnd.y - marqueeStart.y); 387 + if (mw > 3 || mh > 3) { 388 + deps.setSelectedShapeIds(new Set(shapesInRect(wb, { x: mx, y: my, width: mw, height: mh }))); 389 + } 390 + deps.render(); 391 + 392 + } else if (isCreatingShape && createShapeKind) { 393 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 394 + renderCreationPreview(rendererDeps, createStart, pt, createShapeKind); 395 + 396 + } else if (isResizing && resizeHandle && resizeShapeId && resizeShapeOriginal) { 397 + const dx = (e.clientX - resizeStart.x) / wb.zoom; 398 + const dy = (e.clientY - resizeStart.y) / wb.zoom; 399 + const newBounds = applyResize(resizeShapeOriginal, resizeHandle, dx, dy); 400 + const shape = wb.shapes.get(resizeShapeId); 401 + if (shape) { 402 + const snapped = wb.snapToGrid 403 + ? snapPoint(newBounds.x, newBounds.y, wb.gridSize) 404 + : { x: newBounds.x, y: newBounds.y }; 405 + const shapes = new Map(wb.shapes); 406 + shapes.set(resizeShapeId, { ...shape, x: snapped.x, y: snapped.y, width: Math.max(10, newBounds.width), height: Math.max(10, newBounds.height) }); 407 + wb = { ...wb, shapes }; 408 + deps.setState(wb); 409 + deps.render(); 410 + } 411 + 412 + } else if (isDrawingArrow && arrowFromAnchor) { 413 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 414 + renderArrowPreview(rendererDeps, arrowFromAnchor, pt); 415 + const hover = shapeAtPoint(wb, pt.x, pt.y); 416 + const newTarget = hover && hover.id !== arrowFromShape ? hover.id : null; 417 + if (newTarget !== arrowHoverTargetId) { 418 + arrowHoverTargetId = newTarget; 419 + deps.render(); 420 + renderArrowPreview(rendererDeps, arrowFromAnchor, pt); 421 + } 422 + 423 + } else if (isDrawingFreehand) { 424 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 425 + freehandPoints.push(pt); 426 + let tempPath = deps.layer.querySelector('.freehand-preview') as SVGPathElement | null; 427 + if (!tempPath) { 428 + tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGPathElement; 429 + tempPath.classList.add('freehand-preview'); 430 + tempPath.setAttribute('fill', 'none'); 431 + tempPath.setAttribute('stroke', activeTool === 'highlighter' ? 'rgba(255,255,0,0.4)' : 'var(--color-text)'); 432 + tempPath.setAttribute('stroke-width', activeTool === 'highlighter' ? '12' : '2'); 433 + tempPath.setAttribute('stroke-linecap', 'round'); 434 + deps.layer.appendChild(tempPath); 435 + } 436 + tempPath.setAttribute('d', pointsToCatmullRomPath(freehandPoints)); 437 + } 438 + }); 439 + 440 + // --- mouseup --- 441 + canvas.addEventListener('mouseup', (e) => { 442 + let wb = deps.getState(); 443 + const activeTool = deps.getActiveTool(); 444 + let selectedShapeIds = deps.getSelectedShapeIds(); 445 + 446 + if (isErasing) { 447 + isErasing = false; 448 + return; 449 + } 450 + 451 + if (isRotating) { 452 + deps.pushHistory(); 453 + isRotating = false; 454 + rotateShapeId = null; 455 + deps.syncToYjs(); 456 + return; 457 + } 458 + 459 + if (isDragging) { 460 + if (!altDragDuplicated) deps.pushHistory(); 461 + isDragging = false; 462 + dragShapesStart.clear(); 463 + deps.clearSnapGuides(); 464 + deps.stopEdgeScroll(); 465 + deps.syncToYjs(); 466 + } 467 + 468 + if (isPanning) { 469 + isPanning = false; 470 + } 471 + 472 + if (isMarqueeSelecting) { 473 + isMarqueeSelecting = false; 474 + const dx = Math.abs(marqueeEnd.x - marqueeStart.x); 475 + const dy = Math.abs(marqueeEnd.y - marqueeStart.y); 476 + if (dx < 5 && dy < 5) { 477 + deps.setSelectedShapeIds(new Set()); 478 + } 479 + deps.render(); 480 + } 481 + 482 + if (isCreatingShape && createShapeKind) { 483 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 484 + const dx = Math.abs(pt.x - createStart.x); 485 + const dy = Math.abs(pt.y - createStart.y); 486 + 487 + deps.pushHistory(); 488 + if (Math.sqrt(dx * dx + dy * dy) < 5) { 489 + const defaultLabel = createShapeKind === 'text' ? 'Text' : createShapeKind === 'note' ? 'Note' : ''; 490 + wb = addShape(wb, createShapeKind, createStart.x, createStart.y, 120, 80, defaultLabel); 491 + } else { 492 + const x = Math.min(createStart.x, pt.x); 493 + const y = Math.min(createStart.y, pt.y); 494 + const defaultLabel = createShapeKind === 'text' ? 'Text' : createShapeKind === 'note' ? 'Note' : ''; 495 + wb = addShape(wb, createShapeKind, x, y, Math.max(10, dx), Math.max(10, dy), defaultLabel); 496 + } 497 + // Set default note fill 498 + if (createShapeKind === 'note') { 499 + const shapes = [...wb.shapes.values()]; 500 + const lastShape = shapes[shapes.length - 1]; 501 + if (lastShape) { 502 + wb = setShapeStyle(wb, [lastShape.id], { fill: '#fef08a' }); 503 + } 504 + } 505 + deps.setState(wb); 506 + deps.syncToYjs(); 507 + removeCreationPreview(rendererDeps); 508 + isCreatingShape = false; 509 + createShapeKind = null; 510 + deps.render(); 511 + } 512 + 513 + if (isResizing) { 514 + deps.pushHistory(); 515 + isResizing = false; 516 + resizeHandle = null; 517 + resizeShapeId = null; 518 + resizeShapeOriginal = null; 519 + deps.syncToYjs(); 520 + } 521 + 522 + if (isDrawingArrow && arrowFromAnchor) { 523 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 524 + const hit = shapeAtPoint(wb, pt.x, pt.y); 525 + 526 + let fromEp: ArrowEndpoint; 527 + if (arrowFromShape) { 528 + fromEp = { shapeId: arrowFromShape, anchor: arrowFromAnchor.anchor as 'top' | 'bottom' | 'left' | 'right' | 'center' }; 529 + } else { 530 + fromEp = { x: arrowFromAnchor.x, y: arrowFromAnchor.y }; 531 + } 532 + 533 + let toEp: ArrowEndpoint; 534 + if (hit && hit.id !== arrowFromShape) { 535 + const toAnchorInfo = nearestEdgeAnchor(hit, pt.x, pt.y); 536 + toEp = { shapeId: hit.id, anchor: toAnchorInfo.anchor }; 537 + } else { 538 + toEp = { x: pt.x, y: pt.y }; 539 + } 540 + 541 + const fromPt = arrowFromAnchor; 542 + const dist = Math.sqrt((pt.x - fromPt.x) ** 2 + (pt.y - fromPt.y) ** 2); 543 + if (dist > 5) { 544 + deps.pushHistory(); 545 + wb = addArrow(wb, fromEp, toEp); 546 + deps.setState(wb); 547 + deps.syncToYjs(); 548 + } 549 + 550 + removeArrowPreview(rendererDeps); 551 + arrowHoverTargetId = null; 552 + isDrawingArrow = false; 553 + arrowFromShape = null; 554 + arrowFromAnchor = null; 555 + deps.render(); 556 + } 557 + 558 + if (isDrawingFreehand && freehandPoints.length > 2) { 559 + deps.pushHistory(); 560 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 561 + freehandPoints.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); 562 + const normalized = freehandPoints.map(p => ({ x: p.x - minX, y: p.y - minY })); 563 + wb = addShape(wb, 'freehand', minX, minY, maxX - minX || 10, maxY - minY || 10); 564 + const shapes = [...wb.shapes.values()]; 565 + const lastShape = shapes[shapes.length - 1]; 566 + if (lastShape) { 567 + wb.shapes.set(lastShape.id, { ...lastShape, points: normalized }); 568 + if (activeTool === 'highlighter') { 569 + wb = setShapeStyle(wb, [lastShape.id], { stroke: 'rgba(255,255,0,0.4)', strokeWidth: '12' }); 570 + } 571 + } 572 + deps.setState(wb); 573 + deps.syncToYjs(); 574 + deps.render(); 575 + const preview = deps.layer.querySelector('.freehand-preview'); 576 + if (preview) preview.remove(); 577 + isDrawingFreehand = false; 578 + freehandPoints = []; 579 + } else if (isDrawingFreehand) { 580 + isDrawingFreehand = false; 581 + freehandPoints = []; 582 + const preview = deps.layer.querySelector('.freehand-preview'); 583 + if (preview) preview.remove(); 584 + } 585 + }); 586 + 587 + // --- dblclick --- 588 + canvas.addEventListener('dblclick', (e) => { 589 + const activeTool = deps.getActiveTool(); 590 + if (activeTool === 'line' && isDrawingLine) { 591 + deps.finishLine(); 592 + return; 593 + } 594 + if (activeTool !== 'select') return; 595 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 596 + const wb = deps.getState(); 597 + const hit = shapeAtPoint(wb, pt.x, pt.y); 598 + if (hit) { 599 + deps.startTextEditing(hit.id); 600 + } 601 + }); 602 + 603 + // --- wheel --- 604 + canvas.addEventListener('wheel', (e) => { 605 + e.preventDefault(); 606 + let wb = deps.getState(); 607 + const delta = e.deltaY > 0 ? -0.1 : 0.1; 608 + wb = setZoom(wb, wb.zoom + delta); 609 + deps.setState(wb); 610 + deps.render(); 611 + }, { passive: false }); 612 + 613 + // --- Touch: pinch-to-zoom and single-finger pan --- 614 + canvas.addEventListener('touchstart', (e) => { 615 + const wb = deps.getState(); 616 + const activeTool = deps.getActiveTool(); 617 + if (e.touches.length === 2) { 618 + e.preventDefault(); 619 + touchPanning = false; 620 + const dx = e.touches[1]!.clientX - e.touches[0]!.clientX; 621 + const dy = e.touches[1]!.clientY - e.touches[0]!.clientY; 622 + lastTouchDist = Math.sqrt(dx * dx + dy * dy); 623 + lastTouchCenter = { 624 + x: (e.touches[0]!.clientX + e.touches[1]!.clientX) / 2, 625 + y: (e.touches[0]!.clientY + e.touches[1]!.clientY) / 2, 626 + }; 627 + } else if (e.touches.length === 1 && (activeTool === 'hand' || activeTool === 'select')) { 628 + const touch = e.touches[0]!; 629 + const pt = deps.screenToCanvas(touch.clientX, touch.clientY); 630 + const hitShape = shapeAtPoint(wb, pt.x, pt.y); 631 + if (!hitShape || activeTool === 'hand') { 632 + e.preventDefault(); 633 + touchPanning = true; 634 + touchPanStart = { x: touch.clientX, y: touch.clientY }; 635 + touchPanWbStart = { x: wb.panX, y: wb.panY }; 636 + } 637 + } 638 + }, { passive: false }); 639 + 640 + canvas.addEventListener('touchmove', (e) => { 641 + let wb = deps.getState(); 642 + if (e.touches.length === 2) { 643 + e.preventDefault(); 644 + touchPanning = false; 645 + const dx = e.touches[1]!.clientX - e.touches[0]!.clientX; 646 + const dy = e.touches[1]!.clientY - e.touches[0]!.clientY; 647 + const dist = Math.sqrt(dx * dx + dy * dy); 648 + const scale = dist / lastTouchDist; 649 + wb = setZoom(wb, wb.zoom * scale); 650 + lastTouchDist = dist; 651 + 652 + const center = { 653 + x: (e.touches[0]!.clientX + e.touches[1]!.clientX) / 2, 654 + y: (e.touches[0]!.clientY + e.touches[1]!.clientY) / 2, 655 + }; 656 + wb = { ...wb, panX: wb.panX + (center.x - lastTouchCenter.x), panY: wb.panY + (center.y - lastTouchCenter.y) }; 657 + lastTouchCenter = center; 658 + deps.setState(wb); 659 + deps.render(); 660 + } else if (e.touches.length === 1 && touchPanning) { 661 + e.preventDefault(); 662 + const touch = e.touches[0]!; 663 + const dx = touch.clientX - touchPanStart.x; 664 + const dy = touch.clientY - touchPanStart.y; 665 + wb = { ...wb, panX: touchPanWbStart.x + dx, panY: touchPanWbStart.y + dy }; 666 + deps.setState(wb); 667 + deps.render(); 668 + } 669 + }, { passive: false }); 670 + 671 + canvas.addEventListener('touchend', () => { 672 + touchPanning = false; 673 + }); 674 + } 675 + 676 + // --------------------------------------------------------------------------- 677 + // Reset drawing state (used by Escape key handler in keyboard-shortcuts) 678 + // --------------------------------------------------------------------------- 679 + 680 + export function resetDrawingState(deps: { layer: SVGGElement }) { 681 + isDrawingArrow = false; 682 + arrowFromShape = null; 683 + arrowFromAnchor = null; 684 + isDrawingFreehand = false; 685 + freehandPoints = []; 686 + isDrawingLine = false; 687 + linePoints = []; 688 + isCreatingShape = false; 689 + createShapeKind = null; 690 + arrowHoverTargetId = null; 691 + removeCreationPreview({ layer: deps.layer }); 692 + removeArrowPreview({ layer: deps.layer }); 693 + deps.layer.querySelector('.freehand-preview')?.remove(); 694 + deps.layer.querySelector('.line-preview')?.remove(); 695 + } 696 + 697 + /** 698 + * Finish drawing the current line (called by keyboard shortcut or tool switch). 699 + */ 700 + export function finishCurrentLine(deps: CanvasEventDeps) { 701 + if (linePoints.length < 2) { 702 + isDrawingLine = false; 703 + linePoints = []; 704 + deps.layer.querySelector('.line-preview')?.remove(); 705 + return; 706 + } 707 + deps.pushHistory(); 708 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 709 + linePoints.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); 710 + const normalized = linePoints.map(p => ({ x: p.x - minX, y: p.y - minY })); 711 + let wb = deps.getState(); 712 + wb = addShape(wb, 'line', minX, minY, maxX - minX || 10, maxY - minY || 10); 713 + const shapes = [...wb.shapes.values()]; 714 + const lastShape = shapes[shapes.length - 1]; 715 + if (lastShape) { 716 + wb.shapes.set(lastShape.id, { ...lastShape, points: normalized }); 717 + } 718 + deps.setState(wb); 719 + deps.syncToYjs(); 720 + isDrawingLine = false; 721 + linePoints = []; 722 + deps.layer.querySelector('.line-preview')?.remove(); 723 + deps.render(); 724 + }
+214
src/diagrams/keyboard-shortcuts.ts
··· 1 + // @ts-nocheck 2 + /** 3 + * Keyboard Shortcuts — key event handler and shortcuts help dialog. 4 + * 5 + * Extracted from main.ts to isolate keyboard interaction from rendering and state. 6 + */ 7 + 8 + import { 9 + removeShapes, bringToFront, sendToBack, 10 + groupShapes, ungroupShapes, 11 + } from './whiteboard.js'; 12 + import type { WhiteboardState } from './whiteboard.js'; 13 + import { 14 + resetDrawingState, finishCurrentLine, 15 + getIsDrawingLine, getLinePoints, setSpaceHeld, 16 + } from './canvas-events.js'; 17 + import { 18 + doCopy, doPaste, doDuplicate, 19 + toggleFocusMode, hideContextMenu, 20 + } from './toolbar-wiring.js'; 21 + import type { ToolbarWiringDeps } from './toolbar-wiring.js'; 22 + import type { CanvasEventDeps } from './canvas-events.js'; 23 + 24 + // --------------------------------------------------------------------------- 25 + // Deps interface 26 + // --------------------------------------------------------------------------- 27 + 28 + export interface KeyboardShortcutDeps extends ToolbarWiringDeps { 29 + layer: SVGGElement; 30 + getEditingShapeId: () => string | null; 31 + doUndo: () => void; 32 + doRedo: () => void; 33 + /** CanvasEventDeps needed for finishCurrentLine */ 34 + canvasEventDeps: CanvasEventDeps; 35 + } 36 + 37 + // --------------------------------------------------------------------------- 38 + // Shortcuts Dialog 39 + // --------------------------------------------------------------------------- 40 + 41 + let shortcutsDialogOpen = false; 42 + 43 + export function toggleShortcutsDialog() { 44 + if (shortcutsDialogOpen) { closeShortcutsDialog(); return; } 45 + shortcutsDialogOpen = true; 46 + const overlay = document.createElement('div'); 47 + overlay.className = 'diagrams-shortcuts-overlay'; 48 + overlay.addEventListener('click', closeShortcutsDialog); 49 + document.body.appendChild(overlay); 50 + 51 + const dialog = document.createElement('div'); 52 + dialog.className = 'diagrams-shortcuts-dialog'; 53 + dialog.innerHTML = `<h2>Keyboard Shortcuts</h2>`; 54 + 55 + const shortcuts: Array<[string, string]> = [ 56 + ['V', 'Select tool'], 57 + ['H', 'Hand / pan tool'], 58 + ['R', 'Rectangle'], 59 + ['E', 'Ellipse'], 60 + ['D', 'Diamond'], 61 + ['T', 'Text'], 62 + ['P', 'Freehand / pen'], 63 + ['A', 'Arrow'], 64 + ['X', 'Eraser'], 65 + ['N', 'Sticky note'], 66 + ['Space + drag', 'Pan canvas'], 67 + ['Scroll wheel', 'Zoom'], 68 + ['\u2318Z', 'Undo'], 69 + ['\u21e7\u2318Z', 'Redo'], 70 + ['\u2318C', 'Copy'], 71 + ['\u2318V', 'Paste'], 72 + ['\u2318D', 'Duplicate'], 73 + ['\u2318A', 'Select all'], 74 + ['\u2318G', 'Group'], 75 + ['\u21e7\u2318G', 'Ungroup'], 76 + ['\u2318]', 'Bring to front'], 77 + ['\u2318[', 'Send to back'], 78 + ['Delete', 'Delete selected'], 79 + ['Escape', 'Deselect / cancel'], 80 + ['Double-click', 'Edit text'], 81 + ['Shift + click', 'Multi-select'], 82 + ['Shift + rotate', 'Snap to 15\u00b0'], 83 + ['Alt + drag', 'Duplicate while dragging'], 84 + ['\u2318.', 'Toggle focus mode'], 85 + ['\u2318\u2325/', 'Keyboard shortcuts'], 86 + ]; 87 + 88 + for (const [key, desc] of shortcuts) { 89 + const row = document.createElement('div'); 90 + row.className = 'shortcut-row'; 91 + row.innerHTML = `<span>${desc}</span><kbd>${key}</kbd>`; 92 + dialog.appendChild(row); 93 + } 94 + 95 + document.body.appendChild(dialog); 96 + } 97 + 98 + function closeShortcutsDialog() { 99 + shortcutsDialogOpen = false; 100 + document.querySelector('.diagrams-shortcuts-dialog')?.remove(); 101 + document.querySelector('.diagrams-shortcuts-overlay')?.remove(); 102 + } 103 + 104 + // --------------------------------------------------------------------------- 105 + // Helper: finish any active line drawing, then switch tool 106 + // --------------------------------------------------------------------------- 107 + 108 + function finishLineAndSwitchTool(deps: KeyboardShortcutDeps, tool: string) { 109 + if (getIsDrawingLine() && getLinePoints().length >= 2) { 110 + finishCurrentLine(deps.canvasEventDeps); 111 + } 112 + deps.setActiveTool(tool); 113 + deps.updateToolbar(); 114 + } 115 + 116 + // --------------------------------------------------------------------------- 117 + // Wire keyboard events 118 + // --------------------------------------------------------------------------- 119 + 120 + export function wireKeyboardShortcuts(deps: KeyboardShortcutDeps) { 121 + document.addEventListener('keydown', (e) => { 122 + // Don't handle shortcuts when editing text 123 + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; 124 + if (deps.getEditingShapeId()) return; 125 + 126 + if (e.key === ' ') { 127 + setSpaceHeld(true); 128 + e.preventDefault(); 129 + return; 130 + } 131 + 132 + const mod = e.metaKey || e.ctrlKey; 133 + 134 + // Focus mode: Cmd+. 135 + if (mod && e.key === '.') { e.preventDefault(); toggleFocusMode(deps); return; } 136 + 137 + // Shortcuts dialog: Cmd+Alt+/ or ? 138 + if (mod && e.altKey && e.key === '/') { e.preventDefault(); toggleShortcutsDialog(); return; } 139 + if (e.key === '?' && !mod) { toggleShortcutsDialog(); return; } 140 + 141 + if (mod && e.key === 'z' && !e.shiftKey) { e.preventDefault(); deps.doUndo(); return; } 142 + if (mod && e.key === 'z' && e.shiftKey) { e.preventDefault(); deps.doRedo(); return; } 143 + if (mod && e.key === 'y') { e.preventDefault(); deps.doRedo(); return; } 144 + if (mod && e.key === 'c') { e.preventDefault(); doCopy(deps); return; } 145 + if (mod && e.key === 'v') { e.preventDefault(); doPaste(deps); return; } 146 + if (mod && e.key === 'd') { e.preventDefault(); doDuplicate(deps); return; } 147 + if (mod && e.key === 'a') { e.preventDefault(); deps.setSelectedShapeIds(new Set(deps.getState().shapes.keys())); deps.render(); return; } 148 + if (mod && e.key === 'g' && !e.shiftKey) { 149 + e.preventDefault(); 150 + const selectedShapeIds = deps.getSelectedShapeIds(); 151 + if (selectedShapeIds.size >= 2) { 152 + deps.pushHistory(); 153 + const result = groupShapes(deps.getState(), [...selectedShapeIds]); 154 + deps.setState(result.state); 155 + deps.syncToYjs(); 156 + deps.render(); 157 + } 158 + return; 159 + } 160 + if (mod && e.key === 'g' && e.shiftKey) { 161 + e.preventDefault(); 162 + const selectedShapeIds = deps.getSelectedShapeIds(); 163 + if (selectedShapeIds.size > 0) { 164 + const wb = deps.getState(); 165 + const shape = wb.shapes.get([...selectedShapeIds][0]!); 166 + if (shape?.groupId) { 167 + deps.pushHistory(); 168 + deps.setState(ungroupShapes(wb, shape.groupId)); 169 + deps.syncToYjs(); 170 + deps.render(); 171 + } 172 + } 173 + return; 174 + } 175 + // Z-order shortcuts 176 + if (mod && e.key === ']') { e.preventDefault(); if (deps.getSelectedShapeIds().size > 0) { deps.pushHistory(); deps.setState(bringToFront(deps.getState(), deps.getSelectedShapeIds())); deps.syncToYjs(); deps.render(); } return; } 177 + if (mod && e.key === '[') { e.preventDefault(); if (deps.getSelectedShapeIds().size > 0) { deps.pushHistory(); deps.setState(sendToBack(deps.getState(), deps.getSelectedShapeIds())); deps.syncToYjs(); deps.render(); } return; } 178 + 179 + switch (e.key) { 180 + case 'Escape': 181 + if (getIsDrawingLine() && getLinePoints().length >= 2) { finishCurrentLine(deps.canvasEventDeps); break; } 182 + deps.setActiveTool('select'); 183 + deps.setSelectedShapeIds(new Set()); 184 + resetDrawingState({ layer: deps.layer }); 185 + deps.updateToolbar(); 186 + deps.render(); 187 + break; 188 + case 'v': case 'V': finishLineAndSwitchTool(deps, 'select'); break; 189 + case 'r': case 'R': finishLineAndSwitchTool(deps, 'rectangle'); break; 190 + case 'e': case 'E': finishLineAndSwitchTool(deps, 'ellipse'); break; 191 + case 'd': case 'D': finishLineAndSwitchTool(deps, 'diamond'); break; 192 + case 't': case 'T': finishLineAndSwitchTool(deps, 'text'); break; 193 + case 'l': case 'L': deps.setActiveTool('line'); deps.updateToolbar(); break; 194 + case 'p': case 'P': finishLineAndSwitchTool(deps, 'freehand'); break; 195 + case 'a': case 'A': finishLineAndSwitchTool(deps, 'arrow'); break; 196 + case 'h': case 'H': finishLineAndSwitchTool(deps, 'hand'); break; 197 + case 'x': case 'X': finishLineAndSwitchTool(deps, 'eraser'); break; 198 + case 'n': case 'N': finishLineAndSwitchTool(deps, 'note'); break; 199 + case 'Delete': case 'Backspace': 200 + if (deps.getSelectedShapeIds().size > 0) { 201 + deps.pushHistory(); 202 + deps.setState(removeShapes(deps.getState(), deps.getSelectedShapeIds())); 203 + deps.setSelectedShapeIds(new Set()); 204 + deps.syncToYjs(); 205 + deps.render(); 206 + } 207 + break; 208 + } 209 + }); 210 + 211 + document.addEventListener('keyup', (e) => { 212 + if (e.key === ' ') setSpaceHeld(false); 213 + }); 214 + }
+121 -1469
src/diagrams/main.ts
··· 1 - 1 + // @ts-nocheck 2 2 /** 3 3 * Tools Diagrams — E2EE collaborative whiteboard/diagrams. 4 4 * Backed by Yjs for real-time collaboration. ··· 10 10 import { EncryptedProvider } from '../lib/provider.js'; 11 11 import { setupTooltips } from '../lib/tooltips.js'; 12 12 import { 13 - createWhiteboard, addShape, removeShape, removeShapes, moveShape, moveShapes, 14 - resizeShape, setShapeLabel, addArrow, removeArrow, toggleSnap, setZoom, 15 - hitTestShape, shapeAtPoint, shapesInRect, arrowsForShape, getBoundingBox, 16 - getResizeHandles, hitTestResizeHandle, applyResize, pointsToCatmullRomPath, 17 - nearestEdgeAnchor, snapPoint, 18 - bringToFront, sendToBack, bringForward, sendBackward, 19 - alignShapes, distributeShapes, flipShapes, 20 - groupShapes, ungroupShapes, getGroupMembers, 21 - rotateShape, setShapeRotation, 22 - setShapeStyle, setShapeOpacity, setShapeFontFamily, setShapeFontSize, 23 - duplicateShapes, 13 + createWhiteboard, addShape, setShapeLabel, setZoom, 14 + shapeAtPoint, getBoundingBox, getResizeHandles, 15 + pointsToCatmullRomPath, snapPoint, 24 16 } from './whiteboard.js'; 25 17 import type { 26 - WhiteboardState, Shape, Arrow, ShapeKind, ArrowEndpoint, Point, ResizeHandle, 18 + WhiteboardState, Shape, Arrow, ArrowEndpoint, Point, 27 19 } from './whiteboard.js'; 28 20 import History from './history.js'; 29 - import { exportToSVG, exportAndDownloadSVG, exportAndDownloadPNG } from './export.js'; 30 21 import { 31 22 createChatSidebar, createChatState, loadConfig, isConfigured, 32 23 buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, ··· 36 27 import { splitResponse, isDiagramAction } from '../lib/ai-actions.js'; 37 28 import { executeDiagramAction } from './ai-diagram-actions.js'; 38 29 30 + // Extracted modules 31 + import { 32 + appendRect, appendEllipse, appendDiamond, appendTriangle, 33 + appendStar, appendHexagon, appendCloud, appendCylinder, 34 + appendParallelogram, appendNote, 35 + } from './shape-renderers.js'; 36 + import { 37 + wireCanvasEvents, getArrowHoverTargetId, 38 + getIsMarqueeSelecting, getMarqueeStart, getMarqueeEnd, 39 + finishCurrentLine, 40 + } from './canvas-events.js'; 41 + import type { CanvasEventDeps } from './canvas-events.js'; 42 + import { 43 + wireToolbarEvents, startEdgeScroll, stopEdgeScroll, 44 + } from './toolbar-wiring.js'; 45 + import type { ToolbarWiringDeps } from './toolbar-wiring.js'; 46 + import { wireKeyboardShortcuts } from './keyboard-shortcuts.js'; 47 + 39 48 // --- DOM refs --- 40 49 const $ = (id: string) => document.getElementById(id)!; 41 50 const diagramTitle = $('diagram-title') as HTMLInputElement; ··· 67 76 // Selection (multi-select) 68 77 let selectedShapeIds: Set<string> = new Set(); 69 78 70 - // Clipboard for copy/paste 71 - let clipboard: { shapes: Shape[]; arrows: Arrow[] } | null = null; 72 - 73 - // Shape dragging 74 - let isDragging = false; 75 - let dragStart: Point = { x: 0, y: 0 }; 76 - let dragShapesStart: Map<string, Point> = new Map(); 77 - 78 - // Pan (middle-click or Space+drag) 79 - let isPanning = false; 80 - let panStart: Point = { x: 0, y: 0 }; 81 - let panWbStart: Point = { x: 0, y: 0 }; 82 - let spaceHeld = false; 83 - 84 - // Marquee selection 85 - let isMarqueeSelecting = false; 86 - let marqueeStart: Point = { x: 0, y: 0 }; 87 - let marqueeEnd: Point = { x: 0, y: 0 }; 88 - 89 - // Drag-to-create shapes 90 - let isCreatingShape = false; 91 - let createShapeKind: ShapeKind | null = null; 92 - let createStart: Point = { x: 0, y: 0 }; 93 - 94 - // Resize handles 95 - let isResizing = false; 96 - let resizeHandle: ResizeHandle | null = null; 97 - let resizeShapeId: string | null = null; 98 - let resizeStart: Point = { x: 0, y: 0 }; 99 - let resizeShapeOriginal: { x: number; y: number; width: number; height: number } | null = null; 100 - 101 - // Rotation handle 102 - let isRotating = false; 103 - let rotateShapeId: string | null = null; 104 - let rotateStartAngle = 0; 105 - let rotateShapeStartRotation = 0; 106 - 107 - // Alt+drag duplicate 108 - let altDragDuplicated = false; 109 - 110 - // Arrow drawing 111 - let isDrawingArrow = false; 112 - let arrowFromShape: string | null = null; 113 - let arrowFromAnchor: { anchor: string; x: number; y: number } | null = null; 114 - 115 - // Freehand drawing 116 - let freehandPoints: Point[] = []; 117 - let isDrawingFreehand = false; 118 - 119 - // Arrow hover target for visual feedback 120 - let arrowHoverTargetId: string | null = null; 121 - 122 - // Line drawing (multi-point) 123 - let isDrawingLine = false; 124 - let linePoints: Point[] = []; 125 - 126 - // Eraser mode 127 - let isErasing = false; 128 - 129 79 // Inline text editing 130 80 let editingShapeId: string | null = null; 131 81 ··· 136 86 const guides: Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }> = []; 137 87 if (draggedIds.size === 0) return guides; 138 88 139 - // Get bounding box of dragged shapes 140 89 let dMinX = Infinity, dMinY = Infinity, dMaxX = -Infinity, dMaxY = -Infinity; 141 90 for (const id of draggedIds) { 142 91 const s = wb.shapes.get(id); ··· 149 98 const dCx = (dMinX + dMaxX) / 2; 150 99 const dCy = (dMinY + dMaxY) / 2; 151 100 152 - // Edges/centers to check 153 101 const dragEdgesH = [dMinX, dCx, dMaxX]; 154 102 const dragEdgesV = [dMinY, dCy, dMaxY]; 155 103 ··· 177 125 } 178 126 179 127 function renderSnapGuides(guides: Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }>) { 180 - // Remove existing guides 181 128 layer.querySelectorAll('.snap-guide').forEach(el => el.remove()); 182 129 for (const g of guides) { 183 130 const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); ··· 270 217 } catch { /* defaults */ } 271 218 } 272 219 220 + // --- Canvas coordinate conversion --- 221 + function screenToCanvas(sx: number, sy: number): Point { 222 + const rect = canvas.getBoundingClientRect(); 223 + return { 224 + x: (sx - rect.left - wb.panX) / wb.zoom, 225 + y: (sy - rect.top - wb.panY) / wb.zoom, 226 + }; 227 + } 228 + 273 229 // --- Rendering --- 230 + function resolveEndpoint(ep: ArrowEndpoint): Point | null { 231 + if ('x' in ep) return { x: ep.x, y: ep.y }; 232 + const shape = wb.shapes.get(ep.shapeId); 233 + if (!shape) return null; 234 + const cx = shape.x + shape.width / 2; 235 + const cy = shape.y + shape.height / 2; 236 + switch (ep.anchor) { 237 + case 'top': return { x: cx, y: shape.y }; 238 + case 'bottom': return { x: cx, y: shape.y + shape.height }; 239 + case 'left': return { x: shape.x, y: cy }; 240 + case 'right': return { x: shape.x + shape.width, y: cy }; 241 + default: return { x: cx, y: cy }; 242 + } 243 + } 244 + 274 245 function render() { 275 - // Preserve inline text editing overlay across re-renders 276 246 const editOverlay = editingShapeId ? layer.querySelector('.inline-text-edit') : null; 277 247 layer.innerHTML = ''; 278 248 const transform = `translate(${wb.panX}, ${wb.panY}) scale(${wb.zoom})`; 279 249 layer.setAttribute('transform', transform); 280 250 251 + const arrowHoverTargetId = getArrowHoverTargetId(); 252 + 281 253 // Render shapes 282 254 wb.shapes.forEach((shape) => { 283 255 const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); ··· 388 360 circle.setAttribute('data-handle', h.handle); 389 361 layer.appendChild(circle); 390 362 } 391 - // Rotation handle — circle above the shape 363 + // Rotation handle 392 364 const rotY = selShape.y - 25; 393 365 const rotX = selShape.x + selShape.width / 2; 394 366 const rotLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); ··· 442 414 }); 443 415 444 416 // Render marquee selection rect 445 - if (isMarqueeSelecting) { 446 - const mx = Math.min(marqueeStart.x, marqueeEnd.x); 447 - const my = Math.min(marqueeStart.y, marqueeEnd.y); 448 - const mw = Math.abs(marqueeEnd.x - marqueeStart.x); 449 - const mh = Math.abs(marqueeEnd.y - marqueeStart.y); 417 + if (getIsMarqueeSelecting()) { 418 + const ms = getMarqueeStart(); 419 + const me = getMarqueeEnd(); 420 + const mx = Math.min(ms.x, me.x); 421 + const my = Math.min(ms.y, me.y); 422 + const mw = Math.abs(me.x - ms.x); 423 + const mh = Math.abs(me.y - ms.y); 450 424 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 451 425 rect.setAttribute('x', String(mx)); 452 426 rect.setAttribute('y', String(my)); ··· 467 441 updateStylePanel(); 468 442 } 469 443 470 - function resolveEndpoint(ep: ArrowEndpoint): Point | null { 471 - if ('x' in ep) return { x: ep.x, y: ep.y }; 472 - const shape = wb.shapes.get(ep.shapeId); 473 - if (!shape) return null; 474 - const cx = shape.x + shape.width / 2; 475 - const cy = shape.y + shape.height / 2; 476 - switch (ep.anchor) { 477 - case 'top': return { x: cx, y: shape.y }; 478 - case 'bottom': return { x: cx, y: shape.y + shape.height }; 479 - case 'left': return { x: shape.x, y: cy }; 480 - case 'right': return { x: shape.x + shape.width, y: cy }; 481 - default: return { x: cx, y: cy }; 482 - } 483 - } 484 - 485 - // --- Shape renderers --- 486 - function appendRect(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 487 - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 488 - rect.setAttribute('width', String(w)); 489 - rect.setAttribute('height', String(h)); 490 - rect.setAttribute('rx', '4'); 491 - rect.setAttribute('fill', fill); 492 - rect.setAttribute('stroke', stroke); 493 - rect.setAttribute('stroke-width', strokeWidth); 494 - if (dasharray) rect.setAttribute('stroke-dasharray', dasharray); 495 - g.appendChild(rect); 496 - } 497 - 498 - function appendEllipse(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 499 - const el = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 500 - el.setAttribute('cx', String(w / 2)); 501 - el.setAttribute('cy', String(h / 2)); 502 - el.setAttribute('rx', String(w / 2)); 503 - el.setAttribute('ry', String(h / 2)); 504 - el.setAttribute('fill', fill); 505 - el.setAttribute('stroke', stroke); 506 - el.setAttribute('stroke-width', strokeWidth); 507 - if (dasharray) el.setAttribute('stroke-dasharray', dasharray); 508 - g.appendChild(el); 509 - } 510 - 511 - function appendDiamond(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 512 - const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 513 - poly.setAttribute('points', `${w/2},0 ${w},${h/2} ${w/2},${h} 0,${h/2}`); 514 - poly.setAttribute('fill', fill); 515 - poly.setAttribute('stroke', stroke); 516 - poly.setAttribute('stroke-width', strokeWidth); 517 - if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 518 - g.appendChild(poly); 519 - } 520 - 521 - function appendTriangle(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 522 - const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 523 - poly.setAttribute('points', `${w/2},0 ${w},${h} 0,${h}`); 524 - poly.setAttribute('fill', fill); 525 - poly.setAttribute('stroke', stroke); 526 - poly.setAttribute('stroke-width', strokeWidth); 527 - if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 528 - g.appendChild(poly); 529 - } 530 - 531 - function appendStar(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 532 - const cx = w / 2, cy = h / 2; 533 - const outerR = Math.min(w, h) / 2; 534 - const innerR = outerR * 0.38; 535 - const pts: string[] = []; 536 - for (let i = 0; i < 5; i++) { 537 - const outerAngle = (Math.PI / 2 + (i * 2 * Math.PI) / 5) * -1; 538 - const innerAngle = outerAngle - Math.PI / 5; 539 - pts.push(`${cx + outerR * Math.cos(outerAngle)},${cy + outerR * Math.sin(outerAngle)}`); 540 - pts.push(`${cx + innerR * Math.cos(innerAngle)},${cy + innerR * Math.sin(innerAngle)}`); 541 - } 542 - const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 543 - poly.setAttribute('points', pts.join(' ')); 544 - poly.setAttribute('fill', fill); 545 - poly.setAttribute('stroke', stroke); 546 - poly.setAttribute('stroke-width', strokeWidth); 547 - if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 548 - g.appendChild(poly); 549 - } 550 - 551 - function appendHexagon(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 552 - const cx = w / 2, cy = h / 2; 553 - const rx = w / 2, ry = h / 2; 554 - const pts: string[] = []; 555 - for (let i = 0; i < 6; i++) { 556 - const angle = (Math.PI / 3) * i - Math.PI / 6; 557 - pts.push(`${cx + rx * Math.cos(angle)},${cy + ry * Math.sin(angle)}`); 558 - } 559 - const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 560 - poly.setAttribute('points', pts.join(' ')); 561 - poly.setAttribute('fill', fill); 562 - poly.setAttribute('stroke', stroke); 563 - poly.setAttribute('stroke-width', strokeWidth); 564 - if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 565 - g.appendChild(poly); 566 - } 567 - 568 - function appendCloud(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 569 - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 570 - const d = `M${w*0.25},${h*0.8} C${w*0.05},${h*0.8} ${w*0.0},${h*0.55} ${w*0.15},${h*0.45} C${w*0.05},${h*0.3} ${w*0.15},${h*0.1} ${w*0.35},${h*0.2} C${w*0.4},${h*0.05} ${w*0.6},${h*0.05} ${w*0.65},${h*0.2} C${w*0.85},${h*0.1} ${w*0.95},${h*0.3} ${w*0.85},${h*0.45} C${w*1.0},${h*0.55} ${w*0.95},${h*0.8} ${w*0.75},${h*0.8} Z`; 571 - path.setAttribute('d', d); 572 - path.setAttribute('fill', fill); 573 - path.setAttribute('stroke', stroke); 574 - path.setAttribute('stroke-width', strokeWidth); 575 - if (dasharray) path.setAttribute('stroke-dasharray', dasharray); 576 - g.appendChild(path); 577 - } 578 - 579 - function appendCylinder(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 580 - const ry = h * 0.12; 581 - // Body 582 - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 583 - rect.setAttribute('x', '0'); 584 - rect.setAttribute('y', String(ry)); 585 - rect.setAttribute('width', String(w)); 586 - rect.setAttribute('height', String(h - 2 * ry)); 587 - rect.setAttribute('fill', fill); 588 - rect.setAttribute('stroke', 'none'); 589 - g.appendChild(rect); 590 - // Side lines 591 - const leftLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 592 - leftLine.setAttribute('x1', '0'); leftLine.setAttribute('y1', String(ry)); 593 - leftLine.setAttribute('x2', '0'); leftLine.setAttribute('y2', String(h - ry)); 594 - leftLine.setAttribute('stroke', stroke); leftLine.setAttribute('stroke-width', strokeWidth); 595 - if (dasharray) leftLine.setAttribute('stroke-dasharray', dasharray); 596 - g.appendChild(leftLine); 597 - const rightLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 598 - rightLine.setAttribute('x1', String(w)); rightLine.setAttribute('y1', String(ry)); 599 - rightLine.setAttribute('x2', String(w)); rightLine.setAttribute('y2', String(h - ry)); 600 - rightLine.setAttribute('stroke', stroke); rightLine.setAttribute('stroke-width', strokeWidth); 601 - if (dasharray) rightLine.setAttribute('stroke-dasharray', dasharray); 602 - g.appendChild(rightLine); 603 - // Top ellipse 604 - const topEllipse = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 605 - topEllipse.setAttribute('cx', String(w / 2)); topEllipse.setAttribute('cy', String(ry)); 606 - topEllipse.setAttribute('rx', String(w / 2)); topEllipse.setAttribute('ry', String(ry)); 607 - topEllipse.setAttribute('fill', fill); topEllipse.setAttribute('stroke', stroke); 608 - topEllipse.setAttribute('stroke-width', strokeWidth); 609 - if (dasharray) topEllipse.setAttribute('stroke-dasharray', dasharray); 610 - g.appendChild(topEllipse); 611 - // Bottom ellipse (half, front arc) 612 - const botPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 613 - botPath.setAttribute('d', `M0,${h - ry} A${w/2},${ry} 0 0,0 ${w},${h - ry}`); 614 - botPath.setAttribute('fill', 'none'); botPath.setAttribute('stroke', stroke); 615 - botPath.setAttribute('stroke-width', strokeWidth); 616 - if (dasharray) botPath.setAttribute('stroke-dasharray', dasharray); 617 - g.appendChild(botPath); 618 - } 619 - 620 - function appendParallelogram(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 621 - const skew = w * 0.2; 622 - const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 623 - poly.setAttribute('points', `${skew},0 ${w},0 ${w - skew},${h} 0,${h}`); 624 - poly.setAttribute('fill', fill); 625 - poly.setAttribute('stroke', stroke); 626 - poly.setAttribute('stroke-width', strokeWidth); 627 - if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 628 - g.appendChild(poly); 629 - } 630 - 631 - function appendNote(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 632 - // Sticky note with folded corner 633 - const fold = Math.min(w, h) * 0.15; 634 - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 635 - path.setAttribute('d', `M0,0 H${w - fold} L${w},${fold} V${h} H0 Z`); 636 - path.setAttribute('fill', fill || '#fef08a'); 637 - path.setAttribute('stroke', stroke); 638 - path.setAttribute('stroke-width', strokeWidth); 639 - if (dasharray) path.setAttribute('stroke-dasharray', dasharray); 640 - g.appendChild(path); 641 - // Fold triangle 642 - const foldPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 643 - foldPath.setAttribute('d', `M${w - fold},0 V${fold} H${w}`); 644 - foldPath.setAttribute('fill', 'none'); 645 - foldPath.setAttribute('stroke', stroke); 646 - foldPath.setAttribute('stroke-width', '1'); 647 - g.appendChild(foldPath); 648 - } 649 - 650 444 function updateToolbar() { 651 445 document.querySelectorAll('.diagrams-tool').forEach(btn => { 652 446 btn.classList.toggle('active', (btn as HTMLElement).dataset.tool === activeTool); ··· 675 469 return; 676 470 } 677 471 stylePanel.style.display = ''; 678 - // Show first selected shape's styles 679 472 const shape = wb.shapes.get([...selectedShapeIds][0]!); 680 473 if (!shape) return; 681 474 styleFill.value = shape.style?.fill || '#ffffff'; ··· 688 481 styleFontSize.value = String(shape.fontSize || 14); 689 482 } 690 483 691 - // --- Canvas event handling --- 692 - function screenToCanvas(sx: number, sy: number): Point { 693 - const rect = canvas.getBoundingClientRect(); 694 - return { 695 - x: (sx - rect.left - wb.panX) / wb.zoom, 696 - y: (sy - rect.top - wb.panY) / wb.zoom, 697 - }; 698 - } 699 - 700 - // --- Creation preview --- 701 - function renderCreationPreview(start: Point, end: Point, kind: ShapeKind) { 702 - removeCreationPreview(); 703 - const x = Math.min(start.x, end.x); 704 - const y = Math.min(start.y, end.y); 705 - const w = Math.abs(end.x - start.x); 706 - const h = Math.abs(end.y - start.y); 707 - if (w < 2 && h < 2) return; 708 - 709 - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 710 - g.classList.add('creation-preview'); 711 - g.setAttribute('transform', `translate(${x}, ${y})`); 712 - 713 - switch (kind) { 714 - case 'rectangle': case 'text': case 'note': { 715 - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 716 - rect.setAttribute('width', String(w)); 717 - rect.setAttribute('height', String(h)); 718 - rect.setAttribute('rx', '4'); 719 - g.appendChild(rect); 720 - break; 721 - } 722 - case 'ellipse': { 723 - const el = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 724 - el.setAttribute('cx', String(w / 2)); 725 - el.setAttribute('cy', String(h / 2)); 726 - el.setAttribute('rx', String(w / 2)); 727 - el.setAttribute('ry', String(h / 2)); 728 - g.appendChild(el); 729 - break; 730 - } 731 - case 'diamond': { 732 - const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 733 - poly.setAttribute('points', `${w/2},0 ${w},${h/2} ${w/2},${h} 0,${h/2}`); 734 - g.appendChild(poly); 735 - break; 736 - } 737 - case 'triangle': { 738 - const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 739 - poly.setAttribute('points', `${w/2},0 ${w},${h} 0,${h}`); 740 - g.appendChild(poly); 741 - break; 742 - } 743 - default: { 744 - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 745 - rect.setAttribute('width', String(w)); 746 - rect.setAttribute('height', String(h)); 747 - rect.setAttribute('rx', '4'); 748 - g.appendChild(rect); 749 - break; 750 - } 751 - } 752 - layer.appendChild(g); 753 - } 754 - 755 - function removeCreationPreview() { 756 - layer.querySelector('.creation-preview')?.remove(); 757 - } 758 - 759 - // --- Arrow preview --- 760 - function renderArrowPreview(from: Point, to: Point) { 761 - removeArrowPreview(); 762 - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 763 - line.setAttribute('x1', String(from.x)); 764 - line.setAttribute('y1', String(from.y)); 765 - line.setAttribute('x2', String(to.x)); 766 - line.setAttribute('y2', String(to.y)); 767 - line.setAttribute('marker-end', 'url(#arrowhead)'); 768 - line.classList.add('arrow-preview'); 769 - layer.appendChild(line); 770 - } 771 - 772 - function removeArrowPreview() { 773 - layer.querySelector('.arrow-preview')?.remove(); 774 - } 775 - 776 - // --- Line preview --- 777 - function renderLinePreview() { 778 - layer.querySelector('.line-preview')?.remove(); 779 - if (linePoints.length < 1) return; 780 - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 781 - g.classList.add('line-preview'); 782 - if (linePoints.length >= 2) { 783 - const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); 784 - polyline.setAttribute('points', linePoints.map(p => `${p.x},${p.y}`).join(' ')); 785 - polyline.setAttribute('fill', 'none'); 786 - polyline.setAttribute('stroke', 'var(--color-text)'); 787 - polyline.setAttribute('stroke-width', '2'); 788 - g.appendChild(polyline); 789 - } 790 - // Draw dots at each point 791 - for (const p of linePoints) { 792 - const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 793 - c.setAttribute('cx', String(p.x)); 794 - c.setAttribute('cy', String(p.y)); 795 - c.setAttribute('r', '3'); 796 - c.setAttribute('fill', 'var(--color-primary)'); 797 - g.appendChild(c); 798 - } 799 - layer.appendChild(g); 800 - } 801 - 802 - function finishLine() { 803 - if (linePoints.length < 2) { isDrawingLine = false; linePoints = []; layer.querySelector('.line-preview')?.remove(); return; } 804 - pushHistory(); 805 - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 806 - linePoints.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); 807 - const normalized = linePoints.map(p => ({ x: p.x - minX, y: p.y - minY })); 808 - wb = addShape(wb, 'line', minX, minY, maxX - minX || 10, maxY - minY || 10); 809 - const shapes = [...wb.shapes.values()]; 810 - const lastShape = shapes[shapes.length - 1]; 811 - if (lastShape) { 812 - wb.shapes.set(lastShape.id, { ...lastShape, points: normalized }); 813 - } 814 - syncToYjs(); 815 - isDrawingLine = false; 816 - linePoints = []; 817 - layer.querySelector('.line-preview')?.remove(); 818 - render(); 819 - } 820 - 821 484 // --- Inline text editing --- 822 485 function startTextEditing(shapeId: string) { 823 486 const shape = wb.shapes.get(shapeId); ··· 825 488 editingShapeId = shapeId; 826 489 render(); 827 490 828 - // Create a foreignObject with a textarea overlay 829 491 const fo = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); 830 492 fo.setAttribute('x', String(shape.x)); 831 493 fo.setAttribute('y', String(shape.y)); ··· 839 501 textarea.addEventListener('blur', () => finishTextEditing()); 840 502 textarea.addEventListener('keydown', (e) => { 841 503 if (e.key === 'Escape') { finishTextEditing(); e.preventDefault(); } 842 - e.stopPropagation(); // Prevent shortcut keys 504 + e.stopPropagation(); 843 505 }); 844 506 fo.appendChild(textarea); 845 507 layer.appendChild(fo); ··· 863 525 render(); 864 526 } 865 527 866 - // --- Cursor management --- 867 - const HANDLE_CURSORS: Record<ResizeHandle, string> = { 868 - nw: 'nwse-resize', se: 'nwse-resize', 869 - ne: 'nesw-resize', sw: 'nesw-resize', 870 - n: 'ns-resize', s: 'ns-resize', 871 - e: 'ew-resize', w: 'ew-resize', 528 + // --- Shared deps objects for extracted modules --- 529 + const toolbarDeps: ToolbarWiringDeps = { 530 + canvas, 531 + layer, 532 + diagramTitle, 533 + propsSection, 534 + propsDimensions, 535 + propLabel, 536 + propWidth, 537 + propHeight, 538 + stylePanel, 539 + styleFill, 540 + styleStroke, 541 + styleStrokeWidth, 542 + styleStrokeStyle, 543 + styleOpacity, 544 + styleOpacityValue, 545 + styleFontFamily, 546 + styleFontSize, 547 + getState: () => wb, 548 + setState: (s) => { wb = s; }, 549 + getActiveTool: () => activeTool, 550 + setActiveTool: (t) => { activeTool = t; }, 551 + getSelectedShapeIds: () => selectedShapeIds, 552 + setSelectedShapeIds: (ids) => { selectedShapeIds = ids; }, 553 + render, 554 + syncToYjs, 555 + pushHistory, 556 + updateToolbar, 557 + screenToCanvas, 872 558 }; 873 559 874 - function updateCursor(e: MouseEvent) { 875 - const canvasArea = $('canvas-area'); 876 - if (activeTool === 'eraser') { 877 - canvasArea.style.cursor = 'crosshair'; 878 - return; 879 - } 880 - if (activeTool !== 'select' || selectedShapeIds.size !== 1) { 881 - canvasArea.style.cursor = activeTool === 'select' ? '' : 'crosshair'; 882 - return; 883 - } 884 - const pt = screenToCanvas(e.clientX, e.clientY); 885 - const selShape = wb.shapes.get([...selectedShapeIds][0]!); 886 - if (selShape) { 887 - // Check rotation handle 888 - const rotX = selShape.x + selShape.width / 2; 889 - const rotY = selShape.y - 25; 890 - if (Math.abs(pt.x - rotX) <= 8 && Math.abs(pt.y - rotY) <= 8) { 891 - canvasArea.style.cursor = 'grab'; 892 - return; 893 - } 894 - const handle = hitTestResizeHandle(selShape, pt.x, pt.y); 895 - if (handle) { 896 - canvasArea.style.cursor = HANDLE_CURSORS[handle]; 897 - return; 898 - } 899 - } 900 - canvasArea.style.cursor = ''; 901 - } 902 - 903 - // --- Mouse events --- 904 - canvas.addEventListener('mousedown', (e) => { 905 - // Don't disrupt active text editing 906 - if (editingShapeId) return; 907 - 908 - const pt = screenToCanvas(e.clientX, e.clientY); 909 - 910 - // Middle-click or Space+drag or Hand tool = pan 911 - if (e.button === 1 || spaceHeld || activeTool === 'hand') { 912 - isPanning = true; 913 - panStart = { x: e.clientX, y: e.clientY }; 914 - panWbStart = { x: wb.panX, y: wb.panY }; 915 - e.preventDefault(); 916 - return; 917 - } 918 - 919 - if (activeTool === 'eraser') { 920 - isErasing = true; 921 - const hit = shapeAtPoint(wb, pt.x, pt.y); 922 - if (hit) { 923 - pushHistory(); 924 - wb = removeShape(wb, hit.id); 925 - selectedShapeIds.delete(hit.id); 926 - syncToYjs(); 927 - render(); 928 - } 929 - return; 930 - } 931 - 932 - if (activeTool === 'select') { 933 - // Check rotation handle first (single selection) 934 - if (selectedShapeIds.size === 1) { 935 - const selId = [...selectedShapeIds][0]!; 936 - const selShape = wb.shapes.get(selId); 937 - if (selShape) { 938 - const rotX = selShape.x + selShape.width / 2; 939 - const rotY = selShape.y - 25; 940 - if (Math.abs(pt.x - rotX) <= 8 && Math.abs(pt.y - rotY) <= 8) { 941 - isRotating = true; 942 - rotateShapeId = selId; 943 - rotateShapeStartRotation = selShape.rotation; 944 - const cx = selShape.x + selShape.width / 2; 945 - const cy = selShape.y + selShape.height / 2; 946 - rotateStartAngle = Math.atan2(pt.y - cy, pt.x - cx) * 180 / Math.PI; 947 - return; 948 - } 949 - 950 - // Check resize handles 951 - const handle = hitTestResizeHandle(selShape, pt.x, pt.y); 952 - if (handle) { 953 - isResizing = true; 954 - resizeHandle = handle; 955 - resizeShapeId = selId; 956 - resizeStart = { x: e.clientX, y: e.clientY }; 957 - resizeShapeOriginal = { x: selShape.x, y: selShape.y, width: selShape.width, height: selShape.height }; 958 - return; 959 - } 960 - } 961 - } 962 - 963 - const hit = shapeAtPoint(wb, pt.x, pt.y); 964 - if (hit) { 965 - // If hit shape is in a group, select the whole group 966 - if (hit.groupId && !e.shiftKey) { 967 - const members = getGroupMembers(wb, hit.groupId); 968 - selectedShapeIds = new Set(members); 969 - } else if (e.shiftKey) { 970 - const newSet = new Set(selectedShapeIds); 971 - if (newSet.has(hit.id)) newSet.delete(hit.id); 972 - else newSet.add(hit.id); 973 - selectedShapeIds = newSet; 974 - } else if (!selectedShapeIds.has(hit.id)) { 975 - selectedShapeIds = new Set([hit.id]); 976 - } 977 - // Start dragging all selected shapes 978 - isDragging = true; 979 - altDragDuplicated = false; 980 - dragStart = { x: e.clientX, y: e.clientY }; 981 - dragShapesStart = new Map(); 982 - // Alt+drag = duplicate first, then drag the copies 983 - if (e.altKey && selectedShapeIds.size > 0) { 984 - pushHistory(); 985 - const result = duplicateShapes(wb, selectedShapeIds); 986 - wb = result.state; 987 - selectedShapeIds = new Set(result.idMap.values()); 988 - altDragDuplicated = true; 989 - syncToYjs(); 990 - } 991 - for (const id of selectedShapeIds) { 992 - const s = wb.shapes.get(id); 993 - if (s) dragShapesStart.set(id, { x: s.x, y: s.y }); 994 - } 995 - } else { 996 - // Empty canvas click → start marquee selection 997 - if (!e.shiftKey) selectedShapeIds = new Set(); 998 - isMarqueeSelecting = true; 999 - marqueeStart = pt; 1000 - marqueeEnd = pt; 1001 - } 1002 - render(); 1003 - 1004 - } else if (activeTool === 'line') { 1005 - // Click to add points to line; double-click handled separately to finish 1006 - if (!isDrawingLine) { 1007 - isDrawingLine = true; 1008 - linePoints = [pt]; 1009 - } else { 1010 - linePoints.push(pt); 1011 - } 1012 - // Render line preview 1013 - renderLinePreview(); 1014 - return; 1015 - 1016 - } else if (activeTool === 'arrow') { 1017 - isDrawingArrow = true; 1018 - const hit = shapeAtPoint(wb, pt.x, pt.y); 1019 - if (hit) { 1020 - arrowFromShape = hit.id; 1021 - arrowFromAnchor = nearestEdgeAnchor(hit, pt.x, pt.y); 1022 - } else { 1023 - // Free-standing arrow from empty space 1024 - arrowFromShape = null; 1025 - arrowFromAnchor = { anchor: 'center', x: pt.x, y: pt.y }; 1026 - } 1027 - 1028 - } else if (activeTool === 'freehand' || activeTool === 'highlighter') { 1029 - isDrawingFreehand = true; 1030 - freehandPoints = [pt]; 1031 - 1032 - } else { 1033 - // Shape creation tools — start drag-to-create 1034 - const kind = activeTool as ShapeKind; 1035 - const creatableShapes: ShapeKind[] = ['rectangle', 'ellipse', 'diamond', 'text', 'triangle', 'star', 'hexagon', 'cloud', 'cylinder', 'parallelogram', 'note']; 1036 - if (creatableShapes.includes(kind)) { 1037 - isCreatingShape = true; 1038 - createShapeKind = kind; 1039 - createStart = pt; 1040 - } 1041 - } 1042 - }); 1043 - 1044 - canvas.addEventListener('mousemove', (e) => { 1045 - updateCursor(e); 1046 - 1047 - if (isErasing && activeTool === 'eraser') { 1048 - const pt = screenToCanvas(e.clientX, e.clientY); 1049 - const hit = shapeAtPoint(wb, pt.x, pt.y); 1050 - if (hit) { 1051 - pushHistory(); 1052 - wb = removeShape(wb, hit.id); 1053 - selectedShapeIds.delete(hit.id); 1054 - syncToYjs(); 1055 - render(); 1056 - } 1057 - return; 1058 - } 1059 - 1060 - if (isRotating && rotateShapeId) { 1061 - const shape = wb.shapes.get(rotateShapeId); 1062 - if (shape) { 1063 - const pt = screenToCanvas(e.clientX, e.clientY); 1064 - const cx = shape.x + shape.width / 2; 1065 - const cy = shape.y + shape.height / 2; 1066 - const currentAngle = Math.atan2(pt.y - cy, pt.x - cx) * 180 / Math.PI; 1067 - let newRotation = rotateShapeStartRotation + (currentAngle - rotateStartAngle); 1068 - // Snap to 15-degree increments if shift held 1069 - if (e.shiftKey) newRotation = Math.round(newRotation / 15) * 15; 1070 - wb = setShapeRotation(wb, rotateShapeId, newRotation); 1071 - render(); 1072 - } 1073 - return; 1074 - } 1075 - 1076 - if (isDragging && selectedShapeIds.size > 0) { 1077 - const dx = (e.clientX - dragStart.x) / wb.zoom; 1078 - const dy = (e.clientY - dragStart.y) / wb.zoom; 1079 - const shapes = new Map(wb.shapes); 1080 - for (const [id, startPos] of dragShapesStart) { 1081 - const shape = shapes.get(id); 1082 - if (!shape) continue; 1083 - const nx = startPos.x + dx; 1084 - const ny = startPos.y + dy; 1085 - const snapped = wb.snapToGrid ? snapPoint(nx, ny, wb.gridSize) : { x: nx, y: ny }; 1086 - shapes.set(id, { ...shape, x: snapped.x, y: snapped.y }); 1087 - } 1088 - wb = { ...wb, shapes }; 1089 - render(); 1090 - // Show snap guides while dragging 1091 - const guides = computeSnapGuides(selectedShapeIds); 1092 - renderSnapGuides(guides); 1093 - // Edge scrolling while dragging near edges 1094 - startEdgeScroll(e.clientX, e.clientY); 1095 - 1096 - } else if (isPanning) { 1097 - const dx = e.clientX - panStart.x; 1098 - const dy = e.clientY - panStart.y; 1099 - wb = { ...wb, panX: panWbStart.x + dx, panY: panWbStart.y + dy }; 1100 - render(); 1101 - 1102 - } else if (isMarqueeSelecting) { 1103 - marqueeEnd = screenToCanvas(e.clientX, e.clientY); 1104 - const mx = Math.min(marqueeStart.x, marqueeEnd.x); 1105 - const my = Math.min(marqueeStart.y, marqueeEnd.y); 1106 - const mw = Math.abs(marqueeEnd.x - marqueeStart.x); 1107 - const mh = Math.abs(marqueeEnd.y - marqueeStart.y); 1108 - if (mw > 3 || mh > 3) { 1109 - selectedShapeIds = new Set(shapesInRect(wb, { x: mx, y: my, width: mw, height: mh })); 1110 - } 1111 - render(); 1112 - 1113 - } else if (isCreatingShape && createShapeKind) { 1114 - const pt = screenToCanvas(e.clientX, e.clientY); 1115 - renderCreationPreview(createStart, pt, createShapeKind); 1116 - 1117 - } else if (isResizing && resizeHandle && resizeShapeId && resizeShapeOriginal) { 1118 - const dx = (e.clientX - resizeStart.x) / wb.zoom; 1119 - const dy = (e.clientY - resizeStart.y) / wb.zoom; 1120 - const newBounds = applyResize(resizeShapeOriginal, resizeHandle, dx, dy); 1121 - const shape = wb.shapes.get(resizeShapeId); 1122 - if (shape) { 1123 - const snapped = wb.snapToGrid 1124 - ? snapPoint(newBounds.x, newBounds.y, wb.gridSize) 1125 - : { x: newBounds.x, y: newBounds.y }; 1126 - const shapes = new Map(wb.shapes); 1127 - shapes.set(resizeShapeId, { ...shape, x: snapped.x, y: snapped.y, width: Math.max(10, newBounds.width), height: Math.max(10, newBounds.height) }); 1128 - wb = { ...wb, shapes }; 1129 - render(); 1130 - } 1131 - 1132 - } else if (isDrawingArrow && arrowFromAnchor) { 1133 - const pt = screenToCanvas(e.clientX, e.clientY); 1134 - renderArrowPreview(arrowFromAnchor, pt); 1135 - const hover = shapeAtPoint(wb, pt.x, pt.y); 1136 - const newTarget = hover && hover.id !== arrowFromShape ? hover.id : null; 1137 - if (newTarget !== arrowHoverTargetId) { 1138 - arrowHoverTargetId = newTarget; 1139 - render(); 1140 - renderArrowPreview(arrowFromAnchor, pt); 1141 - } 1142 - 1143 - } else if (isDrawingFreehand) { 1144 - const pt = screenToCanvas(e.clientX, e.clientY); 1145 - freehandPoints.push(pt); 1146 - let tempPath = layer.querySelector('.freehand-preview') as SVGPathElement | null; 1147 - if (!tempPath) { 1148 - tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGPathElement; 1149 - tempPath.classList.add('freehand-preview'); 1150 - tempPath.setAttribute('fill', 'none'); 1151 - tempPath.setAttribute('stroke', activeTool === 'highlighter' ? 'rgba(255,255,0,0.4)' : 'var(--color-text)'); 1152 - tempPath.setAttribute('stroke-width', activeTool === 'highlighter' ? '12' : '2'); 1153 - tempPath.setAttribute('stroke-linecap', 'round'); 1154 - layer.appendChild(tempPath); 1155 - } 1156 - tempPath.setAttribute('d', pointsToCatmullRomPath(freehandPoints)); 1157 - } 1158 - }); 1159 - 1160 - canvas.addEventListener('mouseup', (e) => { 1161 - if (isErasing) { 1162 - isErasing = false; 1163 - return; 1164 - } 1165 - 1166 - if (isRotating) { 1167 - pushHistory(); 1168 - isRotating = false; 1169 - rotateShapeId = null; 1170 - syncToYjs(); 1171 - return; 1172 - } 1173 - 1174 - if (isDragging) { 1175 - if (!altDragDuplicated) pushHistory(); 1176 - isDragging = false; 1177 - dragShapesStart.clear(); 1178 - clearSnapGuides(); 1179 - stopEdgeScroll(); 1180 - syncToYjs(); 1181 - } 1182 - 1183 - if (isPanning) { 1184 - isPanning = false; 1185 - } 1186 - 1187 - if (isMarqueeSelecting) { 1188 - isMarqueeSelecting = false; 1189 - const dx = Math.abs(marqueeEnd.x - marqueeStart.x); 1190 - const dy = Math.abs(marqueeEnd.y - marqueeStart.y); 1191 - if (dx < 5 && dy < 5) { 1192 - selectedShapeIds = new Set(); 1193 - } 1194 - render(); 1195 - } 1196 - 1197 - if (isCreatingShape && createShapeKind) { 1198 - const pt = screenToCanvas(e.clientX, e.clientY); 1199 - const dx = Math.abs(pt.x - createStart.x); 1200 - const dy = Math.abs(pt.y - createStart.y); 1201 - 1202 - pushHistory(); 1203 - if (Math.sqrt(dx * dx + dy * dy) < 5) { 1204 - const defaultLabel = createShapeKind === 'text' ? 'Text' : createShapeKind === 'note' ? 'Note' : ''; 1205 - wb = addShape(wb, createShapeKind, createStart.x, createStart.y, 120, 80, defaultLabel); 1206 - } else { 1207 - const x = Math.min(createStart.x, pt.x); 1208 - const y = Math.min(createStart.y, pt.y); 1209 - const defaultLabel = createShapeKind === 'text' ? 'Text' : createShapeKind === 'note' ? 'Note' : ''; 1210 - wb = addShape(wb, createShapeKind, x, y, Math.max(10, dx), Math.max(10, dy), defaultLabel); 1211 - } 1212 - // Set default note fill 1213 - if (createShapeKind === 'note') { 1214 - const shapes = [...wb.shapes.values()]; 1215 - const lastShape = shapes[shapes.length - 1]; 1216 - if (lastShape) { 1217 - wb = setShapeStyle(wb, [lastShape.id], { fill: '#fef08a' }); 1218 - } 1219 - } 1220 - syncToYjs(); 1221 - removeCreationPreview(); 1222 - isCreatingShape = false; 1223 - createShapeKind = null; 1224 - render(); 1225 - } 1226 - 1227 - if (isResizing) { 1228 - pushHistory(); 1229 - isResizing = false; 1230 - resizeHandle = null; 1231 - resizeShapeId = null; 1232 - resizeShapeOriginal = null; 1233 - syncToYjs(); 1234 - } 1235 - 1236 - if (isDrawingArrow && arrowFromAnchor) { 1237 - const pt = screenToCanvas(e.clientX, e.clientY); 1238 - const hit = shapeAtPoint(wb, pt.x, pt.y); 1239 - 1240 - // Build from endpoint 1241 - let fromEp: ArrowEndpoint; 1242 - if (arrowFromShape) { 1243 - fromEp = { shapeId: arrowFromShape, anchor: arrowFromAnchor.anchor as 'top' | 'bottom' | 'left' | 'right' | 'center' }; 1244 - } else { 1245 - fromEp = { x: arrowFromAnchor.x, y: arrowFromAnchor.y }; 1246 - } 1247 - 1248 - // Build to endpoint 1249 - let toEp: ArrowEndpoint; 1250 - if (hit && hit.id !== arrowFromShape) { 1251 - const toAnchorInfo = nearestEdgeAnchor(hit, pt.x, pt.y); 1252 - toEp = { shapeId: hit.id, anchor: toAnchorInfo.anchor }; 1253 - } else { 1254 - toEp = { x: pt.x, y: pt.y }; 1255 - } 1256 - 1257 - // Only create arrow if endpoints are different positions 1258 - const fromPt = arrowFromAnchor; 1259 - const dist = Math.sqrt((pt.x - fromPt.x) ** 2 + (pt.y - fromPt.y) ** 2); 1260 - if (dist > 5) { 1261 - pushHistory(); 1262 - wb = addArrow(wb, fromEp, toEp); 1263 - syncToYjs(); 1264 - } 1265 - 1266 - removeArrowPreview(); 1267 - arrowHoverTargetId = null; 1268 - isDrawingArrow = false; 1269 - arrowFromShape = null; 1270 - arrowFromAnchor = null; 1271 - render(); 1272 - } 1273 - 1274 - if (isDrawingFreehand && freehandPoints.length > 2) { 1275 - pushHistory(); 1276 - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 1277 - freehandPoints.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); 1278 - const normalized = freehandPoints.map(p => ({ x: p.x - minX, y: p.y - minY })); 1279 - wb = addShape(wb, 'freehand', minX, minY, maxX - minX || 10, maxY - minY || 10); 1280 - const shapes = [...wb.shapes.values()]; 1281 - const lastShape = shapes[shapes.length - 1]; 1282 - if (lastShape) { 1283 - wb.shapes.set(lastShape.id, { ...lastShape, points: normalized }); 1284 - // Highlighter gets semi-transparent yellow stroke 1285 - if (activeTool === 'highlighter') { 1286 - wb = setShapeStyle(wb, [lastShape.id], { stroke: 'rgba(255,255,0,0.4)', strokeWidth: '12' }); 1287 - } 1288 - } 1289 - syncToYjs(); 1290 - render(); 1291 - const preview = layer.querySelector('.freehand-preview'); 1292 - if (preview) preview.remove(); 1293 - isDrawingFreehand = false; 1294 - freehandPoints = []; 1295 - } else if (isDrawingFreehand) { 1296 - isDrawingFreehand = false; 1297 - freehandPoints = []; 1298 - const preview = layer.querySelector('.freehand-preview'); 1299 - if (preview) preview.remove(); 1300 - } 1301 - }); 1302 - 1303 - // Double-click for inline text editing or finish line 1304 - canvas.addEventListener('dblclick', (e) => { 1305 - if (activeTool === 'line' && isDrawingLine) { 1306 - finishLine(); 1307 - return; 1308 - } 1309 - if (activeTool !== 'select') return; 1310 - const pt = screenToCanvas(e.clientX, e.clientY); 1311 - const hit = shapeAtPoint(wb, pt.x, pt.y); 1312 - if (hit) { 1313 - startTextEditing(hit.id); 1314 - } 1315 - }); 1316 - 1317 - // Zoom with wheel 1318 - canvas.addEventListener('wheel', (e) => { 1319 - e.preventDefault(); 1320 - const delta = e.deltaY > 0 ? -0.1 : 0.1; 1321 - wb = setZoom(wb, wb.zoom + delta); 1322 - render(); 1323 - }, { passive: false }); 1324 - 1325 - // Touch support: pinch-to-zoom 1326 - let lastTouchDist = 0; 1327 - let lastTouchCenter: Point = { x: 0, y: 0 }; 1328 - 1329 - let touchPanning = false; 1330 - let touchPanStart: Point = { x: 0, y: 0 }; 1331 - let touchPanWbStart: Point = { x: 0, y: 0 }; 1332 - 1333 - canvas.addEventListener('touchstart', (e) => { 1334 - if (e.touches.length === 2) { 1335 - // Pinch-to-zoom 1336 - e.preventDefault(); 1337 - touchPanning = false; 1338 - const dx = e.touches[1]!.clientX - e.touches[0]!.clientX; 1339 - const dy = e.touches[1]!.clientY - e.touches[0]!.clientY; 1340 - lastTouchDist = Math.sqrt(dx * dx + dy * dy); 1341 - lastTouchCenter = { 1342 - x: (e.touches[0]!.clientX + e.touches[1]!.clientX) / 2, 1343 - y: (e.touches[0]!.clientY + e.touches[1]!.clientY) / 2, 1344 - }; 1345 - } else if (e.touches.length === 1 && (activeTool === 'hand' || activeTool === 'select')) { 1346 - // Single-finger pan (when hand tool active, or on empty canvas area with select) 1347 - const touch = e.touches[0]!; 1348 - const pt = screenToCanvas(touch.clientX, touch.clientY); 1349 - const hitShape = shapeAtPoint(wb, pt.x, pt.y); 1350 - if (!hitShape || activeTool === 'hand') { 1351 - e.preventDefault(); 1352 - touchPanning = true; 1353 - touchPanStart = { x: touch.clientX, y: touch.clientY }; 1354 - touchPanWbStart = { x: wb.panX, y: wb.panY }; 1355 - } 1356 - } 1357 - }, { passive: false }); 1358 - 1359 - canvas.addEventListener('touchmove', (e) => { 1360 - if (e.touches.length === 2) { 1361 - // Pinch-to-zoom 1362 - e.preventDefault(); 1363 - touchPanning = false; 1364 - const dx = e.touches[1]!.clientX - e.touches[0]!.clientX; 1365 - const dy = e.touches[1]!.clientY - e.touches[0]!.clientY; 1366 - const dist = Math.sqrt(dx * dx + dy * dy); 1367 - const scale = dist / lastTouchDist; 1368 - wb = setZoom(wb, wb.zoom * scale); 1369 - lastTouchDist = dist; 1370 - 1371 - const center = { 1372 - x: (e.touches[0]!.clientX + e.touches[1]!.clientX) / 2, 1373 - y: (e.touches[0]!.clientY + e.touches[1]!.clientY) / 2, 1374 - }; 1375 - wb = { ...wb, panX: wb.panX + (center.x - lastTouchCenter.x), panY: wb.panY + (center.y - lastTouchCenter.y) }; 1376 - lastTouchCenter = center; 1377 - render(); 1378 - } else if (e.touches.length === 1 && touchPanning) { 1379 - // Single-finger pan 1380 - e.preventDefault(); 1381 - const touch = e.touches[0]!; 1382 - const dx = touch.clientX - touchPanStart.x; 1383 - const dy = touch.clientY - touchPanStart.y; 1384 - wb = { ...wb, panX: touchPanWbStart.x + dx, panY: touchPanWbStart.y + dy }; 1385 - render(); 1386 - } 1387 - }, { passive: false }); 1388 - 1389 - canvas.addEventListener('touchend', () => { 1390 - touchPanning = false; 1391 - }); 1392 - 1393 - // --- Tool buttons --- 1394 - document.querySelectorAll('.diagrams-tool').forEach(btn => { 1395 - btn.addEventListener('click', () => { 1396 - activeTool = (btn as HTMLElement).dataset.tool || 'select'; 1397 - updateToolbar(); 1398 - }); 1399 - }); 1400 - 1401 - $('btn-snap-grid').addEventListener('click', () => { wb = toggleSnap(wb); render(); }); 1402 - $('btn-zoom-in').addEventListener('click', () => { wb = setZoom(wb, wb.zoom + 0.25); render(); }); 1403 - $('btn-zoom-out').addEventListener('click', () => { wb = setZoom(wb, wb.zoom - 0.25); render(); }); 1404 - $('btn-zoom-fit').addEventListener('click', () => { 1405 - const box = getBoundingBox(wb); 1406 - if (!box) return; 1407 - const canvasRect = canvas.getBoundingClientRect(); 1408 - const scaleX = canvasRect.width / (box.width + 100); 1409 - const scaleY = canvasRect.height / (box.height + 100); 1410 - const zoom = Math.min(scaleX, scaleY, 3); 1411 - wb = setZoom(wb, zoom); 1412 - wb = { ...wb, panX: canvasRect.width / 2 - (box.x + box.width / 2) * zoom, panY: canvasRect.height / 2 - (box.y + box.height / 2) * zoom }; 1413 - render(); 1414 - }); 1415 - 1416 - $('btn-delete').addEventListener('click', () => { 1417 - if (selectedShapeIds.size > 0) { 1418 - pushHistory(); 1419 - wb = removeShapes(wb, selectedShapeIds); 1420 - selectedShapeIds = new Set(); 1421 - syncToYjs(); 1422 - render(); 1423 - } 1424 - }); 560 + const canvasEventDeps: CanvasEventDeps = { 561 + canvas, 562 + layer, 563 + getState: () => wb, 564 + setState: (s) => { wb = s; }, 565 + getActiveTool: () => activeTool, 566 + setActiveTool: (t) => { activeTool = t; }, 567 + getSelectedShapeIds: () => selectedShapeIds, 568 + setSelectedShapeIds: (ids) => { selectedShapeIds = ids; }, 569 + getEditingShapeId: () => editingShapeId, 570 + render, 571 + syncToYjs, 572 + pushHistory, 573 + updateToolbar, 574 + screenToCanvas, 575 + startTextEditing, 576 + finishLine: () => finishCurrentLine(canvasEventDeps), 577 + computeSnapGuides, 578 + renderSnapGuides, 579 + clearSnapGuides, 580 + startEdgeScroll: (cx, cy) => startEdgeScroll(toolbarDeps, cx, cy), 581 + stopEdgeScroll, 582 + }; 1425 583 1426 - // Z-order buttons 1427 - $('btn-bring-front')?.addEventListener('click', () => { 1428 - if (selectedShapeIds.size > 0) { 1429 - pushHistory(); 1430 - wb = bringToFront(wb, selectedShapeIds); 1431 - syncToYjs(); 1432 - render(); 1433 - } 1434 - }); 1435 - $('btn-send-back')?.addEventListener('click', () => { 1436 - if (selectedShapeIds.size > 0) { 1437 - pushHistory(); 1438 - wb = sendToBack(wb, selectedShapeIds); 1439 - syncToYjs(); 1440 - render(); 1441 - } 1442 - }); 1443 - 1444 - // Alignment buttons 1445 - document.querySelectorAll('[data-align]').forEach(btn => { 1446 - btn.addEventListener('click', () => { 1447 - if (selectedShapeIds.size < 2) return; 1448 - pushHistory(); 1449 - const alignment = (btn as HTMLElement).dataset.align as any; 1450 - wb = alignShapes(wb, [...selectedShapeIds], alignment); 1451 - syncToYjs(); 1452 - render(); 1453 - }); 1454 - }); 1455 - 1456 - document.querySelectorAll('[data-distribute]').forEach(btn => { 1457 - btn.addEventListener('click', () => { 1458 - if (selectedShapeIds.size < 3) return; 1459 - pushHistory(); 1460 - const axis = (btn as HTMLElement).dataset.distribute as 'horizontal' | 'vertical'; 1461 - wb = distributeShapes(wb, [...selectedShapeIds], axis); 1462 - syncToYjs(); 1463 - render(); 1464 - }); 1465 - }); 1466 - 1467 - // Group/ungroup 1468 - $('btn-group')?.addEventListener('click', () => { 1469 - if (selectedShapeIds.size < 2) return; 1470 - pushHistory(); 1471 - const result = groupShapes(wb, [...selectedShapeIds]); 1472 - wb = result.state; 1473 - syncToYjs(); 1474 - render(); 1475 - }); 1476 - $('btn-ungroup')?.addEventListener('click', () => { 1477 - if (selectedShapeIds.size === 0) return; 1478 - const shape = wb.shapes.get([...selectedShapeIds][0]!); 1479 - if (!shape?.groupId) return; 1480 - pushHistory(); 1481 - wb = ungroupShapes(wb, shape.groupId); 1482 - syncToYjs(); 1483 - render(); 1484 - }); 1485 - 1486 - // Export buttons 1487 - $('btn-export-svg')?.addEventListener('click', () => { 1488 - const ids = selectedShapeIds.size > 0 ? selectedShapeIds : undefined; 1489 - exportAndDownloadSVG(wb, `${diagramTitle.value || 'diagram'}.svg`, ids); 1490 - }); 1491 - $('btn-export-png')?.addEventListener('click', async () => { 1492 - const ids = selectedShapeIds.size > 0 ? selectedShapeIds : undefined; 1493 - await exportAndDownloadPNG(wb, `${diagramTitle.value || 'diagram'}.png`, ids); 1494 - }); 1495 - 1496 - // Style panel events 1497 - styleFill?.addEventListener('input', () => { 1498 - if (selectedShapeIds.size === 0) return; 1499 - pushHistory(); 1500 - wb = setShapeStyle(wb, selectedShapeIds, { fill: styleFill.value }); 1501 - syncToYjs(); 1502 - render(); 1503 - }); 1504 - styleStroke?.addEventListener('input', () => { 1505 - if (selectedShapeIds.size === 0) return; 1506 - pushHistory(); 1507 - wb = setShapeStyle(wb, selectedShapeIds, { stroke: styleStroke.value }); 1508 - syncToYjs(); 1509 - render(); 1510 - }); 1511 - styleStrokeWidth?.addEventListener('change', () => { 1512 - if (selectedShapeIds.size === 0) return; 1513 - pushHistory(); 1514 - wb = setShapeStyle(wb, selectedShapeIds, { strokeWidth: styleStrokeWidth.value }); 1515 - syncToYjs(); 1516 - render(); 1517 - }); 1518 - styleStrokeStyle?.addEventListener('change', () => { 1519 - if (selectedShapeIds.size === 0) return; 1520 - pushHistory(); 1521 - const dashMap: Record<string, string> = { solid: '', dashed: '8 4', dotted: '2 2' }; 1522 - wb = setShapeStyle(wb, selectedShapeIds, { strokeDasharray: dashMap[styleStrokeStyle.value] || '' }); 1523 - syncToYjs(); 1524 - render(); 1525 - }); 1526 - styleOpacity?.addEventListener('input', () => { 1527 - if (selectedShapeIds.size === 0) return; 1528 - pushHistory(); 1529 - const val = Number(styleOpacity.value) / 100; 1530 - wb = setShapeOpacity(wb, selectedShapeIds, val); 1531 - styleOpacityValue.textContent = `${styleOpacity.value}%`; 1532 - syncToYjs(); 1533 - render(); 1534 - }); 1535 - styleFontFamily?.addEventListener('change', () => { 1536 - if (selectedShapeIds.size === 0) return; 1537 - pushHistory(); 1538 - wb = setShapeFontFamily(wb, selectedShapeIds, styleFontFamily.value); 1539 - syncToYjs(); 1540 - render(); 1541 - }); 1542 - styleFontSize?.addEventListener('change', () => { 1543 - if (selectedShapeIds.size === 0) return; 1544 - pushHistory(); 1545 - wb = setShapeFontSize(wb, selectedShapeIds, Number(styleFontSize.value)); 1546 - syncToYjs(); 1547 - render(); 1548 - }); 1549 - 1550 - // Properties panel 1551 - propLabel.addEventListener('change', () => { 1552 - if (selectedShapeIds.size === 1) { 1553 - pushHistory(); 1554 - const id = [...selectedShapeIds][0]!; 1555 - wb = setShapeLabel(wb, id, propLabel.value); syncToYjs(); render(); 1556 - } 1557 - }); 1558 - propWidth.addEventListener('change', () => { 1559 - if (selectedShapeIds.size === 1) { 1560 - pushHistory(); 1561 - const id = [...selectedShapeIds][0]!; 1562 - wb = resizeShape(wb, id, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); 1563 - } 1564 - }); 1565 - propHeight.addEventListener('change', () => { 1566 - if (selectedShapeIds.size === 1) { 1567 - pushHistory(); 1568 - const id = [...selectedShapeIds][0]!; 1569 - wb = resizeShape(wb, id, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); 1570 - } 1571 - }); 1572 - 1573 - // --- Copy / Paste --- 1574 - function doCopy() { 1575 - if (selectedShapeIds.size === 0) return; 1576 - const shapes: Shape[] = []; 1577 - const arrows: Arrow[] = []; 1578 - for (const id of selectedShapeIds) { 1579 - const s = wb.shapes.get(id); 1580 - if (s) shapes.push({ ...s }); 1581 - } 1582 - for (const arrow of wb.arrows.values()) { 1583 - const fromId = 'shapeId' in arrow.from ? arrow.from.shapeId : null; 1584 - const toId = 'shapeId' in arrow.to ? arrow.to.shapeId : null; 1585 - if (fromId && toId && selectedShapeIds.has(fromId) && selectedShapeIds.has(toId)) { 1586 - arrows.push({ ...arrow }); 1587 - } 1588 - } 1589 - clipboard = { shapes, arrows }; 1590 - } 1591 - 1592 - function doPaste() { 1593 - if (!clipboard || clipboard.shapes.length === 0) return; 1594 - pushHistory(); 1595 - const result = duplicateShapes(wb, clipboard.shapes.map(s => s.id)); 1596 - // If duplicateShapes couldn't find original IDs (pasted from different state), manually add 1597 - if (result.idMap.size === 0) { 1598 - // Re-add shapes with new IDs and offset 1599 - let newState = wb; 1600 - const newIds: string[] = []; 1601 - for (const shape of clipboard.shapes) { 1602 - newState = addShape(newState, shape.kind, shape.x + 20, shape.y + 20, shape.width, shape.height, shape.label); 1603 - const lastId = [...newState.shapes.keys()].pop()!; 1604 - newIds.push(lastId); 1605 - const last = newState.shapes.get(lastId)!; 1606 - newState.shapes.set(lastId, { ...last, style: { ...shape.style }, opacity: shape.opacity, rotation: shape.rotation, fontFamily: shape.fontFamily, fontSize: shape.fontSize, points: shape.points }); 1607 - } 1608 - wb = newState; 1609 - selectedShapeIds = new Set(newIds); 1610 - } else { 1611 - wb = result.state; 1612 - selectedShapeIds = new Set(result.idMap.values()); 1613 - } 1614 - syncToYjs(); 1615 - render(); 1616 - } 1617 - 1618 - function doDuplicate() { 1619 - if (selectedShapeIds.size === 0) return; 1620 - pushHistory(); 1621 - const result = duplicateShapes(wb, selectedShapeIds); 1622 - wb = result.state; 1623 - selectedShapeIds = new Set(result.idMap.values()); 1624 - syncToYjs(); 1625 - render(); 1626 - } 1627 - 1628 - // Flip buttons 1629 - $('btn-flip-h')?.addEventListener('click', () => { 1630 - if (selectedShapeIds.size > 0) { 1631 - pushHistory(); 1632 - wb = flipShapes(wb, [...selectedShapeIds], 'horizontal'); 1633 - syncToYjs(); 1634 - render(); 1635 - } 1636 - }); 1637 - $('btn-flip-v')?.addEventListener('click', () => { 1638 - if (selectedShapeIds.size > 0) { 1639 - pushHistory(); 1640 - wb = flipShapes(wb, [...selectedShapeIds], 'vertical'); 1641 - syncToYjs(); 1642 - render(); 1643 - } 1644 - }); 1645 - 1646 - // --- Context Menu --- 1647 - let contextMenuEl: HTMLElement | null = null; 1648 - 1649 - function showContextMenu(x: number, y: number) { 1650 - hideContextMenu(); 1651 - const menu = document.createElement('div'); 1652 - menu.className = 'diagrams-context-menu'; 1653 - menu.style.left = `${x}px`; 1654 - menu.style.top = `${y}px`; 1655 - 1656 - const items: Array<{ label: string; shortcut?: string; action: () => void; divider?: boolean }> = []; 1657 - 1658 - if (selectedShapeIds.size > 0) { 1659 - items.push({ label: 'Copy', shortcut: '\u2318C', action: doCopy }); 1660 - items.push({ label: 'Paste', shortcut: '\u2318V', action: doPaste }); 1661 - items.push({ label: 'Duplicate', shortcut: '\u2318D', action: doDuplicate }); 1662 - items.push({ label: '', shortcut: '', action: () => {}, divider: true }); 1663 - items.push({ label: 'Bring to Front', shortcut: '\u2318]', action: () => { pushHistory(); wb = bringToFront(wb, selectedShapeIds); syncToYjs(); render(); } }); 1664 - items.push({ label: 'Send to Back', shortcut: '\u2318[', action: () => { pushHistory(); wb = sendToBack(wb, selectedShapeIds); syncToYjs(); render(); } }); 1665 - items.push({ label: '', shortcut: '', action: () => {}, divider: true }); 1666 - if (selectedShapeIds.size >= 2) { 1667 - items.push({ label: 'Group', shortcut: '\u2318G', action: () => { pushHistory(); const r = groupShapes(wb, [...selectedShapeIds]); wb = r.state; syncToYjs(); render(); } }); 1668 - } 1669 - const firstShape = wb.shapes.get([...selectedShapeIds][0]!); 1670 - if (firstShape?.groupId) { 1671 - items.push({ label: 'Ungroup', shortcut: '\u21e7\u2318G', action: () => { pushHistory(); wb = ungroupShapes(wb, firstShape.groupId!); syncToYjs(); render(); } }); 1672 - } 1673 - if (selectedShapeIds.size >= 2) { 1674 - items.push({ label: 'Flip Horizontal', action: () => { pushHistory(); wb = flipShapes(wb, [...selectedShapeIds], 'horizontal'); syncToYjs(); render(); } }); 1675 - items.push({ label: 'Flip Vertical', action: () => { pushHistory(); wb = flipShapes(wb, [...selectedShapeIds], 'vertical'); syncToYjs(); render(); } }); 1676 - } 1677 - items.push({ label: '', shortcut: '', action: () => {}, divider: true }); 1678 - items.push({ label: 'Delete', shortcut: 'Del', action: () => { pushHistory(); wb = removeShapes(wb, selectedShapeIds); selectedShapeIds = new Set(); syncToYjs(); render(); } }); 1679 - } else { 1680 - items.push({ label: 'Paste', shortcut: '\u2318V', action: doPaste }); 1681 - items.push({ label: 'Select All', shortcut: '\u2318A', action: () => { selectedShapeIds = new Set(wb.shapes.keys()); render(); } }); 1682 - } 1683 - 1684 - for (const item of items) { 1685 - if (item.divider) { 1686 - const div = document.createElement('div'); 1687 - div.className = 'menu-divider'; 1688 - menu.appendChild(div); 1689 - continue; 1690 - } 1691 - const btn = document.createElement('button'); 1692 - btn.textContent = item.label; 1693 - if (item.shortcut) { 1694 - const kbd = document.createElement('span'); 1695 - kbd.className = 'shortcut'; 1696 - kbd.textContent = item.shortcut; 1697 - btn.appendChild(kbd); 1698 - } 1699 - btn.addEventListener('click', () => { hideContextMenu(); item.action(); }); 1700 - menu.appendChild(btn); 1701 - } 1702 - 1703 - document.body.appendChild(menu); 1704 - contextMenuEl = menu; 1705 - 1706 - // Adjust position if off-screen 1707 - const rect = menu.getBoundingClientRect(); 1708 - if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 4}px`; 1709 - if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 4}px`; 1710 - } 1711 - 1712 - function hideContextMenu() { 1713 - if (contextMenuEl) { 1714 - contextMenuEl.remove(); 1715 - contextMenuEl = null; 1716 - } 1717 - } 1718 - 1719 - canvas.addEventListener('contextmenu', (e) => { 1720 - e.preventDefault(); 1721 - const pt = screenToCanvas(e.clientX, e.clientY); 1722 - const hit = shapeAtPoint(wb, pt.x, pt.y); 1723 - if (hit && !selectedShapeIds.has(hit.id)) { 1724 - selectedShapeIds = new Set([hit.id]); 1725 - render(); 1726 - } 1727 - showContextMenu(e.clientX, e.clientY); 1728 - }); 1729 - 1730 - document.addEventListener('click', (e) => { 1731 - if (contextMenuEl && !contextMenuEl.contains(e.target as Node)) hideContextMenu(); 1732 - }); 1733 - 1734 - // --- Focus Mode --- 1735 - let focusModeActive = false; 1736 - 1737 - function toggleFocusMode() { 1738 - focusModeActive = !focusModeActive; 1739 - const toolbar = document.getElementById('diagrams-toolbar'); 1740 - const topbar = document.querySelector('.app-topbar') as HTMLElement; 1741 - if (toolbar) toolbar.style.display = focusModeActive ? 'none' : ''; 1742 - if (topbar) topbar.style.display = focusModeActive ? 'none' : ''; 1743 - // Also hide style panel 1744 - stylePanel.style.display = 'none'; 1745 - } 1746 - 1747 - // --- Edge Scrolling --- 1748 - const EDGE_SCROLL_ZONE = 40; 1749 - const EDGE_SCROLL_SPEED = 8; 1750 - let edgeScrollInterval: ReturnType<typeof setInterval> | null = null; 1751 - 1752 - function startEdgeScroll(clientX: number, clientY: number) { 1753 - stopEdgeScroll(); 1754 - const rect = canvas.getBoundingClientRect(); 1755 - let dx = 0, dy = 0; 1756 - if (clientX < rect.left + EDGE_SCROLL_ZONE) dx = EDGE_SCROLL_SPEED; 1757 - else if (clientX > rect.right - EDGE_SCROLL_ZONE) dx = -EDGE_SCROLL_SPEED; 1758 - if (clientY < rect.top + EDGE_SCROLL_ZONE) dy = EDGE_SCROLL_SPEED; 1759 - else if (clientY > rect.bottom - EDGE_SCROLL_ZONE) dy = -EDGE_SCROLL_SPEED; 1760 - 1761 - if (dx === 0 && dy === 0) { stopEdgeScroll(); return; } 1762 - edgeScrollInterval = setInterval(() => { 1763 - wb = { ...wb, panX: wb.panX + dx, panY: wb.panY + dy }; 1764 - render(); 1765 - }, 16); 1766 - } 1767 - 1768 - function stopEdgeScroll() { 1769 - if (edgeScrollInterval) { clearInterval(edgeScrollInterval); edgeScrollInterval = null; } 1770 - } 1771 - 1772 - // --- Keyboard Shortcuts Dialog --- 1773 - let shortcutsDialogOpen = false; 1774 - 1775 - function toggleShortcutsDialog() { 1776 - if (shortcutsDialogOpen) { closeShortcutsDialog(); return; } 1777 - shortcutsDialogOpen = true; 1778 - const overlay = document.createElement('div'); 1779 - overlay.className = 'diagrams-shortcuts-overlay'; 1780 - overlay.addEventListener('click', closeShortcutsDialog); 1781 - document.body.appendChild(overlay); 1782 - 1783 - const dialog = document.createElement('div'); 1784 - dialog.className = 'diagrams-shortcuts-dialog'; 1785 - dialog.innerHTML = `<h2>Keyboard Shortcuts</h2>`; 1786 - 1787 - const shortcuts: Array<[string, string]> = [ 1788 - ['V', 'Select tool'], 1789 - ['H', 'Hand / pan tool'], 1790 - ['R', 'Rectangle'], 1791 - ['E', 'Ellipse'], 1792 - ['D', 'Diamond'], 1793 - ['T', 'Text'], 1794 - ['P', 'Freehand / pen'], 1795 - ['A', 'Arrow'], 1796 - ['X', 'Eraser'], 1797 - ['N', 'Sticky note'], 1798 - ['Space + drag', 'Pan canvas'], 1799 - ['Scroll wheel', 'Zoom'], 1800 - ['\u2318Z', 'Undo'], 1801 - ['\u21e7\u2318Z', 'Redo'], 1802 - ['\u2318C', 'Copy'], 1803 - ['\u2318V', 'Paste'], 1804 - ['\u2318D', 'Duplicate'], 1805 - ['\u2318A', 'Select all'], 1806 - ['\u2318G', 'Group'], 1807 - ['\u21e7\u2318G', 'Ungroup'], 1808 - ['\u2318]', 'Bring to front'], 1809 - ['\u2318[', 'Send to back'], 1810 - ['Delete', 'Delete selected'], 1811 - ['Escape', 'Deselect / cancel'], 1812 - ['Double-click', 'Edit text'], 1813 - ['Shift + click', 'Multi-select'], 1814 - ['Shift + rotate', 'Snap to 15\u00b0'], 1815 - ['Alt + drag', 'Duplicate while dragging'], 1816 - ['\u2318.', 'Toggle focus mode'], 1817 - ['\u2318\u2325/', 'Keyboard shortcuts'], 1818 - ]; 1819 - 1820 - for (const [key, desc] of shortcuts) { 1821 - const row = document.createElement('div'); 1822 - row.className = 'shortcut-row'; 1823 - row.innerHTML = `<span>${desc}</span><kbd>${key}</kbd>`; 1824 - dialog.appendChild(row); 1825 - } 1826 - 1827 - document.body.appendChild(dialog); 1828 - } 1829 - 1830 - function closeShortcutsDialog() { 1831 - shortcutsDialogOpen = false; 1832 - document.querySelector('.diagrams-shortcuts-dialog')?.remove(); 1833 - document.querySelector('.diagrams-shortcuts-overlay')?.remove(); 1834 - } 1835 - 1836 - // Keyboard shortcuts 1837 - document.addEventListener('keydown', (e) => { 1838 - // Don't handle shortcuts when editing text 1839 - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; 1840 - if (editingShapeId) return; 1841 - 1842 - if (e.key === ' ') { 1843 - spaceHeld = true; 1844 - e.preventDefault(); 1845 - return; 1846 - } 1847 - 1848 - // Cmd/Ctrl shortcuts 1849 - const mod = e.metaKey || e.ctrlKey; 1850 - 1851 - // Focus mode: Cmd+. 1852 - if (mod && e.key === '.') { e.preventDefault(); toggleFocusMode(); return; } 1853 - 1854 - // Shortcuts dialog: Cmd+Alt+/ or ? 1855 - if (mod && e.altKey && e.key === '/') { e.preventDefault(); toggleShortcutsDialog(); return; } 1856 - if (e.key === '?' && !mod) { toggleShortcutsDialog(); return; } 1857 - 1858 - if (mod && e.key === 'z' && !e.shiftKey) { e.preventDefault(); doUndo(); return; } 1859 - if (mod && e.key === 'z' && e.shiftKey) { e.preventDefault(); doRedo(); return; } 1860 - if (mod && e.key === 'y') { e.preventDefault(); doRedo(); return; } 1861 - if (mod && e.key === 'c') { e.preventDefault(); doCopy(); return; } 1862 - if (mod && e.key === 'v') { e.preventDefault(); doPaste(); return; } 1863 - if (mod && e.key === 'd') { e.preventDefault(); doDuplicate(); return; } 1864 - if (mod && e.key === 'a') { e.preventDefault(); selectedShapeIds = new Set(wb.shapes.keys()); render(); return; } 1865 - if (mod && e.key === 'g' && !e.shiftKey) { 1866 - e.preventDefault(); 1867 - if (selectedShapeIds.size >= 2) { 1868 - pushHistory(); 1869 - const result = groupShapes(wb, [...selectedShapeIds]); 1870 - wb = result.state; 1871 - syncToYjs(); 1872 - render(); 1873 - } 1874 - return; 1875 - } 1876 - if (mod && e.key === 'g' && e.shiftKey) { 1877 - e.preventDefault(); 1878 - if (selectedShapeIds.size > 0) { 1879 - const shape = wb.shapes.get([...selectedShapeIds][0]!); 1880 - if (shape?.groupId) { 1881 - pushHistory(); 1882 - wb = ungroupShapes(wb, shape.groupId); 1883 - syncToYjs(); 1884 - render(); 1885 - } 1886 - } 1887 - return; 1888 - } 1889 - // Z-order shortcuts 1890 - if (mod && e.key === ']') { e.preventDefault(); if (selectedShapeIds.size > 0) { pushHistory(); wb = bringToFront(wb, selectedShapeIds); syncToYjs(); render(); } return; } 1891 - if (mod && e.key === '[') { e.preventDefault(); if (selectedShapeIds.size > 0) { pushHistory(); wb = sendToBack(wb, selectedShapeIds); syncToYjs(); render(); } return; } 1892 - 1893 - switch (e.key) { 1894 - case 'Escape': 1895 - if (isDrawingLine && linePoints.length >= 2) { finishLine(); break; } 1896 - activeTool = 'select'; 1897 - selectedShapeIds = new Set(); 1898 - isDrawingArrow = false; 1899 - arrowFromShape = null; 1900 - arrowFromAnchor = null; 1901 - isDrawingFreehand = false; 1902 - freehandPoints = []; 1903 - isDrawingLine = false; 1904 - linePoints = []; 1905 - isCreatingShape = false; 1906 - createShapeKind = null; 1907 - removeCreationPreview(); 1908 - removeArrowPreview(); 1909 - layer.querySelector('.freehand-preview')?.remove(); 1910 - layer.querySelector('.line-preview')?.remove(); 1911 - arrowHoverTargetId = null; 1912 - updateToolbar(); 1913 - render(); 1914 - break; 1915 - case 'v': case 'V': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'select'; updateToolbar(); break; 1916 - case 'r': case 'R': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'rectangle'; updateToolbar(); break; 1917 - case 'e': case 'E': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'ellipse'; updateToolbar(); break; 1918 - case 'd': case 'D': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'diamond'; updateToolbar(); break; 1919 - case 't': case 'T': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'text'; updateToolbar(); break; 1920 - case 'l': case 'L': activeTool = 'line'; updateToolbar(); break; 1921 - case 'p': case 'P': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'freehand'; updateToolbar(); break; 1922 - case 'a': case 'A': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'arrow'; updateToolbar(); break; 1923 - case 'h': case 'H': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'hand'; updateToolbar(); break; 1924 - case 'x': case 'X': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'eraser'; updateToolbar(); break; 1925 - case 'n': case 'N': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'note'; updateToolbar(); break; 1926 - case 'Delete': case 'Backspace': 1927 - if (selectedShapeIds.size > 0) { 1928 - pushHistory(); 1929 - wb = removeShapes(wb, selectedShapeIds); 1930 - selectedShapeIds = new Set(); 1931 - syncToYjs(); 1932 - render(); 1933 - } 1934 - break; 1935 - } 1936 - }); 1937 - 1938 - document.addEventListener('keyup', (e) => { 1939 - if (e.key === ' ') spaceHeld = false; 584 + // Wire extracted modules 585 + wireCanvasEvents(canvasEventDeps); 586 + wireToolbarEvents(toolbarDeps); 587 + wireKeyboardShortcuts({ 588 + ...toolbarDeps, 589 + layer, 590 + getEditingShapeId: () => editingShapeId, 591 + doUndo, 592 + doRedo, 593 + canvasEventDeps, 1940 594 }); 1941 595 1942 596 // Title editing ··· 2015 669 const actionsEnabled = chatUI.actionsToggle.checked; 2016 670 const contextText = includeContext ? getDiagramContextText() : ''; 2017 671 2018 - // Build selection context from selected shapes 2019 672 let selectionContext: string | undefined; 2020 673 if (selectedShapeIds.size > 0) { 2021 674 const selLines: string[] = []; ··· 2116 769 await initCrypto(); 2117 770 setupTooltips(); 2118 771 2119 - // Push initial state to history 2120 772 pushHistory(); 2121 773 2122 774 if (cryptoKey) {
+289
src/diagrams/shape-renderers.ts
··· 1 + // @ts-nocheck 2 + /** 3 + * SVG Shape Renderers — creates SVG elements for each shape kind, 4 + * plus creation/arrow/line preview overlays. 5 + * 6 + * Extracted from main.ts to keep rendering logic separate from interaction. 7 + */ 8 + 9 + import { pointsToCatmullRomPath } from './whiteboard.js'; 10 + import type { ShapeKind, Point } from './whiteboard.js'; 11 + 12 + // --------------------------------------------------------------------------- 13 + // Deps interface 14 + // --------------------------------------------------------------------------- 15 + 16 + export interface ShapeRendererDeps { 17 + layer: SVGGElement; 18 + } 19 + 20 + // --------------------------------------------------------------------------- 21 + // Shape element renderers 22 + // --------------------------------------------------------------------------- 23 + 24 + export function appendRect(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 25 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 26 + rect.setAttribute('width', String(w)); 27 + rect.setAttribute('height', String(h)); 28 + rect.setAttribute('rx', '4'); 29 + rect.setAttribute('fill', fill); 30 + rect.setAttribute('stroke', stroke); 31 + rect.setAttribute('stroke-width', strokeWidth); 32 + if (dasharray) rect.setAttribute('stroke-dasharray', dasharray); 33 + g.appendChild(rect); 34 + } 35 + 36 + export function appendEllipse(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 37 + const el = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 38 + el.setAttribute('cx', String(w / 2)); 39 + el.setAttribute('cy', String(h / 2)); 40 + el.setAttribute('rx', String(w / 2)); 41 + el.setAttribute('ry', String(h / 2)); 42 + el.setAttribute('fill', fill); 43 + el.setAttribute('stroke', stroke); 44 + el.setAttribute('stroke-width', strokeWidth); 45 + if (dasharray) el.setAttribute('stroke-dasharray', dasharray); 46 + g.appendChild(el); 47 + } 48 + 49 + export function appendDiamond(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 50 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 51 + poly.setAttribute('points', `${w/2},0 ${w},${h/2} ${w/2},${h} 0,${h/2}`); 52 + poly.setAttribute('fill', fill); 53 + poly.setAttribute('stroke', stroke); 54 + poly.setAttribute('stroke-width', strokeWidth); 55 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 56 + g.appendChild(poly); 57 + } 58 + 59 + export function appendTriangle(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 60 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 61 + poly.setAttribute('points', `${w/2},0 ${w},${h} 0,${h}`); 62 + poly.setAttribute('fill', fill); 63 + poly.setAttribute('stroke', stroke); 64 + poly.setAttribute('stroke-width', strokeWidth); 65 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 66 + g.appendChild(poly); 67 + } 68 + 69 + export function appendStar(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 70 + const cx = w / 2, cy = h / 2; 71 + const outerR = Math.min(w, h) / 2; 72 + const innerR = outerR * 0.38; 73 + const pts: string[] = []; 74 + for (let i = 0; i < 5; i++) { 75 + const outerAngle = (Math.PI / 2 + (i * 2 * Math.PI) / 5) * -1; 76 + const innerAngle = outerAngle - Math.PI / 5; 77 + pts.push(`${cx + outerR * Math.cos(outerAngle)},${cy + outerR * Math.sin(outerAngle)}`); 78 + pts.push(`${cx + innerR * Math.cos(innerAngle)},${cy + innerR * Math.sin(innerAngle)}`); 79 + } 80 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 81 + poly.setAttribute('points', pts.join(' ')); 82 + poly.setAttribute('fill', fill); 83 + poly.setAttribute('stroke', stroke); 84 + poly.setAttribute('stroke-width', strokeWidth); 85 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 86 + g.appendChild(poly); 87 + } 88 + 89 + export function appendHexagon(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 90 + const cx = w / 2, cy = h / 2; 91 + const rx = w / 2, ry = h / 2; 92 + const pts: string[] = []; 93 + for (let i = 0; i < 6; i++) { 94 + const angle = (Math.PI / 3) * i - Math.PI / 6; 95 + pts.push(`${cx + rx * Math.cos(angle)},${cy + ry * Math.sin(angle)}`); 96 + } 97 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 98 + poly.setAttribute('points', pts.join(' ')); 99 + poly.setAttribute('fill', fill); 100 + poly.setAttribute('stroke', stroke); 101 + poly.setAttribute('stroke-width', strokeWidth); 102 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 103 + g.appendChild(poly); 104 + } 105 + 106 + export function appendCloud(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 107 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 108 + const d = `M${w*0.25},${h*0.8} C${w*0.05},${h*0.8} ${w*0.0},${h*0.55} ${w*0.15},${h*0.45} C${w*0.05},${h*0.3} ${w*0.15},${h*0.1} ${w*0.35},${h*0.2} C${w*0.4},${h*0.05} ${w*0.6},${h*0.05} ${w*0.65},${h*0.2} C${w*0.85},${h*0.1} ${w*0.95},${h*0.3} ${w*0.85},${h*0.45} C${w*1.0},${h*0.55} ${w*0.95},${h*0.8} ${w*0.75},${h*0.8} Z`; 109 + path.setAttribute('d', d); 110 + path.setAttribute('fill', fill); 111 + path.setAttribute('stroke', stroke); 112 + path.setAttribute('stroke-width', strokeWidth); 113 + if (dasharray) path.setAttribute('stroke-dasharray', dasharray); 114 + g.appendChild(path); 115 + } 116 + 117 + export function appendCylinder(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 118 + const ry = h * 0.12; 119 + // Body 120 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 121 + rect.setAttribute('x', '0'); 122 + rect.setAttribute('y', String(ry)); 123 + rect.setAttribute('width', String(w)); 124 + rect.setAttribute('height', String(h - 2 * ry)); 125 + rect.setAttribute('fill', fill); 126 + rect.setAttribute('stroke', 'none'); 127 + g.appendChild(rect); 128 + // Side lines 129 + const leftLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 130 + leftLine.setAttribute('x1', '0'); leftLine.setAttribute('y1', String(ry)); 131 + leftLine.setAttribute('x2', '0'); leftLine.setAttribute('y2', String(h - ry)); 132 + leftLine.setAttribute('stroke', stroke); leftLine.setAttribute('stroke-width', strokeWidth); 133 + if (dasharray) leftLine.setAttribute('stroke-dasharray', dasharray); 134 + g.appendChild(leftLine); 135 + const rightLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 136 + rightLine.setAttribute('x1', String(w)); rightLine.setAttribute('y1', String(ry)); 137 + rightLine.setAttribute('x2', String(w)); rightLine.setAttribute('y2', String(h - ry)); 138 + rightLine.setAttribute('stroke', stroke); rightLine.setAttribute('stroke-width', strokeWidth); 139 + if (dasharray) rightLine.setAttribute('stroke-dasharray', dasharray); 140 + g.appendChild(rightLine); 141 + // Top ellipse 142 + const topEllipse = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 143 + topEllipse.setAttribute('cx', String(w / 2)); topEllipse.setAttribute('cy', String(ry)); 144 + topEllipse.setAttribute('rx', String(w / 2)); topEllipse.setAttribute('ry', String(ry)); 145 + topEllipse.setAttribute('fill', fill); topEllipse.setAttribute('stroke', stroke); 146 + topEllipse.setAttribute('stroke-width', strokeWidth); 147 + if (dasharray) topEllipse.setAttribute('stroke-dasharray', dasharray); 148 + g.appendChild(topEllipse); 149 + // Bottom ellipse (half, front arc) 150 + const botPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 151 + botPath.setAttribute('d', `M0,${h - ry} A${w/2},${ry} 0 0,0 ${w},${h - ry}`); 152 + botPath.setAttribute('fill', 'none'); botPath.setAttribute('stroke', stroke); 153 + botPath.setAttribute('stroke-width', strokeWidth); 154 + if (dasharray) botPath.setAttribute('stroke-dasharray', dasharray); 155 + g.appendChild(botPath); 156 + } 157 + 158 + export function appendParallelogram(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 159 + const skew = w * 0.2; 160 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 161 + poly.setAttribute('points', `${skew},0 ${w},0 ${w - skew},${h} 0,${h}`); 162 + poly.setAttribute('fill', fill); 163 + poly.setAttribute('stroke', stroke); 164 + poly.setAttribute('stroke-width', strokeWidth); 165 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 166 + g.appendChild(poly); 167 + } 168 + 169 + export function appendNote(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 170 + // Sticky note with folded corner 171 + const fold = Math.min(w, h) * 0.15; 172 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 173 + path.setAttribute('d', `M0,0 H${w - fold} L${w},${fold} V${h} H0 Z`); 174 + path.setAttribute('fill', fill || '#fef08a'); 175 + path.setAttribute('stroke', stroke); 176 + path.setAttribute('stroke-width', strokeWidth); 177 + if (dasharray) path.setAttribute('stroke-dasharray', dasharray); 178 + g.appendChild(path); 179 + // Fold triangle 180 + const foldPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 181 + foldPath.setAttribute('d', `M${w - fold},0 V${fold} H${w}`); 182 + foldPath.setAttribute('fill', 'none'); 183 + foldPath.setAttribute('stroke', stroke); 184 + foldPath.setAttribute('stroke-width', '1'); 185 + g.appendChild(foldPath); 186 + } 187 + 188 + // --------------------------------------------------------------------------- 189 + // Preview overlays 190 + // --------------------------------------------------------------------------- 191 + 192 + export function renderCreationPreview(deps: ShapeRendererDeps, start: Point, end: Point, kind: ShapeKind) { 193 + removeCreationPreview(deps); 194 + const x = Math.min(start.x, end.x); 195 + const y = Math.min(start.y, end.y); 196 + const w = Math.abs(end.x - start.x); 197 + const h = Math.abs(end.y - start.y); 198 + if (w < 2 && h < 2) return; 199 + 200 + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 201 + g.classList.add('creation-preview'); 202 + g.setAttribute('transform', `translate(${x}, ${y})`); 203 + 204 + switch (kind) { 205 + case 'rectangle': case 'text': case 'note': { 206 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 207 + rect.setAttribute('width', String(w)); 208 + rect.setAttribute('height', String(h)); 209 + rect.setAttribute('rx', '4'); 210 + g.appendChild(rect); 211 + break; 212 + } 213 + case 'ellipse': { 214 + const el = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 215 + el.setAttribute('cx', String(w / 2)); 216 + el.setAttribute('cy', String(h / 2)); 217 + el.setAttribute('rx', String(w / 2)); 218 + el.setAttribute('ry', String(h / 2)); 219 + g.appendChild(el); 220 + break; 221 + } 222 + case 'diamond': { 223 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 224 + poly.setAttribute('points', `${w/2},0 ${w},${h/2} ${w/2},${h} 0,${h/2}`); 225 + g.appendChild(poly); 226 + break; 227 + } 228 + case 'triangle': { 229 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 230 + poly.setAttribute('points', `${w/2},0 ${w},${h} 0,${h}`); 231 + g.appendChild(poly); 232 + break; 233 + } 234 + default: { 235 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 236 + rect.setAttribute('width', String(w)); 237 + rect.setAttribute('height', String(h)); 238 + rect.setAttribute('rx', '4'); 239 + g.appendChild(rect); 240 + break; 241 + } 242 + } 243 + deps.layer.appendChild(g); 244 + } 245 + 246 + export function removeCreationPreview(deps: ShapeRendererDeps) { 247 + deps.layer.querySelector('.creation-preview')?.remove(); 248 + } 249 + 250 + export function renderArrowPreview(deps: ShapeRendererDeps, from: Point, to: Point) { 251 + removeArrowPreview(deps); 252 + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 253 + line.setAttribute('x1', String(from.x)); 254 + line.setAttribute('y1', String(from.y)); 255 + line.setAttribute('x2', String(to.x)); 256 + line.setAttribute('y2', String(to.y)); 257 + line.setAttribute('marker-end', 'url(#arrowhead)'); 258 + line.classList.add('arrow-preview'); 259 + deps.layer.appendChild(line); 260 + } 261 + 262 + export function removeArrowPreview(deps: ShapeRendererDeps) { 263 + deps.layer.querySelector('.arrow-preview')?.remove(); 264 + } 265 + 266 + export function renderLinePreview(deps: ShapeRendererDeps, linePoints: Point[]) { 267 + deps.layer.querySelector('.line-preview')?.remove(); 268 + if (linePoints.length < 1) return; 269 + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 270 + g.classList.add('line-preview'); 271 + if (linePoints.length >= 2) { 272 + const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); 273 + polyline.setAttribute('points', linePoints.map(p => `${p.x},${p.y}`).join(' ')); 274 + polyline.setAttribute('fill', 'none'); 275 + polyline.setAttribute('stroke', 'var(--color-text)'); 276 + polyline.setAttribute('stroke-width', '2'); 277 + g.appendChild(polyline); 278 + } 279 + // Draw dots at each point 280 + for (const p of linePoints) { 281 + const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 282 + c.setAttribute('cx', String(p.x)); 283 + c.setAttribute('cy', String(p.y)); 284 + c.setAttribute('r', '3'); 285 + c.setAttribute('fill', 'var(--color-primary)'); 286 + g.appendChild(c); 287 + } 288 + deps.layer.appendChild(g); 289 + }
+485
src/diagrams/toolbar-wiring.ts
··· 1 + // @ts-nocheck 2 + /** 3 + * Toolbar Wiring — tool buttons, z-order, alignment, group/ungroup, 4 + * export, style panel, properties panel, flip buttons, focus mode, 5 + * edge scroll, and context menu. 6 + * 7 + * Extracted from main.ts to isolate UI control wiring from core logic. 8 + */ 9 + 10 + import { 11 + toggleSnap, setZoom, getBoundingBox, 12 + removeShapes, bringToFront, sendToBack, 13 + alignShapes, distributeShapes, flipShapes, 14 + groupShapes, ungroupShapes, 15 + setShapeStyle, setShapeOpacity, setShapeFontFamily, setShapeFontSize, 16 + setShapeLabel, resizeShape, 17 + duplicateShapes, shapeAtPoint, addShape, 18 + } from './whiteboard.js'; 19 + import type { WhiteboardState, Shape, Arrow } from './whiteboard.js'; 20 + import { exportAndDownloadSVG, exportAndDownloadPNG } from './export.js'; 21 + 22 + // --------------------------------------------------------------------------- 23 + // Deps interface 24 + // --------------------------------------------------------------------------- 25 + 26 + export interface ToolbarWiringDeps { 27 + canvas: SVGSVGElement; 28 + layer: SVGGElement; 29 + diagramTitle: HTMLInputElement; 30 + propsSection: HTMLElement; 31 + propsDimensions: HTMLElement; 32 + propLabel: HTMLInputElement; 33 + propWidth: HTMLInputElement; 34 + propHeight: HTMLInputElement; 35 + stylePanel: HTMLElement; 36 + styleFill: HTMLInputElement; 37 + styleStroke: HTMLInputElement; 38 + styleStrokeWidth: HTMLSelectElement; 39 + styleStrokeStyle: HTMLSelectElement; 40 + styleOpacity: HTMLInputElement; 41 + styleOpacityValue: HTMLElement; 42 + styleFontFamily: HTMLSelectElement; 43 + styleFontSize: HTMLInputElement; 44 + getState: () => WhiteboardState; 45 + setState: (wb: WhiteboardState) => void; 46 + getActiveTool: () => string; 47 + setActiveTool: (tool: string) => void; 48 + getSelectedShapeIds: () => Set<string>; 49 + setSelectedShapeIds: (ids: Set<string>) => void; 50 + render: () => void; 51 + syncToYjs: () => void; 52 + pushHistory: () => void; 53 + updateToolbar: () => void; 54 + screenToCanvas: (sx: number, sy: number) => { x: number; y: number }; 55 + } 56 + 57 + // --------------------------------------------------------------------------- 58 + // Copy / Paste / Duplicate 59 + // --------------------------------------------------------------------------- 60 + 61 + let clipboard: { shapes: Shape[]; arrows: Arrow[] } | null = null; 62 + 63 + export function doCopy(deps: ToolbarWiringDeps) { 64 + const selectedShapeIds = deps.getSelectedShapeIds(); 65 + if (selectedShapeIds.size === 0) return; 66 + const wb = deps.getState(); 67 + const shapes: Shape[] = []; 68 + const arrows: Arrow[] = []; 69 + for (const id of selectedShapeIds) { 70 + const s = wb.shapes.get(id); 71 + if (s) shapes.push({ ...s }); 72 + } 73 + for (const arrow of wb.arrows.values()) { 74 + const fromId = 'shapeId' in arrow.from ? arrow.from.shapeId : null; 75 + const toId = 'shapeId' in arrow.to ? arrow.to.shapeId : null; 76 + if (fromId && toId && selectedShapeIds.has(fromId) && selectedShapeIds.has(toId)) { 77 + arrows.push({ ...arrow }); 78 + } 79 + } 80 + clipboard = { shapes, arrows }; 81 + } 82 + 83 + export function doPaste(deps: ToolbarWiringDeps) { 84 + if (!clipboard || clipboard.shapes.length === 0) return; 85 + deps.pushHistory(); 86 + let wb = deps.getState(); 87 + const result = duplicateShapes(wb, clipboard.shapes.map(s => s.id)); 88 + if (result.idMap.size === 0) { 89 + let newState = wb; 90 + const newIds: string[] = []; 91 + for (const shape of clipboard.shapes) { 92 + newState = addShape(newState, shape.kind, shape.x + 20, shape.y + 20, shape.width, shape.height, shape.label); 93 + const lastId = [...newState.shapes.keys()].pop()!; 94 + newIds.push(lastId); 95 + const last = newState.shapes.get(lastId)!; 96 + newState.shapes.set(lastId, { ...last, style: { ...shape.style }, opacity: shape.opacity, rotation: shape.rotation, fontFamily: shape.fontFamily, fontSize: shape.fontSize, points: shape.points }); 97 + } 98 + wb = newState; 99 + deps.setSelectedShapeIds(new Set(newIds)); 100 + } else { 101 + wb = result.state; 102 + deps.setSelectedShapeIds(new Set(result.idMap.values())); 103 + } 104 + deps.setState(wb); 105 + deps.syncToYjs(); 106 + deps.render(); 107 + } 108 + 109 + export function doDuplicate(deps: ToolbarWiringDeps) { 110 + const selectedShapeIds = deps.getSelectedShapeIds(); 111 + if (selectedShapeIds.size === 0) return; 112 + deps.pushHistory(); 113 + let wb = deps.getState(); 114 + const result = duplicateShapes(wb, selectedShapeIds); 115 + wb = result.state; 116 + deps.setState(wb); 117 + deps.setSelectedShapeIds(new Set(result.idMap.values())); 118 + deps.syncToYjs(); 119 + deps.render(); 120 + } 121 + 122 + // --------------------------------------------------------------------------- 123 + // Context Menu 124 + // --------------------------------------------------------------------------- 125 + 126 + let contextMenuEl: HTMLElement | null = null; 127 + 128 + function showContextMenu(deps: ToolbarWiringDeps, x: number, y: number) { 129 + hideContextMenu(); 130 + const menu = document.createElement('div'); 131 + menu.className = 'diagrams-context-menu'; 132 + menu.style.left = `${x}px`; 133 + menu.style.top = `${y}px`; 134 + 135 + const selectedShapeIds = deps.getSelectedShapeIds(); 136 + let wb = deps.getState(); 137 + 138 + const items: Array<{ label: string; shortcut?: string; action: () => void; divider?: boolean }> = []; 139 + 140 + if (selectedShapeIds.size > 0) { 141 + items.push({ label: 'Copy', shortcut: '\u2318C', action: () => doCopy(deps) }); 142 + items.push({ label: 'Paste', shortcut: '\u2318V', action: () => doPaste(deps) }); 143 + items.push({ label: 'Duplicate', shortcut: '\u2318D', action: () => doDuplicate(deps) }); 144 + items.push({ label: '', shortcut: '', action: () => {}, divider: true }); 145 + items.push({ label: 'Bring to Front', shortcut: '\u2318]', action: () => { deps.pushHistory(); deps.setState(bringToFront(deps.getState(), deps.getSelectedShapeIds())); deps.syncToYjs(); deps.render(); } }); 146 + items.push({ label: 'Send to Back', shortcut: '\u2318[', action: () => { deps.pushHistory(); deps.setState(sendToBack(deps.getState(), deps.getSelectedShapeIds())); deps.syncToYjs(); deps.render(); } }); 147 + items.push({ label: '', shortcut: '', action: () => {}, divider: true }); 148 + if (selectedShapeIds.size >= 2) { 149 + items.push({ label: 'Group', shortcut: '\u2318G', action: () => { deps.pushHistory(); const r = groupShapes(deps.getState(), [...deps.getSelectedShapeIds()]); deps.setState(r.state); deps.syncToYjs(); deps.render(); } }); 150 + } 151 + const firstShape = wb.shapes.get([...selectedShapeIds][0]!); 152 + if (firstShape?.groupId) { 153 + items.push({ label: 'Ungroup', shortcut: '\u21e7\u2318G', action: () => { deps.pushHistory(); deps.setState(ungroupShapes(deps.getState(), firstShape.groupId!)); deps.syncToYjs(); deps.render(); } }); 154 + } 155 + if (selectedShapeIds.size >= 2) { 156 + items.push({ label: 'Flip Horizontal', action: () => { deps.pushHistory(); deps.setState(flipShapes(deps.getState(), [...deps.getSelectedShapeIds()], 'horizontal')); deps.syncToYjs(); deps.render(); } }); 157 + items.push({ label: 'Flip Vertical', action: () => { deps.pushHistory(); deps.setState(flipShapes(deps.getState(), [...deps.getSelectedShapeIds()], 'vertical')); deps.syncToYjs(); deps.render(); } }); 158 + } 159 + items.push({ label: '', shortcut: '', action: () => {}, divider: true }); 160 + items.push({ label: 'Delete', shortcut: 'Del', action: () => { deps.pushHistory(); deps.setState(removeShapes(deps.getState(), deps.getSelectedShapeIds())); deps.setSelectedShapeIds(new Set()); deps.syncToYjs(); deps.render(); } }); 161 + } else { 162 + items.push({ label: 'Paste', shortcut: '\u2318V', action: () => doPaste(deps) }); 163 + items.push({ label: 'Select All', shortcut: '\u2318A', action: () => { deps.setSelectedShapeIds(new Set(deps.getState().shapes.keys())); deps.render(); } }); 164 + } 165 + 166 + for (const item of items) { 167 + if (item.divider) { 168 + const div = document.createElement('div'); 169 + div.className = 'menu-divider'; 170 + menu.appendChild(div); 171 + continue; 172 + } 173 + const btn = document.createElement('button'); 174 + btn.textContent = item.label; 175 + if (item.shortcut) { 176 + const kbd = document.createElement('span'); 177 + kbd.className = 'shortcut'; 178 + kbd.textContent = item.shortcut; 179 + btn.appendChild(kbd); 180 + } 181 + btn.addEventListener('click', () => { hideContextMenu(); item.action(); }); 182 + menu.appendChild(btn); 183 + } 184 + 185 + document.body.appendChild(menu); 186 + contextMenuEl = menu; 187 + 188 + const rect = menu.getBoundingClientRect(); 189 + if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 4}px`; 190 + if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 4}px`; 191 + } 192 + 193 + export function hideContextMenu() { 194 + if (contextMenuEl) { 195 + contextMenuEl.remove(); 196 + contextMenuEl = null; 197 + } 198 + } 199 + 200 + // --------------------------------------------------------------------------- 201 + // Focus Mode 202 + // --------------------------------------------------------------------------- 203 + 204 + let focusModeActive = false; 205 + 206 + export function toggleFocusMode(deps: ToolbarWiringDeps) { 207 + focusModeActive = !focusModeActive; 208 + const toolbar = document.getElementById('diagrams-toolbar'); 209 + const topbar = document.querySelector('.app-topbar') as HTMLElement; 210 + if (toolbar) toolbar.style.display = focusModeActive ? 'none' : ''; 211 + if (topbar) topbar.style.display = focusModeActive ? 'none' : ''; 212 + deps.stylePanel.style.display = 'none'; 213 + } 214 + 215 + // --------------------------------------------------------------------------- 216 + // Edge Scrolling 217 + // --------------------------------------------------------------------------- 218 + 219 + const EDGE_SCROLL_ZONE = 40; 220 + const EDGE_SCROLL_SPEED = 8; 221 + let edgeScrollInterval: ReturnType<typeof setInterval> | null = null; 222 + 223 + export function startEdgeScroll(deps: ToolbarWiringDeps, clientX: number, clientY: number) { 224 + stopEdgeScroll(); 225 + const rect = deps.canvas.getBoundingClientRect(); 226 + let dx = 0, dy = 0; 227 + if (clientX < rect.left + EDGE_SCROLL_ZONE) dx = EDGE_SCROLL_SPEED; 228 + else if (clientX > rect.right - EDGE_SCROLL_ZONE) dx = -EDGE_SCROLL_SPEED; 229 + if (clientY < rect.top + EDGE_SCROLL_ZONE) dy = EDGE_SCROLL_SPEED; 230 + else if (clientY > rect.bottom - EDGE_SCROLL_ZONE) dy = -EDGE_SCROLL_SPEED; 231 + 232 + if (dx === 0 && dy === 0) { stopEdgeScroll(); return; } 233 + edgeScrollInterval = setInterval(() => { 234 + let wb = deps.getState(); 235 + wb = { ...wb, panX: wb.panX + dx, panY: wb.panY + dy }; 236 + deps.setState(wb); 237 + deps.render(); 238 + }, 16); 239 + } 240 + 241 + export function stopEdgeScroll() { 242 + if (edgeScrollInterval) { clearInterval(edgeScrollInterval); edgeScrollInterval = null; } 243 + } 244 + 245 + // --------------------------------------------------------------------------- 246 + // Wire all toolbar/UI events 247 + // --------------------------------------------------------------------------- 248 + 249 + export function wireToolbarEvents(deps: ToolbarWiringDeps) { 250 + const $ = (id: string) => document.getElementById(id)!; 251 + 252 + // Tool buttons 253 + document.querySelectorAll('.diagrams-tool').forEach(btn => { 254 + btn.addEventListener('click', () => { 255 + deps.setActiveTool((btn as HTMLElement).dataset.tool || 'select'); 256 + deps.updateToolbar(); 257 + }); 258 + }); 259 + 260 + $('btn-snap-grid').addEventListener('click', () => { deps.setState(toggleSnap(deps.getState())); deps.render(); }); 261 + $('btn-zoom-in').addEventListener('click', () => { deps.setState(setZoom(deps.getState(), deps.getState().zoom + 0.25)); deps.render(); }); 262 + $('btn-zoom-out').addEventListener('click', () => { deps.setState(setZoom(deps.getState(), deps.getState().zoom - 0.25)); deps.render(); }); 263 + $('btn-zoom-fit').addEventListener('click', () => { 264 + let wb = deps.getState(); 265 + const box = getBoundingBox(wb); 266 + if (!box) return; 267 + const canvasRect = deps.canvas.getBoundingClientRect(); 268 + const scaleX = canvasRect.width / (box.width + 100); 269 + const scaleY = canvasRect.height / (box.height + 100); 270 + const zoom = Math.min(scaleX, scaleY, 3); 271 + wb = setZoom(wb, zoom); 272 + wb = { ...wb, panX: canvasRect.width / 2 - (box.x + box.width / 2) * zoom, panY: canvasRect.height / 2 - (box.y + box.height / 2) * zoom }; 273 + deps.setState(wb); 274 + deps.render(); 275 + }); 276 + 277 + $('btn-delete').addEventListener('click', () => { 278 + const selectedShapeIds = deps.getSelectedShapeIds(); 279 + if (selectedShapeIds.size > 0) { 280 + deps.pushHistory(); 281 + deps.setState(removeShapes(deps.getState(), selectedShapeIds)); 282 + deps.setSelectedShapeIds(new Set()); 283 + deps.syncToYjs(); 284 + deps.render(); 285 + } 286 + }); 287 + 288 + // Z-order buttons 289 + $('btn-bring-front')?.addEventListener('click', () => { 290 + if (deps.getSelectedShapeIds().size > 0) { 291 + deps.pushHistory(); 292 + deps.setState(bringToFront(deps.getState(), deps.getSelectedShapeIds())); 293 + deps.syncToYjs(); 294 + deps.render(); 295 + } 296 + }); 297 + $('btn-send-back')?.addEventListener('click', () => { 298 + if (deps.getSelectedShapeIds().size > 0) { 299 + deps.pushHistory(); 300 + deps.setState(sendToBack(deps.getState(), deps.getSelectedShapeIds())); 301 + deps.syncToYjs(); 302 + deps.render(); 303 + } 304 + }); 305 + 306 + // Alignment buttons 307 + document.querySelectorAll('[data-align]').forEach(btn => { 308 + btn.addEventListener('click', () => { 309 + const selectedShapeIds = deps.getSelectedShapeIds(); 310 + if (selectedShapeIds.size < 2) return; 311 + deps.pushHistory(); 312 + const alignment = (btn as HTMLElement).dataset.align as any; 313 + deps.setState(alignShapes(deps.getState(), [...selectedShapeIds], alignment)); 314 + deps.syncToYjs(); 315 + deps.render(); 316 + }); 317 + }); 318 + 319 + document.querySelectorAll('[data-distribute]').forEach(btn => { 320 + btn.addEventListener('click', () => { 321 + const selectedShapeIds = deps.getSelectedShapeIds(); 322 + if (selectedShapeIds.size < 3) return; 323 + deps.pushHistory(); 324 + const axis = (btn as HTMLElement).dataset.distribute as 'horizontal' | 'vertical'; 325 + deps.setState(distributeShapes(deps.getState(), [...selectedShapeIds], axis)); 326 + deps.syncToYjs(); 327 + deps.render(); 328 + }); 329 + }); 330 + 331 + // Group/ungroup 332 + $('btn-group')?.addEventListener('click', () => { 333 + const selectedShapeIds = deps.getSelectedShapeIds(); 334 + if (selectedShapeIds.size < 2) return; 335 + deps.pushHistory(); 336 + const result = groupShapes(deps.getState(), [...selectedShapeIds]); 337 + deps.setState(result.state); 338 + deps.syncToYjs(); 339 + deps.render(); 340 + }); 341 + $('btn-ungroup')?.addEventListener('click', () => { 342 + const selectedShapeIds = deps.getSelectedShapeIds(); 343 + if (selectedShapeIds.size === 0) return; 344 + const wb = deps.getState(); 345 + const shape = wb.shapes.get([...selectedShapeIds][0]!); 346 + if (!shape?.groupId) return; 347 + deps.pushHistory(); 348 + deps.setState(ungroupShapes(wb, shape.groupId)); 349 + deps.syncToYjs(); 350 + deps.render(); 351 + }); 352 + 353 + // Export buttons 354 + $('btn-export-svg')?.addEventListener('click', () => { 355 + const selectedShapeIds = deps.getSelectedShapeIds(); 356 + const ids = selectedShapeIds.size > 0 ? selectedShapeIds : undefined; 357 + exportAndDownloadSVG(deps.getState(), `${deps.diagramTitle.value || 'diagram'}.svg`, ids); 358 + }); 359 + $('btn-export-png')?.addEventListener('click', async () => { 360 + const selectedShapeIds = deps.getSelectedShapeIds(); 361 + const ids = selectedShapeIds.size > 0 ? selectedShapeIds : undefined; 362 + await exportAndDownloadPNG(deps.getState(), `${deps.diagramTitle.value || 'diagram'}.png`, ids); 363 + }); 364 + 365 + // Style panel events 366 + deps.styleFill?.addEventListener('input', () => { 367 + if (deps.getSelectedShapeIds().size === 0) return; 368 + deps.pushHistory(); 369 + deps.setState(setShapeStyle(deps.getState(), deps.getSelectedShapeIds(), { fill: deps.styleFill.value })); 370 + deps.syncToYjs(); 371 + deps.render(); 372 + }); 373 + deps.styleStroke?.addEventListener('input', () => { 374 + if (deps.getSelectedShapeIds().size === 0) return; 375 + deps.pushHistory(); 376 + deps.setState(setShapeStyle(deps.getState(), deps.getSelectedShapeIds(), { stroke: deps.styleStroke.value })); 377 + deps.syncToYjs(); 378 + deps.render(); 379 + }); 380 + deps.styleStrokeWidth?.addEventListener('change', () => { 381 + if (deps.getSelectedShapeIds().size === 0) return; 382 + deps.pushHistory(); 383 + deps.setState(setShapeStyle(deps.getState(), deps.getSelectedShapeIds(), { strokeWidth: deps.styleStrokeWidth.value })); 384 + deps.syncToYjs(); 385 + deps.render(); 386 + }); 387 + deps.styleStrokeStyle?.addEventListener('change', () => { 388 + if (deps.getSelectedShapeIds().size === 0) return; 389 + deps.pushHistory(); 390 + const dashMap: Record<string, string> = { solid: '', dashed: '8 4', dotted: '2 2' }; 391 + deps.setState(setShapeStyle(deps.getState(), deps.getSelectedShapeIds(), { strokeDasharray: dashMap[deps.styleStrokeStyle.value] || '' })); 392 + deps.syncToYjs(); 393 + deps.render(); 394 + }); 395 + deps.styleOpacity?.addEventListener('input', () => { 396 + if (deps.getSelectedShapeIds().size === 0) return; 397 + deps.pushHistory(); 398 + const val = Number(deps.styleOpacity.value) / 100; 399 + deps.setState(setShapeOpacity(deps.getState(), deps.getSelectedShapeIds(), val)); 400 + deps.styleOpacityValue.textContent = `${deps.styleOpacity.value}%`; 401 + deps.syncToYjs(); 402 + deps.render(); 403 + }); 404 + deps.styleFontFamily?.addEventListener('change', () => { 405 + if (deps.getSelectedShapeIds().size === 0) return; 406 + deps.pushHistory(); 407 + deps.setState(setShapeFontFamily(deps.getState(), deps.getSelectedShapeIds(), deps.styleFontFamily.value)); 408 + deps.syncToYjs(); 409 + deps.render(); 410 + }); 411 + deps.styleFontSize?.addEventListener('change', () => { 412 + if (deps.getSelectedShapeIds().size === 0) return; 413 + deps.pushHistory(); 414 + deps.setState(setShapeFontSize(deps.getState(), deps.getSelectedShapeIds(), Number(deps.styleFontSize.value))); 415 + deps.syncToYjs(); 416 + deps.render(); 417 + }); 418 + 419 + // Properties panel 420 + deps.propLabel.addEventListener('change', () => { 421 + const selectedShapeIds = deps.getSelectedShapeIds(); 422 + if (selectedShapeIds.size === 1) { 423 + deps.pushHistory(); 424 + const id = [...selectedShapeIds][0]!; 425 + deps.setState(setShapeLabel(deps.getState(), id, deps.propLabel.value)); 426 + deps.syncToYjs(); 427 + deps.render(); 428 + } 429 + }); 430 + deps.propWidth.addEventListener('change', () => { 431 + const selectedShapeIds = deps.getSelectedShapeIds(); 432 + if (selectedShapeIds.size === 1) { 433 + deps.pushHistory(); 434 + const id = [...selectedShapeIds][0]!; 435 + deps.setState(resizeShape(deps.getState(), id, Number(deps.propWidth.value), Number(deps.propHeight.value))); 436 + deps.syncToYjs(); 437 + deps.render(); 438 + } 439 + }); 440 + deps.propHeight.addEventListener('change', () => { 441 + const selectedShapeIds = deps.getSelectedShapeIds(); 442 + if (selectedShapeIds.size === 1) { 443 + deps.pushHistory(); 444 + const id = [...selectedShapeIds][0]!; 445 + deps.setState(resizeShape(deps.getState(), id, Number(deps.propWidth.value), Number(deps.propHeight.value))); 446 + deps.syncToYjs(); 447 + deps.render(); 448 + } 449 + }); 450 + 451 + // Flip buttons 452 + $('btn-flip-h')?.addEventListener('click', () => { 453 + if (deps.getSelectedShapeIds().size > 0) { 454 + deps.pushHistory(); 455 + deps.setState(flipShapes(deps.getState(), [...deps.getSelectedShapeIds()], 'horizontal')); 456 + deps.syncToYjs(); 457 + deps.render(); 458 + } 459 + }); 460 + $('btn-flip-v')?.addEventListener('click', () => { 461 + if (deps.getSelectedShapeIds().size > 0) { 462 + deps.pushHistory(); 463 + deps.setState(flipShapes(deps.getState(), [...deps.getSelectedShapeIds()], 'vertical')); 464 + deps.syncToYjs(); 465 + deps.render(); 466 + } 467 + }); 468 + 469 + // Context menu 470 + deps.canvas.addEventListener('contextmenu', (e) => { 471 + e.preventDefault(); 472 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 473 + const wb = deps.getState(); 474 + const hit = shapeAtPoint(wb, pt.x, pt.y); 475 + if (hit && !deps.getSelectedShapeIds().has(hit.id)) { 476 + deps.setSelectedShapeIds(new Set([hit.id])); 477 + deps.render(); 478 + } 479 + showContextMenu(deps, e.clientX, e.clientY); 480 + }); 481 + 482 + document.addEventListener('click', (e) => { 483 + if (contextMenuEl && !contextMenuEl.contains(e.target as Node)) hideContextMenu(); 484 + }); 485 + }