/* ── KaTeX CSS cache (module-level, shared across all Whiteboard instances) ── */ let _katexCss = null; // null = not fetched yet; '' = failed/loading; string = ready const _katexCssCbs = []; // pending render callbacks waiting for CSS function _loadKatexCss(cb) { if (_katexCss !== null) { cb(_katexCss); return; } _katexCssCbs.push(cb); if (_katexCssCbs.length > 1) return; // fetch already in flight fetch('https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css') .then(r => r.text()) .then(css => { // Make font paths absolute so they resolve from inside an SVG Blob URL _katexCss = css.replace( /url\(fonts\//g, 'url(https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/fonts/' ); }) .catch(() => { _katexCss = ''; }) .finally(() => { const waiters = _katexCssCbs.splice(0); waiters.forEach(w => w(_katexCss)); }); } /** * Whiteboard — interactive drawing canvas * Virtual space: 1920×1080. All coordinates stored in virtual pixels. * * Tools: 'pencil' | 'eraser' | 'highlighter' | 'laser' | 'select' | 'text' | * 'sticky' | 'formula' | 'table' | 'connector' | * 'rect' | 'ellipse' | 'line' | 'arrow' | * 'triangle' | 'diamond' | 'hexagon' | 'star' | 'roundedrect' | 'callout' * * Stroke.tool (DB): 'pencil' | 'eraser' | 'highlighter' | 'shape' | 'text' | 'image' | * 'sticky' | 'formula' | 'table' | 'connector' * * data shapes: * pencil/eraser/highlighter : {points:[[x,y],...], color, width, lineStyle, opacity} * shape : {shape, x1,y1,x2,y2, color, width, fill, lineStyle, opacity} * connector : {x1,y1,x2,y2, color, width, arrowStart, arrowEnd, lineStyle, opacity} * text : {text, x, y, fontSize, color} * image : {src, x, y, w, h} * sticky : {x, y, w, h, text, bgColor, textColor, fontSize} * formula : {x, y, w, h, latex, color} * table : {x, y, w, h, rows, cols, cells, borderColor, bgColor, textColor, fontSize} */ class Whiteboard { static VW = 1920; static VH = 1080; constructor(canvas, opts = {}) { this._canvas = canvas; this._ctx = canvas.getContext('2d'); this._readOnly = opts.readOnly || false; this._onStrokeDone = opts.onStrokeDone || null; this._onStrokeUndo = opts.onStrokeUndo || null; this._onStrokeProgress = opts.onStrokeProgress || null; this._onStrokeUpdated = opts.onStrokeUpdated || null; this._onCursorMove = opts.onCursorMove || null; this._cursorThrottle = 0; // timestamp of last cursor broadcast this._strokes = []; this._undoStack = []; this._redoStack = []; this._drawing = false; this._curPts = []; this._shapeStart = null; this._shapeEnd = null; this._tool = 'pencil'; this._color = '#ffffff'; this._width = 4; this._fill = false; this._lineStyle = 'solid'; this._opacity = 1.0; // text tool settings this._textFontSize = 22; this._textFontFamily = 'Manrope'; this._textBold = false; this._textItalic = false; this._template = opts.template || 'blank'; this._pageNum = opts.pageNum || 1; this._stylusMultiplier = opts.stylusMultiplier ?? 0; // 0=disabled this._effectiveWidth = null; this._selectedIds = new Set(); // multi-select this._dragState = null; this._clipboard = null; this._lassoRect = null; // {x1,y1,x2,y2} rubber-band selection this._snapGuides = []; // [{axis:'x'|'y', pos:number}] this._snapEnabled = true; // snap guides on/off this._staticDirty = true; // two-layer: re-render static when true this._overlays = []; // ruler/protractor overlays (not saved to DB) this._overlayDrag = null; // {idx, type:'move'|'rotate'|'resize', ...} this._selectedOverlayIdx = -1; this._onOverlayChange = opts.onOverlayChange || null; this._showMeasurements = false; // auto-measurements toggle // Zoom / pan this._zoom = 1; this._panVX = 0; // virtual-space X of top-left corner this._panVY = 0; this._spaceDown = false; this._panStartCss = null; // [cssX, cssY] at pan start this._panStartPan = null; // [panVX, panVY] at pan start this._onZoomChange = opts.onZoomChange || null; this._lastClickTime = 0; this._lastClickVx = 0; this._lastClickVy = 0; this._localIdCounter = -1; this._liveId = null; this._progressTimer = null; this._liveStrokes = new Map(); this._fitPending = false; this._bgNoise = null; // cached noise pattern for chalkboard texture this._textInput = null; this._textInputDocHandler = null; // document pointerdown handler for outside-click this._objectInput = null; this._onObjectCreated = opts.onObjectCreated || null; this._onToolSwitch = opts.onToolSwitch || null; this._onFormulaInsert = opts.onFormulaInsert || null; this._onCoordEdit = opts.onCoordEdit || null; this._onNumberLineEdit = opts.onNumberLineEdit || null; this._onCompassEdit = opts.onCompassEdit || null; this._editingFormulaStroke = null; this._laserPos = null; this._laserTrail = []; // fade trail points (last 25) this._pointerVx = 0; // last pointer virtual X (for eraser cursor) this._pointerVy = 0; this._textAlign = 'left'; // text alignment state this._stickyColor = null; // forced sticky bg color (null = random) this._tableRows = 3; // table picker rows this._tableCols = 4; // table picker cols // Board theme this._boardTheme = opts.boardTheme || 'chalkboard'; this._bgNoiseCache = new Map(); // canvas element per theme this._annotateMode = false; // true = transparent bg (draw over simulation) // Compass state machine this._compassState = 'idle'; // 'idle'|'setting-radius'|'waiting-arc'|'drawing-arc' this._compassCenter = null; this._compassRadius = 0; this._compassArcStart = 0; this._compassArcSweep = 0; this._compassCurrentAngle = 0; this._compassLastAngle = 0; // Mind map editing state this._editingMindmapStroke = null; // compound stroke being edited node-by-node this._selectedMindmapNodeId = null; // id of currently selected node within mindmap this._mmNodeDragStart = null; // {nodeId, origRelX, origRelY, startVx, startVy} // Smart connector anchor snapping this._connSnapStart = null; this._connSnapEnd = null; this._connHoverShapeId = null; // rAF render gate — at most one render per browser frame this._rafPending = false; // Pause rendering when tab is hidden (resume on visibilitychange) this._visHandler = () => { if (!document.hidden) this._doRender(); }; document.addEventListener('visibilitychange', this._visHandler); // dynamic overlay canvas (selection, snap guides, live strokes, laser) this._dynCanvas = document.createElement('canvas'); this._dynCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;'; const _wrap = canvas.parentElement; if (_wrap) _wrap.appendChild(this._dynCanvas); this._dynCtx = this._dynCanvas.getContext('2d'); // minimap canvas — navigation overview (bottom-right corner) this._mmCanvas = document.createElement('canvas'); this._mmCanvas.width = 192; this._mmCanvas.height = 108; this._mmCanvas.style.cssText = [ 'position:absolute;bottom:10px;right:10px;z-index:25;', 'width:192px;height:108px;border-radius:6px;', 'border:1px solid rgba(155,93,229,0.35);', 'box-shadow:0 2px 12px rgba(0,0,0,0.55);', 'cursor:crosshair;display:none;', 'transition:opacity 0.25s;' ].join(''); this._mmCtx = this._mmCanvas.getContext('2d'); this._mmDrag = false; if (_wrap) _wrap.appendChild(this._mmCanvas); const _mmDown = e => { e.stopPropagation(); this._mmDrag = true; this._mmNavigate(e); }; const _mmMove = e => { if (this._mmDrag) this._mmNavigate(e); }; const _mmUp = () => { this._mmDrag = false; }; this._mmCanvas.addEventListener('mousedown', _mmDown); this._mmCanvas.addEventListener('mousemove', _mmMove); this._mmCanvas.addEventListener('mouseup', _mmUp); document.addEventListener('mouseup', _mmUp); this._bindEvents(); this.fit(); this._ro = new ResizeObserver(() => this.fit()); this._ro.observe(canvas.parentElement || canvas); } /* ── backward-compat: single selectedId getter/setter ──────────────── */ get _selectedId() { for (const id of this._selectedIds) return id; return null; } set _selectedId(v) { this._selectedIds.clear(); if (v != null) this._selectedIds.add(v); } /* ── setup ─────────────────────────────────────────────────────────── */ _bindEvents() { const c = this._canvas; const onDown = e => { if (e.button === 1) { e.preventDefault(); this._beginPan(e); return; } if (!this._readOnly) this._onPointerDown(e); }; const onMove = e => { if (this._panStartCss) { this._onPointerMove(e); return; } // pan works for everyone if (!this._readOnly) { this._onPointerMove(e); return; } // readOnly: still broadcast cursor position if callback set if (this._onCursorMove) { const [vx, vy] = this._pointerPos(e); const now = Date.now(); if (now - this._cursorThrottle > 33) { this._cursorThrottle = now; this._onCursorMove(vx, vy); } } }; const onUp = e => { if (this._panStartCss) { this._onPointerUp(e); return; } // pan end for everyone if (!this._readOnly) this._onPointerUp(e); }; // Prevent browser auto-scroll cursor on middle-click c.addEventListener('mousedown', e => { if (e.button === 1) e.preventDefault(); }); c.addEventListener('pointerdown', onDown); c.addEventListener('pointermove', onMove); c.addEventListener('pointerup', onUp); c.addEventListener('pointerleave', onUp); c.addEventListener('pointercancel', onUp); c.addEventListener('touchstart', e => e.preventDefault(), { passive: false }); c.addEventListener('wheel', e => this._onWheel(e), { passive: false }); this._onKeyDown = e => this._handleKeyDown(e); this._onKeyUp = e => { if (e.key === ' ') { this._spaceDown = false; if (!this._panStartCss) this._canvas.style.cursor = ''; } }; this._onPaste = e => this._onClipboardPaste(e); document.addEventListener('keydown', this._onKeyDown); document.addEventListener('keyup', this._onKeyUp); document.addEventListener('paste', this._onPaste); } _handleKeyDown(e) { if (this._readOnly) return; // Mindmap keyboard shortcuts if (this._editingMindmapStroke) { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if ((e.key === 'Delete' || e.key === 'Backspace') && this._selectedMindmapNodeId) { e.preventDefault(); this._deleteMindmapNode(this._editingMindmapStroke, this._selectedMindmapNodeId); return; } if (e.key === 'Tab' && this._selectedMindmapNodeId) { e.preventDefault(); this._addMindmapChild(this._editingMindmapStroke, this._selectedMindmapNodeId); return; } if (e.key === 'Escape') { this._editingMindmapStroke = null; this._selectedMindmapNodeId = null; this._mmNodeDragStart = null; this.render(); return; } if ((e.key === 'F2' || e.key === 'Enter') && this._selectedMindmapNodeId) { e.preventDefault(); this._editMindmapNodeText(this._editingMindmapStroke, this._selectedMindmapNodeId); return; } } const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return; if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { this.copy(); } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { e.preventDefault(); this.paste(); } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); this.undo(); } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); this.redo(); } if (e.key === 'Delete' || e.key === 'Backspace') { if (this._selectedIds.size > 0) { e.preventDefault(); this.deleteSelected(); } } if (e.key === 'Escape') { if (this._tool === 'compass' && this._compassState !== 'idle') { this._compassState = 'idle'; this._drawing = false; this.render(); } else { this._selectedIds.clear(); this._dragState = null; this._lassoRect = null; this.render(); } } // Zoom shortcuts if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { e.preventDefault(); this.zoomTo(this._zoom * 1.25); } if ((e.ctrlKey || e.metaKey) && e.key === '-') { e.preventDefault(); this.zoomTo(this._zoom / 1.25); } if ((e.ctrlKey || e.metaKey) && e.key === '0') { e.preventDefault(); this.resetView(); } // Space = pan mode if (e.key === ' ' && !this._spaceDown) { e.preventDefault(); this._spaceDown = true; this._canvas.style.cursor = 'grab'; } } _isShapeTool() { return ['rect','ellipse','line','arrow', 'triangle','diamond','hexagon','star', 'roundedrect','callout'].includes(this._tool); } _isObjectStroke(s) { return s.tool === 'image' || s.tool === 'sticky' || s.tool === 'formula' || s.tool === 'table' || s.tool === 'coordinate' || s.tool === 'numberline' || s.tool === 'compass' || s.tool === 'mindmap'; } _isResizableStroke(s) { // Compass uses specialized handles, not corner resize; mindmap has its own node handles return (this._isObjectStroke(s) && s.tool !== 'compass' && s.tool !== 'mindmap') || s.tool === 'shape' || s.tool === 'connector'; } /* Returns {x, y, w, h} bounding box in virtual coords for any stroke type */ _getStrokeBBox(stroke) { const d = stroke.data; // Compass uses cx/cy/radius geometry if (stroke.tool === 'compass') { const pad = 40; const r = (d.radius || 50) + pad; return { x: d.cx - r, y: d.cy - r, w: r * 2, h: r * 2 }; } if (stroke.tool === 'mindmap') { if (!d.nodes || !d.nodes.length) return { x: d.x - 100, y: d.y - 60, w: 200, h: 120 }; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const n of d.nodes) { const hw = (this._mmNodeDepth(d, n.id) === 0 ? 140 : 120) / 2 + 30; const hh = (this._mmNodeDepth(d, n.id) === 0 ? 54 : 44) / 2 + 20; minX = Math.min(minX, d.x + n.relX - hw); maxX = Math.max(maxX, d.x + n.relX + hw); minY = Math.min(minY, d.y + n.relY - hh); maxY = Math.max(maxY, d.y + n.relY + hh); } return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; } if (this._isObjectStroke(stroke)) return { x: d.x, y: d.y, w: d.w, h: d.h }; if (stroke.tool === 'text') { const lines = (d.text || '').split('\n').length; return { x: d.x, y: d.y, w: 400, h: (d.fontSize || 22) * 1.45 * (lines + 1) }; } if (stroke.tool === 'connector') { const ep = this._getConnectorEndpoints(d); return { x: Math.min(ep.x1, ep.x2), y: Math.min(ep.y1, ep.y2), w: Math.abs(ep.x2 - ep.x1) || 20, h: Math.abs(ep.y2 - ep.y1) || 20, }; } if (stroke.tool === 'shape') { return { x: Math.min(d.x1, d.x2), y: Math.min(d.y1, d.y2), w: Math.abs(d.x2 - d.x1) || 20, h: Math.abs(d.y2 - d.y1) || 20, }; } if (d.points && d.points.length) { const xs = d.points.map(p => p[0]), ys = d.points.map(p => p[1]); const bx = Math.min(...xs) - 10, by = Math.min(...ys) - 10; return { x: bx, y: by, w: Math.max(...xs) - bx + 20, h: Math.max(...ys) - by + 20 }; } return { x: 0, y: 0, w: Whiteboard.VW, h: Whiteboard.VH }; } /* Move any stroke by virtual delta */ _moveStroke(stroke, dvx, dvy) { const d = stroke.data; if (stroke.tool === 'mindmap') { stroke.data.x += dvx; stroke.data.y += dvy; return; } if (stroke.tool === 'compass') { d.cx += dvx; d.cy += dvy; return; } if (this._isObjectStroke(stroke) || stroke.tool === 'text') { d.x += dvx; d.y += dvy; } else if (stroke.tool === 'shape') { d.x1 += dvx; d.y1 += dvy; d.x2 += dvx; d.y2 += dvy; } else if (stroke.tool === 'connector') { if (!d.fromId) { d.x1 += dvx; d.y1 += dvy; } if (!d.toId) { d.x2 += dvx; d.y2 += dvy; } if (d.fromId && d.toId) { d.x1 += dvx; d.y1 += dvy; d.x2 += dvx; d.y2 += dvy; } } else if (d.points) { d.points = d.points.map(([px, py]) => [px + dvx, py + dvy]); } } /* Resize shape/connector by dragging a corner handle */ _applyResizeShape(data, ds, vx, vy) { const origMinX = Math.min(ds.origX1, ds.origX2); const origMaxX = Math.max(ds.origX1, ds.origX2); const origMinY = Math.min(ds.origY1, ds.origY2); const origMaxY = Math.max(ds.origY1, ds.origY2); const h = ds.handle; let newMinX = origMinX, newMaxX = origMaxX; let newMinY = origMinY, newMaxY = origMaxY; if (h === 'tl' || h === 'bl') newMinX = Math.min(vx, origMaxX - 10); else newMaxX = Math.max(vx, origMinX + 10); if (h === 'tl' || h === 'tr') newMinY = Math.min(vy, origMaxY - 10); else newMaxY = Math.max(vy, origMinY + 10); // Map back to x1,y1,x2,y2 preserving which slot is min/max if (ds.origX1 <= ds.origX2) { data.x1 = newMinX; data.x2 = newMaxX; } else { data.x1 = newMaxX; data.x2 = newMinX; } if (ds.origY1 <= ds.origY2) { data.y1 = newMinY; data.y2 = newMaxY; } else { data.y1 = newMaxY; data.y2 = newMinY; } } setSnapEnabled(v) { this._snapEnabled = v; if (!v) { this._snapGuides = []; this.render(); } } /* Snap guides: compute alignment guides for movingStroke vs. all other strokes */ _computeSnapGuides(movingStroke) { if (!this._snapEnabled) { this._snapGuides = []; return; } const SNAP = 8; // virtual-pixel threshold const b = this._getStrokeBBox(movingStroke); const guides = []; const seenX = new Set(), seenY = new Set(); for (const s of this._strokes) { if (this._selectedIds.has(s.id)) continue; const o = this._getStrokeBBox(s); const xs = [o.x, o.x + o.w, o.x + o.w / 2]; const ys = [o.y, o.y + o.h, o.y + o.h / 2]; for (const sv of xs) { if (seenX.has(sv)) continue; const moving = [b.x, b.x + b.w, b.x + b.w / 2]; if (moving.some(mv => Math.abs(mv - sv) < SNAP)) { guides.push({ axis: 'x', pos: sv }); seenX.add(sv); } } for (const sv of ys) { if (seenY.has(sv)) continue; const moving = [b.y, b.y + b.h, b.y + b.h / 2]; if (moving.some(mv => Math.abs(mv - sv) < SNAP)) { guides.push({ axis: 'y', pos: sv }); seenY.add(sv); } } } this._snapGuides = guides; } /* ── Mind map helpers ─────────────────────────────────────────── */ _mmNodeDepth(data, nodeId) { let depth = 0, current = nodeId; while (depth < 20) { const node = data.nodes.find(n => n.id === current); if (!node || node.parentId === null) break; current = node.parentId; depth++; } return depth; } _mmNodeSize(depth) { if (depth === 0) return { w: 148, h: 54 }; if (depth === 1) return { w: 124, h: 42 }; return { w: 106, h: 36 }; } _mmNodeAbsPos(data, node) { return { x: data.x + node.relX, y: data.y + node.relY }; } _mmNodeRect(data, node) { const pos = this._mmNodeAbsPos(data, node); const sz = this._mmNodeSize(this._mmNodeDepth(data, node.id)); return { x: pos.x - sz.w / 2, y: pos.y - sz.h / 2, w: sz.w, h: sz.h, cx: pos.x, cy: pos.y }; } _hitTestMindmapNode(stroke, vx, vy) { const d = stroke.data; for (let i = d.nodes.length - 1; i >= 0; i--) { const n = d.nodes[i]; const r = this._mmNodeRect(d, n); if (vx >= r.x && vx <= r.x + r.w && vy >= r.y && vy <= r.y + r.h) return n; } return null; } _mmSubtree(data, nodeId) { const ids = new Set([nodeId]); let changed = true; while (changed) { changed = false; for (const n of data.nodes) { if (!ids.has(n.id) && ids.has(n.parentId)) { ids.add(n.id); changed = true; } } } return ids; } _addMindmapChild(stroke, parentNodeId) { const data = stroke.data; const parent = data.nodes.find(n => n.id === parentNodeId); if (!parent) return; const depth = this._mmNodeDepth(data, parentNodeId); const siblings = data.nodes.filter(n => n.parentId === parentNodeId); const colors = ['#06D6E0', '#F15BB5', '#A8E063', '#FF9F43', '#FF6B6B', '#4361EE', '#9B5DE5']; const col = depth === 0 ? colors[siblings.length % colors.length] : (parent.color || '#06D6E0'); const spread = 140; const dist = depth === 0 ? 280 : 220; let relX, relY; if (depth === 0) { const rightCount = data.nodes.filter(n => n.parentId === 'root' && n.relX > 0).length; const leftCount = data.nodes.filter(n => n.parentId === 'root' && n.relX < 0).length; const goRight = rightCount <= leftCount; relX = goRight ? dist : -dist; const sideNodes = data.nodes.filter(n => n.parentId === 'root' && (goRight ? n.relX > 0 : n.relX < 0)); relY = sideNodes.length === 0 ? 0 : (sideNodes.length % 2 === 0 ? -1 : 1) * Math.ceil(sideNodes.length / 2) * spread; } else { relX = parent.relX + (parent.relX >= 0 ? dist : -dist); if (siblings.length === 0) relY = parent.relY; else if (siblings.length === 1) relY = parent.relY - spread; else relY = parent.relY + (siblings.length - 1) * (spread * 0.7) * (siblings.length % 2 === 0 ? 1 : -1); } const newId = `mm_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`; data.nodes.push({ id: newId, text: 'Новый узел', parentId: parentNodeId, relX, relY, color: col }); this._selectedMindmapNodeId = newId; this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); setTimeout(() => { if (this._editingMindmapStroke === stroke) this._editMindmapNodeText(stroke, newId); }, 60); } _deleteMindmapNode(stroke, nodeId) { const data = stroke.data; if (!nodeId) return; const node = data.nodes.find(n => n.id === nodeId); if (!node || node.parentId === null) return; const toDelete = this._mmSubtree(data, nodeId); data.nodes = data.nodes.filter(n => !toDelete.has(n.id)); this._selectedMindmapNodeId = null; this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); } _editMindmapNodeText(stroke, nodeId) { const data = stroke.data; const node = data.nodes.find(n => n.id === nodeId); if (!node) return; const wrap = this._canvas.parentElement; if (!wrap) return; this._removeObjectInput(); const r = this._mmNodeRect(data, node); const [cx, cy] = this._toCanvas(r.x, r.y); const cw = (r.w / Whiteboard.VW) * this._cssW; const ch = (r.h / Whiteboard.VH) * this._cssH; const depth = this._mmNodeDepth(data, nodeId); const vFontSize = depth === 0 ? 15 : 13; const fs = Math.max(9, Math.round((vFontSize / Whiteboard.VH) * this._cssH)); const ta = document.createElement('textarea'); ta.value = node.text || ''; ta.style.cssText = ` position:absolute; left:${cx}px; top:${cy}px; width:${Math.max(60, cw)}px; height:${Math.max(22, ch)}px; font-size:${fs}px; font-family:'Manrope',sans-serif; font-weight:600; color:#fff; background:transparent; border:none; outline:none; resize:none; padding:2px 6px; text-align:center; line-height:1.35; box-sizing:border-box; caret-color:#fff; z-index:20; `; wrap.style.position = 'relative'; wrap.appendChild(ta); ta.focus(); ta.select(); this._objectInput = { el: ta, strokeId: stroke.id }; const commit = () => { const text = ta.value.trim() || node.text; this._removeObjectInput(); const s = this._strokes.find(x => x.id === stroke.id); if (!s) return; const n2 = s.data.nodes.find(n => n.id === nodeId); if (n2) n2.text = text || 'Узел'; this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(s); }; ta.addEventListener('keydown', ev => { ev.stopPropagation(); if (ev.key === 'Escape') { ev.preventDefault(); this._removeObjectInput(); } if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commit(); } if (ev.key === 'Tab') { ev.preventDefault(); commit(); setTimeout(() => { if (this._editingMindmapStroke) this._addMindmapChild(this._editingMindmapStroke, nodeId); }, 80); } }); ta.addEventListener('blur', () => setTimeout(commit, 120)); } _enterMindmapEdit(stroke, vx, vy) { this._editingMindmapStroke = stroke; this._selectedId = stroke.id; const nodeHit = this._hitTestMindmapNode(stroke, vx, vy); this._selectedMindmapNodeId = nodeHit ? nodeHit.id : 'root'; this.render(); } _getAnchorPoint(stroke, anchor) { const b = this._getStrokeBBox(stroke); const cx = b.x + b.w / 2, cy = b.y + b.h / 2; switch (anchor) { case 'n': return { x: cx, y: b.y }; case 's': return { x: cx, y: b.y + b.h }; case 'e': return { x: b.x + b.w, y: cy }; case 'w': return { x: b.x, y: cy }; default: return { x: cx, y: cy }; } } _findNearestAnchor(vx, vy, excludeId = null) { const THRESH = 40; let best = null, bestDist = THRESH; const anchors = ['n', 's', 'e', 'w', 'center']; for (let i = this._strokes.length - 1; i >= 0; i--) { const s = this._strokes[i]; if (s.tool === 'connector' || s.tool === 'pencil' || s.tool === 'highlighter' || s.tool === 'eraser') continue; if (s.data.isBackground || s.data.locked) continue; if (s.id === excludeId) continue; for (const anchor of anchors) { const p = this._getAnchorPoint(s, anchor); const dist = Math.hypot(vx - p.x, vy - p.y); if (dist < bestDist) { bestDist = dist; best = { stroke: s, anchor, x: p.x, y: p.y }; } } } return best; } _getConnectorEndpoints(d) { let x1 = d.x1, y1 = d.y1, x2 = d.x2, y2 = d.y2; if (d.fromId) { const s = this._strokes.find(x => x.id === d.fromId); if (s) { const p = this._getAnchorPoint(s, d.fromAnchor || 'center'); x1 = p.x; y1 = p.y; } } if (d.toId) { const s = this._strokes.find(x => x.id === d.toId); if (s) { const p = this._getAnchorPoint(s, d.toAnchor || 'center'); x2 = p.x; y2 = p.y; } } return { x1, y1, x2, y2 }; } _anchorDirection(anchor) { switch (anchor) { case 'n': return [0, -1]; case 's': return [0, 1]; case 'e': return [1, 0]; case 'w': return [-1, 0]; default: return [0, 0]; } } /* Hit-test any stroke (all types) — returns topmost hit */ _hitTestAny(vx, vy) { for (let i = this._strokes.length - 1; i >= 0; i--) { const s = this._strokes[i]; if (s.data.isBackground || s.data.locked) continue; const b = this._getStrokeBBox(s); if (vx >= b.x && vx <= b.x + b.w && vy >= b.y && vy <= b.y + b.h) return s; } return null; } fit() { const c = this._canvas; const wrap = c.parentElement; if (!wrap) return; const dpr = window.devicePixelRatio || 1; const w = wrap.clientWidth; const h = wrap.clientHeight; if (w === 0 || h === 0) { if (!this._fitPending) { this._fitPending = true; requestAnimationFrame(() => { this._fitPending = false; this.fit(); }); } return; } c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); c.style.width = w + 'px'; c.style.height = h + 'px'; this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // size dynamic overlay canvas const dc = this._dynCanvas; dc.width = c.width; dc.height = c.height; dc.style.width = w + 'px'; dc.style.height = h + 'px'; this._dynCtx.setTransform(dpr, 0, 0, dpr, 0, 0); this._dpr = dpr; this._cssW = w; this._cssH = h; this._staticDirty = true; this.render(); } /* ── coordinate helpers ─────────────────────────────────────────────── */ _toVirtual(cssX, cssY) { const sx = (this._cssW || 1) / Whiteboard.VW; const sy = (this._cssH || 1) / Whiteboard.VH; return [ cssX / (sx * this._zoom) + this._panVX, cssY / (sy * this._zoom) + this._panVY, ]; } _toCanvas(vx, vy) { const sx = (this._cssW || 300) / Whiteboard.VW; const sy = (this._cssH || 150) / Whiteboard.VH; return [ (vx - this._panVX) * sx * this._zoom, (vy - this._panVY) * sy * this._zoom, ]; } _pointerPos(e) { const rect = this._canvas.getBoundingClientRect(); const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; return this._toVirtual(clientX - rect.left, clientY - rect.top); } /* ── pointer handlers ───────────────────────────────────────────────── */ _beginPan(e) { const rect = this._canvas.getBoundingClientRect(); const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; this._panStartCss = [cx, cy]; this._panStartPan = [this._panVX, this._panVY]; this._canvas.style.cursor = 'grabbing'; this._canvas.setPointerCapture(e.pointerId); } _onPointerDown(e) { // ── pan mode (Space + drag) ─────────────────────────────────────────── if (this._spaceDown) { this._beginPan(e); return; } const [vx, vy] = this._pointerPos(e); // Pressure-sensitive width for stylus if (this._stylusMultiplier > 0 && e.pointerType === 'pen') { const p = Math.max(0.1, e.pressure || 0.5); this._effectiveWidth = Math.max(1, Math.round(this._width * p * this._stylusMultiplier)); } else { this._effectiveWidth = null; } // ── overlay drag (ruler/protractor) — always checked first ──────────── if (this._overlays.length > 0) { const hit = this._hitTestOverlay(vx, vy); if (hit) { const ov = this._overlays[hit.idx]; this._selectedOverlayIdx = hit.idx; this._canvas.setPointerCapture(e.pointerId); if (hit.zone === 'body') { this._overlayDrag = { idx: hit.idx, type: 'move', startVx: vx, startVy: vy, origX: ov.x, origY: ov.y }; } else if (hit.zone === 'rot') { this._overlayDrag = { idx: hit.idx, type: 'rotate', startAngle: Math.atan2(vy - ov.y, vx - ov.x), origAngle: ov.angle || 0 }; } else if (hit.zone === 'resize') { const angle = ov.angle || 0; const dx = vx - ov.x, dy = vy - ov.y; if (ov.type === 'ruler') { this._overlayDrag = { idx: hit.idx, type: 'resize', startDot: dx * Math.cos(angle) + dy * Math.sin(angle), origWidth: ov.width }; } else { this._overlayDrag = { idx: hit.idx, type: 'resize', startDist: Math.hypot(dx, dy), origRadius: ov.radius || 80 }; } } if (this._onOverlayChange) this._onOverlayChange(ov); return; } } // ── select tool ────────────────────────────────────────────────────── if (this._tool === 'select') { const now = Date.now(); const dbl = now - this._lastClickTime < 350 && Math.abs(vx - this._lastClickVx) < 40 && Math.abs(vy - this._lastClickVy) < 40; this._lastClickTime = now; this._lastClickVx = vx; this._lastClickVy = vy; // ── mindmap edit mode: intercept pointer events ────────────────────── if (this._editingMindmapStroke && this._tool === 'select') { const mm = this._editingMindmapStroke; const nodeHit = this._hitTestMindmapNode(mm, vx, vy); if (nodeHit) { if (dbl) { this._editMindmapNodeText(mm, nodeHit.id); return; } this._selectedMindmapNodeId = nodeHit.id; this._mmNodeDragStart = { nodeId: nodeHit.id, lastVx: vx, lastVy: vy }; this._canvas.setPointerCapture(e.pointerId); this._staticDirty = true; this.render(); return; } // Check "+" button if (this._selectedMindmapNodeId) { const selNode = mm.data.nodes.find(n => n.id === this._selectedMindmapNodeId); if (selNode) { const r = this._mmNodeRect(mm.data, selNode); const btnAbsX = r.x + r.w + 10 + 10; const btnAbsY = r.y + r.h / 2; if (Math.hypot(vx - btnAbsX, vy - btnAbsY) < 18) { this._addMindmapChild(mm, this._selectedMindmapNodeId); return; } } } // Click outside mindmap bbox: exit editing const bbox = this._getStrokeBBox(mm); const pad = 30; if (vx < bbox.x - pad || vx > bbox.x + bbox.w + pad || vy < bbox.y - pad || vy > bbox.y + bbox.h + pad) { this._editingMindmapStroke = null; this._selectedMindmapNodeId = null; this._mmNodeDragStart = null; this.render(); // Fall through to normal select behavior } else { // Click inside mindmap but not on a node: deselect node this._selectedMindmapNodeId = null; this._staticDirty = true; this.render(); return; } } // Check handles on the primary selected stroke const primarySel = this._selectedIds.size === 1 ? this._strokes.find(s => s.id === this._selectedId) : null; if (primarySel) { // Rotation handle (object strokes only) if (this._isObjectStroke(primarySel)) { const rotH = this._hitTestObjectHandle(vx, vy, primarySel); if (rotH === 'rot') { const b = this._getStrokeBBox(primarySel); const ocx = b.x + b.w / 2, ocy = b.y + b.h / 2; this._dragState = { type: 'rotate', cx: ocx, cy: ocy, origRotation: primarySel.data.rotation || 0, startAngle: Math.atan2(vy - ocy, vx - ocx) }; this._canvas.setPointerCapture(e.pointerId); return; } } if (this._isResizableStroke(primarySel)) { const handle = this._hitTestObjectHandle(vx, vy, primarySel); if (handle && handle !== 'rot') { const d = primarySel.data; if (this._isObjectStroke(primarySel)) { this._dragState = { type: 'resize', handle, startVx: vx, startVy: vy, origX: d.x, origY: d.y, origW: d.w, origH: d.h }; } else { this._dragState = { type: 'resize_shape', handle, origX1: d.x1, origY1: d.y1, origX2: d.x2, origY2: d.y2 }; } this._canvas.setPointerCapture(e.pointerId); return; } } } // Check if click is inside any selected stroke (for move) let clickedSelected = null; for (const id of this._selectedIds) { const s = this._strokes.find(x => x.id === id); if (!s) continue; const bbox = this._getStrokeBBox(s); if (vx >= bbox.x && vx <= bbox.x + bbox.w && vy >= bbox.y && vy <= bbox.y + bbox.h) { clickedSelected = s; break; } } if (clickedSelected) { if (dbl) { this._editObject(clickedSelected, vx, vy); return; } this._dragState = { type: 'move', lastVx: vx, lastVy: vy }; this._canvas.setPointerCapture(e.pointerId); this.render(); return; } const hit = this._hitTestAny(vx, vy); if (hit) { if (dbl) { this._editObject(hit, vx, vy); return; } if (e.shiftKey) { // Shift+click: toggle in selection if (this._selectedIds.has(hit.id)) this._selectedIds.delete(hit.id); else this._selectedIds.add(hit.id); } else { this._selectedIds.clear(); this._selectedIds.add(hit.id); this._dragState = { type: 'move', lastVx: vx, lastVy: vy }; this._canvas.setPointerCapture(e.pointerId); } } else { // Click on empty space: start lasso if (!e.shiftKey) this._selectedIds.clear(); this._lassoRect = { x1: vx, y1: vy, x2: vx, y2: vy }; this._canvas.setPointerCapture(e.pointerId); } this._snapGuides = []; this.render(); return; } // ── text tool ──────────────────────────────────────────────────────── if (this._tool === 'text') { e.preventDefault(); // prevent browser default focus shift during pointerdown this._placeTextInput([vx, vy]); return; } // ── sticky tool ────────────────────────────────────────────────────── if (this._tool === 'sticky') { this._createSticky(vx, vy); return; } // ── formula tool ───────────────────────────────────────────────────── if (this._tool === 'formula') { this._placeFormula(vx, vy); return; } // ── laser pointer (ephemeral — not saved) ──────────────────────────── if (this._tool === 'laser') { this._canvas.setPointerCapture(e.pointerId); this._drawing = true; this._laserPos = [vx, vy]; this._laserTrail = [[vx, vy]]; this._liveId = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`; if (this._onStrokeProgress) this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } }); this.render(); return; } // ── coordinate tool ─────────────────────────────────────────────────── if (this._tool === 'coordinate') { const W = 600, H = 500; const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W)); const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H)); const stroke = { id: this._localIdCounter--, tool: 'coordinate', data: { x, y, w: W, h: H, xMin: -10, xMax: 10, yMin: -10, yMax: 10, gridStep: 1, showLabels: true, functions: [] }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._selectedId = stroke.id; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); if (this._onObjectCreated) this._onObjectCreated(stroke); return; } // ── number line tool ───────────────────────────────────────────────── if (this._tool === 'numberline') { const W = 700, H = 120; const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W)); const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H)); const stroke = { id: this._localIdCounter--, tool: 'numberline', data: { x, y, w: W, h: H, min: -10, max: 10, step: 1, points: [], intervals: [] }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._selectedId = stroke.id; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); if (this._onObjectCreated) this._onObjectCreated(stroke); return; } // ── mindmap tool ─────────────────────────────────────────────────────── if (this._tool === 'mindmap') { const colors = ['#06D6E0', '#F15BB5', '#A8E063', '#FF9F43', '#FF6B6B', '#4361EE']; const rightX = 280, leftX = -280; const stroke = { id: this._localIdCounter--, tool: 'mindmap', data: { x: vx, y: vy, nodes: [ { id: 'root', text: 'Главная идея', parentId: null, relX: 0, relY: 0, color: '#9B5DE5' }, { id: 'mm1', text: 'Ветка 1', parentId: 'root', relX: rightX, relY: -140, color: colors[0] }, { id: 'mm2', text: 'Ветка 2', parentId: 'root', relX: rightX, relY: 0, color: colors[1] }, { id: 'mm3', text: 'Ветка 3', parentId: 'root', relX: rightX, relY: 140, color: colors[2] }, { id: 'mm4', text: 'Ветка 4', parentId: 'root', relX: leftX, relY: -70, color: colors[3] }, { id: 'mm5', text: 'Ветка 5', parentId: 'root', relX: leftX, relY: 70, color: colors[4] }, ], }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._selectedId = stroke.id; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); if (this._onObjectCreated) this._onObjectCreated(stroke); this._editingMindmapStroke = stroke; this._selectedMindmapNodeId = 'root'; if (this._onToolSwitch) this._onToolSwitch('select'); return; } // ── compass tool: two-phase state machine ──────────────────────────── if (this._tool === 'compass') { e.preventDefault(); this._canvas.setPointerCapture(e.pointerId); if (this._compassState === 'idle') { // Phase 1: set center, start dragging for radius this._compassCenter = { x: vx, y: vy }; this._compassRadius = 0; this._compassCurrentAngle = -Math.PI / 2; this._compassLastAngle = -Math.PI / 2; this._compassState = 'setting-radius'; this._drawing = true; } else if (this._compassState === 'waiting-arc') { // Phase 2: set arc start, start dragging for sweep const dx = vx - this._compassCenter.x, dy = vy - this._compassCenter.y; this._compassArcStart = Math.atan2(dy, dx); this._compassArcSweep = 0; this._compassCurrentAngle = this._compassArcStart; this._compassLastAngle = this._compassArcStart; this._compassState = 'drawing-arc'; } this.render(); return; } // ── drawing tools (pencil, eraser, highlighter, shapes, connector, table) ── this._canvas.setPointerCapture(e.pointerId); this._drawing = true; this._liveId = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`; if (this._tool === 'connector') { const snap = this._findNearestAnchor(vx, vy); if (snap) { this._connSnapStart = { strokeId: snap.stroke.id, anchor: snap.anchor, x: snap.x, y: snap.y }; } else { this._connSnapStart = null; } } if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') { this._shapeStart = [vx, vy]; this._shapeEnd = [vx, vy]; } else { this._curPts = [[vx, vy]]; } } _onPointerMove(e) { // ── pan drag ───────────────────────────────────────────────────────── if (this._panStartCss) { const rect = this._canvas.getBoundingClientRect(); const cx = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left; const cy = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top; const sx = (this._cssW || 300) / Whiteboard.VW; const sy = (this._cssH || 150) / Whiteboard.VH; this._panVX = this._panStartPan[0] - (cx - this._panStartCss[0]) / (sx * this._zoom); this._panVY = this._panStartPan[1] - (cy - this._panStartCss[1]) / (sy * this._zoom); this._clampPan(); this._staticDirty = true; this.render(); return; } const [vx, vy] = this._pointerPos(e); this._pointerVx = vx; this._pointerVy = vy; // Broadcast cursor position to other participants (throttled to ~30fps) if (this._onCursorMove) { const now = Date.now(); if (now - this._cursorThrottle > 33) { this._cursorThrottle = now; this._onCursorMove(vx, vy); } } // ── overlay drag ────────────────────────────────────────────────────── if (this._overlayDrag) { const od = this._overlayDrag; const ov = this._overlays[od.idx]; if (ov) { if (od.type === 'move') { ov.x = od.origX + (vx - od.startVx); ov.y = od.origY + (vy - od.startVy); } else if (od.type === 'rotate') { const cur = Math.atan2(vy - ov.y, vx - ov.x); ov.angle = od.origAngle + (cur - od.startAngle); } else if (od.type === 'resize') { const angle = ov.angle || 0; const dx = vx - ov.x, dy = vy - ov.y; if (ov.type === 'ruler') { const dot = dx * Math.cos(angle) + dy * Math.sin(angle); ov.width = Math.max(100, od.origWidth + (dot - od.startDot)); } else if (ov.type === 'protractor') { ov.radius = Math.max(40, od.origRadius + (Math.hypot(dx, dy) - od.startDist)); } } if (this._onOverlayChange) this._onOverlayChange(ov); } this.render(); return; } // ── laser: update position + trail ─────────────────────────────────── if (this._tool === 'laser' && this._drawing) { this._laserPos = [vx, vy]; this._laserTrail.push([vx, vy]); if (this._laserTrail.length > 25) this._laserTrail.shift(); if (this._onStrokeProgress && this._liveId) this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } }); this.render(); return; } // ── compass: update radius or arc sweep ────────────────────────────── if (this._tool === 'compass' && this._compassState !== 'idle') { const dx = vx - this._compassCenter.x, dy = vy - this._compassCenter.y; if (this._compassState === 'setting-radius') { this._compassRadius = Math.hypot(dx, dy); this._compassCurrentAngle = Math.atan2(dy, dx); } else if (this._compassState === 'drawing-arc') { this._compassCurrentAngle = Math.atan2(dy, dx); // Accumulate sweep with wrap-around handling let delta = this._compassCurrentAngle - this._compassLastAngle; if (delta > Math.PI) delta -= Math.PI * 2; if (delta < -Math.PI) delta += Math.PI * 2; this._compassArcSweep += delta; // Clamp to ±2π (full circle max) this._compassArcSweep = Math.max(-Math.PI * 2, Math.min(Math.PI * 2, this._compassArcSweep)); this._compassLastAngle = this._compassCurrentAngle; } this.render(); return; } // ── select: update cursor + drag ────────────────────────────────────── if (this._tool === 'select') { // Lasso update if (this._lassoRect) { this._lassoRect.x2 = vx; this._lassoRect.y2 = vy; this.render(); return; } if (!this._dragState) { let cur = 'default'; if (this._selectedIds.size === 1) { const sel = this._strokes.find(s => s.id === this._selectedId); if (sel) { if (this._isResizableStroke(sel)) { const h = this._hitTestObjectHandle(vx, vy, sel); if (h === 'tl' || h === 'br') cur = 'nwse-resize'; else if (h === 'tr' || h === 'bl') cur = 'nesw-resize'; } if (cur === 'default') { const bbox = this._getStrokeBBox(sel); if (vx >= bbox.x && vx <= bbox.x + bbox.w && vy >= bbox.y && vy <= bbox.y + bbox.h) cur = 'move'; } } } if (cur === 'default') { for (const id of this._selectedIds) { const s = this._strokes.find(x => x.id === id); if (!s) continue; const b = this._getStrokeBBox(s); if (vx >= b.x && vx <= b.x + b.w && vy >= b.y && vy <= b.y + b.h) { cur = 'move'; break; } } } if (cur === 'default' && this._hitTestAny(vx, vy)) cur = 'move'; this._canvas.style.cursor = cur; } // Mindmap node drag if (this._mmNodeDragStart) { const mm = this._editingMindmapStroke; if (mm) { const dvx = vx - this._mmNodeDragStart.lastVx; const dvy = vy - this._mmNodeDragStart.lastVy; this._mmNodeDragStart.lastVx = vx; this._mmNodeDragStart.lastVy = vy; const node = mm.data.nodes.find(n => n.id === this._mmNodeDragStart.nodeId); if (node) { node.relX += dvx; node.relY += dvy; } this._staticDirty = true; this.render(); return; } } if (!this._dragState) return; const ds = this._dragState; if (ds.type === 'move') { const dvx = vx - ds.lastVx, dvy = vy - ds.lastVy; ds.lastVx = vx; ds.lastVy = vy; // Move all selected strokes for (const id of this._selectedIds) { const s = this._strokes.find(x => x.id === id); if (s) this._moveStroke(s, dvx, dvy); } // Compute snap guides for primary selected stroke if (this._selectedIds.size === 1) { const sel = this._strokes.find(s => s.id === this._selectedId); if (sel) this._computeSnapGuides(sel); } else { this._snapGuides = []; } this._staticDirty = true; } else if (ds.type === 'resize') { const sel = this._strokes.find(s => s.id === this._selectedId); if (!sel) return; this._applyResize(sel.data, ds, vx - ds.startVx, vy - ds.startVy); this._staticDirty = true; } else if (ds.type === 'resize_shape') { const sel = this._strokes.find(s => s.id === this._selectedId); if (!sel) return; this._applyResizeShape(sel.data, ds, vx, vy); this._staticDirty = true; } else if (ds.type === 'rotate') { const sel = this._strokes.find(s => s.id === this._selectedId); if (!sel) return; const angle = Math.atan2(vy - ds.cy, vx - ds.cx); let rotation = ds.origRotation + (angle - ds.startAngle); if (e.shiftKey) rotation = Math.round(rotation / (Math.PI / 12)) * (Math.PI / 12); sel.data.rotation = rotation; this._staticDirty = true; } this.render(); return; } // Connector: update hover shape + snap target (even when not drawing) if (this._tool === 'connector') { const hit = this._hitTestAny(vx, vy); this._connHoverShapeId = hit && hit.tool !== 'connector' ? hit.id : null; const snapEndRaw = this._findNearestAnchor(vx, vy, this._connSnapStart?.strokeId); this._connSnapEnd = snapEndRaw ? { strokeId: snapEndRaw.stroke.id, anchor: snapEndRaw.anchor, x: snapEndRaw.x, y: snapEndRaw.y } : null; if (this._connSnapEnd && this._drawing) { this._shapeEnd = [this._connSnapEnd.x, this._connSnapEnd.y]; this.render(); return; } } if (!this._drawing) return; if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') { let [ex, ey] = [vx, vy]; // Shift: proportional constraint for shapes (square/circle) if (e.shiftKey && this._isShapeTool() && this._shapeStart) { const [sx, sy] = this._shapeStart; const dxv = ex - sx, dyv = ey - sy; const side = Math.max(Math.abs(dxv), Math.abs(dyv)); ex = sx + (dxv >= 0 ? side : -side); ey = sy + (dyv >= 0 ? side : -side); } this._shapeEnd = [ex, ey]; this.render(); } else { // Shift: straight line (snapped to 0°/45°/90°) for pencil and highlighter if (e.shiftKey && this._curPts.length >= 1 && (this._tool === 'pencil' || this._tool === 'highlighter')) { const [sx, sy] = this._curPts[0]; const dx = vx - sx, dy = vy - sy; const angle = Math.atan2(dy, dx); const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4); const dist = Math.hypot(dx, dy); this._curPts = [this._curPts[0], [sx + Math.cos(snapped) * dist, sy + Math.sin(snapped) * dist]]; } else { this._curPts.push([vx, vy]); } this.render(); } if (this._onStrokeProgress && !this._progressTimer) { this._progressTimer = setTimeout(() => { this._progressTimer = null; this._flushProgress(); }, 20); } } _onWheel(e) { e.preventDefault(); const rect = this._canvas.getBoundingClientRect(); const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; // Ctrl+wheel or pinch-zoom (ctrlKey set by browser on pinch) if (e.ctrlKey) { const [vx, vy] = this._toVirtual(cx, cy); const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; this.zoomTo(this._zoom * factor, vx, vy); return; } // Shift+wheel → horizontal pan if (e.shiftKey) { const sx = (this._cssW || 300) / Whiteboard.VW; this._panVX += e.deltaY / (sx * this._zoom); this._clampPan(); this._staticDirty = true; this.render(); return; } // Two-finger trackpad scroll (deltaMode=0, both axes) → pan if (e.deltaMode === 0 && Math.abs(e.deltaX) > 2) { const sx = (this._cssW || 300) / Whiteboard.VW; const sy = (this._cssH || 150) / Whiteboard.VH; this._panVX += e.deltaX / (sx * this._zoom); this._panVY += e.deltaY / (sy * this._zoom); this._clampPan(); this._staticDirty = true; this.render(); return; } // Regular wheel → zoom const [vx, vy] = this._toVirtual(cx, cy); const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; this.zoomTo(this._zoom * factor, vx, vy); } zoomTo(newZoom, pivotVX, pivotVY) { const sx = (this._cssW || 300) / Whiteboard.VW; const sy = (this._cssH || 150) / Whiteboard.VH; // Pivot in CSS before zoom change const pcx = pivotVX != null ? (pivotVX - this._panVX) * sx * this._zoom : (this._cssW || 300) / 2; const pcy = pivotVY != null ? (pivotVY - this._panVY) * sy * this._zoom : (this._cssH || 150) / 2; this._zoom = Math.max(0.25, Math.min(8, newZoom)); if (pivotVX != null) { this._panVX = pivotVX - pcx / (sx * this._zoom); this._panVY = pivotVY - pcy / (sy * this._zoom); } this._clampPan(); this._staticDirty = true; this.render(); if (this._onZoomChange) this._onZoomChange(this._zoom); } resetView() { this._zoom = 1; this._panVX = 0; this._panVY = 0; this._staticDirty = true; this.render(); if (this._onZoomChange) this._onZoomChange(1); } /* Zoom to fit all visible strokes, with optional padding in virtual px. Falls back to resetView() when there are no strokes. */ zoomFitStrokes(padding = 80) { const VW = Whiteboard.VW, VH = Whiteboard.VH; if (!this._strokes.length) { this.resetView(); return; } let minX = VW, minY = VH, maxX = 0, maxY = 0; for (const s of this._strokes) { const b = this._getStrokeBBox(s); if (b.w === VW && b.h === VH) continue; // skip fallback bbox (unknown type) minX = Math.min(minX, b.x); minY = Math.min(minY, b.y); maxX = Math.max(maxX, b.x + b.w); maxY = Math.max(maxY, b.y + b.h); } if (maxX <= minX || maxY <= minY) { this.resetView(); return; } // Add padding, clamp to board bounds minX = Math.max(0, minX - padding); minY = Math.max(0, minY - padding); maxX = Math.min(VW, maxX + padding); maxY = Math.min(VH, maxY + padding); const contentW = maxX - minX; const contentH = maxY - minY; // zoom so the content bbox fills the canvas (pick limiting axis) const zoom = Math.min(VW / contentW, VH / contentH, 8); this._zoom = Math.max(0.25, zoom); // center the content this._panVX = (minX + maxX) / 2 - VW / (2 * this._zoom); this._panVY = (minY + maxY) / 2 - VH / (2 * this._zoom); this._clampPan(); this._staticDirty = true; this.render(); if (this._onZoomChange) this._onZoomChange(this._zoom); } _clampPan() { const VW = Whiteboard.VW, VH = Whiteboard.VH; const visW = VW / this._zoom; const visH = VH / this._zoom; if (visW <= VW) { this._panVX = Math.max(0, Math.min(this._panVX, VW - visW)); } else { this._panVX = (VW - visW) / 2; } if (visH <= VH) { this._panVY = Math.max(0, Math.min(this._panVY, VH - visH)); } else { this._panVY = (VH - visH) / 2; } } _applyResize(data, ds, dvx, dvy) { const h = ds.handle; if (h === 'br') { data.w = Math.max(20, ds.origW + dvx); data.h = Math.max(20, ds.origH + dvy); } else if (h === 'tr') { data.w = Math.max(20, ds.origW + dvx); const nh = Math.max(20, ds.origH - dvy); data.y = ds.origY + (ds.origH - nh); data.h = nh; } else if (h === 'bl') { const nw = Math.max(20, ds.origW - dvx); data.x = ds.origX + (ds.origW - nw); data.w = nw; data.h = Math.max(20, ds.origH + dvy); } else if (h === 'tl') { const nw = Math.max(20, ds.origW - dvx); const nh = Math.max(20, ds.origH - dvy); data.x = ds.origX + (ds.origW - nw); data.y = ds.origY + (ds.origH - nh); data.w = nw; data.h = nh; } } _flushProgress() { if (!this._drawing || !this._onStrokeProgress || !this._liveId) return; let data; if (this._isShapeTool()) { if (!this._shapeStart || !this._shapeEnd) return; data = { shape: this._tool, x1: this._shapeStart[0], y1: this._shapeStart[1], x2: this._shapeEnd[0], y2: this._shapeEnd[1], color: this._color, width: this._w, fill: this._fill }; } else if (this._tool === 'connector') { if (!this._shapeStart || !this._shapeEnd) return; data = { x1: this._shapeStart[0], y1: this._shapeStart[1], x2: this._shapeEnd[0], y2: this._shapeEnd[1], color: this._color, width: this._w, arrowEnd: true, arrowStart: false }; } else { if (this._curPts.length === 0) return; data = { points: [...this._curPts], color: this._tool === 'eraser' ? null : this._color, width: this._w }; } this._onStrokeProgress({ liveId: this._liveId, tool: this._isShapeTool() ? 'shape' : this._tool, data, }); } _onPointerUp(_e) { // ── pan end ─────────────────────────────────────────────────────────── if (this._panStartCss) { this._panStartCss = null; this._panStartPan = null; this._canvas.style.cursor = this._spaceDown ? 'grab' : ''; return; } // ── overlay drag end ────────────────────────────────────────────────── if (this._overlayDrag) { const ov = this._overlays[this._overlayDrag.idx]; this._overlayDrag = null; if (ov && this._onOverlayChange) this._onOverlayChange(ov); return; } // ── compass: phase transitions ──────────────────────────────────────── if (this._tool === 'compass') { if (this._compassState === 'setting-radius') { if (this._compassRadius < 15) { // Too small: cancel this._compassState = 'idle'; this._drawing = false; } else { this._compassState = 'waiting-arc'; // Stay in "drawing" state so the preview stays visible } this.render(); } else if (this._compassState === 'drawing-arc') { if (Math.abs(this._compassArcSweep) > 0.05) { const stroke = { id: this._localIdCounter--, tool: 'compass', data: { cx: this._compassCenter.x, cy: this._compassCenter.y, radius: Math.max(1, Math.round(this._compassRadius)), arcStart: this._compassArcStart, arcSweep: this._compassArcSweep, color: this._color, lineWidth: this._width, showLegs: true, }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._staticDirty = true; if (this._onStrokeDone) this._onStrokeDone(stroke); if (this._onObjectCreated) this._onObjectCreated(stroke); } this._compassState = 'idle'; this._drawing = false; this.render(); } return; } // ── laser pointer: cancel preview ───────────────────────────────────── if (this._tool === 'laser') { this._drawing = false; this._laserPos = null; this._laserTrail = []; const liveId = this._liveId; this._liveId = null; if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); this.render(); return; } // ── select tool ────────────────────────────────────────────────────── if (this._tool === 'select') { // Finish lasso selection if (this._lassoRect) { const lr = this._lassoRect; const lx1 = Math.min(lr.x1, lr.x2), lx2 = Math.max(lr.x1, lr.x2); const ly1 = Math.min(lr.y1, lr.y2), ly2 = Math.max(lr.y1, lr.y2); if (lx2 - lx1 > 4 || ly2 - ly1 > 4) { for (const s of this._strokes) { const b = this._getStrokeBBox(s); if (b.x < lx2 && b.x + b.w > lx1 && b.y < ly2 && b.y + b.h > ly1) this._selectedIds.add(s.id); } } this._lassoRect = null; this.render(); return; } // Mindmap node drag end if (this._mmNodeDragStart) { const mm = this._editingMindmapStroke; if (mm && this._onStrokeUpdated) this._onStrokeUpdated(mm); this._mmNodeDragStart = null; this.render(); return; } if (this._dragState) { this._snapGuides = []; // Notify server of updated positions for all moved strokes for (const id of this._selectedIds) { const sel = this._strokes.find(s => s.id === id); if (sel && this._onStrokeUpdated) this._onStrokeUpdated(sel); } this._dragState = null; this.render(); } return; } if (!this._drawing) return; this._drawing = false; if (this._progressTimer) { clearTimeout(this._progressTimer); this._progressTimer = null; } const liveId = this._liveId; this._liveId = null; // ── table tool ──────────────────────────────────────────────────────── if (this._tool === 'table') { const [x1, y1] = this._shapeStart; const [x2, y2] = this._shapeEnd || this._shapeStart; let vx = Math.min(x1, x2), vy = Math.min(y1, y2); let vw = Math.abs(x2 - x1), vh = Math.abs(y2 - y1); if (vw < 40) vw = 360; if (vh < 40) vh = 240; vx = Math.min(vx, Whiteboard.VW - vw); vy = Math.min(vy, Whiteboard.VH - vh); const tR = this._tableRows || 3, tC = this._tableCols || 4; const stroke = { id: this._localIdCounter--, tool: 'table', data: { x: Math.max(0, vx), y: Math.max(0, vy), w: vw, h: vh, rows: tR, cols: tC, cells: Array.from({ length: tR }, () => Array(tC).fill('')), borderColor: '#9B5DE5', bgColor: 'rgba(26,22,37,0.85)', textColor: '#e8e0f7', fontSize: 14, }, }; this._shapeStart = null; this._shapeEnd = null; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._selectedId = stroke.id; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); return; } // ── connector tool ──────────────────────────────────────────────────── if (this._tool === 'connector') { let [x1, y1] = this._shapeStart; let [x2, y2] = this._shapeEnd || this._shapeStart; if (this._connSnapStart) { x1 = this._connSnapStart.x; y1 = this._connSnapStart.y; } if (this._connSnapEnd) { x2 = this._connSnapEnd.x; y2 = this._connSnapEnd.y; } if (Math.abs(x2 - x1) < 5 && Math.abs(y2 - y1) < 5) { if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); this._connSnapStart = null; this._connSnapEnd = null; return; } const isCurved = !!(this._connSnapStart || this._connSnapEnd); const data = { x1, y1, x2, y2, color: this._color, width: this._width, arrowEnd: true, arrowStart: false, lineStyle: this._lineStyle, opacity: this._opacity, connStyle: isCurved ? 'curved' : 'straight', }; if (this._connSnapStart) { data.fromId = this._connSnapStart.strokeId; data.fromAnchor = this._connSnapStart.anchor; } if (this._connSnapEnd) { data.toId = this._connSnapEnd.strokeId; data.toAnchor = this._connSnapEnd.anchor; } const stroke = { id: this._localIdCounter--, tool: 'connector', data, }; this._shapeStart = null; this._shapeEnd = null; this._connSnapStart = null; this._connSnapEnd = null; this._connHoverShapeId = null; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); return; } // ── shape tools ──────────────────────────────────────────────────────── if (this._isShapeTool()) { const [x1, y1] = this._shapeStart; const [x2, y2] = this._shapeEnd || this._shapeStart; if (Math.abs(x2 - x1) < 2 && Math.abs(y2 - y1) < 2) { if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); return; } const stroke = { id: this._localIdCounter--, tool: 'shape', data: { shape: this._tool, x1, y1, x2, y2, color: this._color, width: this._w, fill: this._fill, lineStyle: this._lineStyle, opacity: this._opacity }, }; this._shapeStart = null; this._shapeEnd = null; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); return; } // ── pencil / eraser ──────────────────────────────────────────────────── if (this._curPts.length === 0) { if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); return; } const stroke = { id: this._localIdCounter--, tool: this._tool, data: { points: this._curPts, color: this._tool === 'eraser' ? null : this._color, width: this._w, lineStyle: this._lineStyle, opacity: this._opacity }, }; this._curPts = []; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); } /* ── text tool ──────────────────────────────────────────────────────── */ _placeTextInput([vx, vy]) { const wrap = this._canvas.parentElement; if (!wrap) return; this._removeTextInput(); const cw = this._cssW || 300; const ch = this._cssH || 150; const [cx, cy] = this._toCanvas(vx, vy); // Position textarea accounting for canvas offset within the parent wrapper const canvasRect = this._canvas.getBoundingClientRect(); const wrapRect = wrap.getBoundingClientRect(); const offX = canvasRect.left - wrapRect.left; const offY = canvasRect.top - wrapRect.top; const fs = Math.max(10, Math.round((this._textFontSize / Whiteboard.VH) * ch)); const W = Math.max(120, Math.min(300, cw - 16)); const left = Math.min(cx + offX, offX + cw - W - 4); const top = Math.max(offY, Math.min(cy + offY, offY + ch - 110)); const fw = this._textBold ? 'bold' : 'normal'; const fi = this._textItalic ? 'italic' : 'normal'; const ta = document.createElement('textarea'); ta.placeholder = 'Введите текст… (Enter — вставить, Esc — отмена)'; ta.rows = 3; ta.style.cssText = ` position:absolute; left:${left}px; top:${top}px; width:${W}px; min-height:68px; box-sizing:border-box; font-size:${fs}px; font-family:'${this._textFontFamily}',sans-serif; font-weight:${fw}; font-style:${fi}; color:${this._color}; text-align:${this._textAlign}; background:rgba(12,8,24,0.92); border:1.5px solid rgba(155,93,229,0.7); border-radius:8px; outline:none; resize:none; padding:6px 10px; caret-color:${this._color}; z-index:20; line-height:1.45; box-shadow:0 4px 24px rgba(0,0,0,0.6); `; wrap.appendChild(ta); this._textInput = ta; const commit = () => { const text = ta.value.trim(); this._removeTextInput(); if (!text) return; const stroke = { id: this._localIdCounter--, tool: 'text', data: { text, x: vx, y: vy, fontSize: this._textFontSize, fontFamily: this._textFontFamily, fontWeight: this._textBold ? 'bold' : 'normal', fontStyle: this._textItalic ? 'italic' : 'normal', textAlign: this._textAlign, color: this._color }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); if (this._onObjectCreated) this._onObjectCreated(stroke); }; ta.addEventListener('keydown', ev => { ev.stopPropagation(); // don't leak to canvas key handler if (ev.key === 'Escape') { ev.preventDefault(); this._removeTextInput(); } // Enter without Shift commits; Shift+Enter inserts newline if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commit(); } }); // Outside-click detection: use capture-phase pointerdown on document. // Registered via setTimeout to skip the current pointerdown that spawned this textarea. const onDocDown = e => { if (ta.contains(e.target) || e.target === ta) return; this._textInputDocHandler = null; document.removeEventListener('pointerdown', onDocDown, true); commit(); }; this._textInputDocHandler = onDocDown; setTimeout(() => { if (ta.isConnected) document.addEventListener('pointerdown', onDocDown, true); }, 0); // Focus after the current event cycle so pointer events on the canvas // can't steal focus back immediately. requestAnimationFrame(() => { if (ta.isConnected) ta.focus(); }); } _removeTextInput() { if (this._textInputDocHandler) { document.removeEventListener('pointerdown', this._textInputDocHandler, true); this._textInputDocHandler = null; } if (this._textInput) { this._textInput.remove(); this._textInput = null; } } /* ── sticky tool ────────────────────────────────────────────────────── */ _createSticky(vx, vy) { const W = 220, H = 200; const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W)); const y = Math.max(0, Math.min(vy - 20, Whiteboard.VH - H)); const bgColors = ['#FFE066', '#FF9F7F', '#B5EAD7', '#C7CEEA', '#FFDAC1', '#E2B7F5']; const bgColor = this._stickyColor || bgColors[Math.floor(Math.random() * bgColors.length)]; const stroke = { id: this._localIdCounter--, tool: 'sticky', data: { x, y, w: W, h: H, text: '', bgColor, textColor: '#1a1a2e', fontSize: 16 }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._selectedId = stroke.id; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); if (this._onObjectCreated) this._onObjectCreated(stroke); this._editSticky(stroke); } _editSticky(stroke) { const wrap = this._canvas.parentElement; if (!wrap) return; this._removeObjectInput(); const d = stroke.data; const [cx, cy] = this._toCanvas(d.x, d.y); const cw = (d.w / Whiteboard.VW) * this._cssW; const ch = (d.h / Whiteboard.VH) * this._cssH; const pad = 10; const fs = Math.round((d.fontSize / Whiteboard.VH) * this._cssH); const ta = document.createElement('textarea'); ta.value = d.text || ''; ta.style.cssText = ` position:absolute; left:${cx + pad}px; top:${cy + pad}px; width:${cw - 2 * pad}px; height:${ch - 2 * pad}px; font-size:${fs}px; font-family:'Manrope',sans-serif; color:${d.textColor}; background:transparent; border:none; outline:none; resize:none; padding:0; line-height:1.4; caret-color:${d.textColor}; z-index:10; `; wrap.style.position = 'relative'; wrap.appendChild(ta); ta.focus(); this._objectInput = { el: ta, strokeId: stroke.id }; const commit = () => { const text = ta.value; this._removeObjectInput(); const s = this._strokes.find(x => x.id === stroke.id); if (!s) return; s.data.text = text; this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(s); }; ta.addEventListener('keydown', ev => { if (ev.key === 'Escape') commit(); }); ta.addEventListener('blur', () => setTimeout(commit, 80)); } /* ── formula tool ───────────────────────────────────────────────────── */ _placeFormula(vx, vy) { // If an external formula editor is wired up, delegate to it if (this._onFormulaInsert) { this._onFormulaInsert(vx, vy); return; } // Fallback: inline input (minimal, shown when no modal is wired) const wrap = this._canvas.parentElement; if (!wrap) return; this._removeObjectInput(); const [cx, cy] = this._toCanvas(vx, vy); const inp = document.createElement('input'); inp.type = 'text'; inp.placeholder = 'LaTeX, напр: \\frac{a}{b}'; inp.style.cssText = ` position:absolute; left:${Math.min(cx, (this._cssW||300)-280)}px; top:${cy}px; font-size:14px; font-family:'Manrope',sans-serif; color:#e8e0f7; background:rgba(12,8,24,0.95); border:1.5px solid #9B5DE5; border-radius:6px; outline:none; width:270px; padding:7px 10px; caret-color:#9B5DE5; z-index:20; box-shadow:0 4px 20px rgba(0,0,0,0.55); `; wrap.appendChild(inp); inp.focus(); this._objectInput = { el: inp, strokeId: null }; const commit = () => { const latex = inp.value.trim(); this._removeObjectInput(); if (!latex) return; this.insertFormula(vx, vy, latex); }; inp.addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } if (ev.key === 'Escape') { ev.stopPropagation(); this._removeObjectInput(); } }); inp.addEventListener('blur', () => setTimeout(commit, 100)); } _editFormula(stroke) { // If external editor is wired, use it for edit too if (this._onFormulaInsert) { const d = stroke.data; // We pass the stroke ID so the modal can update instead of insert this._editingFormulaStroke = stroke; this._onFormulaInsert(d.x + d.w / 2, d.y + d.h / 2, d.latex); return; } // Fallback: inline input const wrap = this._canvas.parentElement; if (!wrap) return; this._removeObjectInput(); const d = stroke.data; const [cx, cy] = this._toCanvas(d.x, d.y); const inp = document.createElement('input'); inp.type = 'text'; inp.value = d.latex || ''; inp.placeholder = 'LaTeX формула'; inp.style.cssText = ` position:absolute; left:${Math.min(cx, (this._cssW||300)-280)}px; top:${cy}px; font-size:14px; font-family:'Manrope',sans-serif; color:#e8e0f7; background:rgba(12,8,24,0.95); border:1.5px solid #9B5DE5; border-radius:6px; outline:none; width:270px; padding:7px 10px; caret-color:#9B5DE5; z-index:20; box-shadow:0 4px 20px rgba(0,0,0,0.55); `; wrap.appendChild(inp); inp.focus(); inp.select(); this._objectInput = { el: inp, strokeId: stroke.id }; const commit = () => { const latex = inp.value.trim(); this._removeObjectInput(); const s = this._strokes.find(x => x.id === stroke.id); if (!s || !latex) return; s.data.latex = latex; s._formulaImg = null; this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(s); }; inp.addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } if (ev.key === 'Escape') { ev.stopPropagation(); this._removeObjectInput(); } }); inp.addEventListener('blur', () => setTimeout(commit, 100)); } /* ── table tool ─────────────────────────────────────────────────────── */ _getTableCell(stroke, vx, vy) { const d = stroke.data; if (vx < d.x || vx > d.x + d.w || vy < d.y || vy > d.y + d.h) return null; return { row: Math.min(Math.floor((vy - d.y) / (d.h / d.rows)), d.rows - 1), col: Math.min(Math.floor((vx - d.x) / (d.w / d.cols)), d.cols - 1), }; } _editTableCell(stroke, row, col) { const wrap = this._canvas.parentElement; if (!wrap) return; this._removeObjectInput(); const d = stroke.data; const cellVW = d.w / d.cols; const cellVH = d.h / d.rows; const [cx, cy] = this._toCanvas(d.x + col * cellVW, d.y + row * cellVH); const cw = (cellVW / Whiteboard.VW) * this._cssW; const ch = (cellVH / Whiteboard.VH) * this._cssH; const pad = 4; const fs = Math.round(((d.fontSize || 14) / Whiteboard.VH) * this._cssH); const inp = document.createElement('input'); inp.type = 'text'; inp.value = (d.cells[row] && d.cells[row][col]) || ''; inp.style.cssText = ` position:absolute; left:${cx + pad}px; top:${cy + pad}px; width:${cw - 2 * pad}px; height:${ch - 2 * pad}px; font-size:${fs}px; font-family:'Manrope',sans-serif; color:${d.textColor || '#e8e0f7'}; background:rgba(155,93,229,0.12); border:1.5px solid #9B5DE5; border-radius:3px; outline:none; padding:0 4px; z-index:10; box-sizing:border-box; `; wrap.style.position = 'relative'; wrap.appendChild(inp); inp.focus(); inp.select(); this._objectInput = { el: inp, strokeId: stroke.id }; const commit = () => { const val = inp.value; this._removeObjectInput(); const s = this._strokes.find(x => x.id === stroke.id); if (!s) return; if (!s.data.cells[row]) s.data.cells[row] = []; s.data.cells[row][col] = val; this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(s); }; inp.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === 'Tab') { ev.preventDefault(); commit(); } if (ev.key === 'Escape') { this._removeObjectInput(); } }); inp.addEventListener('blur', () => setTimeout(commit, 80)); } _removeObjectInput() { if (this._objectInput) { this._objectInput.el.remove(); this._objectInput = null; } } /* ── edit dispatch ──────────────────────────────────────────────────── */ _editObject(stroke, vx, vy) { if (stroke.tool === 'mindmap') { this._enterMindmapEdit(stroke, vx, vy); return; } if (stroke.tool === 'coordinate') { if (this._onCoordEdit) this._onCoordEdit(stroke); return; } if (stroke.tool === 'numberline') { if (this._onNumberLineEdit) this._onNumberLineEdit(stroke); return; } if (stroke.tool === 'compass') { if (this._onCompassEdit) this._onCompassEdit(stroke); return; } if (stroke.tool === 'shape') { this._editShapeText(stroke); return; } if (stroke.tool === 'sticky') { this._editSticky(stroke); return; } if (stroke.tool === 'formula') { this._editFormula(stroke); return; } if (stroke.tool === 'table') { const cell = this._getTableCell(stroke, vx, vy); if (cell) this._editTableCell(stroke, cell.row, cell.col); return; } if (stroke.tool === 'text') { const d = stroke.data; this._strokes = this._strokes.filter(s => s.id !== stroke.id); const i = this._undoStack.indexOf(stroke.id); if (i !== -1) this._undoStack.splice(i, 1); this._selectedId = null; this._staticDirty = true; this.render(); this._placeTextInput([d.x, d.y]); if (this._textInput) this._textInput.value = d.text || ''; } if (stroke.tool === 'image') { // images are not editable inline } } _editShapeText(stroke) { const d = stroke.data; if (d.shape === 'line' || d.shape === 'arrow') return; const wrap = this._canvas.parentElement; if (!wrap) return; this._removeObjectInput(); const canvasRect = this._canvas.getBoundingClientRect(); const wrapRect = wrap.getBoundingClientRect(); const offX = canvasRect.left - wrapRect.left; const offY = canvasRect.top - wrapRect.top; const [ccx1, ccy1] = this._toCanvas(Math.min(d.x1, d.x2), Math.min(d.y1, d.y2)); const [ccx2, ccy2] = this._toCanvas(Math.max(d.x1, d.x2), Math.max(d.y1, d.y2)); const cw = ccx2 - ccx1, ch = ccy2 - ccy1; const pad = Math.max(8, cw * 0.06); const tcol = d.textColor || '#ffffff'; const ta = document.createElement('textarea'); ta.value = d.text || ''; ta.placeholder = 'Текст…'; ta.style.cssText = [ 'position:absolute;', `left:${offX + ccx1 + pad}px; top:${offY + ccy1 + pad}px;`, `width:${Math.max(60, cw - pad * 2)}px;`, `height:${Math.max(36, ch - pad * 2)}px;`, 'background:rgba(0,0,0,0.18); border:1px dashed rgba(255,255,255,0.45);', 'border-radius:4px; outline:none; resize:none; overflow:hidden;', `padding:4px 6px; color:${tcol}; caret-color:${tcol};`, 'font:bold 14px Manrope,sans-serif; text-align:center;', 'z-index:20; line-height:1.4;', ].join(''); wrap.appendChild(ta); ta.focus(); ta.select(); this._objectInput = ta; const commit = () => { d.text = ta.value.trim() || null; if (this._objectInput === ta) { ta.remove(); this._objectInput = null; } this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); }; ta.addEventListener('blur', commit); ta.addEventListener('keydown', e => { if (e.key === 'Escape') { ta.value = d.text || ''; commit(); } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commit(); } e.stopPropagation(); }); } /* ── live stroke API (remote preview) ──────────────────────────────── */ // TTL for live (preview) strokes — if remote user crashes mid-stroke, ghost preview disappears. // A normal stroke gets refreshed every ~50ms, so 1500ms is generous. _LIVE_STROKE_TTL_MS = 1500; setLiveStroke(liveId, tool, data, userName, color) { const prev = this._liveStrokes.get(liveId); if (prev?._ttlTimer) clearTimeout(prev._ttlTimer); const _ttlTimer = setTimeout(() => { if (this._liveStrokes.delete(liveId)) this.render(); }, this._LIVE_STROKE_TTL_MS); this._liveStrokes.set(liveId, { tool, data, userName, color, _ttlTimer }); this.render(); } removeLiveStroke(liveId) { const cur = this._liveStrokes.get(liveId); if (cur?._ttlTimer) clearTimeout(cur._ttlTimer); if (this._liveStrokes.delete(liveId)) this.render(); } clearAllLiveStrokes() { if (this._liveStrokes.size === 0) return; for (const [, ls] of this._liveStrokes) { if (ls?._ttlTimer) clearTimeout(ls._ttlTimer); } this._liveStrokes.clear(); this.render(); } /* ── render ─────────────────────────────────────────────────────────── */ /* ── chalkboard background ──────────────────────────────────────────── */ // Returns a noise HTMLCanvasElement for the given theme (cached) _getBgNoiseCanvas(theme) { if (this._bgNoiseCache.has(theme)) return this._bgNoiseCache.get(theme); try { const SIZE = 256; const oc = document.createElement('canvas'); oc.width = oc.height = SIZE; const oc2 = oc.getContext('2d'); const cfg = { chalkboard: { bg: '#213d26', delta: 22, gBias: 4, smear: 'horizontal', smearAlpha: 0.05, smearColor: '#ffffff', smearCount: 6 }, blackboard: { bg: '#1a1a2e', delta: 18, gBias: 0, smear: 'diagonal', smearAlpha: 0.03, smearColor: '#a0a0b8', smearCount: 4 }, corkboard: { bg: '#7a5c1e', delta: 25, gBias: 0, smear: 'diagonal', smearAlpha: 0.04, smearColor: '#c8a050', smearCount: 5 }, whiteboard: { bg: '#f0f0f0', delta: 5, gBias: 0, smear: 'none', smearAlpha: 0.02, smearColor: '#cccccc', smearCount: 3 }, }[theme] || { bg: '#213d26', delta: 22, gBias: 4, smear: 'horizontal', smearAlpha: 0.05, smearColor: '#ffffff', smearCount: 6 }; oc2.fillStyle = cfg.bg; oc2.fillRect(0, 0, SIZE, SIZE); const img = oc2.getImageData(0, 0, SIZE, SIZE); const dd = img.data; for (let i = 0; i < dd.length; i += 4) { const n = (Math.random() - 0.5) * cfg.delta; dd[i] = Math.max(0, Math.min(255, dd[i] + n)); dd[i + 1] = Math.max(0, Math.min(255, dd[i + 1] + n + cfg.gBias)); dd[i + 2] = Math.max(0, Math.min(255, dd[i + 2] + n)); } oc2.putImageData(img, 0, 0); if (cfg.smear !== 'none') { oc2.globalAlpha = cfg.smearAlpha; oc2.strokeStyle = cfg.smearColor; oc2.lineWidth = 1; for (let i = 0; i < cfg.smearCount; i++) { const x0 = Math.random() * SIZE, y0 = Math.random() * SIZE; oc2.beginPath(); if (cfg.smear === 'horizontal') { oc2.moveTo(0, y0); oc2.lineTo(SIZE, y0 + (Math.random() - 0.5) * 6); } else { const angle = Math.random() * Math.PI; oc2.moveTo(x0, y0); oc2.lineTo(x0 + Math.cos(angle) * SIZE * 0.4, y0 + Math.sin(angle) * SIZE * 0.4); } oc2.stroke(); } } // Cork knot spots for corkboard if (theme === 'corkboard') { for (let k = 0; k < 10; k++) { const kx = Math.random() * SIZE, ky = Math.random() * SIZE; const kr = 2 + Math.random() * 3; oc2.globalAlpha = 0.06; oc2.fillStyle = '#4a3010'; oc2.beginPath(); oc2.arc(kx, ky, kr, 0, Math.PI * 2); oc2.fill(); } } this._bgNoiseCache.set(theme, oc); } catch { this._bgNoiseCache.set(theme, null); } return this._bgNoiseCache.get(theme); } setAnnotateMode(v) { this._annotateMode = !!v; this._staticDirty = true; // фон рисуется в статич. слое — перерисовать сразу, не ждать первого штриха this.render(); } _renderBg(ctx) { const W = this._cssW || 300; const H = this._cssH || 150; ctx.clearRect(0, 0, W, H); if (this._annotateMode) return; // transparent bg — simulation shows through const theme = this._boardTheme || 'chalkboard'; // Base background const bgColors = { chalkboard: '#213d26', blackboard: '#1a1a2e', corkboard: '#7a5c1e', whiteboard: null, // uses gradient }; if (theme === 'whiteboard') { const wg = ctx.createLinearGradient(0, 0, 0, H); wg.addColorStop(0, '#f6f6f6'); wg.addColorStop(1, '#e8e8e8'); ctx.fillStyle = wg; } else { ctx.fillStyle = bgColors[theme] || '#213d26'; } ctx.fillRect(0, 0, W, H); // Noise texture (overlay composite) const noiseCanvas = this._getBgNoiseCanvas(theme); if (noiseCanvas) { try { const pat = ctx.createPattern(noiseCanvas, 'repeat'); if (pat) { ctx.save(); ctx.globalCompositeOperation = theme === 'whiteboard' ? 'multiply' : 'overlay'; ctx.globalAlpha = theme === 'whiteboard' ? 0.08 : 0.12; ctx.fillStyle = pat; ctx.fillRect(0, 0, W, H); ctx.restore(); } } catch { /* ignore pattern errors */ } } // Vignette const vigColors = { chalkboard: [0, 0, 0, 0.28], blackboard: [0, 0, 0, 0.35], corkboard: [40, 20, 0, 0.22], whiteboard: [0, 0, 0, 0.04], }[theme] || [0, 0, 0, 0.28]; const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.25, W / 2, H / 2, Math.max(W, H) * 0.78); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, `rgba(${vigColors.join(',')})`); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H); } _renderTemplate(ctx) { const W = this._cssW || 300; const H = this._cssH || 150; const isLight = this._boardTheme === 'whiteboard'; const lineC = isLight ? 'rgba(0,0,0,0.10)' : 'rgba(255,255,255,0.08)'; const dotC = isLight ? 'rgba(0,0,0,0.20)' : 'rgba(255,255,255,0.18)'; ctx.save(); if (this._template === 'grid') { const stepX = (40 / Whiteboard.VW) * W; const stepY = (40 / Whiteboard.VH) * H; ctx.strokeStyle = lineC; ctx.lineWidth = 0.8; ctx.beginPath(); for (let x = stepX; x < W; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } for (let y = stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); } ctx.stroke(); } else if (this._template === 'lined') { const stepY = (36 / Whiteboard.VH) * H; ctx.strokeStyle = lineC; ctx.lineWidth = 0.8; ctx.beginPath(); for (let y = stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); } ctx.stroke(); } else if (this._template === 'dots') { const stepX = (40 / Whiteboard.VW) * W; const stepY = (40 / Whiteboard.VH) * H; const r = Math.max(1, (1.5 / Whiteboard.VW) * W); ctx.fillStyle = dotC; for (let x = stepX; x < W; x += stepX) for (let y = stepY; y < H; y += stepY) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } } else if (this._template === 'coordinate') { const ox = W / 2, oy = H / 2; const stepX = (40 / Whiteboard.VW) * W; const stepY = (40 / Whiteboard.VH) * H; const coordGridC = isLight ? 'rgba(0,0,0,0.07)' : 'rgba(255,255,255,0.06)'; const coordAxisC = isLight ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.35)'; const coordTickC = isLight ? 'rgba(0,0,0,0.28)' : 'rgba(255,255,255,0.25)'; // Light grid ctx.strokeStyle = coordGridC; ctx.lineWidth = 0.6; ctx.beginPath(); for (let x = ox % stepX; x < W; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } for (let y = oy % stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); } ctx.stroke(); // Axes ctx.strokeStyle = coordAxisC; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(0, oy); ctx.lineTo(W, oy); ctx.moveTo(ox, 0); ctx.lineTo(ox, H); ctx.stroke(); // Arrows const ar = 7; ctx.fillStyle = coordAxisC; ctx.beginPath(); ctx.moveTo(W, oy); ctx.lineTo(W-ar, oy-ar/2); ctx.lineTo(W-ar, oy+ar/2); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox-ar/2, ar); ctx.lineTo(ox+ar/2, ar); ctx.closePath(); ctx.fill(); // Tick marks ctx.strokeStyle = coordTickC; ctx.lineWidth = 0.8; const tk = 4; ctx.beginPath(); for (let x = ox + stepX; x < W - ar; x += stepX) { ctx.moveTo(x, oy-tk); ctx.lineTo(x, oy+tk); } for (let x = ox - stepX; x > 0; x -= stepX) { ctx.moveTo(x, oy-tk); ctx.lineTo(x, oy+tk); } for (let y = oy + stepY; y < H - ar; y += stepY) { ctx.moveTo(ox-tk, y); ctx.lineTo(ox+tk, y); } for (let y = oy - stepY; y > 0; y -= stepY) { ctx.moveTo(ox-tk, y); ctx.lineTo(ox+tk, y); } ctx.stroke(); } ctx.restore(); } /* Schedule a render on the next animation frame. Multiple calls within the same frame are collapsed into one. */ render() { if (this._rafPending) return; this._rafPending = true; requestAnimationFrame(() => { this._rafPending = false; if (!document.hidden) this._doRender(); }); } _doRender() { if (this._staticDirty) { this._renderStatic(); this._staticDirty = false; } this._renderDynamic(); this._renderMinimap(); } _renderStatic() { const ctx = this._ctx; this._renderBg(ctx); if (this._template && this._template !== 'blank') this._renderTemplate(ctx); // Background images render first (locked layer, always behind everything) for (const s of this._strokes) { if (s.tool === 'image' && s.data.isBackground) this._renderStroke(ctx, s); } for (const s of this._strokes) { if (!(s.tool === 'image' && s.data.isBackground)) this._renderStroke(ctx, s); } } _renderDynamic() { const ctx = this._dynCtx; const W = this._cssW || 300, H = this._cssH || 150; ctx.clearRect(0, 0, W, H); // Remote live strokes (other users drawing in real-time) // Name/cursor dot is handled by DOM cursors (_showRemoteCursor) — no duplication here for (const [, ls] of this._liveStrokes) { this._renderStroke(ctx, ls); } // Laser pointer (local teacher view) — fade trail if (this._tool === 'laser' && this._drawing && this._laserTrail.length > 0) { const n = this._laserTrail.length; ctx.save(); for (let i = 0; i < n; i++) { const [lvx, lvy] = this._laserTrail[i]; const [lcx, lcy] = this._toCanvas(lvx, lvy); const t = (i + 1) / n; ctx.globalAlpha = t * 0.85; ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15 * t; ctx.fillStyle = '#ff4444'; ctx.beginPath(); ctx.arc(lcx, lcy, Math.max(2, 6 * t), 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } // Eraser: visual cursor circle follows pointer if (this._tool === 'eraser') { const [ecx, ecy] = this._toCanvas(this._pointerVx, this._pointerVy); const er = Math.max(4, ((this._w / Whiteboard.VW) * W) / 2); ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = 1.5; ctx.setLineDash([3, 2]); ctx.beginPath(); ctx.arc(ecx, ecy, er, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } // Connector: anchor point hints on hover shape if (this._tool === 'connector') { const anchors = ['n', 's', 'e', 'w', 'center']; const shapeIdsToShow = new Set(); if (this._connHoverShapeId) shapeIdsToShow.add(this._connHoverShapeId); if (this._drawing && this._connSnapStart) shapeIdsToShow.add(this._connSnapStart.strokeId); for (const shapeId of shapeIdsToShow) { const shape = this._strokes.find(s => s.id === shapeId); if (!shape || shape.tool === 'connector') continue; ctx.save(); for (const anchor of anchors) { const p = this._getAnchorPoint(shape, anchor); const [acx, acy] = this._toCanvas(p.x, p.y); const isSnap = this._connSnapEnd && this._connSnapEnd.strokeId === shapeId && this._connSnapEnd.anchor === anchor; ctx.beginPath(); ctx.arc(acx, acy, isSnap ? 7 : 5, 0, Math.PI * 2); ctx.fillStyle = isSnap ? '#06D6E0' : 'rgba(6,214,224,0.35)'; ctx.strokeStyle = isSnap ? '#06D6E0' : 'rgba(6,214,224,0.7)'; ctx.lineWidth = 1.5; ctx.fill(); ctx.stroke(); } ctx.restore(); } } // Compass tool live preview (3 state-machine phases) if (this._tool === 'compass' && this._compassState !== 'idle' && this._compassCenter) { const [ccx, ccy] = this._toCanvas(this._compassCenter.x, this._compassCenter.y); const sx = W / Whiteboard.VW; const cr = this._compassRadius * sx; const lw = Math.max(1.5, (this._width || 2) * sx); const arcColor = this._color || '#FFE066'; ctx.save(); if (this._compassState === 'setting-radius') { // Dashed ghost circle + radius line to pointer if (cr > 2) { ctx.strokeStyle = 'rgba(255,255,255,0.28)'; ctx.lineWidth = 1; ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.arc(ccx, ccy, cr, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); } const [pmx, pmy] = this._toCanvas(this._pointerVx, this._pointerVy); ctx.strokeStyle = 'rgba(6,214,224,0.4)'; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(ccx, ccy); ctx.lineTo(pmx, pmy); ctx.stroke(); ctx.setLineDash([]); // Center dot ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(ccx, ccy, 4, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; // Radius label if (cr > 14) { const rLabel = `r = ${Math.round(this._compassRadius)}`; ctx.font = 'bold 11px Manrope, sans-serif'; ctx.textBaseline = 'bottom'; ctx.textAlign = 'left'; const tw = ctx.measureText(rLabel).width; const lx = ccx + cr * 0.28 + 2, ly = ccy - cr * 0.18 - 4; ctx.fillStyle = 'rgba(20,14,36,0.78)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(lx - 4, ly - 15, tw + 8, 17, 3); else ctx.rect(lx - 4, ly - 15, tw + 8, 17); ctx.fill(); ctx.fillStyle = 'rgba(230,225,255,0.9)'; ctx.fillText(rLabel, lx, ly); ctx.textAlign = 'left'; } } else if (this._compassState === 'waiting-arc') { // Dashed full-circle preview + "click to draw arc" hint ctx.strokeStyle = arcColor + '77'; ctx.lineWidth = lw; ctx.setLineDash([7, 5]); ctx.beginPath(); ctx.arc(ccx, ccy, cr, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(ccx, ccy, 4, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; // Hint text below circle const hintText = 'Кликни, чтобы начать дугу'; ctx.font = '11px Manrope, sans-serif'; ctx.textBaseline = 'top'; ctx.textAlign = 'center'; const tw2 = ctx.measureText(hintText).width; const hx2 = ccx, hy2 = ccy + cr + 10; ctx.fillStyle = 'rgba(20,14,36,0.82)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(hx2 - tw2 / 2 - 6, hy2 - 2, tw2 + 12, 19, 4); else ctx.rect(hx2 - tw2 / 2 - 6, hy2 - 2, tw2 + 12, 19); ctx.fill(); ctx.fillStyle = 'rgba(200,190,230,0.92)'; ctx.fillText(hintText, hx2, hy2 + 1); ctx.textAlign = 'left'; } else if (this._compassState === 'drawing-arc') { // Live arc + instrument legs const arcEnd = this._compassArcStart + this._compassArcSweep; const ccwArc = this._compassArcSweep < 0; ctx.strokeStyle = arcColor; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.setLineDash([]); ctx.shadowColor = arcColor; ctx.shadowBlur = 4; ctx.beginPath(); ctx.arc(ccx, ccy, cr, this._compassArcStart, arcEnd, ccwArc); ctx.stroke(); ctx.shadowBlur = 0; const ptx = ccx + cr * Math.cos(arcEnd); const pty = ccy + cr * Math.sin(arcEnd); const legLen = cr * 1.55; const dist = Math.hypot(ptx - ccx, pty - ccy); if (dist >= 2 && dist < legLen * 2) { const mx = (ccx + ptx) / 2, my = (ccy + pty) / 2; const perpX = -(pty - ccy) / dist; const perpY = (ptx - ccx) / dist; const halfH = Math.sqrt(Math.max(0, legLen * legLen - (dist / 2) * (dist / 2))); const sign = (my + perpY * halfH) <= (my - perpY * halfH) ? 1 : -1; const hx3 = mx + sign * perpX * halfH; const hy3 = my + sign * perpY * halfH; ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = Math.max(1, lw * 0.7); ctx.beginPath(); ctx.moveTo(hx3, hy3); ctx.lineTo(ccx, ccy); ctx.stroke(); ctx.strokeStyle = 'rgba(155,93,229,0.6)'; ctx.beginPath(); ctx.moveTo(hx3, hy3); ctx.lineTo(ptx, pty); ctx.stroke(); ctx.fillStyle = 'rgba(215,210,232,0.85)'; ctx.beginPath(); ctx.arc(hx3, hy3, Math.max(3, lw * 1.2), 0, Math.PI * 2); ctx.fill(); } // Center dot ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(ccx, ccy, 4, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; // Pencil tip dot ctx.fillStyle = arcColor; ctx.shadowColor = arcColor; ctx.shadowBlur = 6; ctx.beginPath(); ctx.arc(ptx, pty, Math.max(2.5, lw * 0.8), 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; // Sweep angle label const sweepDeg = Math.abs(this._compassArcSweep * 180 / Math.PI); if (sweepDeg > 3) { const midAngle = this._compassArcStart + this._compassArcSweep / 2; const llx = ccx + (cr + 18) * Math.cos(midAngle); const lly = ccy + (cr + 18) * Math.sin(midAngle); const lText = `${sweepDeg.toFixed(1)}°`; ctx.font = 'bold 11px Manrope, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const ltw = ctx.measureText(lText).width; ctx.fillStyle = 'rgba(20,14,36,0.8)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(llx - ltw / 2 - 4, lly - 9, ltw + 8, 18, 3); else ctx.rect(llx - ltw / 2 - 4, lly - 9, ltw + 8, 18); ctx.fill(); ctx.fillStyle = arcColor; ctx.fillText(lText, llx, lly); ctx.textAlign = 'left'; } } ctx.restore(); } // Selection overlays (all selected strokes) for (const id of this._selectedIds) { const sel = this._strokes.find(s => s.id === id); if (sel) { this._renderObjectSelection(ctx, sel); // Auto-measurements for shapes if (this._showMeasurements && sel.tool === 'shape') this.renderMeasurements(ctx, sel); } } // Lasso rubber-band rectangle if (this._lassoRect) { const lr = this._lassoRect; const [cx1, cy1] = this._toCanvas(Math.min(lr.x1, lr.x2), Math.min(lr.y1, lr.y2)); const [cx2, cy2] = this._toCanvas(Math.max(lr.x1, lr.x2), Math.max(lr.y1, lr.y2)); ctx.save(); ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]); ctx.strokeRect(cx1, cy1, cx2 - cx1, cy2 - cy1); ctx.fillStyle = 'rgba(155,93,229,0.06)'; ctx.fillRect(cx1, cy1, cx2 - cx1, cy2 - cy1); ctx.setLineDash([]); ctx.restore(); } // Snap guides (cyan lines) if (this._snapGuides.length > 0) { ctx.save(); ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1; ctx.globalAlpha = 0.7; ctx.setLineDash([4, 4]); for (const g of this._snapGuides) { const [gx, gy] = this._toCanvas(g.axis === 'x' ? g.pos : 0, g.axis === 'y' ? g.pos : 0); ctx.beginPath(); if (g.axis === 'x') { ctx.moveTo(gx, 0); ctx.lineTo(gx, H); } else { ctx.moveTo(0, gy); ctx.lineTo(W, gy); } ctx.stroke(); } ctx.setLineDash([]); ctx.restore(); } // Live drawing preview (current user) if (this._drawing) { if ((this._isShapeTool() || this._tool === 'connector') && this._shapeStart && this._shapeEnd) { if (this._tool === 'connector') { this._renderStroke(ctx, { tool: 'connector', data: { x1: this._shapeStart[0], y1: this._shapeStart[1], x2: this._shapeEnd[0], y2: this._shapeEnd[1], color: this._color, width: this._w, arrowEnd: true, arrowStart: false, }}); } else { this._renderStroke(ctx, { tool: 'shape', data: { shape: this._tool, x1: this._shapeStart[0], y1: this._shapeStart[1], x2: this._shapeEnd[0], y2: this._shapeEnd[1], color: this._color, width: this._w, fill: this._fill, }}); // Size label near bottom-right corner of the shape const [sx, sy] = this._shapeStart, [ex, ey] = this._shapeEnd; const vw2 = Math.abs(ex - sx), vh2 = Math.abs(ey - sy); if (vw2 > 8 || vh2 > 8) { const [labx, laby] = this._toCanvas(Math.max(sx, ex), Math.max(sy, ey)); const label = `${Math.round(vw2)} × ${Math.round(vh2)}`; ctx.save(); ctx.font = 'bold 11px Manrope, sans-serif'; ctx.textBaseline = 'top'; const tw = ctx.measureText(label).width; ctx.fillStyle = 'rgba(20,14,36,0.82)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(labx + 6, laby + 4, tw + 12, 20, 4); else ctx.rect(labx + 6, laby + 4, tw + 12, 20); ctx.fill(); ctx.fillStyle = '#e8e0f7'; ctx.fillText(label, labx + 12, laby + 8); ctx.restore(); } } } else if (this._tool === 'table' && this._shapeStart && this._shapeEnd) { const [x1, y1] = this._shapeStart, [x2, y2] = this._shapeEnd; const [cx1, cy1] = this._toCanvas(Math.min(x1, x2), Math.min(y1, y2)); const [cx2, cy2] = this._toCanvas(Math.max(x1, x2), Math.max(y1, y2)); ctx.save(); ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]); ctx.strokeRect(cx1, cy1, cx2 - cx1, cy2 - cy1); ctx.setLineDash([]); ctx.restore(); } else if (!this._isShapeTool() && this._tool !== 'connector' && this._tool !== 'table' && this._curPts.length > 0) { this._renderStroke(ctx, { tool: this._tool, data: { points: this._curPts, color: this._color, width: this._width } }); } } // Ruler / Protractor overlays if (this._overlays.length > 0) this._renderOverlays(ctx); } _renderStroke(ctx, stroke) { if (stroke.tool === 'mindmap') { this._renderMindmap(ctx, stroke); return; } if (stroke.tool === 'shape') { this._renderShape(ctx, stroke); return; } if (stroke.tool === 'text') { this._renderText(ctx, stroke); return; } if (stroke.tool === 'image') { this._renderImage(ctx, stroke); return; } if (stroke.tool === 'sticky') { this._renderSticky(ctx, stroke); return; } if (stroke.tool === 'formula') { this._renderFormula(ctx, stroke); return; } if (stroke.tool === 'table') { this._renderTable(ctx, stroke); return; } if (stroke.tool === 'connector') { this._renderConnector(ctx, stroke); return; } if (stroke.tool === 'highlighter') { this._renderHighlighter(ctx, stroke); return; } if (stroke.tool === 'laser') { this._renderLaser(ctx, stroke); return; } if (stroke.tool === 'coordinate') { this._renderCoordinate(ctx, stroke); return; } if (stroke.tool === 'numberline') { this._renderNumberLine(ctx, stroke); return; } if (stroke.tool === 'compass') { this._renderCompass(ctx, stroke); return; } // pencil / eraser const pts = stroke.data.points; if (!pts || pts.length === 0) return; ctx.save(); if (stroke.tool === 'eraser') { ctx.globalCompositeOperation = 'destination-out'; ctx.strokeStyle = 'rgba(0,0,0,1)'; ctx.fillStyle = 'rgba(0,0,0,1)'; } else { ctx.globalCompositeOperation = 'source-over'; ctx.strokeStyle = stroke.data.color || '#ffffff'; ctx.fillStyle = stroke.data.color || '#ffffff'; // Chalk effect: soft powdery edges + slight transparency, modulated by opacity ctx.globalAlpha = (stroke.data.opacity ?? 1.0) * 0.88; ctx.shadowColor = stroke.data.color || '#ffffff'; ctx.shadowBlur = 1.4; } ctx.lineWidth = (stroke.data.width / Whiteboard.VW) * (this._cssW || 300); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); const [x0, y0] = this._toCanvas(pts[0][0], pts[0][1]); ctx.moveTo(x0, y0); if (pts.length === 1) { ctx.arc(x0, y0, ctx.lineWidth / 2, 0, Math.PI * 2); ctx.fill(); } else if (pts.length === 2) { const [x1, y1] = this._toCanvas(pts[1][0], pts[1][1]); ctx.lineTo(x1, y1); ctx.stroke(); } else { // Catmull-Rom spline for smooth curves for (let i = 0; i < pts.length - 1; i++) { const i0 = Math.max(0, i - 1), i3 = Math.min(pts.length - 1, i + 2); const p0 = this._toCanvas(pts[i0][0], pts[i0][1]); const p1 = this._toCanvas(pts[i][0], pts[i][1]); const p2 = this._toCanvas(pts[i + 1][0], pts[i + 1][1]); const p3 = this._toCanvas(pts[i3][0], pts[i3][1]); const cp1x = p1[0] + (p2[0] - p0[0]) / 6; const cp1y = p1[1] + (p2[1] - p0[1]) / 6; const cp2x = p2[0] - (p3[0] - p1[0]) / 6; const cp2y = p2[1] - (p3[1] - p1[1]) / 6; ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2[0], p2[1]); } ctx.stroke(); } ctx.restore(); } _renderShape(ctx, stroke) { const d = stroke.data; const [cx1, cy1] = this._toCanvas(d.x1, d.y1); const [cx2, cy2] = this._toCanvas(d.x2, d.y2); const lw = Math.max(1, (d.width / Whiteboard.VW) * (this._cssW || 300)); ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = d.opacity ?? 1.0; ctx.strokeStyle = d.color || '#ffffff'; ctx.fillStyle = d.color || '#ffffff'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const dash = d.lineStyle === 'dashed' ? [lw * 3, lw * 2] : d.lineStyle === 'dotted' ? [lw, lw * 2.5] : []; ctx.setLineDash(dash); const minX = Math.min(cx1, cx2), minY = Math.min(cy1, cy2); const W = Math.abs(cx2 - cx1), H = Math.abs(cy2 - cy1); const midX = (cx1 + cx2) / 2, midY = (cy1 + cy2) / 2; ctx.beginPath(); switch (d.shape) { case 'rect': ctx.rect(minX, minY, W, H); if (d.fill) ctx.fill(); ctx.stroke(); break; case 'ellipse': { ctx.ellipse(midX, midY, W / 2 || 1, H / 2 || 1, 0, 0, Math.PI * 2); if (d.fill) ctx.fill(); ctx.stroke(); break; } case 'line': ctx.moveTo(cx1, cy1); ctx.lineTo(cx2, cy2); ctx.stroke(); break; case 'triangle': ctx.moveTo(midX, minY); ctx.lineTo(Math.max(cx1, cx2), Math.max(cy1, cy2)); ctx.lineTo(Math.min(cx1, cx2), Math.max(cy1, cy2)); ctx.closePath(); if (d.fill) ctx.fill(); ctx.stroke(); break; case 'diamond': ctx.moveTo(midX, minY); ctx.lineTo(Math.max(cx1, cx2), midY); ctx.lineTo(midX, Math.max(cy1, cy2)); ctx.lineTo(Math.min(cx1, cx2), midY); ctx.closePath(); if (d.fill) ctx.fill(); ctx.stroke(); break; case 'hexagon': { const angles = [0, 60, 120, 180, 240, 300].map(a => a * Math.PI / 180); ctx.moveTo(midX + W / 2 * Math.cos(angles[0]), midY + H / 2 * Math.sin(angles[0])); for (let i = 1; i < 6; i++) ctx.lineTo(midX + W / 2 * Math.cos(angles[i]), midY + H / 2 * Math.sin(angles[i])); ctx.closePath(); if (d.fill) ctx.fill(); ctx.stroke(); break; } case 'star': { const outerR = Math.min(W, H) / 2; const innerR = outerR * 0.4; for (let i = 0; i < 10; i++) { const r = i % 2 === 0 ? outerR : innerR; const angle = (i * Math.PI / 5) - Math.PI / 2; const sx = midX + r * Math.cos(angle); const sy = midY + r * Math.sin(angle); i === 0 ? ctx.moveTo(sx, sy) : ctx.lineTo(sx, sy); } ctx.closePath(); if (d.fill) ctx.fill(); ctx.stroke(); break; } case 'roundedrect': { const r = Math.min(W, H) * 0.15; ctx.roundRect(minX, minY, W, H, r); if (d.fill) ctx.fill(); ctx.stroke(); break; } case 'callout': { const r = Math.min(W, H) * 0.12; const tailH = H * 0.22; const tailW = W * 0.16; const tailX = minX + W * 0.18; const boxH = H - tailH; ctx.roundRect(minX, minY, W, boxH, r); if (d.fill) ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(tailX, minY + boxH); ctx.lineTo(tailX + tailW, minY + boxH); ctx.lineTo(tailX + tailW * 0.35, minY + H); ctx.closePath(); if (d.fill) ctx.fill(); else ctx.stroke(); break; } case 'arrow': { // Block arrow from cx1,cy1 to cx2,cy2 const dx = cx2 - cx1, dy = cy2 - cy1; const len = Math.sqrt(dx * dx + dy * dy) || 1; const ux = dx / len, uy = dy / len; const bodyW = Math.max(4, H * 0.3); const headH = Math.min(W * 0.35, H * 0.85); const mx = cx2 - ux * headH, my = cy2 - uy * headH; const px = -uy * bodyW, py = ux * bodyW; const hpx = -uy * H * 0.5, hpy = ux * H * 0.5; ctx.moveTo(cx1 + px, cy1 + py); ctx.lineTo(mx + px, my + py); ctx.lineTo(mx + hpx, my + hpy); ctx.lineTo(cx2, cy2); ctx.lineTo(mx - hpx, my - hpy); ctx.lineTo(mx - px, my - py); ctx.lineTo(cx1 - px, cy1 - py); ctx.closePath(); if (d.fill) ctx.fill(); else { ctx.fill(); } break; } } // Text inside shape (set via double-click) if (d.text && d.shape !== 'line' && d.shape !== 'arrow') { const vH2 = Math.abs(d.y2 - d.y1); const autoFsV = Math.max(12, Math.min(38, vH2 * 0.18)); const fs = Math.max(9, Math.round(((d.fontSize || autoFsV) / Whiteboard.VH) * (this._cssH || 150))); const tpad = Math.max(8, W * 0.06); const maxTW = W - tpad * 2; if (maxTW > 10) { ctx.setLineDash([]); ctx.fillStyle = d.textColor || '#ffffff'; ctx.shadowBlur = 0; ctx.font = `bold ${fs}px Manrope, sans-serif`; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; const tmidX = (cx1 + cx2) / 2, tmidY = (cy1 + cy2) / 2; const tLines = this._wrapText(ctx, d.text, maxTW); const lineH = fs * 1.3; const totalH = tLines.length * lineH; let ty = tmidY - totalH / 2 + lineH / 2; const botLim = Math.max(cy1, cy2) - tpad; for (const line of tLines) { if (ty > botLim) break; ctx.fillText(line, tmidX, ty); ty += lineH; } } } ctx.restore(); } _renderConnector(ctx, stroke) { const d = stroke.data; const ep = this._getConnectorEndpoints(d); const [cx1, cy1] = this._toCanvas(ep.x1, ep.y1); const [cx2, cy2] = this._toCanvas(ep.x2, ep.y2); const lw = Math.max(1, (d.width / Whiteboard.VW) * (this._cssW || 300)); const dx = cx2 - cx1, dy = cy2 - cy1; const len = Math.sqrt(dx * dx + dy * dy) || 1; const ux = dx / len, uy = dy / len; const headL = Math.max(8, Math.min(18, len * 0.22)); const headW = headL * 0.55; ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = d.opacity ?? 1.0; ctx.strokeStyle = d.color || '#ffffff'; ctx.fillStyle = d.color || '#ffffff'; ctx.lineWidth = lw; ctx.lineCap = 'round'; const dash = d.lineStyle === 'dashed' ? [lw * 3, lw * 2] : d.lineStyle === 'dotted' ? [lw, lw * 2.5] : []; ctx.setLineDash(dash); // Line (straight or bezier) ctx.beginPath(); if (d.connStyle === 'curved' && (d.fromAnchor || d.toAnchor)) { const dist = Math.hypot(cx2 - cx1, cy2 - cy1); const k = Math.max(40, dist * 0.4); const [d1x, d1y] = this._anchorDirection(d.fromAnchor || 'center'); const [d2x, d2y] = this._anchorDirection(d.toAnchor || 'center'); const cpx1 = cx1 + d1x * k, cpy1 = cy1 + d1y * k; const cpx2 = cx2 - d2x * k, cpy2 = cy2 - d2y * k; ctx.moveTo(cx1, cy1); ctx.bezierCurveTo(cpx1, cpy1, cpx2, cpy2, cx2 - ux * headL * 0.7, cy2 - uy * headL * 0.7); } else { ctx.moveTo(cx1, cy1); ctx.lineTo(cx2 - ux * headL * 0.7, cy2 - uy * headL * 0.7); } ctx.stroke(); // End arrowhead if (d.arrowEnd !== false) { ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(cx2, cy2); ctx.lineTo(cx2 - ux * headL + uy * headW, cy2 - uy * headL - ux * headW); ctx.lineTo(cx2 - ux * headL - uy * headW, cy2 - uy * headL + ux * headW); ctx.closePath(); ctx.fill(); } // Start arrowhead if (d.arrowStart) { ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(cx1, cy1); ctx.lineTo(cx1 + ux * headL + uy * headW, cy1 + uy * headL - ux * headW); ctx.lineTo(cx1 + ux * headL - uy * headW, cy1 + uy * headL + ux * headW); ctx.closePath(); ctx.fill(); } ctx.restore(); } _renderHighlighter(ctx, stroke) { const d = stroke.data; if (!d.points || d.points.length < 2) return; const W = this._cssW || 300, H = this._cssH || 150; const lw = Math.max(8, (d.width / Whiteboard.VW) * W * 3); // Render on offscreen canvas first so self-overlapping segments // don't accumulate alpha (uniform highlight, no darker crossing points) const off = document.createElement('canvas'); off.width = W; off.height = H; const octx = off.getContext('2d'); octx.strokeStyle = d.color || '#FFE066'; octx.lineWidth = lw; octx.lineCap = 'square'; octx.lineJoin = 'round'; octx.beginPath(); const [fx, fy] = this._toCanvas(d.points[0][0], d.points[0][1]); octx.moveTo(fx, fy); for (let i = 1; i < d.points.length; i++) { const [px, py] = this._toCanvas(d.points[i][0], d.points[i][1]); octx.lineTo(px, py); } octx.stroke(); ctx.save(); ctx.globalAlpha = 0.38; ctx.globalCompositeOperation = 'source-over'; ctx.drawImage(off, 0, 0); ctx.restore(); } _renderLaser(ctx, stroke) { const d = stroke.data; if (!d.points || d.points.length === 0) return; // Render only the last point (remote users don't get trail history) const [vx, vy] = d.points[d.points.length - 1]; const [cx, cy] = this._toCanvas(vx, vy); ctx.save(); ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15; ctx.fillStyle = '#ff4444'; ctx.beginPath(); ctx.arc(cx, cy, 6, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } _renderText(ctx, stroke) { const d = stroke.data; if (!d.text) return; const [cx, cy] = this._toCanvas(d.x, d.y); const fontSize = Math.round(((d.fontSize || 22) / Whiteboard.VH) * (this._cssH || 150)); const fw = d.fontWeight || 'normal'; const fi = d.fontStyle || 'normal'; const ff = d.fontFamily || 'Manrope'; ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = d.color || '#ffffff'; ctx.font = `${fi} ${fw} ${fontSize}px '${ff}', sans-serif`; ctx.textBaseline = 'top'; ctx.textAlign = d.textAlign || 'left'; // chalk effect on text too ctx.shadowColor = d.color || '#ffffff'; ctx.shadowBlur = 1.2; const lines = d.text.split('\n'); const lh = fontSize * 1.45; lines.forEach((line, i) => ctx.fillText(line, cx, cy + i * lh)); ctx.restore(); } _renderImage(ctx, stroke) { const d = stroke.data; if (!d.src) return; const [cx, cy] = this._toCanvas(d.x, d.y); const cw = (d.w / Whiteboard.VW) * (this._cssW || 300); const ch = (d.h / Whiteboard.VH) * (this._cssH || 150); if (!stroke._img) { stroke._img = new Image(); stroke._img.onload = () => { this._staticDirty = true; this.render(); }; stroke._img.src = d.src; } if (stroke._img.complete && stroke._img.naturalWidth > 0) { ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = d.opacity ?? 1.0; if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } if (d.isBackground && d.fit === 'contain') { // Letterbox: maintain aspect ratio, center in canvas const iW = stroke._img.naturalWidth, iH = stroke._img.naturalHeight; const scale = Math.min(cw / iW, ch / iH); const dw = iW * scale, dh = iH * scale; ctx.drawImage(stroke._img, cx + (cw - dw) / 2, cy + (ch - dh) / 2, dw, dh); } else { ctx.drawImage(stroke._img, cx, cy, cw, ch); } ctx.restore(); } } _renderSticky(ctx, stroke) { const d = stroke.data; const [cx, cy] = this._toCanvas(d.x, d.y); const cw = (d.w / Whiteboard.VW) * (this._cssW || 300); const ch = (d.h / Whiteboard.VH) * (this._cssH || 150); ctx.save(); ctx.globalCompositeOperation = 'source-over'; if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } // Shadow ctx.shadowColor = 'rgba(0,0,0,0.35)'; ctx.shadowBlur = 8; ctx.shadowOffsetX = 3; ctx.shadowOffsetY = 4; // Body ctx.fillStyle = d.bgColor || '#FFE066'; ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 3); ctx.fill(); ctx.shadowColor = 'transparent'; // Folded corner (top-right) const fold = Math.min(cw, ch) * 0.14; ctx.fillStyle = 'rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.moveTo(cx + cw - fold, cy); ctx.lineTo(cx + cw, cy + fold); ctx.lineTo(cx + cw, cy); ctx.closePath(); ctx.fill(); ctx.fillStyle = this._darkenHex(d.bgColor || '#FFE066', 25); ctx.beginPath(); ctx.moveTo(cx + cw - fold, cy); ctx.lineTo(cx + cw - fold, cy + fold); ctx.lineTo(cx + cw, cy + fold); ctx.closePath(); ctx.fill(); // Text if (d.text) { const fs = Math.round((d.fontSize / Whiteboard.VH) * (this._cssH || 150)); const pad = Math.max(8, cw * 0.08); ctx.fillStyle = d.textColor || '#1a1a2e'; ctx.font = `${fs}px 'Manrope', sans-serif`; ctx.textBaseline = 'top'; const maxW = cw - 2 * pad; const lines = this._wrapText(ctx, d.text, maxW); let ty = cy + pad; const lineH = fs * 1.4; for (const line of lines) { if (ty + lineH > cy + ch - pad * 0.5) break; ctx.fillText(line, cx + pad, ty); ty += lineH; } } ctx.restore(); } _renderMindmap(ctx, stroke) { const d = stroke.data; if (!d.nodes) return; const isEditing = this._editingMindmapStroke === stroke; const selNodeId = isEditing ? this._selectedMindmapNodeId : null; // Draw edges first (behind nodes) for (const node of d.nodes) { if (node.parentId === null) continue; const parent = d.nodes.find(n => n.id === node.parentId); if (!parent) continue; const pr = this._mmNodeRect(d, parent); const cr = this._mmNodeRect(d, node); const dx = cr.cx - pr.cx; const px1 = dx >= 0 ? pr.cx + pr.w / 2 : pr.cx - pr.w / 2; const px2 = dx >= 0 ? cr.cx - cr.w / 2 : cr.cx + cr.w / 2; const [cpx1, cpy1] = this._toCanvas(px1, pr.cy); const [cpx2, cpy2] = this._toCanvas(px2, cr.cy); const k = Math.abs(cpx2 - cpx1) * 0.5; ctx.save(); ctx.strokeStyle = node.color || '#9B5DE5'; ctx.lineWidth = 1.8; ctx.globalAlpha = 0.65; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(cpx1, cpy1); ctx.bezierCurveTo(cpx1 + (dx >= 0 ? k : -k), cpy1, cpx2 + (dx >= 0 ? -k : k), cpy2, cpx2, cpy2); ctx.stroke(); ctx.restore(); } // Draw nodes for (const node of d.nodes) { const r = this._mmNodeRect(d, node); const depth = this._mmNodeDepth(d, node.id); const [cx, cy] = this._toCanvas(r.x, r.y); const cw = (r.w / Whiteboard.VW) * this._cssW; const ch = (r.h / Whiteboard.VH) * this._cssH; const isSelected = isEditing && node.id === selNodeId; const color = node.color || '#9B5DE5'; ctx.save(); ctx.globalAlpha = 0.92; ctx.fillStyle = depth === 0 ? color : `${color}33`; const rad = depth === 0 ? 10 : 8; if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, rad); else { ctx.beginPath(); ctx.rect(cx, cy, cw, ch); } ctx.fill(); ctx.strokeStyle = isSelected ? '#ffffff' : color; ctx.lineWidth = isSelected ? 2 : 1.5; ctx.globalAlpha = isSelected ? 1.0 : 0.8; if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, rad); else { ctx.beginPath(); ctx.rect(cx, cy, cw, ch); } ctx.stroke(); const vFontSize = depth === 0 ? 15 : 13; const fs = Math.max(8, Math.round((vFontSize / Whiteboard.VH) * this._cssH)); ctx.fillStyle = depth === 0 ? '#fff' : '#e8e0f7'; ctx.globalAlpha = 1.0; ctx.font = `${depth === 0 ? '700' : '600'} ${fs}px Manrope, sans-serif`; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.shadowBlur = 0; const lines = this._wrapText(ctx, node.text || '', cw - 10); const lh = fs * 1.3; const totalH = lines.length * lh; let ty = cy + ch / 2 - totalH / 2 + lh / 2; for (const line of lines) { ctx.fillText(line, cx + cw / 2, ty); ty += lh; } if (isSelected) { const [bx, by] = this._toCanvas(r.x + r.w + 10, r.y + r.h / 2 - 10); const btnR = Math.max(7, (10 / Whiteboard.VW) * this._cssW); ctx.beginPath(); ctx.arc(bx + btnR, by + btnR, btnR, 0, Math.PI * 2); ctx.fillStyle = '#06D6E0'; ctx.globalAlpha = 0.9; ctx.fill(); ctx.fillStyle = '#fff'; ctx.globalAlpha = 1.0; ctx.font = `bold ${Math.round(btnR * 1.4)}px Manrope`; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText('+', bx + btnR, by + btnR); } ctx.restore(); } // Outer selection rect when whole mindmap selected but NOT in node-edit mode if (!isEditing && this._selectedIds.has(stroke.id)) { const bbox = this._getStrokeBBox(stroke); const [bx, by] = this._toCanvas(bbox.x - 10, bbox.y - 10); const bw = ((bbox.w + 20) / Whiteboard.VW) * this._cssW; const bh = ((bbox.h + 20) / Whiteboard.VH) * this._cssH; ctx.save(); ctx.strokeStyle = 'rgba(155,93,229,0.6)'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 3]); if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 8); else { ctx.beginPath(); ctx.rect(bx, by, bw, bh); } ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } } _darkenHex(hex, amount) { try { const r = Math.max(0, parseInt(hex.slice(1, 3), 16) - amount); const g = Math.max(0, parseInt(hex.slice(3, 5), 16) - amount); const b = Math.max(0, parseInt(hex.slice(5, 7), 16) - amount); return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; } catch { return hex; } } _wrapText(ctx, text, maxW) { const lines = []; for (const para of text.split('\n')) { const words = para.split(' '); let cur = ''; for (const w of words) { const test = cur ? cur + ' ' + w : w; if (ctx.measureText(test).width > maxW && cur) { lines.push(cur); cur = w; } else { cur = test; } } lines.push(cur); } return lines; } _renderFormula(ctx, stroke) { const d = stroke.data; if (!d.latex) return; const [cx, cy] = this._toCanvas(d.x, d.y); const cw = (d.w / Whiteboard.VW) * (this._cssW || 300); const ch = (d.h / Whiteboard.VH) * (this._cssH || 150); ctx.save(); if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } ctx.fillStyle = 'rgba(26,22,37,0.75)'; ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.fill(); const fi = stroke._formulaImg; if (fi === null || fi === undefined) { // Not started yet — kick off async render and show placeholder text this._renderFormulaAsync(stroke, cw, ch); this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch); } else if (fi === 'pending') { // CSS / image loading in progress this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch); } else if (fi === false) { // Render failed — show raw LaTeX so user knows what's there this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch); } else if (fi.complete && fi.naturalWidth > 0) { ctx.drawImage(fi, cx, cy, cw, ch); } else { this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch); } ctx.restore(); } _drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch) { const fs = Math.max(11, Math.round((13 / Whiteboard.VH) * (this._cssH || 150))); ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.font = `italic ${fs}px 'Manrope',sans-serif`; ctx.textBaseline = 'middle'; // Clip text to box so it doesn't overflow ctx.save(); ctx.beginPath(); ctx.rect(cx + 4, cy, cw - 8, ch); ctx.clip(); ctx.fillText(d.latex, cx + 8, cy + ch / 2); ctx.restore(); } _renderFormulaAsync(stroke, cw, ch) { stroke._formulaImg = 'pending'; if (!window.katex) { stroke._formulaImg = false; return; } _loadKatexCss(css => { // Stroke may have been removed while CSS was loading if (!this._strokes.includes(stroke)) { stroke._formulaImg = false; return; } try { const html = window.katex.renderToString(stroke.data.latex, { throwOnError: false, displayMode: true, output: 'html', }); const color = stroke.data.color || '#e8e0f7'; const vfs = stroke.data.fontSize || 32; // Render at 3× for crisp, PowerPoint-quality formulas const scale = 3; const W = Math.ceil(cw * scale); const H = Math.ceil(ch * scale); const fs = Math.max(18, Math.round((vfs / Whiteboard.VH) * (this._cssH || 150) * 2.8 * scale)); const svgStr = [ ``, ``, `
`, ``, `
`, html, `
`, ].join(''); const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); stroke._formulaImg = img; this._staticDirty = true; this.render(); }; img.onerror = () => { URL.revokeObjectURL(url); stroke._formulaImg = false; // use false (not null) to avoid re-render loop this._staticDirty = true; this.render(); }; img.src = url; } catch { stroke._formulaImg = false; } }); } _renderTable(ctx, stroke) { const d = stroke.data; const [cx, cy] = this._toCanvas(d.x, d.y); const cw = (d.w / Whiteboard.VW) * (this._cssW || 300); const ch = (d.h / Whiteboard.VH) * (this._cssH || 150); const cellW = cw / d.cols; const cellH = ch / d.rows; const fs = Math.round(((d.fontSize || 14) / Whiteboard.VH) * (this._cssH || 150)); const border = d.borderColor || '#9B5DE5'; const bg = d.bgColor || 'rgba(26,22,37,0.85)'; const tc = d.textColor || '#e8e0f7'; ctx.save(); ctx.globalCompositeOperation = 'source-over'; if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } // Background ctx.fillStyle = bg; ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 4); ctx.fill(); // Grid ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 4); ctx.stroke(); for (let c = 1; c < d.cols; c++) { const x = cx + c * cellW; ctx.beginPath(); ctx.moveTo(x, cy); ctx.lineTo(x, cy + ch); ctx.stroke(); } for (let r = 1; r < d.rows; r++) { const y = cy + r * cellH; ctx.beginPath(); ctx.moveTo(cx, y); ctx.lineTo(cx + cw, y); ctx.stroke(); } // Cell text ctx.fillStyle = tc; ctx.font = `${fs}px 'Manrope', sans-serif`; ctx.textBaseline = 'middle'; const pad = Math.max(4, cellW * 0.06); for (let r = 0; r < d.rows; r++) { for (let c = 0; c < d.cols; c++) { const text = (d.cells && d.cells[r] && d.cells[r][c]) || ''; if (!text) continue; ctx.save(); ctx.beginPath(); ctx.rect(cx + c * cellW + 1, cy + r * cellH + 1, cellW - 2, cellH - 2); ctx.clip(); ctx.fillText(text, cx + c * cellW + pad, cy + r * cellH + cellH / 2); ctx.restore(); } } ctx.restore(); } /* ── select tool helpers ─────────────────────────────────────────────── */ _hitTestObject(vx, vy) { for (let i = this._strokes.length - 1; i >= 0; i--) { const s = this._strokes[i]; if (!this._isObjectStroke(s)) continue; const d = s.data; if (vx >= d.x && vx <= d.x + d.w && vy >= d.y && vy <= d.y + d.h) return s; } return null; } _hitTestObjectHandle(vx, vy, stroke) { const r = (12 / (this._cssW || 300)) * Whiteboard.VW; const b = this._getStrokeBBox(stroke); // Rotation handle (object strokes only): above top-center if (this._isObjectStroke(stroke)) { const rotVX = b.x + b.w / 2; const rotVY = b.y - (28 / (this._cssH || 150)) * Whiteboard.VH; if (Math.abs(vx - rotVX) < r && Math.abs(vy - rotVY) < r) return 'rot'; } const corners = { tl: [b.x, b.y], tr: [b.x + b.w, b.y], bl: [b.x, b.y + b.h], br: [b.x + b.w, b.y + b.h], }; for (const [name, [hx, hy]] of Object.entries(corners)) { if (Math.abs(vx - hx) < r && Math.abs(vy - hy) < r) return name; } return null; } _renderObjectSelection(ctx, stroke) { const b = this._getStrokeBBox(stroke); const [cx, cy] = this._toCanvas(b.x, b.y); const cw = (b.w / Whiteboard.VW) * (this._cssW || 300); const ch = (b.h / Whiteboard.VH) * (this._cssH || 150); const HS = 8; ctx.save(); ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 3]); ctx.strokeRect(cx, cy, cw, ch); ctx.setLineDash([]); // Resize handles only for resizable strokes if (this._isResizableStroke(stroke)) { for (const [hx, hy] of [[cx, cy], [cx + cw, cy], [cx, cy + ch], [cx + cw, cy + ch]]) { ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; ctx.fillRect(hx - HS / 2, hy - HS / 2, HS, HS); ctx.strokeRect(hx - HS / 2, hy - HS / 2, HS, HS); } } // Rotation handle (object strokes only) if (this._isObjectStroke(stroke)) { const rotCx = cx + cw / 2; const rotCy = cy - 28; ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(cx + cw / 2, cy); ctx.lineTo(rotCx, rotCy); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#9B5DE5'; ctx.beginPath(); ctx.arc(rotCx, rotCy, 6, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } ctx.restore(); } /* ── public API ─────────────────────────────────────────────────────── */ setTool(name) { this._removeTextInput(); this._removeObjectInput(); this._drawing = false; this._shapeStart = null; if (name !== 'select') { this._selectedId = null; this._dragState = null; this._canvas.style.cursor = ''; } this._tool = name; } setColor(hex) { this._color = hex; if (this._textInput) this._textInput.style.color = hex; } setWidth(px) { this._width = px; } setFill(v) { this._fill = v; } setTextFontSize(n) { this._textFontSize = Math.max(8, Math.min(120, n)); } setTextFontFamily(f){ this._textFontFamily = f; } setTextBold(v) { this._textBold = v; } setTextItalic(v) { this._textItalic = v; } setTextAlign(v) { this._textAlign = v || 'left'; if (this._textInput) this._textInput.style.textAlign = v; } setReadOnly(v) { this._readOnly = v; } setLineStyle(style) { this._lineStyle = style; } setOpacity(v) { this._opacity = Math.max(0.05, Math.min(1, v)); } setStylusMultiplier(v) { this._stylusMultiplier = v; } setBoardTheme(name) { this._boardTheme = name || 'chalkboard'; this._bgNoiseCache.delete(name); this._staticDirty = true; this.render(); } setStickyColor(c) { this._stickyColor = c || null; } setTableSize(r, c) { this._tableRows = Math.max(1, r); this._tableCols = Math.max(1, c); } // Toggle arrowStart or arrowEnd on the selected connector toggleConnectorArrow(which) { if (this._selectedIds.size !== 1) return; const sel = this._strokes.find(s => s.id === this._selectedId); if (!sel || sel.tool !== 'connector') return; sel.data[which] = !sel.data[which]; this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(sel); } // Align all selected strokes by edge/center alignStrokes(direction) { if (this._selectedIds.size < 2) return; const items = []; for (const id of this._selectedIds) { const s = this._strokes.find(x => x.id === id); if (s) items.push({ stroke: s, bbox: this._getStrokeBBox(s) }); } let ref; if (direction === 'left') ref = Math.min(...items.map(b => b.bbox.x)); else if (direction === 'right') ref = Math.max(...items.map(b => b.bbox.x + b.bbox.w)); else if (direction === 'top') ref = Math.min(...items.map(b => b.bbox.y)); else if (direction === 'bottom') ref = Math.max(...items.map(b => b.bbox.y + b.bbox.h)); else if (direction === 'centerH') ref = (Math.min(...items.map(b => b.bbox.x)) + Math.max(...items.map(b => b.bbox.x + b.bbox.w))) / 2; else if (direction === 'centerV') ref = (Math.min(...items.map(b => b.bbox.y)) + Math.max(...items.map(b => b.bbox.y + b.bbox.h))) / 2; for (const { stroke, bbox } of items) { let dvx = 0, dvy = 0; if (direction === 'left') dvx = ref - bbox.x; else if (direction === 'right') dvx = ref - (bbox.x + bbox.w); else if (direction === 'top') dvy = ref - bbox.y; else if (direction === 'bottom') dvy = ref - (bbox.y + bbox.h); else if (direction === 'centerH') dvx = ref - (bbox.x + bbox.w / 2); else if (direction === 'centerV') dvy = ref - (bbox.y + bbox.h / 2); if (dvx !== 0 || dvy !== 0) { this._moveStroke(stroke, dvx, dvy); if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); } } this._staticDirty = true; this.render(); } get _w() { return this._effectiveWidth ?? this._width; } setTemplate(name) { this._template = name || 'blank'; this._staticDirty = true; this.render(); } setPageNum(n) { this._pageNum = n; } /* ── Insert pre-built educational template ───────────────────────────── */ insertTemplate(name) { const VW = Whiteboard.VW, VH = Whiteboard.VH; // Center of current viewport in virtual coords const [vcx, vcy] = this._toVirtual((this._cssW || VW) / 2, (this._cssH || VH) / 2); const cx = vcx, cy = vcy; const mk = (tool, data) => ({ tool, data: { ...data } }); const sh = (shape, x1, y1, x2, y2, color = '#06D6E0', width = 3, fill = false, extra = {}) => mk('shape', { shape, x1: cx + x1, y1: cy + y1, x2: cx + x2, y2: cy + y2, color, width, fill, ...extra }); const tx = (x, y, text, color = '#e8e0f7', fontSize = 18) => mk('text', { x: cx + x, y: cy + y, text, color, fontSize }); const tpls = { venn2: () => [ sh('ellipse', -310, -200, +90, +200, '#06D6E0'), sh('ellipse', -90, -200, +310, +200, '#F15BB5'), ], venn3: () => [ sh('ellipse', -250, -220, +50, +100, '#06D6E0'), sh('ellipse', -50, -220, +250, +100, '#F15BB5'), sh('ellipse', -140, -80, +140, +240, '#FFE066'), ], tchart: () => [ sh('rect', -390, -230, +390, +230, '#9B5DE5', 2), sh('line', 0, -230, 0, +230, '#9B5DE5', 2), sh('line', -390, -150, +390, -150, '#9B5DE5', 2), tx(-290, -210, 'Колонка 1', '#9B5DE5'), tx( +20, -210, 'Колонка 2', '#9B5DE5'), ], timeline: () => [ sh('line', -460, 0, +460, 0, '#06D6E0', 3), sh('line', 462, -8, 462, 8, '#06D6E0', 0, false), // pseudo arrow tip ...Array.from({ length: 5 }, (_, i) => { const bx = -360 + i * 180; return sh('line', bx, -35, bx, +35, '#06D6E0', 2); }), ...Array.from({ length: 5 }, (_, i) => tx(-378 + i * 180, +48, `Событие ${i + 1}`, '#9ca3af', 15) ), ], quadrant: () => [ sh('line', -380, 0, +380, 0, '#9B5DE5', 2), sh('line', 0, -260, 0, +260, '#9B5DE5', 2), tx(+20, -250, 'I', 'rgba(155,93,229,0.65)', 22), tx(-70, -250, 'II', 'rgba(155,93,229,0.65)', 22), tx(-80, +210, 'III', 'rgba(155,93,229,0.65)', 22), tx(+20, +210, 'IV', 'rgba(155,93,229,0.65)', 22), ], pyramid: () => { const levels = 4; return [ sh('triangle', -300, -230, +300, +230, '#06D6E0', 2), ...Array.from({ length: levels - 1 }, (_, i) => { const t = (i + 1) / levels; const y = -230 + t * 460; const halfW = 300 * t; return sh('line', -halfW, y, +halfW, y, '#06D6E0', 1); }), ]; }, swot: () => [ sh('rect', -400, -250, +400, +250, '#9B5DE5', 2), sh('line', 0, -250, 0, +250, '#9B5DE5', 2), sh('line', -400, 0, +400, 0, '#9B5DE5', 2), tx(-380, -240, 'Strengths', '#06D6E0', 18), tx( 20, -240, 'Weaknesses', '#F15BB5', 18), tx(-380, +15, 'Opportunities','#A8E063', 18), tx( 20, +15, 'Threats', '#FF6B6B', 18), ], }; const strokes = (tpls[name] ? tpls[name]() : []); if (!strokes.length) return; const newIds = []; for (const s of strokes) { s.id = this._localIdCounter--; this._strokes.push(s); this._undoStack.push(s.id); newIds.push(s.id); if (this._onStrokeDone) this._onStrokeDone(s); } this._redoStack = []; this._staticDirty = true; this._selectedIds.clear(); for (const id of newIds) this._selectedIds.add(id); this.render(); } exportPNG() { const off = document.createElement('canvas'); off.width = Whiteboard.VW; off.height = Whiteboard.VH; const ctx = off.getContext('2d'); const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY]; this._cssW = Whiteboard.VW; this._cssH = Whiteboard.VH; this._zoom = 1; this._panVX = 0; this._panVY = 0; this._renderBg(ctx); if (this._template && this._template !== 'blank') this._renderTemplate(ctx); for (const s of this._strokes) this._renderStroke(ctx, s); this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy; off.toBlob(blob => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `whiteboard-p${this._pageNum}.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(a.href), 3000); }, 'image/png'); } /* Same composite as exportPNG, but hands the PNG Blob to a callback (used to save a board page into the student's personal materials). */ exportBlob(cb) { const off = document.createElement('canvas'); off.width = Whiteboard.VW; off.height = Whiteboard.VH; const ctx = off.getContext('2d'); const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY]; this._cssW = Whiteboard.VW; this._cssH = Whiteboard.VH; this._zoom = 1; this._panVX = 0; this._panVY = 0; this._renderBg(ctx); if (this._template && this._template !== 'blank') this._renderTemplate(ctx); for (const s of this._strokes) this._renderStroke(ctx, s); this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy; off.toBlob(cb, 'image/png'); } _renderMinimap() { if (!this._mmCanvas) return; const visible = this._zoom > 1.04; this._mmCanvas.style.display = visible ? 'block' : 'none'; if (!visible) return; const mm = this._mmCtx; const MW = 192, MH = 108; // Render full board at zoom=1 (same approach as renderThumbnail) const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY]; this._cssW = MW; this._cssH = MH; this._zoom = 1; this._panVX = 0; this._panVY = 0; this._renderBg(mm); if (this._template && this._template !== 'blank') this._renderTemplate(mm); for (const s of this._strokes) this._renderStroke(mm, s); // Restore this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy; // Viewport indicator const vpW = MW / sz; const vpH = MH / sz; const vpX = (spx / Whiteboard.VW) * MW; const vpY = (spy / Whiteboard.VH) * MH; // Dark overlay on non-viewport areas mm.fillStyle = 'rgba(0,0,0,0.42)'; mm.fillRect(0, 0, MW, vpY); mm.fillRect(0, vpY + vpH, MW, MH - vpY - vpH); mm.fillRect(0, vpY, vpX, vpH); mm.fillRect(vpX + vpW, vpY, MW - vpX - vpW, vpH); // Viewport border mm.strokeStyle = 'rgba(155,93,229,0.95)'; mm.lineWidth = 1.5; mm.strokeRect(vpX, vpY, vpW, vpH); // Crosshair at viewport center const cx = vpX + vpW / 2, cy = vpY + vpH / 2; mm.strokeStyle = 'rgba(155,93,229,0.6)'; mm.lineWidth = 0.8; mm.beginPath(); mm.moveTo(cx - 5, cy); mm.lineTo(cx + 5, cy); mm.stroke(); mm.beginPath(); mm.moveTo(cx, cy - 5); mm.lineTo(cx, cy + 5); mm.stroke(); } _mmNavigate(e) { const rect = this._mmCanvas.getBoundingClientRect(); const mx = (e.clientX - rect.left) * (192 / rect.width); const my = (e.clientY - rect.top) * (108 / rect.height); // Center the viewport on the clicked virtual position this._panVX = (mx / 192) * Whiteboard.VW - Whiteboard.VW / (2 * this._zoom); this._panVY = (my / 108) * Whiteboard.VH - Whiteboard.VH / (2 * this._zoom); this._clampPan(); this._staticDirty = true; this.render(); } renderThumbnail(targetCanvas) { const ctx = targetCanvas.getContext('2d'); const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY]; this._cssW = targetCanvas.width; this._cssH = targetCanvas.height; this._zoom = 1; this._panVX = 0; this._panVY = 0; this._renderBg(ctx); if (this._template && this._template !== 'blank') this._renderTemplate(ctx); for (const s of this._strokes) this._renderStroke(ctx, s); this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy; } updateOpacity(v) { if (this._selectedId == null) return; const sel = this._strokes.find(s => s.id === this._selectedId); if (!sel) return; sel.data.opacity = Math.max(0.05, Math.min(1, v)); this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(sel); } /* ── formula insert (called by external modal) ──────────────────────── */ insertFormula(vx, vy, latex, fontSize = 32) { if (!latex) return; this._editingFormulaStroke = null; // clear edit-mode flag const W = Math.round(460 * (fontSize / 32)); const H = Math.round(160 * (fontSize / 32)); const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W)); const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H)); const stroke = { id: this._localIdCounter--, tool: 'formula', data: { x, y, w: W, h: H, latex, fontSize, color: this._color }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._selectedId = stroke.id; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); if (this._onObjectCreated) this._onObjectCreated(stroke); } /* Called by external modal when editing an existing formula */ updateFormula(stroke, latex, fontSize) { if (!stroke || !latex) return; stroke.data.latex = latex; stroke.data.fontSize = fontSize || stroke.data.fontSize; stroke._formulaImg = null; this._editingFormulaStroke = null; this._staticDirty = true; this.render(); if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); } /* ── Clipboard paste (system clipboard: images & text) ──────────────── */ _onClipboardPaste(e) { if (this._readOnly) return; // Don't intercept paste when a real input/textarea is focused const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return; const items = e.clipboardData?.items; if (!items) return; // Prefer image over text for (const item of Array.from(items)) { if (item.type.startsWith('image/')) { e.preventDefault(); const blob = item.getAsFile(); if (blob) this._pasteImageBlob(blob); return; } } for (const item of Array.from(items)) { if (item.type === 'text/plain') { e.preventDefault(); item.getAsString(text => { if (!text.trim()) return; // Place pasted text at upper-left area of the board const stroke = { id: this._localIdCounter--, tool: 'text', data: { text: text.trim(), x: 80, y: 80, fontSize: 22, color: this._color }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._selectedId = stroke.id; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); }); return; } } } _pasteImageBlob(blob) { const maxPx = 800; const reader = new FileReader(); reader.onload = ev => { const img = new Image(); img.onload = () => { let pw = img.naturalWidth, ph = img.naturalHeight; if (pw > maxPx || ph > maxPx) { if (pw >= ph) { ph = Math.round(ph * maxPx / pw); pw = maxPx; } else { pw = Math.round(pw * maxPx / ph); ph = maxPx; } } const tmp = document.createElement('canvas'); tmp.width = pw; tmp.height = ph; tmp.getContext('2d').drawImage(img, 0, 0, pw, ph); const src = tmp.toDataURL('image/jpeg', 0.8); // Virtual dimensions: fit into ~800×600 vp, maintain aspect ratio const maxVW = 800, maxVH = 600; let vw = maxVW, vh = Math.round(maxVW * ph / pw); if (vh > maxVH) { vh = maxVH; vw = Math.round(maxVH * pw / ph); } const vx = Math.round((Whiteboard.VW - vw) / 2); const vy = Math.round((Whiteboard.VH - vh) / 2); const stroke = { id: this._localIdCounter--, tool: 'image', data: { src, x: vx, y: vy, w: vw, h: vh }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._selectedId = stroke.id; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); if (this._onObjectCreated) this._onObjectCreated(stroke); }; img.src = ev.target.result; }; reader.readAsDataURL(blob); } /* ── page background image ──────────────────────────────────────────── */ pasteImageAsBackground(blob) { const reader = new FileReader(); reader.onload = ev => { const img = new Image(); img.onload = () => { // Resize to max 1920 for reasonable data size const maxPx = 1920; let pw = img.naturalWidth, ph = img.naturalHeight; if (pw > maxPx || ph > maxPx) { if (pw >= ph) { ph = Math.round(ph * maxPx / pw); pw = maxPx; } else { pw = Math.round(pw * maxPx / ph); ph = maxPx; } } const tmp = document.createElement('canvas'); tmp.width = pw; tmp.height = ph; tmp.getContext('2d').drawImage(img, 0, 0, pw, ph); const src = tmp.toDataURL('image/jpeg', 0.85); this.removePageBackground(); const stroke = { id: this._localIdCounter--, tool: 'image', data: { src, x: 0, y: 0, w: Whiteboard.VW, h: Whiteboard.VH, isBackground: true, fit: 'contain' }, }; this._strokes.unshift(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); }; img.src = ev.target.result; }; reader.readAsDataURL(blob); } removePageBackground() { const idx = this._strokes.findIndex(s => s.tool === 'image' && s.data.isBackground); if (idx === -1) return; const stroke = this._strokes.splice(idx, 1)[0]; const ui = this._undoStack.indexOf(stroke.id); if (ui !== -1) this._undoStack.splice(ui, 1); this._staticDirty = true; this.render(); if (this._onStrokeUndo) this._onStrokeUndo(stroke.id); } hasPageBackground() { return this._strokes.some(s => s.tool === 'image' && s.data.isBackground); } /* ── copy / paste / delete / z-order ─────────────────────────────────── */ copy() { if (this._selectedId == null) return; const sel = this._strokes.find(s => s.id === this._selectedId); if (!sel) return; this._clipboard = { tool: sel.tool, data: JSON.parse(JSON.stringify(sel.data)) }; } paste() { if (!this._clipboard) return; const data = JSON.parse(JSON.stringify(this._clipboard.data)); const OFF = 30; if (data.x1 != null) { // shape / connector data.x1 += OFF; data.y1 += OFF; data.x2 += OFF; data.y2 += OFF; } else if (data.points) { // pencil / highlighter data.points = data.points.map(([px, py]) => [px + OFF, py + OFF]); } else if (data.x != null && data.w != null) { // object stroke (image/sticky/formula/table/text with w) data.x = Math.min(data.x + OFF, Whiteboard.VW - (data.w || 10)); data.y = Math.min(data.y + OFF, Whiteboard.VH - (data.h || 10)); } else if (data.x != null) { // text (no w) data.x += OFF; data.y += OFF; } const stroke = { id: this._localIdCounter--, tool: this._clipboard.tool, data }; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; this._selectedId = stroke.id; this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); } deleteSelected() { if (this._selectedIds.size === 0) return; const ids = [...this._selectedIds]; this._selectedIds.clear(); this._dragState = null; this._strokes = this._strokes.filter(s => !ids.includes(s.id)); for (const id of ids) { const ui = this._undoStack.indexOf(id); if (ui !== -1) this._undoStack.splice(ui, 1); } const deletedIds = new Set(ids); for (const s of this._strokes) { if (s.tool !== 'connector') continue; if (deletedIds.has(s.data.fromId)) { delete s.data.fromId; delete s.data.fromAnchor; } if (deletedIds.has(s.data.toId)) { delete s.data.toId; delete s.data.toAnchor; } } this._staticDirty = true; this.render(); for (const id of ids) { if (this._onStrokeUndo) this._onStrokeUndo(id); } } bringToFront() { if (this._selectedIds.size === 0) return; const toMove = this._strokes.filter(s => this._selectedIds.has(s.id)); this._strokes = this._strokes.filter(s => !this._selectedIds.has(s.id)); this._strokes.push(...toMove); this._staticDirty = true; this.render(); for (const s of toMove) { if (this._onStrokeUpdated) this._onStrokeUpdated(s); } } sendToBack() { if (this._selectedIds.size === 0) return; const toMove = this._strokes.filter(s => this._selectedIds.has(s.id)); this._strokes = this._strokes.filter(s => !this._selectedIds.has(s.id)); this._strokes.unshift(...toMove); this._staticDirty = true; this.render(); for (const s of toMove) { if (this._onStrokeUpdated) this._onStrokeUpdated(s); } } /* ── strokes management ─────────────────────────────────────────────── */ loadStrokes(strokes) { this._strokes = (strokes || []).map(s => this._normalise(s)); this._liveStrokes.clear(); this._staticDirty = true; this.render(); } /* Returns strokes not yet confirmed by server (negative IDs = locally drawn). */ getLocalStrokes() { return this._strokes.filter(s => s.id < 0); } addStrokes(strokes) { let changed = false; for (const s of strokes) { if (this._strokes.some(x => x.id === s.id)) continue; this._strokes.push(this._normalise(s)); changed = true; } if (changed) { this._staticDirty = true; this.render(); } } _normalise(s) { return { id: s.id, tool: s.tool, data: typeof s.data === 'string' ? JSON.parse(s.data) : s.data, }; } removeStroke(id) { this._strokes = this._strokes.filter(s => s.id !== id); if (this._selectedIds.has(id)) { this._selectedIds.delete(id); this._dragState = null; } this._staticDirty = true; this.render(); } updateStroke(id, data) { const s = this._strokes.find(x => x.id === id); if (!s) return; s.data = typeof data === 'string' ? JSON.parse(data) : { ...data }; s._img = null; s._formulaImg = null; this._staticDirty = true; this.render(); } confirmStroke(localId, serverId) { const s = this._strokes.find(x => x.id === localId); if (s) { s.id = serverId; const before = this._strokes.length; this._strokes = this._strokes.filter(x => x === s || x.id !== serverId); if (this._strokes.length !== before) { this._staticDirty = true; this.render(); } } const i = this._undoStack.indexOf(localId); if (i !== -1) this._undoStack[i] = serverId; } undo() { if (this._undoStack.length === 0) return; const id = this._undoStack.pop(); const stroke = this._strokes.find(s => s.id === id); this._strokes = this._strokes.filter(s => s.id !== id); if (stroke) this._redoStack.push(stroke); this._staticDirty = true; this.render(); if (this._onStrokeUndo) this._onStrokeUndo(id); } redo() { if (this._redoStack.length === 0) return; const stroke = this._redoStack.pop(); this._strokes.push(stroke); this._undoStack.push(stroke.id); this._staticDirty = true; this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); } clearPage() { this._strokes = []; this._undoStack = []; this._redoStack = []; this._curPts = []; this._drawing = false; this._shapeStart = null; this._selectedIds.clear(); this._dragState = null; this._lassoRect = null; this._snapGuides = []; this._liveStrokes.clear(); this._removeTextInput(); this._removeObjectInput(); this._staticDirty = true; this.render(); } destroy() { if (this._ro) this._ro.disconnect(); this._removeTextInput(); this._removeObjectInput(); document.removeEventListener('keydown', this._onKeyDown); document.removeEventListener('keyup', this._onKeyUp); document.removeEventListener('paste', this._onPaste); document.removeEventListener('visibilitychange', this._visHandler); if (this._dynCanvas?.parentElement) this._dynCanvas.parentElement.removeChild(this._dynCanvas); if (this._mmCanvas?.parentElement) this._mmCanvas.parentElement.removeChild(this._mmCanvas); } /* ── Phase 5: Coordinate system stroke ─────────────────────────────── */ _renderCoordinate(ctx, stroke) { const d = stroke.data; const [cx, cy] = this._toCanvas(d.x, d.y); const cw = (d.w / Whiteboard.VW) * (this._cssW || 300); const ch = (d.h / Whiteboard.VH) * (this._cssH || 150); const xMin = d.xMin ?? -10, xMax = d.xMax ?? 10; const yMin = d.yMin ?? -10, yMax = d.yMax ?? 10; const step = d.gridStep || 1; ctx.save(); if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } // Background ctx.fillStyle = 'rgba(18,13,30,0.88)'; ctx.strokeStyle = 'rgba(155,93,229,0.35)'; ctx.lineWidth = 1; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 6); else ctx.rect(cx, cy, cw, ch); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.rect(cx, cy, cw, ch); ctx.clip(); const xRange = xMax - xMin, yRange = yMax - yMin; const scaleX = cw / xRange, scaleY = ch / yRange; // Origin in canvas coords (0,0 of math space) const ox = cx + (-xMin) * scaleX; const oy = cy + yMax * scaleY; // Grid lines ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 0.5; for (let v = Math.ceil(xMin / step) * step; v <= xMax; v += step) { const px = cx + (v - xMin) * scaleX; ctx.beginPath(); ctx.moveTo(px, cy); ctx.lineTo(px, cy + ch); ctx.stroke(); } for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) { const py = cy + (yMax - v) * scaleY; ctx.beginPath(); ctx.moveTo(cx, py); ctx.lineTo(cx + cw, py); ctx.stroke(); } // Axes ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5; // X axis ctx.beginPath(); ctx.moveTo(cx, oy); ctx.lineTo(cx + cw, oy); ctx.stroke(); // Y axis ctx.beginPath(); ctx.moveTo(ox, cy); ctx.lineTo(ox, cy + ch); ctx.stroke(); // Tick marks + labels if (d.showLabels !== false) { ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.font = `${Math.max(8, Math.round(9 * cw / 400))}px Manrope,sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 0.8; const tickH = 4; for (let v = Math.ceil(xMin / step) * step; v <= xMax; v += step) { if (Math.abs(v) < 1e-9) continue; const px = cx + (v - xMin) * scaleX; ctx.beginPath(); ctx.moveTo(px, oy - tickH); ctx.lineTo(px, oy + tickH); ctx.stroke(); ctx.fillText(v, px, oy + tickH + 1); } ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) { if (Math.abs(v) < 1e-9) continue; const py = cy + (yMax - v) * scaleY; ctx.beginPath(); ctx.moveTo(ox - tickH, py); ctx.lineTo(ox + tickH, py); ctx.stroke(); ctx.fillText(v, ox - tickH - 2, py); } ctx.fillText('0', ox - tickH - 2, oy + tickH + 1); } // Arrow heads on axes const aw = 6, ah = 4; ctx.fillStyle = 'rgba(255,255,255,0.55)'; // X arrow right const xEnd = cx + cw; ctx.beginPath(); ctx.moveTo(xEnd, oy); ctx.lineTo(xEnd - aw, oy - ah); ctx.lineTo(xEnd - aw, oy + ah); ctx.closePath(); ctx.fill(); // Y arrow up ctx.beginPath(); ctx.moveTo(ox, cy); ctx.lineTo(ox - ah, cy + aw); ctx.lineTo(ox + ah, cy + aw); ctx.closePath(); ctx.fill(); // Plot functions for (const fn of (d.functions || [])) { ctx.strokeStyle = fn.color || '#06D6E0'; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.9; ctx.beginPath(); let started = false; for (let px = cx; px <= cx + cw; px += 0.8) { const mathX = xMin + (px - cx) / scaleX; let mathY; try { mathY = this._evalMath(fn.expr, mathX); } catch { continue; } if (!isFinite(mathY)) { started = false; continue; } const py = oy - mathY * scaleY; if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py); } ctx.stroke(); ctx.globalAlpha = 1; } ctx.restore(); } /* Simple recursive descent math expression evaluator */ _evalMath(expr, x) { const tokens = this._mathTokenise(expr.replace(/\s+/g, '')); let pos = 0; const peek = () => tokens[pos]; const consume = () => tokens[pos++]; const num = () => { const t = peek(); if (t === '(') { consume(); const v = addSub(); consume(/* ')' */); return v; } if (t === '-') { consume(); return -num(); } if (t === 'x') { consume(); return x; } if (/^[a-z]+$/.test(t)) { consume(); const arg = peek() === '(' ? (consume(), (() => { const v = addSub(); consume(); return v; })()) : num(); const fns = { sin: Math.sin, cos: Math.cos, tan: Math.tan, sqrt: Math.sqrt, abs: Math.abs, log: Math.log, exp: Math.exp, asin: Math.asin, acos: Math.acos, atan: Math.atan, floor: Math.floor, ceil: Math.ceil }; if (fns[t]) return fns[t](arg); if (t === 'pi') { pos--; return Math.PI; } if (t === 'e') { pos--; return Math.E; } return NaN; } consume(); return parseFloat(t); }; const pow = () => { let v = num(); while (peek() === '^') { consume(); v = Math.pow(v, num()); } return v; }; const mulDiv = () => { let v = pow(); while (peek() === '*' || peek() === '/') { const op = consume(); v = op === '*' ? v * pow() : v / pow(); } return v; }; const addSub = () => { let v = mulDiv(); while (peek() === '+' || peek() === '-') { const op = consume(); v = op === '+' ? v + mulDiv() : v - mulDiv(); } return v; }; return addSub(); } _mathTokenise(expr) { const tokens = []; let i = 0; const consts = { pi: Math.PI, e: Math.E }; while (i < expr.length) { if (/\d|\./.test(expr[i])) { let n = ''; while (i < expr.length && /[\d.]/.test(expr[i])) n += expr[i++]; tokens.push(n); } else if (/[a-z]/i.test(expr[i])) { let w = ''; while (i < expr.length && /[a-z]/i.test(expr[i])) w += expr[i++].toLowerCase(); if (w === 'pi' || w === 'e') tokens.push(parseFloat(consts[w]).toString()); else tokens.push(w); } else { tokens.push(expr[i++]); } } tokens.push(null); // EOF sentinel return tokens; } /* ── Phase 5: Ruler/Protractor overlays ────────────────────────────── */ // Overlays are rendered on the dynamic layer and are not saved to DB. _renderOverlays(ctx) { for (const ov of this._overlays) { if (ov.type === 'ruler') this._renderRuler(ctx, ov); else if (ov.type === 'protractor') this._renderProtractor(ctx, ov); } } _renderRuler(ctx, ov) { const [cx, cy] = this._toCanvas(ov.x, ov.y); const rulerW = (ov.width / Whiteboard.VW) * (this._cssW || 300); const rulerH = Math.max(18, rulerW * 0.07); ctx.save(); ctx.translate(cx, cy); ctx.rotate(ov.angle || 0); ctx.fillStyle = 'rgba(255,230,180,0.15)'; ctx.strokeStyle = 'rgba(255,230,180,0.5)'; ctx.lineWidth = 1; ctx.fillRect(0, 0, rulerW, rulerH); ctx.strokeRect(0, 0, rulerW, rulerH); // Tick marks every ~40vp const tickStep = (40 / Whiteboard.VW) * (this._cssW || 300); ctx.strokeStyle = 'rgba(255,230,180,0.4)'; ctx.font = `${Math.max(6, Math.round(7 * rulerW / 300))}px Manrope,sans-serif`; ctx.fillStyle = 'rgba(255,230,180,0.5)'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; for (let t = 0; t * tickStep <= rulerW; t++) { const tx = t * tickStep; const th = t % 5 === 0 ? rulerH * 0.6 : rulerH * 0.3; ctx.beginPath(); ctx.moveTo(tx, rulerH); ctx.lineTo(tx, rulerH - th); ctx.stroke(); if (t % 5 === 0 && t > 0) ctx.fillText(t, tx, rulerH - th - 1); } // ── handles ──────────────────────────────────────────────────────────── // Rotation handle — purple circle above ruler center const rotHx = rulerW / 2, rotHy = -20; ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1; ctx.setLineDash([3, 2]); ctx.beginPath(); ctx.moveTo(rulerW / 2, 0); ctx.lineTo(rotHx, rotHy); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); ctx.arc(rotHx, rotHy, 7, 0, Math.PI * 2); ctx.fillStyle = 'rgba(155,93,229,0.75)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); // ↺ symbol inside ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('↺', rotHx, rotHy); // Resize handle — cyan circle at right end const resHx = rulerW + 10, resHy = rulerH / 2; ctx.beginPath(); ctx.arc(resHx, resHy, 7, 0, Math.PI * 2); ctx.fillStyle = 'rgba(6,214,224,0.75)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('↔', resHx, resHy); ctx.restore(); } _renderProtractor(ctx, ov) { const [cx, cy] = this._toCanvas(ov.x, ov.y); const vR = ov.radius || 80; const r = Math.max(30, (vR / Whiteboard.VW) * (this._cssW || 300)); ctx.save(); ctx.translate(cx, cy); ctx.rotate(ov.angle || 0); ctx.fillStyle = 'rgba(6,214,224,0.08)'; ctx.strokeStyle = 'rgba(6,214,224,0.5)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 0, r, Math.PI, 0, false); // top semicircle ctx.lineTo(-r, 0); ctx.closePath(); ctx.fill(); ctx.stroke(); // Degree ticks ctx.font = `${Math.max(6, Math.round(7 * r / 80))}px Manrope,sans-serif`; ctx.fillStyle = 'rgba(6,214,224,0.6)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (let deg = 0; deg <= 180; deg += 10) { const rad = (Math.PI - deg * Math.PI / 180); const major = deg % 30 === 0; const tl = major ? r * 0.15 : r * 0.08; ctx.strokeStyle = major ? 'rgba(6,214,224,0.7)' : 'rgba(6,214,224,0.3)'; ctx.beginPath(); ctx.moveTo(Math.cos(rad) * (r - tl), Math.sin(rad) * (r - tl)); ctx.lineTo(Math.cos(rad) * r, Math.sin(rad) * r); ctx.stroke(); if (major && deg > 0 && deg < 180) { const lr = r - tl - 5; ctx.fillText(deg, Math.cos(rad) * lr, Math.sin(rad) * lr - 1); } } // ── handles ──────────────────────────────────────────────────────────── // Rotation handle — above center of flat edge (local y negative = upward) const pRotHy = -(r + 20); ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1; ctx.setLineDash([3, 2]); ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(0, pRotHy); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); ctx.arc(0, pRotHy, 7, 0, Math.PI * 2); ctx.fillStyle = 'rgba(155,93,229,0.75)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('↺', 0, pRotHy); // Resize handle — at right end of flat edge ctx.beginPath(); ctx.arc(r + 10, 0, 7, 0, Math.PI * 2); ctx.fillStyle = 'rgba(6,214,224,0.75)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('↔', r + 10, 0); ctx.restore(); } /* Overlay drag helpers */ _hitTestOverlay(vx, vy) { const [pcx, pcy] = this._toCanvas(vx, vy); for (let i = this._overlays.length - 1; i >= 0; i--) { const ov = this._overlays[i]; const [ox, oy] = this._toCanvas(ov.x, ov.y); const angle = ov.angle || 0; const dx = pcx - ox, dy = pcy - oy; // rotate pointer into overlay local space const lx = dx * Math.cos(-angle) - dy * Math.sin(-angle); const ly = dx * Math.sin(-angle) + dy * Math.cos(-angle); if (ov.type === 'ruler') { const W = (ov.width / Whiteboard.VW) * (this._cssW || 300); const H = Math.max(18, W * 0.07); if (Math.hypot(lx - W / 2, ly + 20) < 10) return { idx: i, zone: 'rot' }; if (Math.hypot(lx - (W + 10), ly - H / 2) < 10) return { idx: i, zone: 'resize' }; if (lx >= -5 && lx <= W + 5 && ly >= -5 && ly <= H + 5) return { idx: i, zone: 'body' }; } if (ov.type === 'protractor') { const vR = ov.radius || 80; const r = Math.max(30, (vR / Whiteboard.VW) * (this._cssW || 300)); if (Math.hypot(lx, ly + r + 20) < 10) return { idx: i, zone: 'rot' }; if (Math.hypot(lx - r - 10, ly) < 10) return { idx: i, zone: 'resize' }; const dist = Math.hypot(lx, ly); if (dist < r && ly <= 0) return { idx: i, zone: 'body' }; if (Math.abs(ly) < 8 && Math.abs(lx) <= r) return { idx: i, zone: 'body' }; } } return null; } /* ── Number line stroke ─────────────────────────────────────────────── */ _renderNumberLine(ctx, stroke) { const d = stroke.data; const [cx, cy] = this._toCanvas(d.x, d.y); const cw = (d.w / Whiteboard.VW) * (this._cssW || 300); const ch = (d.h / Whiteboard.VH) * (this._cssH || 150); const min = d.min ?? -10, max = d.max ?? 10; const step = d.step || 1; const range = max - min; ctx.save(); if (d.rotation) { const [ocx, ocy] = [cx + cw / 2, cy + ch / 2]; ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } // Background ctx.fillStyle = 'rgba(18,13,30,0.82)'; ctx.strokeStyle = 'rgba(155,93,229,0.3)'; ctx.lineWidth = 1; if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 6); else ctx.rect(cx, cy, cw, ch); ctx.fill(); ctx.stroke(); const axY = cy + ch / 2; const padX = cw * 0.04; const axX1 = cx + padX, axX2 = cx + cw - padX; const scaleX = (axX2 - axX1) / range; // Main axis line ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(axX1 - 6, axY); ctx.lineTo(axX2 + 8, axY); ctx.stroke(); // Arrow right ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.beginPath(); ctx.moveTo(axX2 + 8, axY); ctx.lineTo(axX2 + 2, axY - 5); ctx.lineTo(axX2 + 2, axY + 5); ctx.closePath(); ctx.fill(); // Tick marks + labels const fs = Math.max(8, Math.round((11 / Whiteboard.VH) * (this._cssH || 150))); ctx.font = `${fs}px Manrope,sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (let v = Math.ceil(min / step) * step; v <= max; v += step) { const px = axX1 + (v - min) * scaleX; const major = (v % (step * 5) === 0) || step >= 1; const th = major ? ch * 0.22 : ch * 0.12; ctx.strokeStyle = major ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.25)'; ctx.lineWidth = major ? 1.2 : 0.7; ctx.beginPath(); ctx.moveTo(px, axY - th); ctx.lineTo(px, axY + th); ctx.stroke(); if (major) { ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillText(v === 0 ? '0' : v.toString(), px, axY + th + 2); } } // Origin line if (min <= 0 && 0 <= max) { const ox = axX1 + (0 - min) * scaleX; ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(ox, axY - ch * 0.3); ctx.lineTo(ox, axY + ch * 0.3); ctx.stroke(); } // Points if (d.points) { for (const pt of d.points) { const px = axX1 + (pt.val - min) * scaleX; if (px < cx || px > cx + cw) continue; const color = pt.color || '#06D6E0'; ctx.fillStyle = pt.open ? 'transparent' : color; ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(px, axY, ch * 0.12, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); if (pt.label) { ctx.fillStyle = color; ctx.font = `bold ${fs}px Manrope,sans-serif`; ctx.fillText(pt.label, px, axY - ch * 0.35); } } } // Intervals (shaded ranges) if (d.intervals) { for (const iv of d.intervals) { const x1 = axX1 + (Math.max(iv.from, min) - min) * scaleX; const x2 = axX1 + (Math.min(iv.to, max) - min) * scaleX; ctx.fillStyle = (iv.color || '#9B5DE5') + '33'; ctx.fillRect(x1, axY - ch * 0.1, x2 - x1, ch * 0.2); } } ctx.restore(); } /* ── Compass stroke ─────────────────────────────────────────────────── */ _renderCompass(ctx, stroke) { const d = stroke.data; // New format: {cx, cy, radius, arcStart, arcSweep, color, lineWidth, showLegs} if (d.cx == null) return; // skip legacy strokes with old format const [ccx, ccy] = this._toCanvas(d.cx, d.cy); const sx = (this._cssW || 300) / Whiteboard.VW; const cr = d.radius * sx; const lw = Math.max(1.5, (d.lineWidth || 2) * sx); const arcColor = d.color || '#FFE066'; ctx.save(); ctx.globalAlpha = d.opacity ?? 1.0; // 1. Draw the arc const arcEnd = d.arcStart + d.arcSweep; const ccw = d.arcSweep < 0; ctx.strokeStyle = arcColor; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.setLineDash([]); ctx.shadowColor = arcColor; ctx.shadowBlur = Math.max(2, lw * 0.8); ctx.beginPath(); ctx.arc(ccx, ccy, cr, d.arcStart, arcEnd, ccw); ctx.stroke(); ctx.shadowBlur = 0; // 2. Compass instrument legs if (d.showLegs !== false) { const ptx = ccx + cr * Math.cos(arcEnd); const pty = ccy + cr * Math.sin(arcEnd); const legLen = cr * 1.55; const dist = Math.hypot(ptx - ccx, pty - ccy); // Hinge above center & pencil tip (isoceles triangle apex) let hx, hy; if (dist < 2) { hx = ccx; hy = ccy - legLen; } else { const mx = (ccx + ptx) / 2, my = (ccy + pty) / 2; const perpX = -(pty - ccy) / dist; const perpY = (ptx - ccx) / dist; const halfH = Math.sqrt(Math.max(0, legLen * legLen - (dist / 2) * (dist / 2))); // Choose perpendicular direction that is "upward" (smaller canvas Y) const sign = (my + perpY * halfH) <= (my - perpY * halfH) ? 1 : -1; hx = mx + sign * perpX * halfH; hy = my + sign * perpY * halfH; } // Needle leg: hinge → center (fixed pivot, cyan) ctx.strokeStyle = 'rgba(6,214,224,0.75)'; ctx.lineWidth = Math.max(1, lw * 0.7); ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(ccx, ccy); ctx.stroke(); // Pencil leg: hinge → pencil tip (violet) ctx.strokeStyle = 'rgba(155,93,229,0.75)'; ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(ptx, pty); ctx.stroke(); // Hinge screw dot const hr = Math.max(3, lw * 1.3); ctx.fillStyle = 'rgba(215,210,232,0.92)'; ctx.strokeStyle = 'rgba(100,80,140,0.6)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(hx, hy, hr, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Center needle tip (cyan glow) ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(ccx, ccy, Math.max(2.5, lw), 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; // Pencil tip dot (arc color glow) ctx.fillStyle = arcColor; ctx.shadowColor = arcColor; ctx.shadowBlur = 6; ctx.beginPath(); ctx.arc(ptx, pty, Math.max(2, lw * 0.85), 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; } ctx.restore(); } /* ── Auto-measurements: display geometric annotations on shapes ─────── */ renderMeasurements(ctx, stroke) { if (!stroke || stroke.tool !== 'shape') return; const d = stroke.data; const fs = Math.max(9, Math.round((10 / Whiteboard.VH) * (this._cssH || 150))); ctx.save(); ctx.font = `${fs}px Manrope,sans-serif`; ctx.fillStyle = 'rgba(255,230,100,0.85)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const [cx1, cy1] = this._toCanvas(d.x1, d.y1); const [cx2, cy2] = this._toCanvas(d.x2, d.y2); const midCx = (cx1 + cx2) / 2, midCy = (cy1 + cy2) / 2; if (d.shape === 'line' || d.shape === 'arrow') { const len = Math.hypot(d.x2 - d.x1, d.y2 - d.y1); const ang = Math.atan2(d.y2 - d.y1, d.x2 - d.x1) * 180 / Math.PI; this._drawMeasLabel(ctx, `${len.toFixed(0)} vp ${ang.toFixed(1)}°`, midCx, midCy - 12); } else if (d.shape === 'rect' || d.shape === 'roundedrect') { const vw = Math.abs(d.x2 - d.x1), vh = Math.abs(d.y2 - d.y1); this._drawMeasLabel(ctx, `${vw.toFixed(0)}`, midCx, Math.min(cy1, cy2) - 8); this._drawMeasLabel(ctx, `${vh.toFixed(0)}`, Math.max(cx1, cx2) + 8, midCy); } else if (d.shape === 'ellipse') { const rx = Math.abs(d.x2 - d.x1) / 2, ry = Math.abs(d.y2 - d.y1) / 2; this._drawMeasLabel(ctx, `rx=${rx.toFixed(0)} ry=${ry.toFixed(0)}`, midCx, Math.min(cy1, cy2) - 8); } else if (d.shape === 'triangle') { const base = Math.abs(d.x2 - d.x1), height = Math.abs(d.y2 - d.y1); const area = (base * height / 2).toFixed(0); this._drawMeasLabel(ctx, `A≈${area}`, midCx, Math.min(cy1, cy2) - 8); } ctx.restore(); } _drawMeasLabel(ctx, text, x, y) { const m = ctx.measureText(text); const pad = 3; ctx.fillStyle = 'rgba(26,20,40,0.7)'; ctx.fillRect(x - m.width / 2 - pad, y - 7, m.width + pad * 2, 14); ctx.fillStyle = 'rgba(255,230,100,0.9)'; ctx.fillText(text, x, y); } toggleMeasurements() { this._showMeasurements = !this._showMeasurements; this.render(); return this._showMeasurements; } toggleRuler() { const idx = this._overlays.findIndex(o => o.type === 'ruler'); if (idx >= 0) this._overlays.splice(idx, 1); else this._overlays.push({ type: 'ruler', x: 200, y: 200, angle: 0, width: 400 }); this.render(); } toggleProtractor() { const idx = this._overlays.findIndex(o => o.type === 'protractor'); if (idx >= 0) this._overlays.splice(idx, 1); else this._overlays.push({ type: 'protractor', x: 960, y: 350, radius: 80, angle: 0 }); this.render(); } } if (typeof module !== 'undefined') module.exports = Whiteboard;