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 and canvas-events.ts into focused modules' (#299) from refactor/diagrams-decompose-final into main

scott 9eeb485a 64ebfb4c

+1075 -721
+178
src/diagrams/ai-chat-wiring.ts
··· 1 + // @ts-nocheck 2 + /** 3 + * AI Chat sidebar setup and wiring for diagrams. 4 + * Extracted from main.ts. 5 + */ 6 + 7 + import type { WhiteboardState } from './whiteboard.js'; 8 + import { 9 + createChatSidebar, createChatState, loadConfig, isConfigured, 10 + buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 11 + renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 12 + type ChatMessage, 13 + } from '../lib/ai-chat.js'; 14 + import { splitResponse, isDiagramAction } from '../lib/ai-actions.js'; 15 + import { executeDiagramAction } from './ai-diagram-actions.js'; 16 + 17 + // --------------------------------------------------------------------------- 18 + // Deps interface 19 + // --------------------------------------------------------------------------- 20 + 21 + export interface AiChatDeps { 22 + getState: () => WhiteboardState; 23 + setState: (s: WhiteboardState) => void; 24 + getSelectedShapeIds: () => Set<string>; 25 + getDiagramTitle: () => string; 26 + render: () => void; 27 + syncToYjs: () => void; 28 + pushHistory: () => void; 29 + mainContent: HTMLElement; 30 + toggleBtn: HTMLElement; 31 + } 32 + 33 + // --------------------------------------------------------------------------- 34 + // Build text summary of current diagram for AI context 35 + // --------------------------------------------------------------------------- 36 + 37 + export function getDiagramContextText(wb: WhiteboardState): string { 38 + const lines: string[] = []; 39 + for (const [, shape] of wb.shapes) { 40 + const label = shape.label || '(unlabeled)'; 41 + lines.push(`${shape.kind} "${label}" at (${Math.round(shape.x)},${Math.round(shape.y)}) size ${Math.round(shape.width)}x${Math.round(shape.height)}`); 42 + } 43 + for (const [, arrow] of wb.arrows) { 44 + const fromDesc = 'shapeId' in arrow.from 45 + ? `"${wb.shapes.get(arrow.from.shapeId)?.label || 'unknown'}"` 46 + : `(${arrow.from.x},${arrow.from.y})`; 47 + const toDesc = 'shapeId' in arrow.to 48 + ? `"${wb.shapes.get(arrow.to.shapeId)?.label || 'unknown'}"` 49 + : `(${arrow.to.x},${arrow.to.y})`; 50 + lines.push(`arrow from ${fromDesc} to ${toDesc}`); 51 + } 52 + return lines.join('\n'); 53 + } 54 + 55 + // --------------------------------------------------------------------------- 56 + // Initialize AI chat panel 57 + // --------------------------------------------------------------------------- 58 + 59 + export function initAiChat(deps: AiChatDeps) { 60 + const chatUI = createChatSidebar(); 61 + deps.mainContent.appendChild(chatUI.container); 62 + 63 + const chatState = createChatState(); 64 + 65 + const chatWiring = initChatWiring({ 66 + chatUI, 67 + chatState, 68 + chatConfig: loadConfig(), 69 + toggleBtn: deps.toggleBtn, 70 + editorType: 'diagram', 71 + onSend: sendChatMessage, 72 + }); 73 + 74 + async function sendChatMessage(): Promise<void> { 75 + const text = chatUI.input.value.trim(); 76 + if (!text || chatState.loading) return; 77 + 78 + const cfg = chatWiring.getConfig(); 79 + if (!isConfigured(cfg)) { 80 + chatUI.settingsPanel.style.display = ''; 81 + chatUI.endpointInput.focus(); 82 + return; 83 + } 84 + 85 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 86 + chatState.messages.push(userMsg); 87 + appendMessage(chatUI.messageList, userMsg); 88 + 89 + chatUI.input.value = ''; 90 + chatUI.input.style.height = ''; 91 + chatUI.sendBtn.style.display = 'none'; 92 + chatUI.stopBtn.style.display = ''; 93 + chatState.loading = true; 94 + chatState.error = null; 95 + 96 + const wb = deps.getState(); 97 + const title = deps.getDiagramTitle() || 'Untitled Diagram'; 98 + const includeContext = chatUI.contextToggle.checked; 99 + const actionsEnabled = chatUI.actionsToggle.checked; 100 + const contextText = includeContext ? getDiagramContextText(wb) : ''; 101 + 102 + let selectionContext: string | undefined; 103 + const selectedShapeIds = deps.getSelectedShapeIds(); 104 + if (selectedShapeIds.size > 0) { 105 + const selLines: string[] = []; 106 + for (const id of selectedShapeIds) { 107 + const shape = wb.shapes.get(id); 108 + if (shape) selLines.push(`${shape.kind} "${shape.label || '(unlabeled)'}"`); 109 + } 110 + selectionContext = `Selected shapes: ${selLines.join(', ')}`; 111 + } 112 + 113 + const systemPrompt = buildSystemMessage(title, contextText, { 114 + editorType: 'diagram', 115 + actionsEnabled, 116 + selectionContext, 117 + }); 118 + 119 + const diagramDeps = { 120 + getState: deps.getState, 121 + setState: (s: WhiteboardState) => { deps.setState(s); deps.syncToYjs(); }, 122 + render: deps.render, 123 + pushHistory: deps.pushHistory, 124 + }; 125 + 126 + const abortController = new AbortController(); 127 + chatState.abortController = abortController; 128 + const bubble = appendStreamingBubble(chatUI.messageList); 129 + let fullText = ''; 130 + 131 + await streamChat( 132 + cfg, 133 + chatState.messages, 134 + systemPrompt, 135 + { 136 + onChunk(chunk) { 137 + fullText += chunk; 138 + bubble.update(renderMarkdown(fullText)); 139 + }, 140 + onDone(text) { 141 + if (text) { 142 + chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 143 + 144 + if (actionsEnabled) { 145 + const { displayText, actions } = splitResponse(text); 146 + if (actions.length > 0) { 147 + bubble.update(renderMarkdown(displayText)); 148 + for (const action of actions) { 149 + if (!isDiagramAction(action)) continue; 150 + appendActionCard(chatUI.messageList, action, { 151 + onApply: (a) => { 152 + const result = executeDiagramAction(a as Parameters<typeof executeDiagramAction>[0], diagramDeps); 153 + if (!result.success && result.error) { 154 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 155 + } 156 + }, 157 + onDismiss: () => {}, 158 + }); 159 + } 160 + } 161 + } 162 + } 163 + }, 164 + onError(err) { 165 + chatState.error = err; 166 + bubble.el.classList.add('ai-chat-bubble--error'); 167 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 168 + }, 169 + }, 170 + abortController.signal, 171 + ); 172 + 173 + chatState.loading = false; 174 + chatState.abortController = null; 175 + chatUI.sendBtn.style.display = ''; 176 + chatUI.stopBtn.style.display = 'none'; 177 + } 178 + }
+44 -248
src/diagrams/canvas-events.ts
··· 7 7 8 8 import { 9 9 shapeAtPoint, shapesInRect, nearestEdgeAnchor, snapPoint, 10 - hitTestResizeHandle, applyResize, setShapeRotation, addShape, 10 + hitTestResizeHandle, addShape, 11 11 removeShape, removeShapes, addArrow, setShapeStyle, duplicateShapes, 12 - setZoom, pointsToCatmullRomPath, getGroupMembers, 12 + setZoom, getGroupMembers, 13 13 } from './whiteboard.js'; 14 14 import type { 15 15 WhiteboardState, Shape, ShapeKind, ArrowEndpoint, Point, ResizeHandle, ··· 20 20 renderLinePreview, 21 21 } from './shape-renderers.js'; 22 22 import type { ShapeRendererDeps } from './shape-renderers.js'; 23 + import { 24 + updateCursor as _updateCursor, handleRotateMove, handleDragMove, 25 + handleResizeMove, handleArrowMove, handleFreehandMove, 26 + finalizeShapeCreation, finalizeArrow, finalizeFreehand, 27 + } from './canvas-interaction-helpers.js'; 28 + import type { InteractionDeps } from './canvas-interaction-helpers.js'; 29 + import { wireTouchEvents } from './touch-events.js'; 23 30 24 31 // --------------------------------------------------------------------------- 25 32 // Deps interface ··· 97 104 98 105 let isErasing = false; 99 106 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 107 // --------------------------------------------------------------------------- 108 108 // Public getters for state needed by other modules 109 109 // --------------------------------------------------------------------------- ··· 118 118 export function getLinePoints(): Point[] { return linePoints; } 119 119 120 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 121 // Wire up all canvas events 165 122 // --------------------------------------------------------------------------- 166 123 ··· 168 125 const { canvas } = deps; 169 126 const rendererDeps: ShapeRendererDeps = { layer: deps.layer }; 170 127 128 + const interactionDeps: InteractionDeps = { 129 + layer: deps.layer, 130 + getState: deps.getState, 131 + setState: deps.setState, 132 + getActiveTool: deps.getActiveTool, 133 + getSelectedShapeIds: deps.getSelectedShapeIds, 134 + setSelectedShapeIds: deps.setSelectedShapeIds, 135 + pushHistory: deps.pushHistory, 136 + syncToYjs: deps.syncToYjs, 137 + render: deps.render, 138 + screenToCanvas: deps.screenToCanvas, 139 + computeSnapGuides: deps.computeSnapGuides, 140 + renderSnapGuides: deps.renderSnapGuides, 141 + startEdgeScroll: deps.startEdgeScroll, 142 + }; 143 + 171 144 // --- mousedown --- 172 145 canvas.addEventListener('mousedown', (e) => { 173 146 if (deps.getEditingShapeId()) return; ··· 315 288 316 289 // --- mousemove --- 317 290 canvas.addEventListener('mousemove', (e) => { 318 - updateCursor(deps, e); 291 + _updateCursor(interactionDeps, e); 319 292 let wb = deps.getState(); 320 293 const activeTool = deps.getActiveTool(); 321 294 const selectedShapeIds = deps.getSelectedShapeIds(); ··· 337 310 } 338 311 339 312 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 - } 313 + handleRotateMove(interactionDeps, e, rotateShapeId, rotateShapeStartRotation, rotateStartAngle); 352 314 return; 353 315 } 354 316 355 317 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); 318 + handleDragMove(interactionDeps, e, dragStart, dragShapesStart); 373 319 374 320 } else if (isPanning) { 375 321 const dx = e.clientX - panStart.x; ··· 394 340 renderCreationPreview(rendererDeps, createStart, pt, createShapeKind); 395 341 396 342 } 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 - } 343 + handleResizeMove(interactionDeps, e, resizeStart, resizeHandle, resizeShapeId, resizeShapeOriginal); 411 344 412 345 } 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 - } 346 + arrowHoverTargetId = handleArrowMove(interactionDeps, e, arrowFromShape, arrowFromAnchor, arrowHoverTargetId); 422 347 423 348 } 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)); 349 + handleFreehandMove(interactionDeps, e, freehandPoints); 437 350 } 438 351 }); 439 352 ··· 480 393 } 481 394 482 395 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); 396 + finalizeShapeCreation(interactionDeps, e, createStart, createShapeKind); 508 397 isCreatingShape = false; 509 398 createShapeKind = null; 510 399 deps.render(); ··· 520 409 } 521 410 522 411 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 - 412 + finalizeArrow(interactionDeps, e, arrowFromShape, arrowFromAnchor); 550 413 removeArrowPreview(rendererDeps); 551 414 arrowHoverTargetId = null; 552 415 isDrawingArrow = false; ··· 556 419 } 557 420 558 421 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(); 422 + finalizeFreehand(interactionDeps, freehandPoints, activeTool === 'highlighter'); 423 + isDrawingFreehand = false; 424 + freehandPoints = []; 575 425 const preview = deps.layer.querySelector('.freehand-preview'); 576 426 if (preview) preview.remove(); 577 - isDrawingFreehand = false; 578 - freehandPoints = []; 579 427 } else if (isDrawingFreehand) { 580 428 isDrawingFreehand = false; 581 429 freehandPoints = []; ··· 610 458 deps.render(); 611 459 }, { passive: false }); 612 460 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; 461 + // --- Touch: pinch-to-zoom and single-finger pan (delegated to touch-events.ts) --- 462 + wireTouchEvents({ 463 + canvas: deps.canvas, 464 + getState: deps.getState, 465 + setState: deps.setState, 466 + getActiveTool: deps.getActiveTool, 467 + screenToCanvas: deps.screenToCanvas, 468 + render: deps.render, 673 469 }); 674 470 } 675 471
+304
src/diagrams/canvas-interaction-helpers.ts
··· 1 + // @ts-nocheck 2 + /** 3 + * Canvas interaction helpers — cursor, drag, resize, rotate, arrow, freehand move/finalize. 4 + * Extracted from canvas-events.ts. 5 + */ 6 + 7 + import { 8 + shapeAtPoint, nearestEdgeAnchor, snapPoint, 9 + hitTestResizeHandle, applyResize, setShapeRotation, addShape, 10 + addArrow, setShapeStyle, pointsToCatmullRomPath, 11 + } from './whiteboard.js'; 12 + import type { 13 + WhiteboardState, Shape, ShapeKind, ArrowEndpoint, Point, ResizeHandle, 14 + } from './whiteboard.js'; 15 + import { 16 + removeCreationPreview, renderArrowPreview, 17 + } from './shape-renderers.js'; 18 + 19 + // --------------------------------------------------------------------------- 20 + // Deps interface 21 + // --------------------------------------------------------------------------- 22 + 23 + export interface InteractionDeps { 24 + layer: SVGGElement; 25 + getState: () => WhiteboardState; 26 + setState: (wb: WhiteboardState) => void; 27 + getActiveTool: () => string; 28 + getSelectedShapeIds: () => Set<string>; 29 + setSelectedShapeIds: (ids: Set<string>) => void; 30 + pushHistory: () => void; 31 + syncToYjs: () => void; 32 + render: () => void; 33 + screenToCanvas: (sx: number, sy: number) => Point; 34 + computeSnapGuides: (draggedIds: Set<string>) => Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }>; 35 + renderSnapGuides: (guides: Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }>) => void; 36 + startEdgeScroll: (clientX: number, clientY: number) => void; 37 + } 38 + 39 + // --------------------------------------------------------------------------- 40 + // Cursor management 41 + // --------------------------------------------------------------------------- 42 + 43 + const HANDLE_CURSORS: Record<ResizeHandle, string> = { 44 + nw: 'nwse-resize', se: 'nwse-resize', 45 + ne: 'nesw-resize', sw: 'nesw-resize', 46 + n: 'ns-resize', s: 'ns-resize', 47 + e: 'ew-resize', w: 'ew-resize', 48 + }; 49 + 50 + export function updateCursor(deps: InteractionDeps, e: MouseEvent) { 51 + const canvasArea = document.getElementById('canvas-area')!; 52 + const activeTool = deps.getActiveTool(); 53 + if (activeTool === 'eraser') { 54 + canvasArea.style.cursor = 'crosshair'; 55 + return; 56 + } 57 + const selectedShapeIds = deps.getSelectedShapeIds(); 58 + if (activeTool !== 'select' || selectedShapeIds.size !== 1) { 59 + canvasArea.style.cursor = activeTool === 'select' ? '' : 'crosshair'; 60 + return; 61 + } 62 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 63 + const wb = deps.getState(); 64 + const selShape = wb.shapes.get([...selectedShapeIds][0]!); 65 + if (selShape) { 66 + // Check rotation handle 67 + const rotX = selShape.x + selShape.width / 2; 68 + const rotY = selShape.y - 25; 69 + if (Math.abs(pt.x - rotX) <= 8 && Math.abs(pt.y - rotY) <= 8) { 70 + canvasArea.style.cursor = 'grab'; 71 + return; 72 + } 73 + const handle = hitTestResizeHandle(selShape, pt.x, pt.y); 74 + if (handle) { 75 + canvasArea.style.cursor = HANDLE_CURSORS[handle]; 76 + return; 77 + } 78 + } 79 + canvasArea.style.cursor = ''; 80 + } 81 + 82 + // --------------------------------------------------------------------------- 83 + // Rotation drag 84 + // --------------------------------------------------------------------------- 85 + 86 + export function handleRotateMove( 87 + deps: InteractionDeps, e: MouseEvent, 88 + rotateShapeId: string, rotateShapeStartRotation: number, rotateStartAngle: number, 89 + ) { 90 + const wb = deps.getState(); 91 + const shape = wb.shapes.get(rotateShapeId); 92 + if (shape) { 93 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 94 + const cx = shape.x + shape.width / 2; 95 + const cy = shape.y + shape.height / 2; 96 + const currentAngle = Math.atan2(pt.y - cy, pt.x - cx) * 180 / Math.PI; 97 + let newRotation = rotateShapeStartRotation + (currentAngle - rotateStartAngle); 98 + if (e.shiftKey) newRotation = Math.round(newRotation / 15) * 15; 99 + const newWb = setShapeRotation(wb, rotateShapeId, newRotation); 100 + deps.setState(newWb); 101 + deps.render(); 102 + } 103 + } 104 + 105 + // --------------------------------------------------------------------------- 106 + // Shape drag 107 + // --------------------------------------------------------------------------- 108 + 109 + export function handleDragMove( 110 + deps: InteractionDeps, e: MouseEvent, 111 + dragStart: Point, dragShapesStart: Map<string, Point>, 112 + ) { 113 + const wb = deps.getState(); 114 + const selectedShapeIds = deps.getSelectedShapeIds(); 115 + const dx = (e.clientX - dragStart.x) / wb.zoom; 116 + const dy = (e.clientY - dragStart.y) / wb.zoom; 117 + const shapes = new Map(wb.shapes); 118 + for (const [id, startPos] of dragShapesStart) { 119 + const shape = shapes.get(id); 120 + if (!shape) continue; 121 + const nx = startPos.x + dx; 122 + const ny = startPos.y + dy; 123 + const snapped = wb.snapToGrid ? snapPoint(nx, ny, wb.gridSize) : { x: nx, y: ny }; 124 + shapes.set(id, { ...shape, x: snapped.x, y: snapped.y }); 125 + } 126 + deps.setState({ ...wb, shapes }); 127 + deps.render(); 128 + const guides = deps.computeSnapGuides(selectedShapeIds); 129 + deps.renderSnapGuides(guides); 130 + deps.startEdgeScroll(e.clientX, e.clientY); 131 + } 132 + 133 + // --------------------------------------------------------------------------- 134 + // Resize drag 135 + // --------------------------------------------------------------------------- 136 + 137 + export function handleResizeMove( 138 + deps: InteractionDeps, e: MouseEvent, 139 + resizeStart: Point, resizeHandle: ResizeHandle, 140 + resizeShapeId: string, resizeShapeOriginal: { x: number; y: number; width: number; height: number }, 141 + ) { 142 + const wb = deps.getState(); 143 + const dx = (e.clientX - resizeStart.x) / wb.zoom; 144 + const dy = (e.clientY - resizeStart.y) / wb.zoom; 145 + const newBounds = applyResize(resizeShapeOriginal, resizeHandle, dx, dy); 146 + const shape = wb.shapes.get(resizeShapeId); 147 + if (shape) { 148 + const snapped = wb.snapToGrid 149 + ? snapPoint(newBounds.x, newBounds.y, wb.gridSize) 150 + : { x: newBounds.x, y: newBounds.y }; 151 + const shapes = new Map(wb.shapes); 152 + shapes.set(resizeShapeId, { ...shape, x: snapped.x, y: snapped.y, width: Math.max(10, newBounds.width), height: Math.max(10, newBounds.height) }); 153 + deps.setState({ ...wb, shapes }); 154 + deps.render(); 155 + } 156 + } 157 + 158 + // --------------------------------------------------------------------------- 159 + // Arrow preview move (returns new hover target id) 160 + // --------------------------------------------------------------------------- 161 + 162 + export function handleArrowMove( 163 + deps: InteractionDeps, e: MouseEvent, 164 + arrowFromShape: string | null, arrowFromAnchor: { anchor: string; x: number; y: number }, 165 + arrowHoverTargetId: string | null, 166 + ): string | null { 167 + const wb = deps.getState(); 168 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 169 + const rendererDeps = { layer: deps.layer }; 170 + renderArrowPreview(rendererDeps, arrowFromAnchor, pt); 171 + const hover = shapeAtPoint(wb, pt.x, pt.y); 172 + const newTarget = hover && hover.id !== arrowFromShape ? hover.id : null; 173 + if (newTarget !== arrowHoverTargetId) { 174 + renderArrowPreview(rendererDeps, arrowFromAnchor, pt); 175 + deps.render(); 176 + renderArrowPreview(rendererDeps, arrowFromAnchor, pt); 177 + } 178 + return newTarget; 179 + } 180 + 181 + // --------------------------------------------------------------------------- 182 + // Freehand move 183 + // --------------------------------------------------------------------------- 184 + 185 + export function handleFreehandMove( 186 + deps: InteractionDeps, e: MouseEvent, 187 + freehandPoints: Point[], 188 + ) { 189 + const activeTool = deps.getActiveTool(); 190 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 191 + freehandPoints.push(pt); 192 + let tempPath = deps.layer.querySelector('.freehand-preview') as SVGPathElement | null; 193 + if (!tempPath) { 194 + tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGPathElement; 195 + tempPath.classList.add('freehand-preview'); 196 + tempPath.setAttribute('fill', 'none'); 197 + tempPath.setAttribute('stroke', activeTool === 'highlighter' ? 'rgba(255,255,0,0.4)' : 'var(--color-text)'); 198 + tempPath.setAttribute('stroke-width', activeTool === 'highlighter' ? '12' : '2'); 199 + tempPath.setAttribute('stroke-linecap', 'round'); 200 + deps.layer.appendChild(tempPath); 201 + } 202 + tempPath.setAttribute('d', pointsToCatmullRomPath(freehandPoints)); 203 + } 204 + 205 + // --------------------------------------------------------------------------- 206 + // Mouseup: finalize shape creation 207 + // --------------------------------------------------------------------------- 208 + 209 + export function finalizeShapeCreation( 210 + deps: InteractionDeps, e: MouseEvent, 211 + createStart: Point, createShapeKind: ShapeKind, 212 + ) { 213 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 214 + const dx = Math.abs(pt.x - createStart.x); 215 + const dy = Math.abs(pt.y - createStart.y); 216 + let wb = deps.getState(); 217 + 218 + deps.pushHistory(); 219 + if (Math.sqrt(dx * dx + dy * dy) < 5) { 220 + const defaultLabel = createShapeKind === 'text' ? 'Text' : createShapeKind === 'note' ? 'Note' : ''; 221 + wb = addShape(wb, createShapeKind, createStart.x, createStart.y, 120, 80, defaultLabel); 222 + } else { 223 + const x = Math.min(createStart.x, pt.x); 224 + const y = Math.min(createStart.y, pt.y); 225 + const defaultLabel = createShapeKind === 'text' ? 'Text' : createShapeKind === 'note' ? 'Note' : ''; 226 + wb = addShape(wb, createShapeKind, x, y, Math.max(10, dx), Math.max(10, dy), defaultLabel); 227 + } 228 + // Set default note fill 229 + if (createShapeKind === 'note') { 230 + const shapes = [...wb.shapes.values()]; 231 + const lastShape = shapes[shapes.length - 1]; 232 + if (lastShape) { 233 + wb = setShapeStyle(wb, [lastShape.id], { fill: '#fef08a' }); 234 + } 235 + } 236 + deps.setState(wb); 237 + deps.syncToYjs(); 238 + removeCreationPreview({ layer: deps.layer }); 239 + } 240 + 241 + // --------------------------------------------------------------------------- 242 + // Mouseup: finalize arrow 243 + // --------------------------------------------------------------------------- 244 + 245 + export function finalizeArrow( 246 + deps: InteractionDeps, e: MouseEvent, 247 + arrowFromShape: string | null, arrowFromAnchor: { anchor: string; x: number; y: number }, 248 + ) { 249 + let wb = deps.getState(); 250 + const pt = deps.screenToCanvas(e.clientX, e.clientY); 251 + const hit = shapeAtPoint(wb, pt.x, pt.y); 252 + 253 + let fromEp: ArrowEndpoint; 254 + if (arrowFromShape) { 255 + fromEp = { shapeId: arrowFromShape, anchor: arrowFromAnchor.anchor as 'top' | 'bottom' | 'left' | 'right' | 'center' }; 256 + } else { 257 + fromEp = { x: arrowFromAnchor.x, y: arrowFromAnchor.y }; 258 + } 259 + 260 + let toEp: ArrowEndpoint; 261 + if (hit && hit.id !== arrowFromShape) { 262 + const toAnchorInfo = nearestEdgeAnchor(hit, pt.x, pt.y); 263 + toEp = { shapeId: hit.id, anchor: toAnchorInfo.anchor }; 264 + } else { 265 + toEp = { x: pt.x, y: pt.y }; 266 + } 267 + 268 + const fromPt = arrowFromAnchor; 269 + const dist = Math.sqrt((pt.x - fromPt.x) ** 2 + (pt.y - fromPt.y) ** 2); 270 + if (dist > 5) { 271 + deps.pushHistory(); 272 + wb = addArrow(wb, fromEp, toEp); 273 + deps.setState(wb); 274 + deps.syncToYjs(); 275 + } 276 + } 277 + 278 + // --------------------------------------------------------------------------- 279 + // Mouseup: finalize freehand 280 + // --------------------------------------------------------------------------- 281 + 282 + export function finalizeFreehand( 283 + deps: InteractionDeps, 284 + freehandPoints: Point[], 285 + isHighlighter: boolean, 286 + ) { 287 + deps.pushHistory(); 288 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 289 + 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); }); 290 + const normalized = freehandPoints.map(p => ({ x: p.x - minX, y: p.y - minY })); 291 + let wb = deps.getState(); 292 + wb = addShape(wb, 'freehand', minX, minY, maxX - minX || 10, maxY - minY || 10); 293 + const shapes = [...wb.shapes.values()]; 294 + const lastShape = shapes[shapes.length - 1]; 295 + if (lastShape) { 296 + wb.shapes.set(lastShape.id, { ...lastShape, points: normalized }); 297 + if (isHighlighter) { 298 + wb = setShapeStyle(wb, [lastShape.id], { stroke: 'rgba(255,255,0,0.4)', strokeWidth: '12' }); 299 + } 300 + } 301 + deps.setState(wb); 302 + deps.syncToYjs(); 303 + deps.render(); 304 + }
+43 -473
src/diagrams/main.ts
··· 10 10 import { EncryptedProvider } from '../lib/provider.js'; 11 11 import { setupTooltips } from '../lib/tooltips.js'; 12 12 import { 13 - createWhiteboard, addShape, setShapeLabel, setZoom, 14 - shapeAtPoint, getBoundingBox, getResizeHandles, 15 - pointsToCatmullRomPath, snapPoint, 13 + createWhiteboard, setShapeLabel, 16 14 } from './whiteboard.js'; 17 15 import type { 18 - WhiteboardState, Shape, Arrow, ArrowEndpoint, Point, 16 + WhiteboardState, Shape, Arrow, Point, 19 17 } from './whiteboard.js'; 20 18 import History from './history.js'; 21 - import { 22 - createChatSidebar, createChatState, loadConfig, isConfigured, 23 - buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 24 - renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 25 - type ChatMessage, 26 - } from '../lib/ai-chat.js'; 27 - import { splitResponse, isDiagramAction } from '../lib/ai-actions.js'; 28 - import { executeDiagramAction } from './ai-diagram-actions.js'; 29 19 30 20 // Extracted modules 31 21 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, 22 + wireCanvasEvents, finishCurrentLine, 40 23 } from './canvas-events.js'; 41 24 import type { CanvasEventDeps } from './canvas-events.js'; 42 25 import { ··· 44 27 } from './toolbar-wiring.js'; 45 28 import type { ToolbarWiringDeps } from './toolbar-wiring.js'; 46 29 import { wireKeyboardShortcuts } from './keyboard-shortcuts.js'; 30 + import { 31 + render as _render, updateToolbarFull as _updateToolbarFull, 32 + updateProps as _updateProps, updateStylePanel as _updateStylePanel, 33 + } from './rendering.js'; 34 + import type { RenderDeps } from './rendering.js'; 35 + import { 36 + computeSnapGuides as _computeSnapGuides, 37 + renderSnapGuides as _renderSnapGuides, 38 + clearSnapGuides as _clearSnapGuides, 39 + } from './snap-guides.js'; 40 + import { initAiChat } from './ai-chat-wiring.js'; 47 41 48 42 // --- DOM refs --- 49 43 const $ = (id: string) => document.getElementById(id)!; ··· 79 73 // Inline text editing 80 74 let editingShapeId: string | null = null; 81 75 82 - // --- Snap guides --- 83 - const SNAP_THRESHOLD = 6; 84 - 85 - function computeSnapGuides(draggedIds: Set<string>): Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }> { 86 - const guides: Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }> = []; 87 - if (draggedIds.size === 0) return guides; 88 - 89 - let dMinX = Infinity, dMinY = Infinity, dMaxX = -Infinity, dMaxY = -Infinity; 90 - for (const id of draggedIds) { 91 - const s = wb.shapes.get(id); 92 - if (!s) continue; 93 - dMinX = Math.min(dMinX, s.x); 94 - dMinY = Math.min(dMinY, s.y); 95 - dMaxX = Math.max(dMaxX, s.x + s.width); 96 - dMaxY = Math.max(dMaxY, s.y + s.height); 97 - } 98 - const dCx = (dMinX + dMaxX) / 2; 99 - const dCy = (dMinY + dMaxY) / 2; 100 - 101 - const dragEdgesH = [dMinX, dCx, dMaxX]; 102 - const dragEdgesV = [dMinY, dCy, dMaxY]; 103 - 104 - for (const [id, shape] of wb.shapes) { 105 - if (draggedIds.has(id)) continue; 106 - const sEdgesH = [shape.x, shape.x + shape.width / 2, shape.x + shape.width]; 107 - const sEdgesV = [shape.y, shape.y + shape.height / 2, shape.y + shape.height]; 108 - 109 - for (const de of dragEdgesH) { 110 - for (const se of sEdgesH) { 111 - if (Math.abs(de - se) < SNAP_THRESHOLD) { 112 - guides.push({ axis: 'v', pos: se, from: Math.min(dMinY, shape.y), to: Math.max(dMaxY, shape.y + shape.height) }); 113 - } 114 - } 115 - } 116 - for (const de of dragEdgesV) { 117 - for (const se of sEdgesV) { 118 - if (Math.abs(de - se) < SNAP_THRESHOLD) { 119 - guides.push({ axis: 'h', pos: se, from: Math.min(dMinX, shape.x), to: Math.max(dMaxX, shape.x + shape.width) }); 120 - } 121 - } 122 - } 123 - } 124 - return guides; 76 + // --- Snap guides (delegated to snap-guides.ts) --- 77 + function computeSnapGuides(draggedIds: Set<string>) { 78 + return _computeSnapGuides(wb, draggedIds); 125 79 } 126 - 127 80 function renderSnapGuides(guides: Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }>) { 128 - layer.querySelectorAll('.snap-guide').forEach(el => el.remove()); 129 - for (const g of guides) { 130 - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 131 - if (g.axis === 'v') { 132 - line.setAttribute('x1', String(g.pos)); 133 - line.setAttribute('y1', String(g.from)); 134 - line.setAttribute('x2', String(g.pos)); 135 - line.setAttribute('y2', String(g.to)); 136 - } else { 137 - line.setAttribute('x1', String(g.from)); 138 - line.setAttribute('y1', String(g.pos)); 139 - line.setAttribute('x2', String(g.to)); 140 - line.setAttribute('y2', String(g.pos)); 141 - } 142 - line.classList.add('snap-guide'); 143 - layer.appendChild(line); 144 - } 81 + _renderSnapGuides(layer, guides); 145 82 } 146 - 147 83 function clearSnapGuides() { 148 - layer.querySelectorAll('.snap-guide').forEach(el => el.remove()); 84 + _clearSnapGuides(layer); 149 85 } 86 + 87 + // --- Rendering (delegated to rendering.ts) --- 88 + const renderDeps: RenderDeps = { 89 + layer, zoomLabel, propsSection, propsDimensions, propLabel, propWidth, propHeight, 90 + stylePanel, styleFill, styleStroke, styleStrokeWidth, styleStrokeStyle, 91 + styleOpacity, styleOpacityValue, styleFontFamily, styleFontSize, 92 + getState: () => wb, getSelectedShapeIds: () => selectedShapeIds, 93 + getEditingShapeId: () => editingShapeId, getActiveTool: () => activeTool, 94 + }; 95 + 96 + function render() { _render(renderDeps); _updateToolbarFull(renderDeps); _updateProps(renderDeps); _updateStylePanel(renderDeps); } 97 + function updateToolbar() { _updateToolbarFull(renderDeps); } 98 + function updateProps() { _updateProps(renderDeps); } 99 + function updateStylePanel() { _updateStylePanel(renderDeps); } 150 100 151 101 // --- History helpers --- 152 102 function pushHistory() { ··· 226 176 }; 227 177 } 228 178 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 - 245 - function render() { 246 - const editOverlay = editingShapeId ? layer.querySelector('.inline-text-edit') : null; 247 - layer.innerHTML = ''; 248 - const transform = `translate(${wb.panX}, ${wb.panY}) scale(${wb.zoom})`; 249 - layer.setAttribute('transform', transform); 250 - 251 - const arrowHoverTargetId = getArrowHoverTargetId(); 252 - 253 - // Render shapes 254 - wb.shapes.forEach((shape) => { 255 - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 256 - g.setAttribute('data-shape-id', shape.id); 257 - g.setAttribute('transform', `translate(${shape.x}, ${shape.y})${shape.rotation ? ` rotate(${shape.rotation}, ${shape.width / 2}, ${shape.height / 2})` : ''}`); 258 - g.classList.add('diagram-shape'); 259 - if (selectedShapeIds.has(shape.id)) g.classList.add('selected'); 260 - if (shape.id === arrowHoverTargetId) g.classList.add('arrow-hover-target'); 261 - if (shape.opacity !== undefined && shape.opacity < 1) { 262 - g.setAttribute('opacity', String(shape.opacity)); 263 - } 264 - 265 - const fill = shape.style?.fill || 'var(--color-surface)'; 266 - const stroke = shape.style?.stroke || 'var(--color-text)'; 267 - const strokeWidth = shape.style?.strokeWidth || '2'; 268 - const strokeDasharray = shape.style?.strokeDasharray || ''; 269 - 270 - switch (shape.kind) { 271 - case 'rectangle': 272 - appendRect(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 273 - break; 274 - case 'ellipse': 275 - appendEllipse(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 276 - break; 277 - case 'diamond': 278 - appendDiamond(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 279 - break; 280 - case 'triangle': 281 - appendTriangle(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 282 - break; 283 - case 'star': 284 - appendStar(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 285 - break; 286 - case 'hexagon': 287 - appendHexagon(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 288 - break; 289 - case 'cloud': 290 - appendCloud(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 291 - break; 292 - case 'cylinder': 293 - appendCylinder(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 294 - break; 295 - case 'parallelogram': 296 - appendParallelogram(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 297 - break; 298 - case 'note': 299 - appendNote(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 300 - break; 301 - case 'text': 302 - appendRect(g, shape.width, shape.height, 'transparent', 'transparent', '0', ''); 303 - break; 304 - case 'line': 305 - if (shape.points && shape.points.length >= 2) { 306 - const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); 307 - polyline.setAttribute('points', shape.points.map(p => `${p.x},${p.y}`).join(' ')); 308 - polyline.setAttribute('fill', 'none'); 309 - polyline.setAttribute('stroke', stroke); 310 - polyline.setAttribute('stroke-width', strokeWidth); 311 - polyline.setAttribute('stroke-linecap', 'round'); 312 - polyline.setAttribute('stroke-linejoin', 'round'); 313 - if (strokeDasharray) polyline.setAttribute('stroke-dasharray', strokeDasharray); 314 - g.appendChild(polyline); 315 - } 316 - break; 317 - case 'freehand': 318 - if (shape.points && shape.points.length > 1) { 319 - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 320 - path.setAttribute('d', pointsToCatmullRomPath(shape.points)); 321 - path.setAttribute('fill', 'none'); 322 - path.setAttribute('stroke', stroke); 323 - path.setAttribute('stroke-width', strokeWidth); 324 - path.setAttribute('stroke-linecap', 'round'); 325 - if (strokeDasharray) path.setAttribute('stroke-dasharray', strokeDasharray); 326 - g.appendChild(path); 327 - } 328 - break; 329 - } 330 - 331 - // Label 332 - if (shape.label && editingShapeId !== shape.id) { 333 - const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 334 - text.setAttribute('x', String(shape.width / 2)); 335 - text.setAttribute('y', String(shape.height / 2)); 336 - text.setAttribute('text-anchor', 'middle'); 337 - text.setAttribute('dominant-baseline', 'central'); 338 - text.setAttribute('fill', 'var(--color-text)'); 339 - text.setAttribute('font-size', String(shape.fontSize || 14)); 340 - text.setAttribute('font-family', shape.fontFamily || 'system-ui'); 341 - text.textContent = shape.label; 342 - g.appendChild(text); 343 - } 344 - 345 - layer.appendChild(g); 346 - }); 347 - 348 - // Render resize handles + rotation handle for single selection 349 - if (selectedShapeIds.size === 1 && !editingShapeId) { 350 - const selId = [...selectedShapeIds][0]!; 351 - const selShape = wb.shapes.get(selId); 352 - if (selShape) { 353 - const handles = getResizeHandles(selShape); 354 - for (const h of handles) { 355 - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 356 - circle.setAttribute('cx', String(h.x)); 357 - circle.setAttribute('cy', String(h.y)); 358 - circle.setAttribute('r', '4'); 359 - circle.classList.add('resize-handle'); 360 - circle.setAttribute('data-handle', h.handle); 361 - layer.appendChild(circle); 362 - } 363 - // Rotation handle 364 - const rotY = selShape.y - 25; 365 - const rotX = selShape.x + selShape.width / 2; 366 - const rotLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 367 - rotLine.setAttribute('x1', String(rotX)); 368 - rotLine.setAttribute('y1', String(selShape.y)); 369 - rotLine.setAttribute('x2', String(rotX)); 370 - rotLine.setAttribute('y2', String(rotY)); 371 - rotLine.setAttribute('stroke', 'var(--color-primary)'); 372 - rotLine.setAttribute('stroke-width', '1'); 373 - rotLine.classList.add('rotation-line'); 374 - layer.appendChild(rotLine); 375 - 376 - const rotCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 377 - rotCircle.setAttribute('cx', String(rotX)); 378 - rotCircle.setAttribute('cy', String(rotY)); 379 - rotCircle.setAttribute('r', '5'); 380 - rotCircle.classList.add('rotation-handle'); 381 - layer.appendChild(rotCircle); 382 - } 383 - } 384 - 385 - // Render arrows 386 - wb.arrows.forEach((arrow) => { 387 - const from = resolveEndpoint(arrow.from); 388 - const to = resolveEndpoint(arrow.to); 389 - if (!from || !to) return; 390 - 391 - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 392 - line.setAttribute('x1', String(from.x)); 393 - line.setAttribute('y1', String(from.y)); 394 - line.setAttribute('x2', String(to.x)); 395 - line.setAttribute('y2', String(to.y)); 396 - line.setAttribute('stroke', arrow.style?.stroke || 'var(--color-text)'); 397 - line.setAttribute('stroke-width', arrow.style?.strokeWidth || '2'); 398 - if (arrow.style?.strokeDasharray) line.setAttribute('stroke-dasharray', arrow.style.strokeDasharray); 399 - line.setAttribute('marker-end', 'url(#arrowhead)'); 400 - line.classList.add('diagram-arrow'); 401 - line.setAttribute('data-arrow-id', arrow.id); 402 - layer.appendChild(line); 403 - 404 - if (arrow.label) { 405 - const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 406 - text.setAttribute('x', String((from.x + to.x) / 2)); 407 - text.setAttribute('y', String((from.y + to.y) / 2 - 8)); 408 - text.setAttribute('text-anchor', 'middle'); 409 - text.setAttribute('fill', 'var(--color-text-secondary)'); 410 - text.setAttribute('font-size', '12'); 411 - text.textContent = arrow.label; 412 - layer.appendChild(text); 413 - } 414 - }); 415 - 416 - // Render marquee selection rect 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); 424 - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 425 - rect.setAttribute('x', String(mx)); 426 - rect.setAttribute('y', String(my)); 427 - rect.setAttribute('width', String(mw)); 428 - rect.setAttribute('height', String(mh)); 429 - rect.classList.add('marquee-rect'); 430 - layer.appendChild(rect); 431 - } 432 - 433 - // Re-attach inline text editing overlay if it was active 434 - if (editOverlay) { 435 - layer.appendChild(editOverlay); 436 - } 437 - 438 - zoomLabel.textContent = `${Math.round(wb.zoom * 100)}%`; 439 - updateToolbar(); 440 - updateProps(); 441 - updateStylePanel(); 442 - } 443 - 444 - function updateToolbar() { 445 - document.querySelectorAll('.diagrams-tool').forEach(btn => { 446 - btn.classList.toggle('active', (btn as HTMLElement).dataset.tool === activeTool); 447 - }); 448 - $('btn-snap-grid').classList.toggle('active', wb.snapToGrid); 449 - } 450 - 451 - function updateProps() { 452 - if (selectedShapeIds.size !== 1) { 453 - propsSection.style.display = 'none'; 454 - propsDimensions.style.display = 'none'; 455 - return; 456 - } 457 - const shape = wb.shapes.get([...selectedShapeIds][0]!); 458 - if (!shape) { propsSection.style.display = 'none'; propsDimensions.style.display = 'none'; return; } 459 - propsSection.style.display = ''; 460 - propsDimensions.style.display = ''; 461 - propLabel.value = shape.label || ''; 462 - propWidth.value = String(shape.width); 463 - propHeight.value = String(shape.height); 464 - } 465 - 466 - function updateStylePanel() { 467 - if (selectedShapeIds.size === 0) { 468 - stylePanel.style.display = 'none'; 469 - return; 470 - } 471 - stylePanel.style.display = ''; 472 - const shape = wb.shapes.get([...selectedShapeIds][0]!); 473 - if (!shape) return; 474 - styleFill.value = shape.style?.fill || '#ffffff'; 475 - styleStroke.value = shape.style?.stroke || '#000000'; 476 - styleStrokeWidth.value = shape.style?.strokeWidth || '2'; 477 - styleStrokeStyle.value = shape.style?.strokeDasharray ? (shape.style.strokeDasharray === '8 4' ? 'dashed' : 'dotted') : 'solid'; 478 - styleOpacity.value = String(Math.round((shape.opacity ?? 1) * 100)); 479 - styleOpacityValue.textContent = `${Math.round((shape.opacity ?? 1) * 100)}%`; 480 - styleFontFamily.value = shape.fontFamily || 'system-ui'; 481 - styleFontSize.value = String(shape.fontSize || 14); 482 - } 483 - 484 179 // --- Inline text editing --- 485 180 function startTextEditing(shapeId: string) { 486 181 const shape = wb.shapes.get(shapeId); ··· 607 302 }); 608 303 }); 609 304 610 - // ── AI Chat Panel ──────────────────────────────────────────────────────── 611 - 612 - const chatUI = createChatSidebar(); 613 - $('main-content').appendChild(chatUI.container); 614 - 615 - const chatState = createChatState(); 616 - 617 - const chatWiring = initChatWiring({ 618 - chatUI, 619 - chatState, 620 - chatConfig: loadConfig(), 305 + // ── AI Chat Panel (delegated to ai-chat-wiring.ts) ── 306 + initAiChat({ 307 + getState: () => wb, 308 + setState: (s) => { wb = s; }, 309 + getSelectedShapeIds: () => selectedShapeIds, 310 + getDiagramTitle: () => diagramTitle.value.trim(), 311 + render, 312 + syncToYjs, 313 + pushHistory, 314 + mainContent: $('main-content'), 621 315 toggleBtn: $('btn-ai-chat'), 622 - editorType: 'diagram', 623 - onSend: sendChatMessage, 624 316 }); 625 - 626 - /** Build text summary of current diagram for AI context */ 627 - function getDiagramContextText(): string { 628 - const lines: string[] = []; 629 - for (const [, shape] of wb.shapes) { 630 - const label = shape.label || '(unlabeled)'; 631 - lines.push(`${shape.kind} "${label}" at (${Math.round(shape.x)},${Math.round(shape.y)}) size ${Math.round(shape.width)}x${Math.round(shape.height)}`); 632 - } 633 - for (const [, arrow] of wb.arrows) { 634 - const fromDesc = 'shapeId' in arrow.from 635 - ? `"${wb.shapes.get(arrow.from.shapeId)?.label || 'unknown'}"` 636 - : `(${arrow.from.x},${arrow.from.y})`; 637 - const toDesc = 'shapeId' in arrow.to 638 - ? `"${wb.shapes.get(arrow.to.shapeId)?.label || 'unknown'}"` 639 - : `(${arrow.to.x},${arrow.to.y})`; 640 - lines.push(`arrow from ${fromDesc} to ${toDesc}`); 641 - } 642 - return lines.join('\n'); 643 - } 644 - 645 - async function sendChatMessage(): Promise<void> { 646 - const text = chatUI.input.value.trim(); 647 - if (!text || chatState.loading) return; 648 - 649 - const cfg = chatWiring.getConfig(); 650 - if (!isConfigured(cfg)) { 651 - chatUI.settingsPanel.style.display = ''; 652 - chatUI.endpointInput.focus(); 653 - return; 654 - } 655 - 656 - const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 657 - chatState.messages.push(userMsg); 658 - appendMessage(chatUI.messageList, userMsg); 659 - 660 - chatUI.input.value = ''; 661 - chatUI.input.style.height = ''; 662 - chatUI.sendBtn.style.display = 'none'; 663 - chatUI.stopBtn.style.display = ''; 664 - chatState.loading = true; 665 - chatState.error = null; 666 - 667 - const title = diagramTitle.value.trim() || 'Untitled Diagram'; 668 - const includeContext = chatUI.contextToggle.checked; 669 - const actionsEnabled = chatUI.actionsToggle.checked; 670 - const contextText = includeContext ? getDiagramContextText() : ''; 671 - 672 - let selectionContext: string | undefined; 673 - if (selectedShapeIds.size > 0) { 674 - const selLines: string[] = []; 675 - for (const id of selectedShapeIds) { 676 - const shape = wb.shapes.get(id); 677 - if (shape) selLines.push(`${shape.kind} "${shape.label || '(unlabeled)'}"`); 678 - } 679 - selectionContext = `Selected shapes: ${selLines.join(', ')}`; 680 - } 681 - 682 - const systemPrompt = buildSystemMessage(title, contextText, { 683 - editorType: 'diagram', 684 - actionsEnabled, 685 - selectionContext, 686 - }); 687 - 688 - const diagramDeps = { 689 - getState: () => wb, 690 - setState: (s: typeof wb) => { wb = s; syncToYjs(); }, 691 - render, 692 - pushHistory, 693 - }; 694 - 695 - const abortController = new AbortController(); 696 - chatState.abortController = abortController; 697 - const bubble = appendStreamingBubble(chatUI.messageList); 698 - let fullText = ''; 699 - 700 - await streamChat( 701 - cfg, 702 - chatState.messages, 703 - systemPrompt, 704 - { 705 - onChunk(chunk) { 706 - fullText += chunk; 707 - bubble.update(renderMarkdown(fullText)); 708 - }, 709 - onDone(text) { 710 - if (text) { 711 - chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 712 - 713 - if (actionsEnabled) { 714 - const { displayText, actions } = splitResponse(text); 715 - if (actions.length > 0) { 716 - bubble.update(renderMarkdown(displayText)); 717 - for (const action of actions) { 718 - if (!isDiagramAction(action)) continue; 719 - appendActionCard(chatUI.messageList, action, { 720 - onApply: (a) => { 721 - const result = executeDiagramAction(a as Parameters<typeof executeDiagramAction>[0], diagramDeps); 722 - if (!result.success && result.error) { 723 - appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 724 - } 725 - }, 726 - onDismiss: () => {}, 727 - }); 728 - } 729 - } 730 - } 731 - } 732 - }, 733 - onError(err) { 734 - chatState.error = err; 735 - bubble.el.classList.add('ai-chat-bubble--error'); 736 - bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 737 - }, 738 - }, 739 - abortController.signal, 740 - ); 741 - 742 - chatState.loading = false; 743 - chatState.abortController = null; 744 - chatUI.sendBtn.style.display = ''; 745 - chatUI.stopBtn.style.display = 'none'; 746 - } 747 317 748 318 // --- Initialize --- 749 319 function ensureArrowheadMarker() {
+333
src/diagrams/rendering.ts
··· 1 + // @ts-nocheck 2 + /** 3 + * Rendering logic for the diagram canvas — shapes, arrows, handles, marquee. 4 + * Extracted from main.ts. 5 + */ 6 + 7 + import { 8 + getResizeHandles, pointsToCatmullRomPath, 9 + } from './whiteboard.js'; 10 + import type { 11 + WhiteboardState, Shape, ArrowEndpoint, Point, 12 + } from './whiteboard.js'; 13 + import { 14 + appendRect, appendEllipse, appendDiamond, appendTriangle, 15 + appendStar, appendHexagon, appendCloud, appendCylinder, 16 + appendParallelogram, appendNote, 17 + } from './shape-renderers.js'; 18 + import { 19 + getArrowHoverTargetId, getIsMarqueeSelecting, getMarqueeStart, getMarqueeEnd, 20 + } from './canvas-events.js'; 21 + 22 + // --------------------------------------------------------------------------- 23 + // Deps interface 24 + // --------------------------------------------------------------------------- 25 + 26 + export interface RenderDeps { 27 + layer: SVGGElement; 28 + zoomLabel: HTMLElement; 29 + propsSection: HTMLElement; 30 + propsDimensions: HTMLElement; 31 + propLabel: HTMLInputElement; 32 + propWidth: HTMLInputElement; 33 + propHeight: HTMLInputElement; 34 + stylePanel: HTMLElement; 35 + styleFill: HTMLInputElement; 36 + styleStroke: HTMLInputElement; 37 + styleStrokeWidth: HTMLSelectElement; 38 + styleStrokeStyle: HTMLSelectElement; 39 + styleOpacity: HTMLInputElement; 40 + styleOpacityValue: HTMLElement; 41 + styleFontFamily: HTMLSelectElement; 42 + styleFontSize: HTMLInputElement; 43 + getState: () => WhiteboardState; 44 + getSelectedShapeIds: () => Set<string>; 45 + getEditingShapeId: () => string | null; 46 + getActiveTool: () => string; 47 + } 48 + 49 + // --------------------------------------------------------------------------- 50 + // Arrow endpoint resolution 51 + // --------------------------------------------------------------------------- 52 + 53 + export function resolveEndpoint(wb: WhiteboardState, ep: ArrowEndpoint): Point | null { 54 + if ('x' in ep) return { x: ep.x, y: ep.y }; 55 + const shape = wb.shapes.get(ep.shapeId); 56 + if (!shape) return null; 57 + const cx = shape.x + shape.width / 2; 58 + const cy = shape.y + shape.height / 2; 59 + switch (ep.anchor) { 60 + case 'top': return { x: cx, y: shape.y }; 61 + case 'bottom': return { x: cx, y: shape.y + shape.height }; 62 + case 'left': return { x: shape.x, y: cy }; 63 + case 'right': return { x: shape.x + shape.width, y: cy }; 64 + default: return { x: cx, y: cy }; 65 + } 66 + } 67 + 68 + // --------------------------------------------------------------------------- 69 + // Full SVG render 70 + // --------------------------------------------------------------------------- 71 + 72 + export function render(deps: RenderDeps) { 73 + const { layer } = deps; 74 + const wb = deps.getState(); 75 + const selectedShapeIds = deps.getSelectedShapeIds(); 76 + const editingShapeId = deps.getEditingShapeId(); 77 + 78 + const editOverlay = editingShapeId ? layer.querySelector('.inline-text-edit') : null; 79 + layer.innerHTML = ''; 80 + const transform = `translate(${wb.panX}, ${wb.panY}) scale(${wb.zoom})`; 81 + layer.setAttribute('transform', transform); 82 + 83 + const arrowHoverTargetId = getArrowHoverTargetId(); 84 + 85 + // Render shapes 86 + wb.shapes.forEach((shape) => { 87 + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 88 + g.setAttribute('data-shape-id', shape.id); 89 + g.setAttribute('transform', `translate(${shape.x}, ${shape.y})${shape.rotation ? ` rotate(${shape.rotation}, ${shape.width / 2}, ${shape.height / 2})` : ''}`); 90 + g.classList.add('diagram-shape'); 91 + if (selectedShapeIds.has(shape.id)) g.classList.add('selected'); 92 + if (shape.id === arrowHoverTargetId) g.classList.add('arrow-hover-target'); 93 + if (shape.opacity !== undefined && shape.opacity < 1) { 94 + g.setAttribute('opacity', String(shape.opacity)); 95 + } 96 + 97 + const fill = shape.style?.fill || 'var(--color-surface)'; 98 + const stroke = shape.style?.stroke || 'var(--color-text)'; 99 + const strokeWidth = shape.style?.strokeWidth || '2'; 100 + const strokeDasharray = shape.style?.strokeDasharray || ''; 101 + 102 + switch (shape.kind) { 103 + case 'rectangle': 104 + appendRect(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 105 + break; 106 + case 'ellipse': 107 + appendEllipse(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 108 + break; 109 + case 'diamond': 110 + appendDiamond(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 111 + break; 112 + case 'triangle': 113 + appendTriangle(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 114 + break; 115 + case 'star': 116 + appendStar(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 117 + break; 118 + case 'hexagon': 119 + appendHexagon(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 120 + break; 121 + case 'cloud': 122 + appendCloud(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 123 + break; 124 + case 'cylinder': 125 + appendCylinder(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 126 + break; 127 + case 'parallelogram': 128 + appendParallelogram(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 129 + break; 130 + case 'note': 131 + appendNote(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 132 + break; 133 + case 'text': 134 + appendRect(g, shape.width, shape.height, 'transparent', 'transparent', '0', ''); 135 + break; 136 + case 'line': 137 + if (shape.points && shape.points.length >= 2) { 138 + const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); 139 + polyline.setAttribute('points', shape.points.map(p => `${p.x},${p.y}`).join(' ')); 140 + polyline.setAttribute('fill', 'none'); 141 + polyline.setAttribute('stroke', stroke); 142 + polyline.setAttribute('stroke-width', strokeWidth); 143 + polyline.setAttribute('stroke-linecap', 'round'); 144 + polyline.setAttribute('stroke-linejoin', 'round'); 145 + if (strokeDasharray) polyline.setAttribute('stroke-dasharray', strokeDasharray); 146 + g.appendChild(polyline); 147 + } 148 + break; 149 + case 'freehand': 150 + if (shape.points && shape.points.length > 1) { 151 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 152 + path.setAttribute('d', pointsToCatmullRomPath(shape.points)); 153 + path.setAttribute('fill', 'none'); 154 + path.setAttribute('stroke', stroke); 155 + path.setAttribute('stroke-width', strokeWidth); 156 + path.setAttribute('stroke-linecap', 'round'); 157 + if (strokeDasharray) path.setAttribute('stroke-dasharray', strokeDasharray); 158 + g.appendChild(path); 159 + } 160 + break; 161 + } 162 + 163 + // Label 164 + if (shape.label && editingShapeId !== shape.id) { 165 + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 166 + text.setAttribute('x', String(shape.width / 2)); 167 + text.setAttribute('y', String(shape.height / 2)); 168 + text.setAttribute('text-anchor', 'middle'); 169 + text.setAttribute('dominant-baseline', 'central'); 170 + text.setAttribute('fill', 'var(--color-text)'); 171 + text.setAttribute('font-size', String(shape.fontSize || 14)); 172 + text.setAttribute('font-family', shape.fontFamily || 'system-ui'); 173 + text.textContent = shape.label; 174 + g.appendChild(text); 175 + } 176 + 177 + layer.appendChild(g); 178 + }); 179 + 180 + // Render resize handles + rotation handle for single selection 181 + if (selectedShapeIds.size === 1 && !editingShapeId) { 182 + const selId = [...selectedShapeIds][0]!; 183 + const selShape = wb.shapes.get(selId); 184 + if (selShape) { 185 + const handles = getResizeHandles(selShape); 186 + for (const h of handles) { 187 + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 188 + circle.setAttribute('cx', String(h.x)); 189 + circle.setAttribute('cy', String(h.y)); 190 + circle.setAttribute('r', '4'); 191 + circle.classList.add('resize-handle'); 192 + circle.setAttribute('data-handle', h.handle); 193 + layer.appendChild(circle); 194 + } 195 + // Rotation handle 196 + const rotY = selShape.y - 25; 197 + const rotX = selShape.x + selShape.width / 2; 198 + const rotLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 199 + rotLine.setAttribute('x1', String(rotX)); 200 + rotLine.setAttribute('y1', String(selShape.y)); 201 + rotLine.setAttribute('x2', String(rotX)); 202 + rotLine.setAttribute('y2', String(rotY)); 203 + rotLine.setAttribute('stroke', 'var(--color-primary)'); 204 + rotLine.setAttribute('stroke-width', '1'); 205 + rotLine.classList.add('rotation-line'); 206 + layer.appendChild(rotLine); 207 + 208 + const rotCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 209 + rotCircle.setAttribute('cx', String(rotX)); 210 + rotCircle.setAttribute('cy', String(rotY)); 211 + rotCircle.setAttribute('r', '5'); 212 + rotCircle.classList.add('rotation-handle'); 213 + layer.appendChild(rotCircle); 214 + } 215 + } 216 + 217 + // Render arrows 218 + wb.arrows.forEach((arrow) => { 219 + const from = resolveEndpoint(wb, arrow.from); 220 + const to = resolveEndpoint(wb, arrow.to); 221 + if (!from || !to) return; 222 + 223 + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 224 + line.setAttribute('x1', String(from.x)); 225 + line.setAttribute('y1', String(from.y)); 226 + line.setAttribute('x2', String(to.x)); 227 + line.setAttribute('y2', String(to.y)); 228 + line.setAttribute('stroke', arrow.style?.stroke || 'var(--color-text)'); 229 + line.setAttribute('stroke-width', arrow.style?.strokeWidth || '2'); 230 + if (arrow.style?.strokeDasharray) line.setAttribute('stroke-dasharray', arrow.style.strokeDasharray); 231 + line.setAttribute('marker-end', 'url(#arrowhead)'); 232 + line.classList.add('diagram-arrow'); 233 + line.setAttribute('data-arrow-id', arrow.id); 234 + layer.appendChild(line); 235 + 236 + if (arrow.label) { 237 + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 238 + text.setAttribute('x', String((from.x + to.x) / 2)); 239 + text.setAttribute('y', String((from.y + to.y) / 2 - 8)); 240 + text.setAttribute('text-anchor', 'middle'); 241 + text.setAttribute('fill', 'var(--color-text-secondary)'); 242 + text.setAttribute('font-size', '12'); 243 + text.textContent = arrow.label; 244 + layer.appendChild(text); 245 + } 246 + }); 247 + 248 + // Render marquee selection rect 249 + if (getIsMarqueeSelecting()) { 250 + const ms = getMarqueeStart(); 251 + const me = getMarqueeEnd(); 252 + const mx = Math.min(ms.x, me.x); 253 + const my = Math.min(ms.y, me.y); 254 + const mw = Math.abs(me.x - ms.x); 255 + const mh = Math.abs(me.y - ms.y); 256 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 257 + rect.setAttribute('x', String(mx)); 258 + rect.setAttribute('y', String(my)); 259 + rect.setAttribute('width', String(mw)); 260 + rect.setAttribute('height', String(mh)); 261 + rect.classList.add('marquee-rect'); 262 + layer.appendChild(rect); 263 + } 264 + 265 + // Re-attach inline text editing overlay if it was active 266 + if (editOverlay) { 267 + layer.appendChild(editOverlay); 268 + } 269 + 270 + deps.zoomLabel.textContent = `${Math.round(wb.zoom * 100)}%`; 271 + } 272 + 273 + // --------------------------------------------------------------------------- 274 + // Toolbar update (basic — snap-grid always false; used by canvas-events) 275 + // --------------------------------------------------------------------------- 276 + 277 + export function updateToolbar(deps: RenderDeps) { 278 + document.querySelectorAll('.diagrams-tool').forEach(btn => { 279 + btn.classList.toggle('active', (btn as HTMLElement).dataset.tool === deps.getActiveTool()); 280 + }); 281 + } 282 + 283 + // --------------------------------------------------------------------------- 284 + // Toolbar update (full — includes snap-grid state; used by main) 285 + // --------------------------------------------------------------------------- 286 + 287 + export function updateToolbarFull(deps: RenderDeps) { 288 + updateToolbar(deps); 289 + document.getElementById('btn-snap-grid')?.classList.toggle('active', deps.getState().snapToGrid); 290 + } 291 + 292 + // --------------------------------------------------------------------------- 293 + // Props panel update 294 + // --------------------------------------------------------------------------- 295 + 296 + export function updateProps(deps: RenderDeps) { 297 + const selectedShapeIds = deps.getSelectedShapeIds(); 298 + if (selectedShapeIds.size !== 1) { 299 + deps.propsSection.style.display = 'none'; 300 + deps.propsDimensions.style.display = 'none'; 301 + return; 302 + } 303 + const shape = deps.getState().shapes.get([...selectedShapeIds][0]!); 304 + if (!shape) { deps.propsSection.style.display = 'none'; deps.propsDimensions.style.display = 'none'; return; } 305 + deps.propsSection.style.display = ''; 306 + deps.propsDimensions.style.display = ''; 307 + deps.propLabel.value = shape.label || ''; 308 + deps.propWidth.value = String(shape.width); 309 + deps.propHeight.value = String(shape.height); 310 + } 311 + 312 + // --------------------------------------------------------------------------- 313 + // Style panel update 314 + // --------------------------------------------------------------------------- 315 + 316 + export function updateStylePanel(deps: RenderDeps) { 317 + const selectedShapeIds = deps.getSelectedShapeIds(); 318 + if (selectedShapeIds.size === 0) { 319 + deps.stylePanel.style.display = 'none'; 320 + return; 321 + } 322 + deps.stylePanel.style.display = ''; 323 + const shape = deps.getState().shapes.get([...selectedShapeIds][0]!); 324 + if (!shape) return; 325 + deps.styleFill.value = shape.style?.fill || '#ffffff'; 326 + deps.styleStroke.value = shape.style?.stroke || '#000000'; 327 + deps.styleStrokeWidth.value = shape.style?.strokeWidth || '2'; 328 + deps.styleStrokeStyle.value = shape.style?.strokeDasharray ? (shape.style.strokeDasharray === '8 4' ? 'dashed' : 'dotted') : 'solid'; 329 + deps.styleOpacity.value = String(Math.round((shape.opacity ?? 1) * 100)); 330 + deps.styleOpacityValue.textContent = `${Math.round((shape.opacity ?? 1) * 100)}%`; 331 + deps.styleFontFamily.value = shape.fontFamily || 'system-ui'; 332 + deps.styleFontSize.value = String(shape.fontSize || 14); 333 + }
+77
src/diagrams/snap-guides.ts
··· 1 + // @ts-nocheck 2 + /** 3 + * Snap guide computation and rendering for alignment snapping during drag. 4 + * Extracted from main.ts. 5 + */ 6 + 7 + import type { WhiteboardState } from './whiteboard.js'; 8 + 9 + export const SNAP_THRESHOLD = 6; 10 + 11 + export type SnapGuide = { axis: 'h' | 'v'; pos: number; from: number; to: number }; 12 + 13 + export function computeSnapGuides(wb: WhiteboardState, draggedIds: Set<string>): SnapGuide[] { 14 + const guides: SnapGuide[] = []; 15 + if (draggedIds.size === 0) return guides; 16 + 17 + let dMinX = Infinity, dMinY = Infinity, dMaxX = -Infinity, dMaxY = -Infinity; 18 + for (const id of draggedIds) { 19 + const s = wb.shapes.get(id); 20 + if (!s) continue; 21 + dMinX = Math.min(dMinX, s.x); 22 + dMinY = Math.min(dMinY, s.y); 23 + dMaxX = Math.max(dMaxX, s.x + s.width); 24 + dMaxY = Math.max(dMaxY, s.y + s.height); 25 + } 26 + const dCx = (dMinX + dMaxX) / 2; 27 + const dCy = (dMinY + dMaxY) / 2; 28 + 29 + const dragEdgesH = [dMinX, dCx, dMaxX]; 30 + const dragEdgesV = [dMinY, dCy, dMaxY]; 31 + 32 + for (const [id, shape] of wb.shapes) { 33 + if (draggedIds.has(id)) continue; 34 + const sEdgesH = [shape.x, shape.x + shape.width / 2, shape.x + shape.width]; 35 + const sEdgesV = [shape.y, shape.y + shape.height / 2, shape.y + shape.height]; 36 + 37 + for (const de of dragEdgesH) { 38 + for (const se of sEdgesH) { 39 + if (Math.abs(de - se) < SNAP_THRESHOLD) { 40 + guides.push({ axis: 'v', pos: se, from: Math.min(dMinY, shape.y), to: Math.max(dMaxY, shape.y + shape.height) }); 41 + } 42 + } 43 + } 44 + for (const de of dragEdgesV) { 45 + for (const se of sEdgesV) { 46 + if (Math.abs(de - se) < SNAP_THRESHOLD) { 47 + guides.push({ axis: 'h', pos: se, from: Math.min(dMinX, shape.x), to: Math.max(dMaxX, shape.x + shape.width) }); 48 + } 49 + } 50 + } 51 + } 52 + return guides; 53 + } 54 + 55 + export function renderSnapGuides(layer: SVGGElement, guides: SnapGuide[]) { 56 + layer.querySelectorAll('.snap-guide').forEach(el => el.remove()); 57 + for (const g of guides) { 58 + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 59 + if (g.axis === 'v') { 60 + line.setAttribute('x1', String(g.pos)); 61 + line.setAttribute('y1', String(g.from)); 62 + line.setAttribute('x2', String(g.pos)); 63 + line.setAttribute('y2', String(g.to)); 64 + } else { 65 + line.setAttribute('x1', String(g.from)); 66 + line.setAttribute('y1', String(g.pos)); 67 + line.setAttribute('x2', String(g.to)); 68 + line.setAttribute('y2', String(g.pos)); 69 + } 70 + line.classList.add('snap-guide'); 71 + layer.appendChild(line); 72 + } 73 + } 74 + 75 + export function clearSnapGuides(layer: SVGGElement) { 76 + layer.querySelectorAll('.snap-guide').forEach(el => el.remove()); 77 + }
+96
src/diagrams/touch-events.ts
··· 1 + // @ts-nocheck 2 + /** 3 + * Touch event handlers — pinch-to-zoom and single-finger pan. 4 + * Extracted from canvas-events.ts. 5 + */ 6 + 7 + import { shapeAtPoint, setZoom } from './whiteboard.js'; 8 + import type { WhiteboardState, Point } from './whiteboard.js'; 9 + 10 + // --------------------------------------------------------------------------- 11 + // Deps interface 12 + // --------------------------------------------------------------------------- 13 + 14 + export interface TouchEventDeps { 15 + canvas: SVGSVGElement; 16 + getState: () => WhiteboardState; 17 + setState: (wb: WhiteboardState) => void; 18 + getActiveTool: () => string; 19 + screenToCanvas: (sx: number, sy: number) => Point; 20 + render: () => void; 21 + } 22 + 23 + // --------------------------------------------------------------------------- 24 + // Wire touch events 25 + // --------------------------------------------------------------------------- 26 + 27 + export function wireTouchEvents(deps: TouchEventDeps) { 28 + const { canvas } = deps; 29 + 30 + let lastTouchDist = 0; 31 + let lastTouchCenter: Point = { x: 0, y: 0 }; 32 + let touchPanning = false; 33 + let touchPanStart: Point = { x: 0, y: 0 }; 34 + let touchPanWbStart: Point = { x: 0, y: 0 }; 35 + 36 + canvas.addEventListener('touchstart', (e) => { 37 + const wb = deps.getState(); 38 + const activeTool = deps.getActiveTool(); 39 + if (e.touches.length === 2) { 40 + e.preventDefault(); 41 + touchPanning = false; 42 + const dx = e.touches[1]!.clientX - e.touches[0]!.clientX; 43 + const dy = e.touches[1]!.clientY - e.touches[0]!.clientY; 44 + lastTouchDist = Math.sqrt(dx * dx + dy * dy); 45 + lastTouchCenter = { 46 + x: (e.touches[0]!.clientX + e.touches[1]!.clientX) / 2, 47 + y: (e.touches[0]!.clientY + e.touches[1]!.clientY) / 2, 48 + }; 49 + } else if (e.touches.length === 1 && (activeTool === 'hand' || activeTool === 'select')) { 50 + const touch = e.touches[0]!; 51 + const pt = deps.screenToCanvas(touch.clientX, touch.clientY); 52 + const hitShape = shapeAtPoint(wb, pt.x, pt.y); 53 + if (!hitShape || activeTool === 'hand') { 54 + e.preventDefault(); 55 + touchPanning = true; 56 + touchPanStart = { x: touch.clientX, y: touch.clientY }; 57 + touchPanWbStart = { x: wb.panX, y: wb.panY }; 58 + } 59 + } 60 + }, { passive: false }); 61 + 62 + canvas.addEventListener('touchmove', (e) => { 63 + let wb = deps.getState(); 64 + if (e.touches.length === 2) { 65 + e.preventDefault(); 66 + touchPanning = false; 67 + const dx = e.touches[1]!.clientX - e.touches[0]!.clientX; 68 + const dy = e.touches[1]!.clientY - e.touches[0]!.clientY; 69 + const dist = Math.sqrt(dx * dx + dy * dy); 70 + const scale = dist / lastTouchDist; 71 + wb = setZoom(wb, wb.zoom * scale); 72 + lastTouchDist = dist; 73 + 74 + const center = { 75 + x: (e.touches[0]!.clientX + e.touches[1]!.clientX) / 2, 76 + y: (e.touches[0]!.clientY + e.touches[1]!.clientY) / 2, 77 + }; 78 + wb = { ...wb, panX: wb.panX + (center.x - lastTouchCenter.x), panY: wb.panY + (center.y - lastTouchCenter.y) }; 79 + lastTouchCenter = center; 80 + deps.setState(wb); 81 + deps.render(); 82 + } else if (e.touches.length === 1 && touchPanning) { 83 + e.preventDefault(); 84 + const touch = e.touches[0]!; 85 + const dx = touch.clientX - touchPanStart.x; 86 + const dy = touch.clientY - touchPanStart.y; 87 + wb = { ...wb, panX: touchPanWbStart.x + dx, panY: touchPanWbStart.y + dy }; 88 + deps.setState(wb); 89 + deps.render(); 90 + } 91 + }, { passive: false }); 92 + 93 + canvas.addEventListener('touchend', () => { 94 + touchPanning = false; 95 + }); 96 + }