Files
Learn_System/frontend/js/whiteboard.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

4581 lines
183 KiB
JavaScript
Raw Blame History

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