/* ── 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; this._template = opts.template || 'blank'; this._pageNum = opts.pageNum || 1; 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._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._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; // 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 (!this._readOnly) this._onPointerDown(e); }; const onMove = e => { 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 > 100) { this._cursorThrottle = now; this._onCursorMove(vx, vy); } } }; const onUp = e => { if (!this._readOnly) this._onPointerUp(e); }; 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; 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') { 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'; } _isResizableStroke(s) { return this._isObjectStroke(s) || 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; 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 === 'shape' || stroke.tool === 'connector') { 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 (this._isObjectStroke(stroke) || stroke.tool === 'text') { d.x += dvx; d.y += dvy; } else if (stroke.tool === 'shape' || stroke.tool === 'connector') { 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; } } /* Snap guides: compute alignment guides for movingStroke vs. all other strokes */ _computeSnapGuides(movingStroke) { 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; } /* 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]; 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 ───────────────────────────────────────────────── */ _onPointerDown(e) { // ── pan mode (Space + drag) ─────────────────────────────────────────── if (this._spaceDown) { 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; this._panStartCss = [cx, cy]; this._panStartPan = [this._panVX, this._panVY]; this._canvas.style.cursor = 'grabbing'; this._canvas.setPointerCapture(e.pointerId); return; } const [vx, vy] = this._pointerPos(e); // ── 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; // 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._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; } // ── compass tool ───────────────────────────────────────────────────── if (this._tool === 'compass') { const W = 260, H = 260; 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: 'compass', data: { x, y, w: W, h: H, angle: 0, spread: Math.PI / 4, arcAngle: Math.PI * 2, arcStart: 0, color: this._color, radius: null, showRadius: true }, }; 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; } // ── 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._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); // Broadcast cursor position to other participants (throttled to 50ms) if (this._onCursorMove) { const now = Date.now(); if (now - this._cursorThrottle > 100) { 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 ──────────────────────────────────────────── if (this._tool === 'laser' && this._drawing) { this._laserPos = [vx, vy]; if (this._onStrokeProgress && this._liveId) this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } }); 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; } 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; } if (!this._drawing) return; if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') { this._shapeEnd = [vx, vy]; this.render(); } else { this._curPts.push([vx, vy]); this.render(); } if (this._onStrokeProgress && !this._progressTimer) { this._progressTimer = setTimeout(() => { this._progressTimer = null; this._flushProgress(); }, 50); } } _onWheel(e) { e.preventDefault(); const rect = this._canvas.getBoundingClientRect(); const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; 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); } _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._width, 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._width, arrowEnd: true, arrowStart: false }; } else { if (this._curPts.length === 0) return; data = { points: [...this._curPts], color: this._tool === 'eraser' ? null : this._color, width: this._width }; } 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; } // ── laser pointer: cancel preview ───────────────────────────────────── if (this._tool === 'laser') { this._drawing = false; this._laserPos = null; 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; } 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 stroke = { id: this._localIdCounter--, tool: 'table', data: { x: Math.max(0, vx), y: Math.max(0, vy), w: vw, h: vh, rows: 3, cols: 4, cells: [['','','',''], ['','','',''], ['','','','']], 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') { const [x1, y1] = this._shapeStart; const [x2, y2] = this._shapeEnd || this._shapeStart; if (Math.abs(x2 - x1) < 5 && Math.abs(y2 - y1) < 5) { if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); return; } const stroke = { id: this._localIdCounter--, tool: 'connector', data: { x1, y1, x2, y2, color: this._color, width: this._width, arrowEnd: true, arrowStart: false, 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; } // ── 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._width, 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._width, 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(14, Math.round((22 / 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 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:'Manrope',sans-serif; color:${this._color}; 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: 22, 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 = 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 === '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 === '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 } } /* ── live stroke API (remote preview) ──────────────────────────────── */ setLiveStroke(liveId, tool, data, userName, color) { this._liveStrokes.set(liveId, { tool, data, userName, color }); this.render(); } removeLiveStroke(liveId) { if (this._liveStrokes.delete(liveId)) this.render(); } clearAllLiveStrokes() { if (this._liveStrokes.size === 0) return; this._liveStrokes.clear(); this.render(); } /* ── render ─────────────────────────────────────────────────────────── */ /* ── chalkboard background ──────────────────────────────────────────── */ _getBgNoise() { if (this._bgNoise) return this._bgNoise; try { const SIZE = 256; const oc = document.createElement('canvas'); oc.width = oc.height = SIZE; const oc2 = oc.getContext('2d'); // Base chalkboard green oc2.fillStyle = '#213d26'; oc2.fillRect(0, 0, SIZE, SIZE); // Per-pixel noise for chalk dust texture const img = oc2.getImageData(0, 0, SIZE, SIZE); const d = img.data; for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 22; d[i] = Math.max(0, Math.min(255, d[i] + n)); d[i + 1] = Math.max(0, Math.min(255, d[i + 1] + n + 4)); // slightly more green variation d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + n)); } oc2.putImageData(img, 0, 0); // Faint horizontal chalk smear lines oc2.globalAlpha = 0.05; oc2.strokeStyle = '#ffffff'; oc2.lineWidth = 1; for (let i = 0; i < 6; i++) { const y = Math.random() * SIZE; oc2.beginPath(); oc2.moveTo(0, y); oc2.lineTo(SIZE, y + (Math.random() - 0.5) * 6); oc2.stroke(); } this._bgNoise = this._ctx.createPattern(oc, 'repeat'); } catch { this._bgNoise = false; } return this._bgNoise; } _renderBg(ctx) { const W = this._cssW || 300; const H = this._cssH || 150; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#213d26'; ctx.fillRect(0, 0, W, H); const noise = this._getBgNoise(); if (noise) { ctx.save(); ctx.globalCompositeOperation = 'overlay'; ctx.globalAlpha = 0.12; ctx.fillStyle = noise; ctx.fillRect(0, 0, W, H); ctx.restore(); } 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(0,0,0,0.28)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H); } _renderTemplate(ctx) { const W = this._cssW || 300; const H = this._cssH || 150; ctx.save(); if (this._template === 'grid') { const stepX = (40 / Whiteboard.VW) * W; const stepY = (40 / Whiteboard.VH) * H; ctx.strokeStyle = 'rgba(255,255,255,0.08)'; 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 = 'rgba(255,255,255,0.08)'; 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 = 'rgba(255,255,255,0.18)'; 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; // Light grid ctx.strokeStyle = 'rgba(255,255,255,0.06)'; 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 = 'rgba(255,255,255,0.35)'; 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 = 'rgba(255,255,255,0.35)'; 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 = 'rgba(255,255,255,0.25)'; 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(); } render() { 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); for (const s of this._strokes) 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) if (this._tool === 'laser' && this._drawing && this._laserPos) { const [lvx, lvy] = this._laserPos; const [lcx, lcy] = this._toCanvas(lvx, lvy); ctx.save(); ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15; ctx.fillStyle = '#ff4444'; ctx.beginPath(); ctx.arc(lcx, lcy, 6, 0, Math.PI * 2); ctx.fill(); 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._width, 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._width, fill: this._fill, }}); } } 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 === '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; } } ctx.restore(); } _renderConnector(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)); 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 ctx.beginPath(); 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.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.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 lw = Math.max(8, (d.width / Whiteboard.VW) * (this._cssW || 300) * 3); ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 0.38; ctx.strokeStyle = d.color || '#FFE066'; ctx.lineWidth = lw; ctx.lineCap = 'square'; ctx.lineJoin = 'round'; ctx.beginPath(); const [fx, fy] = this._toCanvas(d.points[0][0], d.points[0][1]); ctx.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]); ctx.lineTo(px, py); } ctx.stroke(); ctx.restore(); } _renderLaser(ctx, stroke) { const d = stroke.data; if (!d.points || d.points.length === 0) return; 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)); ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = d.color || '#ffffff'; ctx.font = `${fontSize}px 'Manrope', sans-serif`; ctx.textBaseline = 'top'; // 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'; 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.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(); } _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; } setReadOnly(v) { this._readOnly = v; } setLineStyle(style) { this._lineStyle = style; } setOpacity(v) { this._opacity = Math.max(0.05, Math.min(1, v)); } setTemplate(name) { this._template = name || 'blank'; this._staticDirty = true; this.render(); } setPageNum(n) { this._pageNum = n; } 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'); } _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); } /* ── 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); } 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); 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; 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 r = Math.min(cw, ch) * 0.38; const ocx = cx + cw / 2, ocy = cy + ch / 2; ctx.save(); if (d.rotation) { ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } // Background ctx.fillStyle = 'rgba(18,13,30,0.82)'; ctx.strokeStyle = 'rgba(6,214,224,0.25)'; ctx.lineWidth = 1; if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 8); else ctx.rect(cx, cy, cw, ch); ctx.fill(); ctx.stroke(); // Draw arm of compass (needle) const angle = (d.angle || 0); // current arc angle const spread = d.spread || Math.PI / 4; // angle between legs (half = radius) const legLen = r * 1.15; // Left leg (pivot) — straight down ctx.strokeStyle = 'rgba(6,214,224,0.7)'; ctx.lineWidth = 2; ctx.lineCap = 'round'; const pivotAngle = -Math.PI / 2 - spread / 2; const drawAngle = -Math.PI / 2 + spread / 2; ctx.beginPath(); ctx.moveTo(ocx, ocy); ctx.lineTo(ocx + Math.cos(pivotAngle + angle) * legLen, ocy + Math.sin(pivotAngle + angle) * legLen); ctx.stroke(); // Right leg (pencil tip) ctx.strokeStyle = 'rgba(155,93,229,0.8)'; ctx.beginPath(); ctx.moveTo(ocx, ocy); ctx.lineTo(ocx + Math.cos(drawAngle + angle) * legLen, ocy + Math.sin(drawAngle + angle) * legLen); ctx.stroke(); // Hinge circle at top ctx.fillStyle = 'rgba(6,214,224,0.9)'; ctx.beginPath(); ctx.arc(ocx, ocy, 4, 0, Math.PI * 2); ctx.fill(); // Drawn arc const arcR = (Math.cos(spread / 2) * legLen); // approximate radius const arcAngle = d.arcAngle || Math.PI * 2; const arcStart = d.arcStart || 0; ctx.strokeStyle = (d.color || '#FFE066') + 'cc'; ctx.lineWidth = 1.8; ctx.setLineDash([]); ctx.beginPath(); ctx.arc(ocx + Math.cos(pivotAngle + angle) * legLen, ocy + Math.sin(pivotAngle + angle) * legLen, arcR, arcStart, arcStart + arcAngle); ctx.stroke(); // Radius label if (d.showRadius && d.radius) { const fs = Math.max(9, Math.round((11 / Whiteboard.VH) * (this._cssH || 150))); ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = `${fs}px Manrope,sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(`r = ${d.radius}`, ocx, cy + 6); } 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;