Files
Learn_System/frontend/js/whiteboard.js
T
Maxim Dolgolyov f4eee2af3f feat: minimap navigation overlay + ruler/protractor property controls
Minimap:
- Auto-shows in bottom-right corner when zoom > 1.05
- Renders full board content at scale (background + all strokes)
- Purple viewport indicator with darkened outer areas
- Click/drag to jump-pan the viewport
- Cleaned up on destroy()

Ruler/protractor property controls:
- Rotation handle (purple ↺) — drag to rotate around origin
- Resize handle (cyan ↔) — drag to change length/radius
- Protractor now supports rotation via ctx.rotate(ov.angle)
- Floating props panel in toolbar: angle° and length/radius inputs
- Panel auto-shows on first click/drag, hides when overlay toggled off
- Canvas-space hit testing with rotation-aware local coordinates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:12:27 +03:00

3250 lines
126 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;
this._template = opts.template || 'blank';
this._pageNum = opts.pageNum || 1;
this._selectedIds = new Set(); // multi-select
this._dragState = null;
this._clipboard = null;
this._lassoRect = null; // {x1,y1,x2,y2} rubber-band selection
this._snapGuides = []; // [{axis:'x'|'y', pos:number}]
this._staticDirty = true; // two-layer: re-render static when true
this._overlays = []; // ruler/protractor overlays (not saved to DB)
this._overlayDrag = null; // {idx, type:'move'|'rotate'|'resize', ...}
this._selectedOverlayIdx = -1;
this._onOverlayChange = opts.onOverlayChange || null;
this._showMeasurements = false; // auto-measurements toggle
// Zoom / pan
this._zoom = 1;
this._panVX = 0; // virtual-space X of top-left corner
this._panVY = 0;
this._spaceDown = false;
this._panStartCss = null; // [cssX, cssY] at pan start
this._panStartPan = null; // [panVX, panVY] at pan start
this._onZoomChange = opts.onZoomChange || null;
this._lastClickTime = 0;
this._lastClickVx = 0;
this._lastClickVy = 0;
this._localIdCounter = -1;
this._liveId = null;
this._progressTimer = null;
this._liveStrokes = new Map();
this._fitPending = false;
this._bgNoise = null; // cached noise pattern for chalkboard texture
this._textInput = null;
this._textInputDocHandler = null; // document pointerdown handler for outside-click
this._objectInput = null;
this._onObjectCreated = opts.onObjectCreated || null;
this._onFormulaInsert = opts.onFormulaInsert || null;
this._onCoordEdit = opts.onCoordEdit || null;
this._onNumberLineEdit = opts.onNumberLineEdit || null;
this._onCompassEdit = opts.onCompassEdit || null;
this._editingFormulaStroke = null;
this._laserPos = null;
// dynamic overlay canvas (selection, snap guides, live strokes, laser)
this._dynCanvas = document.createElement('canvas');
this._dynCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;';
const _wrap = canvas.parentElement;
if (_wrap) _wrap.appendChild(this._dynCanvas);
this._dynCtx = this._dynCanvas.getContext('2d');
// minimap canvas — navigation overview (bottom-right corner)
this._mmCanvas = document.createElement('canvas');
this._mmCanvas.width = 192;
this._mmCanvas.height = 108;
this._mmCanvas.style.cssText = [
'position:absolute;bottom:10px;right:10px;z-index:25;',
'width:192px;height:108px;border-radius:6px;',
'border:1px solid rgba(155,93,229,0.35);',
'box-shadow:0 2px 12px rgba(0,0,0,0.55);',
'cursor:crosshair;display:none;',
'transition:opacity 0.25s;'
].join('');
this._mmCtx = this._mmCanvas.getContext('2d');
this._mmDrag = false;
if (_wrap) _wrap.appendChild(this._mmCanvas);
const _mmDown = e => { e.stopPropagation(); this._mmDrag = true; this._mmNavigate(e); };
const _mmMove = e => { if (this._mmDrag) this._mmNavigate(e); };
const _mmUp = () => { this._mmDrag = false; };
this._mmCanvas.addEventListener('mousedown', _mmDown);
this._mmCanvas.addEventListener('mousemove', _mmMove);
this._mmCanvas.addEventListener('mouseup', _mmUp);
document.addEventListener('mouseup', _mmUp);
this._bindEvents();
this.fit();
this._ro = new ResizeObserver(() => this.fit());
this._ro.observe(canvas.parentElement || canvas);
}
/* ── backward-compat: single selectedId getter/setter ──────────────── */
get _selectedId() { for (const id of this._selectedIds) return id; return null; }
set _selectedId(v) { this._selectedIds.clear(); if (v != null) this._selectedIds.add(v); }
/* ── setup ─────────────────────────────────────────────────────────── */
_bindEvents() {
const c = this._canvas;
const onDown = e => { if (!this._readOnly) this._onPointerDown(e); };
const onMove = e => {
if (!this._readOnly) { this._onPointerMove(e); return; }
// readOnly: still broadcast cursor position if callback set
if (this._onCursorMove) {
const [vx, vy] = this._pointerPos(e);
const now = Date.now();
if (now - this._cursorThrottle > 100) {
this._cursorThrottle = now;
this._onCursorMove(vx, vy);
}
}
};
const onUp = e => { if (!this._readOnly) this._onPointerUp(e); };
c.addEventListener('pointerdown', onDown);
c.addEventListener('pointermove', onMove);
c.addEventListener('pointerup', onUp);
c.addEventListener('pointerleave', onUp);
c.addEventListener('pointercancel', onUp);
c.addEventListener('touchstart', e => e.preventDefault(), { passive: false });
c.addEventListener('wheel', e => this._onWheel(e), { passive: false });
this._onKeyDown = e => this._handleKeyDown(e);
this._onKeyUp = e => { if (e.key === ' ') { this._spaceDown = false; if (!this._panStartCss) this._canvas.style.cursor = ''; } };
this._onPaste = e => this._onClipboardPaste(e);
document.addEventListener('keydown', this._onKeyDown);
document.addEventListener('keyup', this._onKeyUp);
document.addEventListener('paste', this._onPaste);
}
_handleKeyDown(e) {
if (this._readOnly) return;
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { this.copy(); }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { e.preventDefault(); this.paste(); }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); this.undo(); }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); this.redo(); }
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this._selectedIds.size > 0) { e.preventDefault(); this.deleteSelected(); }
}
if (e.key === 'Escape') {
this._selectedIds.clear(); this._dragState = null; this._lassoRect = null; this.render();
}
// Zoom shortcuts
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { e.preventDefault(); this.zoomTo(this._zoom * 1.25); }
if ((e.ctrlKey || e.metaKey) && e.key === '-') { e.preventDefault(); this.zoomTo(this._zoom / 1.25); }
if ((e.ctrlKey || e.metaKey) && e.key === '0') { e.preventDefault(); this.resetView(); }
// Space = pan mode
if (e.key === ' ' && !this._spaceDown) { e.preventDefault(); this._spaceDown = true; this._canvas.style.cursor = 'grab'; }
}
_isShapeTool() {
return ['rect','ellipse','line','arrow',
'triangle','diamond','hexagon','star',
'roundedrect','callout'].includes(this._tool);
}
_isObjectStroke(s) {
return s.tool === 'image' || s.tool === 'sticky' ||
s.tool === 'formula' || s.tool === 'table' || s.tool === 'coordinate' ||
s.tool === 'numberline' || s.tool === 'compass';
}
_isResizableStroke(s) {
return this._isObjectStroke(s) || s.tool === 'shape' || s.tool === 'connector';
}
/* Returns {x, y, w, h} bounding box in virtual coords for any stroke type */
_getStrokeBBox(stroke) {
const d = stroke.data;
if (this._isObjectStroke(stroke)) return { x: d.x, y: d.y, w: d.w, h: d.h };
if (stroke.tool === 'text') {
const lines = (d.text || '').split('\n').length;
return { x: d.x, y: d.y, w: 400, h: (d.fontSize || 22) * 1.45 * (lines + 1) };
}
if (stroke.tool === 'shape' || stroke.tool === 'connector') {
return {
x: Math.min(d.x1, d.x2), y: Math.min(d.y1, d.y2),
w: Math.abs(d.x2 - d.x1) || 20, h: Math.abs(d.y2 - d.y1) || 20,
};
}
if (d.points && d.points.length) {
const xs = d.points.map(p => p[0]), ys = d.points.map(p => p[1]);
const bx = Math.min(...xs) - 10, by = Math.min(...ys) - 10;
return { x: bx, y: by, w: Math.max(...xs) - bx + 20, h: Math.max(...ys) - by + 20 };
}
return { x: 0, y: 0, w: Whiteboard.VW, h: Whiteboard.VH };
}
/* Move any stroke by virtual delta */
_moveStroke(stroke, dvx, dvy) {
const d = stroke.data;
if (this._isObjectStroke(stroke) || stroke.tool === 'text') {
d.x += dvx; d.y += dvy;
} else if (stroke.tool === 'shape' || stroke.tool === 'connector') {
d.x1 += dvx; d.y1 += dvy; d.x2 += dvx; d.y2 += dvy;
} else if (d.points) {
d.points = d.points.map(([px, py]) => [px + dvx, py + dvy]);
}
}
/* Resize shape/connector by dragging a corner handle */
_applyResizeShape(data, ds, vx, vy) {
const origMinX = Math.min(ds.origX1, ds.origX2);
const origMaxX = Math.max(ds.origX1, ds.origX2);
const origMinY = Math.min(ds.origY1, ds.origY2);
const origMaxY = Math.max(ds.origY1, ds.origY2);
const h = ds.handle;
let newMinX = origMinX, newMaxX = origMaxX;
let newMinY = origMinY, newMaxY = origMaxY;
if (h === 'tl' || h === 'bl') newMinX = Math.min(vx, origMaxX - 10);
else newMaxX = Math.max(vx, origMinX + 10);
if (h === 'tl' || h === 'tr') newMinY = Math.min(vy, origMaxY - 10);
else newMaxY = Math.max(vy, origMinY + 10);
// Map back to x1,y1,x2,y2 preserving which slot is min/max
if (ds.origX1 <= ds.origX2) { data.x1 = newMinX; data.x2 = newMaxX; }
else { data.x1 = newMaxX; data.x2 = newMinX; }
if (ds.origY1 <= ds.origY2) { data.y1 = newMinY; data.y2 = newMaxY; }
else { data.y1 = newMaxY; data.y2 = newMinY; }
}
/* Snap guides: compute alignment guides for movingStroke vs. all other strokes */
_computeSnapGuides(movingStroke) {
const SNAP = 8; // virtual-pixel threshold
const b = this._getStrokeBBox(movingStroke);
const guides = [];
const seenX = new Set(), seenY = new Set();
for (const s of this._strokes) {
if (this._selectedIds.has(s.id)) continue;
const o = this._getStrokeBBox(s);
const xs = [o.x, o.x + o.w, o.x + o.w / 2];
const ys = [o.y, o.y + o.h, o.y + o.h / 2];
for (const sv of xs) {
if (seenX.has(sv)) continue;
const moving = [b.x, b.x + b.w, b.x + b.w / 2];
if (moving.some(mv => Math.abs(mv - sv) < SNAP)) { guides.push({ axis: 'x', pos: sv }); seenX.add(sv); }
}
for (const sv of ys) {
if (seenY.has(sv)) continue;
const moving = [b.y, b.y + b.h, b.y + b.h / 2];
if (moving.some(mv => Math.abs(mv - sv) < SNAP)) { guides.push({ axis: 'y', pos: sv }); seenY.add(sv); }
}
}
this._snapGuides = guides;
}
/* Hit-test any stroke (all types) — returns topmost hit */
_hitTestAny(vx, vy) {
for (let i = this._strokes.length - 1; i >= 0; i--) {
const s = this._strokes[i];
const b = this._getStrokeBBox(s);
if (vx >= b.x && vx <= b.x + b.w && vy >= b.y && vy <= b.y + b.h) return s;
}
return null;
}
fit() {
const c = this._canvas;
const wrap = c.parentElement;
if (!wrap) return;
const dpr = window.devicePixelRatio || 1;
const w = wrap.clientWidth;
const h = wrap.clientHeight;
if (w === 0 || h === 0) {
if (!this._fitPending) {
this._fitPending = true;
requestAnimationFrame(() => { this._fitPending = false; this.fit(); });
}
return;
}
c.width = Math.round(w * dpr);
c.height = Math.round(h * dpr);
c.style.width = w + 'px';
c.style.height = h + 'px';
this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// size dynamic overlay canvas
const dc = this._dynCanvas;
dc.width = c.width;
dc.height = c.height;
dc.style.width = w + 'px';
dc.style.height = h + 'px';
this._dynCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
this._dpr = dpr;
this._cssW = w;
this._cssH = h;
this._staticDirty = true;
this.render();
}
/* ── coordinate helpers ─────────────────────────────────────────────── */
_toVirtual(cssX, cssY) {
const sx = (this._cssW || 1) / Whiteboard.VW;
const sy = (this._cssH || 1) / Whiteboard.VH;
return [
cssX / (sx * this._zoom) + this._panVX,
cssY / (sy * this._zoom) + this._panVY,
];
}
_toCanvas(vx, vy) {
const sx = (this._cssW || 300) / Whiteboard.VW;
const sy = (this._cssH || 150) / Whiteboard.VH;
return [
(vx - this._panVX) * sx * this._zoom,
(vy - this._panVY) * sy * this._zoom,
];
}
_pointerPos(e) {
const rect = this._canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return this._toVirtual(clientX - rect.left, clientY - rect.top);
}
/* ── pointer handlers ───────────────────────────────────────────────── */
_onPointerDown(e) {
// ── pan mode (Space + drag) ───────────────────────────────────────────
if (this._spaceDown) {
const rect = this._canvas.getBoundingClientRect();
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const cy = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top;
this._panStartCss = [cx, cy];
this._panStartPan = [this._panVX, this._panVY];
this._canvas.style.cursor = 'grabbing';
this._canvas.setPointerCapture(e.pointerId);
return;
}
const [vx, vy] = this._pointerPos(e);
// ── overlay drag (ruler/protractor) — always checked first ────────────
if (this._overlays.length > 0) {
const hit = this._hitTestOverlay(vx, vy);
if (hit) {
const ov = this._overlays[hit.idx];
this._selectedOverlayIdx = hit.idx;
this._canvas.setPointerCapture(e.pointerId);
if (hit.zone === 'body') {
this._overlayDrag = { idx: hit.idx, type: 'move',
startVx: vx, startVy: vy, origX: ov.x, origY: ov.y };
} else if (hit.zone === 'rot') {
this._overlayDrag = { idx: hit.idx, type: 'rotate',
startAngle: Math.atan2(vy - ov.y, vx - ov.x),
origAngle: ov.angle || 0 };
} else if (hit.zone === 'resize') {
const angle = ov.angle || 0;
const dx = vx - ov.x, dy = vy - ov.y;
if (ov.type === 'ruler') {
this._overlayDrag = { idx: hit.idx, type: 'resize',
startDot: dx * Math.cos(angle) + dy * Math.sin(angle),
origWidth: ov.width };
} else {
this._overlayDrag = { idx: hit.idx, type: 'resize',
startDist: Math.hypot(dx, dy),
origRadius: ov.radius || 80 };
}
}
if (this._onOverlayChange) this._onOverlayChange(ov);
return;
}
}
// ── select tool ──────────────────────────────────────────────────────
if (this._tool === 'select') {
const now = Date.now();
const dbl = now - this._lastClickTime < 350 &&
Math.abs(vx - this._lastClickVx) < 40 &&
Math.abs(vy - this._lastClickVy) < 40;
this._lastClickTime = now;
this._lastClickVx = vx;
this._lastClickVy = vy;
// Check handles on the primary selected stroke
const primarySel = this._selectedIds.size === 1
? this._strokes.find(s => s.id === this._selectedId) : null;
if (primarySel) {
// Rotation handle (object strokes only)
if (this._isObjectStroke(primarySel)) {
const rotH = this._hitTestObjectHandle(vx, vy, primarySel);
if (rotH === 'rot') {
const b = this._getStrokeBBox(primarySel);
const ocx = b.x + b.w / 2, ocy = b.y + b.h / 2;
this._dragState = { type: 'rotate', cx: ocx, cy: ocy,
origRotation: primarySel.data.rotation || 0,
startAngle: Math.atan2(vy - ocy, vx - ocx) };
this._canvas.setPointerCapture(e.pointerId);
return;
}
}
if (this._isResizableStroke(primarySel)) {
const handle = this._hitTestObjectHandle(vx, vy, primarySel);
if (handle && handle !== 'rot') {
const d = primarySel.data;
if (this._isObjectStroke(primarySel)) {
this._dragState = { type: 'resize', handle,
startVx: vx, startVy: vy,
origX: d.x, origY: d.y, origW: d.w, origH: d.h };
} else {
this._dragState = { type: 'resize_shape', handle,
origX1: d.x1, origY1: d.y1, origX2: d.x2, origY2: d.y2 };
}
this._canvas.setPointerCapture(e.pointerId);
return;
}
}
}
// Check if click is inside any selected stroke (for move)
let clickedSelected = null;
for (const id of this._selectedIds) {
const s = this._strokes.find(x => x.id === id);
if (!s) continue;
const bbox = this._getStrokeBBox(s);
if (vx >= bbox.x && vx <= bbox.x + bbox.w && vy >= bbox.y && vy <= bbox.y + bbox.h) {
clickedSelected = s; break;
}
}
if (clickedSelected) {
if (dbl) { this._editObject(clickedSelected, vx, vy); return; }
this._dragState = { type: 'move', lastVx: vx, lastVy: vy };
this._canvas.setPointerCapture(e.pointerId);
this.render();
return;
}
const hit = this._hitTestAny(vx, vy);
if (hit) {
if (dbl) { this._editObject(hit, vx, vy); return; }
if (e.shiftKey) {
// Shift+click: toggle in selection
if (this._selectedIds.has(hit.id)) this._selectedIds.delete(hit.id);
else this._selectedIds.add(hit.id);
} else {
this._selectedIds.clear();
this._selectedIds.add(hit.id);
this._dragState = { type: 'move', lastVx: vx, lastVy: vy };
this._canvas.setPointerCapture(e.pointerId);
}
} else {
// Click on empty space: start lasso
if (!e.shiftKey) this._selectedIds.clear();
this._lassoRect = { x1: vx, y1: vy, x2: vx, y2: vy };
this._canvas.setPointerCapture(e.pointerId);
}
this._snapGuides = [];
this.render();
return;
}
// ── text tool ────────────────────────────────────────────────────────
if (this._tool === 'text') {
e.preventDefault(); // prevent browser default focus shift during pointerdown
this._placeTextInput([vx, vy]);
return;
}
// ── sticky tool ──────────────────────────────────────────────────────
if (this._tool === 'sticky') { this._createSticky(vx, vy); return; }
// ── formula tool ─────────────────────────────────────────────────────
if (this._tool === 'formula') { this._placeFormula(vx, vy); return; }
// ── laser pointer (ephemeral — not saved) ────────────────────────────
if (this._tool === 'laser') {
this._canvas.setPointerCapture(e.pointerId);
this._drawing = true;
this._laserPos = [vx, vy];
this._liveId = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`;
if (this._onStrokeProgress)
this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } });
this.render();
return;
}
// ── coordinate tool ───────────────────────────────────────────────────
if (this._tool === 'coordinate') {
const W = 600, H = 500;
const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W));
const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H));
const stroke = {
id: this._localIdCounter--, tool: 'coordinate',
data: { x, y, w: W, h: H, xMin: -10, xMax: 10, yMin: -10, yMax: 10,
gridStep: 1, showLabels: true, functions: [] },
};
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._selectedId = stroke.id;
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
if (this._onObjectCreated) this._onObjectCreated(stroke);
return;
}
// ── number line tool ─────────────────────────────────────────────────
if (this._tool === 'numberline') {
const W = 700, H = 120;
const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W));
const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H));
const stroke = {
id: this._localIdCounter--, tool: 'numberline',
data: { x, y, w: W, h: H, min: -10, max: 10, step: 1, points: [], intervals: [] },
};
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._selectedId = stroke.id;
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
if (this._onObjectCreated) this._onObjectCreated(stroke);
return;
}
// ── compass tool ─────────────────────────────────────────────────────
if (this._tool === 'compass') {
const W = 260, H = 260;
const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W));
const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H));
const stroke = {
id: this._localIdCounter--, tool: 'compass',
data: { x, y, w: W, h: H, angle: 0, spread: Math.PI / 4,
arcAngle: Math.PI * 2, arcStart: 0,
color: this._color, radius: null, showRadius: true },
};
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._selectedId = stroke.id;
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
if (this._onObjectCreated) this._onObjectCreated(stroke);
return;
}
// ── drawing tools (pencil, eraser, highlighter, shapes, connector, table) ──
this._canvas.setPointerCapture(e.pointerId);
this._drawing = true;
this._liveId = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`;
if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') {
this._shapeStart = [vx, vy];
this._shapeEnd = [vx, vy];
} else {
this._curPts = [[vx, vy]];
}
}
_onPointerMove(e) {
// ── pan drag ─────────────────────────────────────────────────────────
if (this._panStartCss) {
const rect = this._canvas.getBoundingClientRect();
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const cy = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top;
const sx = (this._cssW || 300) / Whiteboard.VW;
const sy = (this._cssH || 150) / Whiteboard.VH;
this._panVX = this._panStartPan[0] - (cx - this._panStartCss[0]) / (sx * this._zoom);
this._panVY = this._panStartPan[1] - (cy - this._panStartCss[1]) / (sy * this._zoom);
this._clampPan();
this._staticDirty = true;
this.render();
return;
}
const [vx, vy] = this._pointerPos(e);
// Broadcast cursor position to other participants (throttled to 50ms)
if (this._onCursorMove) {
const now = Date.now();
if (now - this._cursorThrottle > 100) {
this._cursorThrottle = now;
this._onCursorMove(vx, vy);
}
}
// ── overlay drag ──────────────────────────────────────────────────────
if (this._overlayDrag) {
const od = this._overlayDrag;
const ov = this._overlays[od.idx];
if (ov) {
if (od.type === 'move') {
ov.x = od.origX + (vx - od.startVx);
ov.y = od.origY + (vy - od.startVy);
} else if (od.type === 'rotate') {
const cur = Math.atan2(vy - ov.y, vx - ov.x);
ov.angle = od.origAngle + (cur - od.startAngle);
} else if (od.type === 'resize') {
const angle = ov.angle || 0;
const dx = vx - ov.x, dy = vy - ov.y;
if (ov.type === 'ruler') {
const dot = dx * Math.cos(angle) + dy * Math.sin(angle);
ov.width = Math.max(100, od.origWidth + (dot - od.startDot));
} else if (ov.type === 'protractor') {
ov.radius = Math.max(40, od.origRadius + (Math.hypot(dx, dy) - od.startDist));
}
}
if (this._onOverlayChange) this._onOverlayChange(ov);
}
this.render();
return;
}
// ── laser: update position ────────────────────────────────────────────
if (this._tool === 'laser' && this._drawing) {
this._laserPos = [vx, vy];
if (this._onStrokeProgress && this._liveId)
this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } });
this.render();
return;
}
// ── select: update cursor + drag ──────────────────────────────────────
if (this._tool === 'select') {
// Lasso update
if (this._lassoRect) {
this._lassoRect.x2 = vx;
this._lassoRect.y2 = vy;
this.render();
return;
}
if (!this._dragState) {
let cur = 'default';
if (this._selectedIds.size === 1) {
const sel = this._strokes.find(s => s.id === this._selectedId);
if (sel) {
if (this._isResizableStroke(sel)) {
const h = this._hitTestObjectHandle(vx, vy, sel);
if (h === 'tl' || h === 'br') cur = 'nwse-resize';
else if (h === 'tr' || h === 'bl') cur = 'nesw-resize';
}
if (cur === 'default') {
const bbox = this._getStrokeBBox(sel);
if (vx >= bbox.x && vx <= bbox.x + bbox.w && vy >= bbox.y && vy <= bbox.y + bbox.h)
cur = 'move';
}
}
}
if (cur === 'default') {
for (const id of this._selectedIds) {
const s = this._strokes.find(x => x.id === id);
if (!s) continue;
const b = this._getStrokeBBox(s);
if (vx >= b.x && vx <= b.x + b.w && vy >= b.y && vy <= b.y + b.h) { cur = 'move'; break; }
}
}
if (cur === 'default' && this._hitTestAny(vx, vy)) cur = 'move';
this._canvas.style.cursor = cur;
}
if (!this._dragState) return;
const ds = this._dragState;
if (ds.type === 'move') {
const dvx = vx - ds.lastVx, dvy = vy - ds.lastVy;
ds.lastVx = vx; ds.lastVy = vy;
// Move all selected strokes
for (const id of this._selectedIds) {
const s = this._strokes.find(x => x.id === id);
if (s) this._moveStroke(s, dvx, dvy);
}
// Compute snap guides for primary selected stroke
if (this._selectedIds.size === 1) {
const sel = this._strokes.find(s => s.id === this._selectedId);
if (sel) this._computeSnapGuides(sel);
} else { this._snapGuides = []; }
this._staticDirty = true;
} else if (ds.type === 'resize') {
const sel = this._strokes.find(s => s.id === this._selectedId);
if (!sel) return;
this._applyResize(sel.data, ds, vx - ds.startVx, vy - ds.startVy);
this._staticDirty = true;
} else if (ds.type === 'resize_shape') {
const sel = this._strokes.find(s => s.id === this._selectedId);
if (!sel) return;
this._applyResizeShape(sel.data, ds, vx, vy);
this._staticDirty = true;
} else if (ds.type === 'rotate') {
const sel = this._strokes.find(s => s.id === this._selectedId);
if (!sel) return;
const angle = Math.atan2(vy - ds.cy, vx - ds.cx);
let rotation = ds.origRotation + (angle - ds.startAngle);
if (e.shiftKey) rotation = Math.round(rotation / (Math.PI / 12)) * (Math.PI / 12);
sel.data.rotation = rotation;
this._staticDirty = true;
}
this.render();
return;
}
if (!this._drawing) return;
if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') {
this._shapeEnd = [vx, vy];
this.render();
} else {
this._curPts.push([vx, vy]);
this.render();
}
if (this._onStrokeProgress && !this._progressTimer) {
this._progressTimer = setTimeout(() => {
this._progressTimer = null;
this._flushProgress();
}, 50);
}
}
_onWheel(e) {
e.preventDefault();
const rect = this._canvas.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const [vx, vy] = this._toVirtual(cx, cy);
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
this.zoomTo(this._zoom * factor, vx, vy);
}
zoomTo(newZoom, pivotVX, pivotVY) {
const sx = (this._cssW || 300) / Whiteboard.VW;
const sy = (this._cssH || 150) / Whiteboard.VH;
// Pivot in CSS before zoom change
const pcx = pivotVX != null ? (pivotVX - this._panVX) * sx * this._zoom : (this._cssW || 300) / 2;
const pcy = pivotVY != null ? (pivotVY - this._panVY) * sy * this._zoom : (this._cssH || 150) / 2;
this._zoom = Math.max(0.25, Math.min(8, newZoom));
if (pivotVX != null) {
this._panVX = pivotVX - pcx / (sx * this._zoom);
this._panVY = pivotVY - pcy / (sy * this._zoom);
}
this._clampPan();
this._staticDirty = true;
this.render();
if (this._onZoomChange) this._onZoomChange(this._zoom);
}
resetView() {
this._zoom = 1;
this._panVX = 0;
this._panVY = 0;
this._staticDirty = true;
this.render();
if (this._onZoomChange) this._onZoomChange(1);
}
_clampPan() {
const VW = Whiteboard.VW, VH = Whiteboard.VH;
const visW = VW / this._zoom;
const visH = VH / this._zoom;
if (visW <= VW) {
this._panVX = Math.max(0, Math.min(this._panVX, VW - visW));
} else {
this._panVX = (VW - visW) / 2;
}
if (visH <= VH) {
this._panVY = Math.max(0, Math.min(this._panVY, VH - visH));
} else {
this._panVY = (VH - visH) / 2;
}
}
_applyResize(data, ds, dvx, dvy) {
const h = ds.handle;
if (h === 'br') {
data.w = Math.max(20, ds.origW + dvx);
data.h = Math.max(20, ds.origH + dvy);
} else if (h === 'tr') {
data.w = Math.max(20, ds.origW + dvx);
const nh = Math.max(20, ds.origH - dvy);
data.y = ds.origY + (ds.origH - nh);
data.h = nh;
} else if (h === 'bl') {
const nw = Math.max(20, ds.origW - dvx);
data.x = ds.origX + (ds.origW - nw);
data.w = nw;
data.h = Math.max(20, ds.origH + dvy);
} else if (h === 'tl') {
const nw = Math.max(20, ds.origW - dvx);
const nh = Math.max(20, ds.origH - dvy);
data.x = ds.origX + (ds.origW - nw);
data.y = ds.origY + (ds.origH - nh);
data.w = nw;
data.h = nh;
}
}
_flushProgress() {
if (!this._drawing || !this._onStrokeProgress || !this._liveId) return;
let data;
if (this._isShapeTool()) {
if (!this._shapeStart || !this._shapeEnd) return;
data = { shape: this._tool, x1: this._shapeStart[0], y1: this._shapeStart[1],
x2: this._shapeEnd[0], y2: this._shapeEnd[1],
color: this._color, width: this._width, fill: this._fill };
} else if (this._tool === 'connector') {
if (!this._shapeStart || !this._shapeEnd) return;
data = { x1: this._shapeStart[0], y1: this._shapeStart[1],
x2: this._shapeEnd[0], y2: this._shapeEnd[1],
color: this._color, width: this._width, arrowEnd: true, arrowStart: false };
} else {
if (this._curPts.length === 0) return;
data = { points: [...this._curPts],
color: this._tool === 'eraser' ? null : this._color, width: this._width };
}
this._onStrokeProgress({
liveId: this._liveId,
tool: this._isShapeTool() ? 'shape' : this._tool,
data,
});
}
_onPointerUp(_e) {
// ── pan end ───────────────────────────────────────────────────────────
if (this._panStartCss) {
this._panStartCss = null;
this._panStartPan = null;
this._canvas.style.cursor = this._spaceDown ? 'grab' : '';
return;
}
// ── overlay drag end ──────────────────────────────────────────────────
if (this._overlayDrag) {
const ov = this._overlays[this._overlayDrag.idx];
this._overlayDrag = null;
if (ov && this._onOverlayChange) this._onOverlayChange(ov);
return;
}
// ── laser pointer: cancel preview ─────────────────────────────────────
if (this._tool === 'laser') {
this._drawing = false;
this._laserPos = null;
const liveId = this._liveId;
this._liveId = null;
if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true });
this.render();
return;
}
// ── select tool ──────────────────────────────────────────────────────
if (this._tool === 'select') {
// Finish lasso selection
if (this._lassoRect) {
const lr = this._lassoRect;
const lx1 = Math.min(lr.x1, lr.x2), lx2 = Math.max(lr.x1, lr.x2);
const ly1 = Math.min(lr.y1, lr.y2), ly2 = Math.max(lr.y1, lr.y2);
if (lx2 - lx1 > 4 || ly2 - ly1 > 4) {
for (const s of this._strokes) {
const b = this._getStrokeBBox(s);
if (b.x < lx2 && b.x + b.w > lx1 && b.y < ly2 && b.y + b.h > ly1)
this._selectedIds.add(s.id);
}
}
this._lassoRect = null;
this.render();
return;
}
if (this._dragState) {
this._snapGuides = [];
// Notify server of updated positions for all moved strokes
for (const id of this._selectedIds) {
const sel = this._strokes.find(s => s.id === id);
if (sel && this._onStrokeUpdated) this._onStrokeUpdated(sel);
}
this._dragState = null;
this.render();
}
return;
}
if (!this._drawing) return;
this._drawing = false;
if (this._progressTimer) { clearTimeout(this._progressTimer); this._progressTimer = null; }
const liveId = this._liveId;
this._liveId = null;
// ── table tool ────────────────────────────────────────────────────────
if (this._tool === 'table') {
const [x1, y1] = this._shapeStart;
const [x2, y2] = this._shapeEnd || this._shapeStart;
let vx = Math.min(x1, x2), vy = Math.min(y1, y2);
let vw = Math.abs(x2 - x1), vh = Math.abs(y2 - y1);
if (vw < 40) vw = 360;
if (vh < 40) vh = 240;
vx = Math.min(vx, Whiteboard.VW - vw);
vy = Math.min(vy, Whiteboard.VH - vh);
const stroke = {
id: this._localIdCounter--, tool: 'table',
data: {
x: Math.max(0, vx), y: Math.max(0, vy), w: vw, h: vh,
rows: 3, cols: 4,
cells: [['','','',''], ['','','',''], ['','','','']],
borderColor: '#9B5DE5',
bgColor: 'rgba(26,22,37,0.85)',
textColor: '#e8e0f7',
fontSize: 14,
},
};
this._shapeStart = null; this._shapeEnd = null;
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._selectedId = stroke.id;
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
return;
}
// ── connector tool ────────────────────────────────────────────────────
if (this._tool === 'connector') {
const [x1, y1] = this._shapeStart;
const [x2, y2] = this._shapeEnd || this._shapeStart;
if (Math.abs(x2 - x1) < 5 && Math.abs(y2 - y1) < 5) {
if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true });
return;
}
const stroke = {
id: this._localIdCounter--, tool: 'connector',
data: { x1, y1, x2, y2, color: this._color, width: this._width,
arrowEnd: true, arrowStart: false,
lineStyle: this._lineStyle, opacity: this._opacity },
};
this._shapeStart = null; this._shapeEnd = null;
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
return;
}
// ── shape tools ────────────────────────────────────────────────────────
if (this._isShapeTool()) {
const [x1, y1] = this._shapeStart;
const [x2, y2] = this._shapeEnd || this._shapeStart;
if (Math.abs(x2 - x1) < 2 && Math.abs(y2 - y1) < 2) {
if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true });
return;
}
const stroke = {
id: this._localIdCounter--, tool: 'shape',
data: { shape: this._tool, x1, y1, x2, y2,
color: this._color, width: this._width, fill: this._fill,
lineStyle: this._lineStyle, opacity: this._opacity },
};
this._shapeStart = null; this._shapeEnd = null;
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
return;
}
// ── pencil / eraser ────────────────────────────────────────────────────
if (this._curPts.length === 0) {
if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true });
return;
}
const stroke = {
id: this._localIdCounter--, tool: this._tool,
data: { points: this._curPts,
color: this._tool === 'eraser' ? null : this._color, width: this._width,
lineStyle: this._lineStyle, opacity: this._opacity },
};
this._curPts = [];
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
}
/* ── text tool ──────────────────────────────────────────────────────── */
_placeTextInput([vx, vy]) {
const wrap = this._canvas.parentElement;
if (!wrap) return;
this._removeTextInput();
const cw = this._cssW || 300;
const ch = this._cssH || 150;
const [cx, cy] = this._toCanvas(vx, vy);
// Position textarea accounting for canvas offset within the parent wrapper
const canvasRect = this._canvas.getBoundingClientRect();
const wrapRect = wrap.getBoundingClientRect();
const offX = canvasRect.left - wrapRect.left;
const offY = canvasRect.top - wrapRect.top;
const fs = Math.max(14, Math.round((22 / Whiteboard.VH) * ch));
const W = Math.max(120, Math.min(300, cw - 16));
const left = Math.min(cx + offX, offX + cw - W - 4);
const top = Math.max(offY, Math.min(cy + offY, offY + ch - 110));
const ta = document.createElement('textarea');
ta.placeholder = 'Введите текст… (Enter — вставить, Esc — отмена)';
ta.rows = 3;
ta.style.cssText = `
position:absolute; left:${left}px; top:${top}px;
width:${W}px; min-height:68px; box-sizing:border-box;
font-size:${fs}px; font-family:'Manrope',sans-serif;
color:${this._color};
background:rgba(12,8,24,0.92);
border:1.5px solid rgba(155,93,229,0.7);
border-radius:8px; outline:none; resize:none;
padding:6px 10px; caret-color:${this._color};
z-index:20; line-height:1.45;
box-shadow:0 4px 24px rgba(0,0,0,0.6);
`;
wrap.appendChild(ta);
this._textInput = ta;
const commit = () => {
const text = ta.value.trim();
this._removeTextInput();
if (!text) return;
const stroke = {
id: this._localIdCounter--, tool: 'text',
data: { text, x: vx, y: vy, fontSize: 22, color: this._color },
};
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
if (this._onObjectCreated) this._onObjectCreated(stroke);
};
ta.addEventListener('keydown', ev => {
ev.stopPropagation(); // don't leak to canvas key handler
if (ev.key === 'Escape') { ev.preventDefault(); this._removeTextInput(); }
// Enter without Shift commits; Shift+Enter inserts newline
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commit(); }
});
// Outside-click detection: use capture-phase pointerdown on document.
// Registered via setTimeout to skip the current pointerdown that spawned this textarea.
const onDocDown = e => {
if (ta.contains(e.target) || e.target === ta) return;
this._textInputDocHandler = null;
document.removeEventListener('pointerdown', onDocDown, true);
commit();
};
this._textInputDocHandler = onDocDown;
setTimeout(() => {
if (ta.isConnected) document.addEventListener('pointerdown', onDocDown, true);
}, 0);
// Focus after the current event cycle so pointer events on the canvas
// can't steal focus back immediately.
requestAnimationFrame(() => { if (ta.isConnected) ta.focus(); });
}
_removeTextInput() {
if (this._textInputDocHandler) {
document.removeEventListener('pointerdown', this._textInputDocHandler, true);
this._textInputDocHandler = null;
}
if (this._textInput) { this._textInput.remove(); this._textInput = null; }
}
/* ── sticky tool ────────────────────────────────────────────────────── */
_createSticky(vx, vy) {
const W = 220, H = 200;
const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W));
const y = Math.max(0, Math.min(vy - 20, Whiteboard.VH - H));
const bgColors = ['#FFE066', '#FF9F7F', '#B5EAD7', '#C7CEEA', '#FFDAC1', '#E2B7F5'];
const bgColor = bgColors[Math.floor(Math.random() * bgColors.length)];
const stroke = {
id: this._localIdCounter--, tool: 'sticky',
data: { x, y, w: W, h: H, text: '', bgColor, textColor: '#1a1a2e', fontSize: 16 },
};
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._selectedId = stroke.id;
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
if (this._onObjectCreated) this._onObjectCreated(stroke);
this._editSticky(stroke);
}
_editSticky(stroke) {
const wrap = this._canvas.parentElement;
if (!wrap) return;
this._removeObjectInput();
const d = stroke.data;
const [cx, cy] = this._toCanvas(d.x, d.y);
const cw = (d.w / Whiteboard.VW) * this._cssW;
const ch = (d.h / Whiteboard.VH) * this._cssH;
const pad = 10;
const fs = Math.round((d.fontSize / Whiteboard.VH) * this._cssH);
const ta = document.createElement('textarea');
ta.value = d.text || '';
ta.style.cssText = `
position:absolute; left:${cx + pad}px; top:${cy + pad}px;
width:${cw - 2 * pad}px; height:${ch - 2 * pad}px;
font-size:${fs}px; font-family:'Manrope',sans-serif;
color:${d.textColor}; background:transparent;
border:none; outline:none; resize:none;
padding:0; line-height:1.4; caret-color:${d.textColor}; z-index:10;
`;
wrap.style.position = 'relative';
wrap.appendChild(ta);
ta.focus();
this._objectInput = { el: ta, strokeId: stroke.id };
const commit = () => {
const text = ta.value;
this._removeObjectInput();
const s = this._strokes.find(x => x.id === stroke.id);
if (!s) return;
s.data.text = text;
this._staticDirty = true;
this.render();
if (this._onStrokeUpdated) this._onStrokeUpdated(s);
};
ta.addEventListener('keydown', ev => { if (ev.key === 'Escape') commit(); });
ta.addEventListener('blur', () => setTimeout(commit, 80));
}
/* ── formula tool ───────────────────────────────────────────────────── */
_placeFormula(vx, vy) {
// If an external formula editor is wired up, delegate to it
if (this._onFormulaInsert) {
this._onFormulaInsert(vx, vy);
return;
}
// Fallback: inline input (minimal, shown when no modal is wired)
const wrap = this._canvas.parentElement;
if (!wrap) return;
this._removeObjectInput();
const [cx, cy] = this._toCanvas(vx, vy);
const inp = document.createElement('input');
inp.type = 'text';
inp.placeholder = 'LaTeX, напр: \\frac{a}{b}';
inp.style.cssText = `
position:absolute; left:${Math.min(cx, (this._cssW||300)-280)}px; top:${cy}px;
font-size:14px; font-family:'Manrope',sans-serif;
color:#e8e0f7; background:rgba(12,8,24,0.95);
border:1.5px solid #9B5DE5; border-radius:6px;
outline:none; width:270px; padding:7px 10px;
caret-color:#9B5DE5; z-index:20;
box-shadow:0 4px 20px rgba(0,0,0,0.55);
`;
wrap.appendChild(inp);
inp.focus();
this._objectInput = { el: inp, strokeId: null };
const commit = () => {
const latex = inp.value.trim();
this._removeObjectInput();
if (!latex) return;
this.insertFormula(vx, vy, latex);
};
inp.addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
if (ev.key === 'Escape') { ev.stopPropagation(); this._removeObjectInput(); }
});
inp.addEventListener('blur', () => setTimeout(commit, 100));
}
_editFormula(stroke) {
// If external editor is wired, use it for edit too
if (this._onFormulaInsert) {
const d = stroke.data;
// We pass the stroke ID so the modal can update instead of insert
this._editingFormulaStroke = stroke;
this._onFormulaInsert(d.x + d.w / 2, d.y + d.h / 2, d.latex);
return;
}
// Fallback: inline input
const wrap = this._canvas.parentElement;
if (!wrap) return;
this._removeObjectInput();
const d = stroke.data;
const [cx, cy] = this._toCanvas(d.x, d.y);
const inp = document.createElement('input');
inp.type = 'text';
inp.value = d.latex || '';
inp.placeholder = 'LaTeX формула';
inp.style.cssText = `
position:absolute; left:${Math.min(cx, (this._cssW||300)-280)}px; top:${cy}px;
font-size:14px; font-family:'Manrope',sans-serif;
color:#e8e0f7; background:rgba(12,8,24,0.95);
border:1.5px solid #9B5DE5; border-radius:6px;
outline:none; width:270px; padding:7px 10px;
caret-color:#9B5DE5; z-index:20;
box-shadow:0 4px 20px rgba(0,0,0,0.55);
`;
wrap.appendChild(inp);
inp.focus();
inp.select();
this._objectInput = { el: inp, strokeId: stroke.id };
const commit = () => {
const latex = inp.value.trim();
this._removeObjectInput();
const s = this._strokes.find(x => x.id === stroke.id);
if (!s || !latex) return;
s.data.latex = latex;
s._formulaImg = null;
this._staticDirty = true;
this.render();
if (this._onStrokeUpdated) this._onStrokeUpdated(s);
};
inp.addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
if (ev.key === 'Escape') { ev.stopPropagation(); this._removeObjectInput(); }
});
inp.addEventListener('blur', () => setTimeout(commit, 100));
}
/* ── table tool ─────────────────────────────────────────────────────── */
_getTableCell(stroke, vx, vy) {
const d = stroke.data;
if (vx < d.x || vx > d.x + d.w || vy < d.y || vy > d.y + d.h) return null;
return {
row: Math.min(Math.floor((vy - d.y) / (d.h / d.rows)), d.rows - 1),
col: Math.min(Math.floor((vx - d.x) / (d.w / d.cols)), d.cols - 1),
};
}
_editTableCell(stroke, row, col) {
const wrap = this._canvas.parentElement;
if (!wrap) return;
this._removeObjectInput();
const d = stroke.data;
const cellVW = d.w / d.cols;
const cellVH = d.h / d.rows;
const [cx, cy] = this._toCanvas(d.x + col * cellVW, d.y + row * cellVH);
const cw = (cellVW / Whiteboard.VW) * this._cssW;
const ch = (cellVH / Whiteboard.VH) * this._cssH;
const pad = 4;
const fs = Math.round(((d.fontSize || 14) / Whiteboard.VH) * this._cssH);
const inp = document.createElement('input');
inp.type = 'text';
inp.value = (d.cells[row] && d.cells[row][col]) || '';
inp.style.cssText = `
position:absolute; left:${cx + pad}px; top:${cy + pad}px;
width:${cw - 2 * pad}px; height:${ch - 2 * pad}px;
font-size:${fs}px; font-family:'Manrope',sans-serif;
color:${d.textColor || '#e8e0f7'};
background:rgba(155,93,229,0.12);
border:1.5px solid #9B5DE5; border-radius:3px;
outline:none; padding:0 4px; z-index:10; box-sizing:border-box;
`;
wrap.style.position = 'relative';
wrap.appendChild(inp);
inp.focus();
inp.select();
this._objectInput = { el: inp, strokeId: stroke.id };
const commit = () => {
const val = inp.value;
this._removeObjectInput();
const s = this._strokes.find(x => x.id === stroke.id);
if (!s) return;
if (!s.data.cells[row]) s.data.cells[row] = [];
s.data.cells[row][col] = val;
this._staticDirty = true;
this.render();
if (this._onStrokeUpdated) this._onStrokeUpdated(s);
};
inp.addEventListener('keydown', ev => {
if (ev.key === 'Enter' || ev.key === 'Tab') { ev.preventDefault(); commit(); }
if (ev.key === 'Escape') { this._removeObjectInput(); }
});
inp.addEventListener('blur', () => setTimeout(commit, 80));
}
_removeObjectInput() {
if (this._objectInput) { this._objectInput.el.remove(); this._objectInput = null; }
}
/* ── edit dispatch ──────────────────────────────────────────────────── */
_editObject(stroke, vx, vy) {
if (stroke.tool === 'coordinate') { if (this._onCoordEdit) this._onCoordEdit(stroke); return; }
if (stroke.tool === 'numberline') { if (this._onNumberLineEdit) this._onNumberLineEdit(stroke); return; }
if (stroke.tool === 'compass') { if (this._onCompassEdit) this._onCompassEdit(stroke); return; }
if (stroke.tool === 'sticky') { this._editSticky(stroke); return; }
if (stroke.tool === 'formula') { this._editFormula(stroke); return; }
if (stroke.tool === 'table') {
const cell = this._getTableCell(stroke, vx, vy);
if (cell) this._editTableCell(stroke, cell.row, cell.col);
return;
}
if (stroke.tool === 'text') {
const d = stroke.data;
this._strokes = this._strokes.filter(s => s.id !== stroke.id);
const i = this._undoStack.indexOf(stroke.id);
if (i !== -1) this._undoStack.splice(i, 1);
this._selectedId = null;
this._staticDirty = true;
this.render();
this._placeTextInput([d.x, d.y]);
if (this._textInput) this._textInput.value = d.text || '';
}
if (stroke.tool === 'image') {
// images are not editable inline
}
}
/* ── live stroke API (remote preview) ──────────────────────────────── */
setLiveStroke(liveId, tool, data, userName, color) {
this._liveStrokes.set(liveId, { tool, data, userName, color });
this.render();
}
removeLiveStroke(liveId) {
if (this._liveStrokes.delete(liveId)) this.render();
}
clearAllLiveStrokes() {
if (this._liveStrokes.size === 0) return;
this._liveStrokes.clear();
this.render();
}
/* ── render ─────────────────────────────────────────────────────────── */
/* ── chalkboard background ──────────────────────────────────────────── */
_getBgNoise() {
if (this._bgNoise) return this._bgNoise;
try {
const SIZE = 256;
const oc = document.createElement('canvas');
oc.width = oc.height = SIZE;
const oc2 = oc.getContext('2d');
// Base chalkboard green
oc2.fillStyle = '#213d26';
oc2.fillRect(0, 0, SIZE, SIZE);
// Per-pixel noise for chalk dust texture
const img = oc2.getImageData(0, 0, SIZE, SIZE);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const n = (Math.random() - 0.5) * 22;
d[i] = Math.max(0, Math.min(255, d[i] + n));
d[i + 1] = Math.max(0, Math.min(255, d[i + 1] + n + 4)); // slightly more green variation
d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + n));
}
oc2.putImageData(img, 0, 0);
// Faint horizontal chalk smear lines
oc2.globalAlpha = 0.05;
oc2.strokeStyle = '#ffffff';
oc2.lineWidth = 1;
for (let i = 0; i < 6; i++) {
const y = Math.random() * SIZE;
oc2.beginPath();
oc2.moveTo(0, y);
oc2.lineTo(SIZE, y + (Math.random() - 0.5) * 6);
oc2.stroke();
}
this._bgNoise = this._ctx.createPattern(oc, 'repeat');
} catch { this._bgNoise = false; }
return this._bgNoise;
}
_renderBg(ctx) {
const W = this._cssW || 300;
const H = this._cssH || 150;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#213d26';
ctx.fillRect(0, 0, W, H);
const noise = this._getBgNoise();
if (noise) {
ctx.save();
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = 0.12;
ctx.fillStyle = noise;
ctx.fillRect(0, 0, W, H);
ctx.restore();
}
const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.25, W / 2, H / 2, Math.max(W, H) * 0.78);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.28)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
_renderTemplate(ctx) {
const W = this._cssW || 300;
const H = this._cssH || 150;
ctx.save();
if (this._template === 'grid') {
const stepX = (40 / Whiteboard.VW) * W;
const stepY = (40 / Whiteboard.VH) * H;
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 0.8;
ctx.beginPath();
for (let x = stepX; x < W; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for (let y = stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
} else if (this._template === 'lined') {
const stepY = (36 / Whiteboard.VH) * H;
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 0.8;
ctx.beginPath();
for (let y = stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
} else if (this._template === 'dots') {
const stepX = (40 / Whiteboard.VW) * W;
const stepY = (40 / Whiteboard.VH) * H;
const r = Math.max(1, (1.5 / Whiteboard.VW) * W);
ctx.fillStyle = 'rgba(255,255,255,0.18)';
for (let x = stepX; x < W; x += stepX)
for (let y = stepY; y < H; y += stepY) {
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
} else if (this._template === 'coordinate') {
const ox = W / 2, oy = H / 2;
const stepX = (40 / Whiteboard.VW) * W;
const stepY = (40 / Whiteboard.VH) * H;
// Light grid
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 0.6;
ctx.beginPath();
for (let x = ox % stepX; x < W; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for (let y = oy % stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
// Axes
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(0, oy); ctx.lineTo(W, oy);
ctx.moveTo(ox, 0); ctx.lineTo(ox, H);
ctx.stroke();
// Arrows
const ar = 7; ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.beginPath(); ctx.moveTo(W, oy); ctx.lineTo(W-ar, oy-ar/2); ctx.lineTo(W-ar, oy+ar/2); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox-ar/2, ar); ctx.lineTo(ox+ar/2, ar); ctx.closePath(); ctx.fill();
// Tick marks
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 0.8;
const tk = 4; ctx.beginPath();
for (let x = ox + stepX; x < W - ar; x += stepX) { ctx.moveTo(x, oy-tk); ctx.lineTo(x, oy+tk); }
for (let x = ox - stepX; x > 0; x -= stepX) { ctx.moveTo(x, oy-tk); ctx.lineTo(x, oy+tk); }
for (let y = oy + stepY; y < H - ar; y += stepY) { ctx.moveTo(ox-tk, y); ctx.lineTo(ox+tk, y); }
for (let y = oy - stepY; y > 0; y -= stepY) { ctx.moveTo(ox-tk, y); ctx.lineTo(ox+tk, y); }
ctx.stroke();
}
ctx.restore();
}
render() {
if (this._staticDirty) { this._renderStatic(); this._staticDirty = false; }
this._renderDynamic();
this._renderMinimap();
}
_renderStatic() {
const ctx = this._ctx;
this._renderBg(ctx);
if (this._template && this._template !== 'blank') this._renderTemplate(ctx);
for (const s of this._strokes) this._renderStroke(ctx, s);
}
_renderDynamic() {
const ctx = this._dynCtx;
const W = this._cssW || 300, H = this._cssH || 150;
ctx.clearRect(0, 0, W, H);
// Remote live strokes (other users drawing in real-time)
// Name/cursor dot is handled by DOM cursors (_showRemoteCursor) — no duplication here
for (const [, ls] of this._liveStrokes) {
this._renderStroke(ctx, ls);
}
// Laser pointer (local teacher view)
if (this._tool === 'laser' && this._drawing && this._laserPos) {
const [lvx, lvy] = this._laserPos;
const [lcx, lcy] = this._toCanvas(lvx, lvy);
ctx.save();
ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15;
ctx.fillStyle = '#ff4444';
ctx.beginPath(); ctx.arc(lcx, lcy, 6, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
// Selection overlays (all selected strokes)
for (const id of this._selectedIds) {
const sel = this._strokes.find(s => s.id === id);
if (sel) {
this._renderObjectSelection(ctx, sel);
// Auto-measurements for shapes
if (this._showMeasurements && sel.tool === 'shape') this.renderMeasurements(ctx, sel);
}
}
// Lasso rubber-band rectangle
if (this._lassoRect) {
const lr = this._lassoRect;
const [cx1, cy1] = this._toCanvas(Math.min(lr.x1, lr.x2), Math.min(lr.y1, lr.y2));
const [cx2, cy2] = this._toCanvas(Math.max(lr.x1, lr.x2), Math.max(lr.y1, lr.y2));
ctx.save();
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
ctx.strokeRect(cx1, cy1, cx2 - cx1, cy2 - cy1);
ctx.fillStyle = 'rgba(155,93,229,0.06)';
ctx.fillRect(cx1, cy1, cx2 - cx1, cy2 - cy1);
ctx.setLineDash([]); ctx.restore();
}
// Snap guides (cyan lines)
if (this._snapGuides.length > 0) {
ctx.save();
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1; ctx.globalAlpha = 0.7;
ctx.setLineDash([4, 4]);
for (const g of this._snapGuides) {
const [gx, gy] = this._toCanvas(g.axis === 'x' ? g.pos : 0, g.axis === 'y' ? g.pos : 0);
ctx.beginPath();
if (g.axis === 'x') { ctx.moveTo(gx, 0); ctx.lineTo(gx, H); }
else { ctx.moveTo(0, gy); ctx.lineTo(W, gy); }
ctx.stroke();
}
ctx.setLineDash([]); ctx.restore();
}
// Live drawing preview (current user)
if (this._drawing) {
if ((this._isShapeTool() || this._tool === 'connector') && this._shapeStart && this._shapeEnd) {
if (this._tool === 'connector') {
this._renderStroke(ctx, { tool: 'connector', data: {
x1: this._shapeStart[0], y1: this._shapeStart[1],
x2: this._shapeEnd[0], y2: this._shapeEnd[1],
color: this._color, width: this._width, arrowEnd: true, arrowStart: false,
}});
} else {
this._renderStroke(ctx, { tool: 'shape', data: {
shape: this._tool, x1: this._shapeStart[0], y1: this._shapeStart[1],
x2: this._shapeEnd[0], y2: this._shapeEnd[1],
color: this._color, width: this._width, fill: this._fill,
}});
}
} else if (this._tool === 'table' && this._shapeStart && this._shapeEnd) {
const [x1, y1] = this._shapeStart, [x2, y2] = this._shapeEnd;
const [cx1, cy1] = this._toCanvas(Math.min(x1, x2), Math.min(y1, y2));
const [cx2, cy2] = this._toCanvas(Math.max(x1, x2), Math.max(y1, y2));
ctx.save();
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
ctx.strokeRect(cx1, cy1, cx2 - cx1, cy2 - cy1);
ctx.setLineDash([]); ctx.restore();
} else if (!this._isShapeTool() && this._tool !== 'connector' && this._tool !== 'table' && this._curPts.length > 0) {
this._renderStroke(ctx, { tool: this._tool,
data: { points: this._curPts, color: this._color, width: this._width } });
}
}
// Ruler / Protractor overlays
if (this._overlays.length > 0) this._renderOverlays(ctx);
}
_renderStroke(ctx, stroke) {
if (stroke.tool === 'shape') { this._renderShape(ctx, stroke); return; }
if (stroke.tool === 'text') { this._renderText(ctx, stroke); return; }
if (stroke.tool === 'image') { this._renderImage(ctx, stroke); return; }
if (stroke.tool === 'sticky') { this._renderSticky(ctx, stroke); return; }
if (stroke.tool === 'formula') { this._renderFormula(ctx, stroke); return; }
if (stroke.tool === 'table') { this._renderTable(ctx, stroke); return; }
if (stroke.tool === 'connector') { this._renderConnector(ctx, stroke); return; }
if (stroke.tool === 'highlighter') { this._renderHighlighter(ctx, stroke); return; }
if (stroke.tool === 'laser') { this._renderLaser(ctx, stroke); return; }
if (stroke.tool === 'coordinate') { this._renderCoordinate(ctx, stroke); return; }
if (stroke.tool === 'numberline') { this._renderNumberLine(ctx, stroke); return; }
if (stroke.tool === 'compass') { this._renderCompass(ctx, stroke); return; }
// pencil / eraser
const pts = stroke.data.points;
if (!pts || pts.length === 0) return;
ctx.save();
if (stroke.tool === 'eraser') {
ctx.globalCompositeOperation = 'destination-out';
ctx.strokeStyle = 'rgba(0,0,0,1)';
ctx.fillStyle = 'rgba(0,0,0,1)';
} else {
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = stroke.data.color || '#ffffff';
ctx.fillStyle = stroke.data.color || '#ffffff';
// Chalk effect: soft powdery edges + slight transparency, modulated by opacity
ctx.globalAlpha = (stroke.data.opacity ?? 1.0) * 0.88;
ctx.shadowColor = stroke.data.color || '#ffffff';
ctx.shadowBlur = 1.4;
}
ctx.lineWidth = (stroke.data.width / Whiteboard.VW) * (this._cssW || 300);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
const [x0, y0] = this._toCanvas(pts[0][0], pts[0][1]);
ctx.moveTo(x0, y0);
if (pts.length === 1) {
ctx.arc(x0, y0, ctx.lineWidth / 2, 0, Math.PI * 2);
ctx.fill();
} else if (pts.length === 2) {
const [x1, y1] = this._toCanvas(pts[1][0], pts[1][1]);
ctx.lineTo(x1, y1); ctx.stroke();
} else {
// Catmull-Rom spline for smooth curves
for (let i = 0; i < pts.length - 1; i++) {
const i0 = Math.max(0, i - 1), i3 = Math.min(pts.length - 1, i + 2);
const p0 = this._toCanvas(pts[i0][0], pts[i0][1]);
const p1 = this._toCanvas(pts[i][0], pts[i][1]);
const p2 = this._toCanvas(pts[i + 1][0], pts[i + 1][1]);
const p3 = this._toCanvas(pts[i3][0], pts[i3][1]);
const cp1x = p1[0] + (p2[0] - p0[0]) / 6;
const cp1y = p1[1] + (p2[1] - p0[1]) / 6;
const cp2x = p2[0] - (p3[0] - p1[0]) / 6;
const cp2y = p2[1] - (p3[1] - p1[1]) / 6;
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2[0], p2[1]);
}
ctx.stroke();
}
ctx.restore();
}
_renderShape(ctx, stroke) {
const d = stroke.data;
const [cx1, cy1] = this._toCanvas(d.x1, d.y1);
const [cx2, cy2] = this._toCanvas(d.x2, d.y2);
const lw = Math.max(1, (d.width / Whiteboard.VW) * (this._cssW || 300));
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = d.opacity ?? 1.0;
ctx.strokeStyle = d.color || '#ffffff';
ctx.fillStyle = d.color || '#ffffff';
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const dash = d.lineStyle === 'dashed' ? [lw * 3, lw * 2]
: d.lineStyle === 'dotted' ? [lw, lw * 2.5] : [];
ctx.setLineDash(dash);
const minX = Math.min(cx1, cx2), minY = Math.min(cy1, cy2);
const W = Math.abs(cx2 - cx1), H = Math.abs(cy2 - cy1);
const midX = (cx1 + cx2) / 2, midY = (cy1 + cy2) / 2;
ctx.beginPath();
switch (d.shape) {
case 'rect':
ctx.rect(minX, minY, W, H);
if (d.fill) ctx.fill(); ctx.stroke();
break;
case 'ellipse': {
ctx.ellipse(midX, midY, W / 2 || 1, H / 2 || 1, 0, 0, Math.PI * 2);
if (d.fill) ctx.fill(); ctx.stroke();
break;
}
case 'line':
ctx.moveTo(cx1, cy1); ctx.lineTo(cx2, cy2); ctx.stroke();
break;
case 'triangle':
ctx.moveTo(midX, minY);
ctx.lineTo(Math.max(cx1, cx2), Math.max(cy1, cy2));
ctx.lineTo(Math.min(cx1, cx2), Math.max(cy1, cy2));
ctx.closePath();
if (d.fill) ctx.fill(); ctx.stroke();
break;
case 'diamond':
ctx.moveTo(midX, minY);
ctx.lineTo(Math.max(cx1, cx2), midY);
ctx.lineTo(midX, Math.max(cy1, cy2));
ctx.lineTo(Math.min(cx1, cx2), midY);
ctx.closePath();
if (d.fill) ctx.fill(); ctx.stroke();
break;
case 'hexagon': {
const angles = [0, 60, 120, 180, 240, 300].map(a => a * Math.PI / 180);
ctx.moveTo(midX + W / 2 * Math.cos(angles[0]), midY + H / 2 * Math.sin(angles[0]));
for (let i = 1; i < 6; i++)
ctx.lineTo(midX + W / 2 * Math.cos(angles[i]), midY + H / 2 * Math.sin(angles[i]));
ctx.closePath();
if (d.fill) ctx.fill(); ctx.stroke();
break;
}
case 'star': {
const outerR = Math.min(W, H) / 2;
const innerR = outerR * 0.4;
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? outerR : innerR;
const angle = (i * Math.PI / 5) - Math.PI / 2;
const sx = midX + r * Math.cos(angle);
const sy = midY + r * Math.sin(angle);
i === 0 ? ctx.moveTo(sx, sy) : ctx.lineTo(sx, sy);
}
ctx.closePath();
if (d.fill) ctx.fill(); ctx.stroke();
break;
}
case 'roundedrect': {
const r = Math.min(W, H) * 0.15;
ctx.roundRect(minX, minY, W, H, r);
if (d.fill) ctx.fill(); ctx.stroke();
break;
}
case 'callout': {
const r = Math.min(W, H) * 0.12;
const tailH = H * 0.22;
const tailW = W * 0.16;
const tailX = minX + W * 0.18;
const boxH = H - tailH;
ctx.roundRect(minX, minY, W, boxH, r);
if (d.fill) ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.moveTo(tailX, minY + boxH);
ctx.lineTo(tailX + tailW, minY + boxH);
ctx.lineTo(tailX + tailW * 0.35, minY + H);
ctx.closePath();
if (d.fill) ctx.fill(); else ctx.stroke();
break;
}
case 'arrow': {
// Block arrow from cx1,cy1 to cx2,cy2
const dx = cx2 - cx1, dy = cy2 - cy1;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / len, uy = dy / len;
const bodyW = Math.max(4, H * 0.3);
const headH = Math.min(W * 0.35, H * 0.85);
const mx = cx2 - ux * headH, my = cy2 - uy * headH;
const px = -uy * bodyW, py = ux * bodyW;
const hpx = -uy * H * 0.5, hpy = ux * H * 0.5;
ctx.moveTo(cx1 + px, cy1 + py);
ctx.lineTo(mx + px, my + py);
ctx.lineTo(mx + hpx, my + hpy);
ctx.lineTo(cx2, cy2);
ctx.lineTo(mx - hpx, my - hpy);
ctx.lineTo(mx - px, my - py);
ctx.lineTo(cx1 - px, cy1 - py);
ctx.closePath();
if (d.fill) ctx.fill(); else { ctx.fill(); }
break;
}
}
ctx.restore();
}
_renderConnector(ctx, stroke) {
const d = stroke.data;
const [cx1, cy1] = this._toCanvas(d.x1, d.y1);
const [cx2, cy2] = this._toCanvas(d.x2, d.y2);
const lw = Math.max(1, (d.width / Whiteboard.VW) * (this._cssW || 300));
const dx = cx2 - cx1, dy = cy2 - cy1;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / len, uy = dy / len;
const headL = Math.max(8, Math.min(18, len * 0.22));
const headW = headL * 0.55;
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = d.opacity ?? 1.0;
ctx.strokeStyle = d.color || '#ffffff';
ctx.fillStyle = d.color || '#ffffff';
ctx.lineWidth = lw;
ctx.lineCap = 'round';
const dash = d.lineStyle === 'dashed' ? [lw * 3, lw * 2]
: d.lineStyle === 'dotted' ? [lw, lw * 2.5] : [];
ctx.setLineDash(dash);
// Line
ctx.beginPath();
ctx.moveTo(cx1, cy1);
ctx.lineTo(cx2 - ux * headL * 0.7, cy2 - uy * headL * 0.7);
ctx.stroke();
// End arrowhead
if (d.arrowEnd !== false) {
ctx.beginPath();
ctx.moveTo(cx2, cy2);
ctx.lineTo(cx2 - ux * headL + uy * headW, cy2 - uy * headL - ux * headW);
ctx.lineTo(cx2 - ux * headL - uy * headW, cy2 - uy * headL + ux * headW);
ctx.closePath();
ctx.fill();
}
// Start arrowhead
if (d.arrowStart) {
ctx.beginPath();
ctx.moveTo(cx1, cy1);
ctx.lineTo(cx1 + ux * headL + uy * headW, cy1 + uy * headL - ux * headW);
ctx.lineTo(cx1 + ux * headL - uy * headW, cy1 + uy * headL + ux * headW);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
_renderHighlighter(ctx, stroke) {
const d = stroke.data;
if (!d.points || d.points.length < 2) return;
const lw = Math.max(8, (d.width / Whiteboard.VW) * (this._cssW || 300) * 3);
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.38;
ctx.strokeStyle = d.color || '#FFE066';
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.lineJoin = 'round';
ctx.beginPath();
const [fx, fy] = this._toCanvas(d.points[0][0], d.points[0][1]);
ctx.moveTo(fx, fy);
for (let i = 1; i < d.points.length; i++) {
const [px, py] = this._toCanvas(d.points[i][0], d.points[i][1]);
ctx.lineTo(px, py);
}
ctx.stroke();
ctx.restore();
}
_renderLaser(ctx, stroke) {
const d = stroke.data;
if (!d.points || d.points.length === 0) return;
const [vx, vy] = d.points[d.points.length - 1];
const [cx, cy] = this._toCanvas(vx, vy);
ctx.save();
ctx.shadowColor = '#ff2222';
ctx.shadowBlur = 15;
ctx.fillStyle = '#ff4444';
ctx.beginPath();
ctx.arc(cx, cy, 6, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
_renderText(ctx, stroke) {
const d = stroke.data;
if (!d.text) return;
const [cx, cy] = this._toCanvas(d.x, d.y);
const fontSize = Math.round(((d.fontSize || 22) / Whiteboard.VH) * (this._cssH || 150));
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = d.color || '#ffffff';
ctx.font = `${fontSize}px 'Manrope', sans-serif`;
ctx.textBaseline = 'top';
// chalk effect on text too
ctx.shadowColor = d.color || '#ffffff';
ctx.shadowBlur = 1.2;
const lines = d.text.split('\n');
const lh = fontSize * 1.45;
lines.forEach((line, i) => ctx.fillText(line, cx, cy + i * lh));
ctx.restore();
}
_renderImage(ctx, stroke) {
const d = stroke.data;
if (!d.src) return;
const [cx, cy] = this._toCanvas(d.x, d.y);
const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);
if (!stroke._img) {
stroke._img = new Image();
stroke._img.onload = () => { this._staticDirty = true; this.render(); };
stroke._img.src = d.src;
}
if (stroke._img.complete && stroke._img.naturalWidth > 0) {
ctx.save();
ctx.globalCompositeOperation = 'source-over';
if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }
ctx.drawImage(stroke._img, cx, cy, cw, ch);
ctx.restore();
}
}
_renderSticky(ctx, stroke) {
const d = stroke.data;
const [cx, cy] = this._toCanvas(d.x, d.y);
const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);
ctx.save();
ctx.globalCompositeOperation = 'source-over';
if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }
// Shadow
ctx.shadowColor = 'rgba(0,0,0,0.35)';
ctx.shadowBlur = 8;
ctx.shadowOffsetX = 3;
ctx.shadowOffsetY = 4;
// Body
ctx.fillStyle = d.bgColor || '#FFE066';
ctx.beginPath();
ctx.roundRect(cx, cy, cw, ch, 3);
ctx.fill();
ctx.shadowColor = 'transparent';
// Folded corner (top-right)
const fold = Math.min(cw, ch) * 0.14;
ctx.fillStyle = 'rgba(0,0,0,0.18)';
ctx.beginPath();
ctx.moveTo(cx + cw - fold, cy);
ctx.lineTo(cx + cw, cy + fold);
ctx.lineTo(cx + cw, cy);
ctx.closePath();
ctx.fill();
ctx.fillStyle = this._darkenHex(d.bgColor || '#FFE066', 25);
ctx.beginPath();
ctx.moveTo(cx + cw - fold, cy);
ctx.lineTo(cx + cw - fold, cy + fold);
ctx.lineTo(cx + cw, cy + fold);
ctx.closePath();
ctx.fill();
// Text
if (d.text) {
const fs = Math.round((d.fontSize / Whiteboard.VH) * (this._cssH || 150));
const pad = Math.max(8, cw * 0.08);
ctx.fillStyle = d.textColor || '#1a1a2e';
ctx.font = `${fs}px 'Manrope', sans-serif`;
ctx.textBaseline = 'top';
const maxW = cw - 2 * pad;
const lines = this._wrapText(ctx, d.text, maxW);
let ty = cy + pad;
const lineH = fs * 1.4;
for (const line of lines) {
if (ty + lineH > cy + ch - pad * 0.5) break;
ctx.fillText(line, cx + pad, ty);
ty += lineH;
}
}
ctx.restore();
}
_darkenHex(hex, amount) {
try {
const r = Math.max(0, parseInt(hex.slice(1, 3), 16) - amount);
const g = Math.max(0, parseInt(hex.slice(3, 5), 16) - amount);
const b = Math.max(0, parseInt(hex.slice(5, 7), 16) - amount);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
} catch { return hex; }
}
_wrapText(ctx, text, maxW) {
const lines = [];
for (const para of text.split('\n')) {
const words = para.split(' ');
let cur = '';
for (const w of words) {
const test = cur ? cur + ' ' + w : w;
if (ctx.measureText(test).width > maxW && cur) {
lines.push(cur); cur = w;
} else { cur = test; }
}
lines.push(cur);
}
return lines;
}
_renderFormula(ctx, stroke) {
const d = stroke.data;
if (!d.latex) return;
const [cx, cy] = this._toCanvas(d.x, d.y);
const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);
ctx.save();
if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }
ctx.fillStyle = 'rgba(26,22,37,0.75)';
ctx.beginPath();
ctx.roundRect(cx, cy, cw, ch, 6);
ctx.fill();
const fi = stroke._formulaImg;
if (fi === null || fi === undefined) {
// Not started yet — kick off async render and show placeholder text
this._renderFormulaAsync(stroke, cw, ch);
this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch);
} else if (fi === 'pending') {
// CSS / image loading in progress
this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch);
} else if (fi === false) {
// Render failed — show raw LaTeX so user knows what's there
this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch);
} else if (fi.complete && fi.naturalWidth > 0) {
ctx.drawImage(fi, cx, cy, cw, ch);
} else {
this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch);
}
ctx.restore();
}
_drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch) {
const fs = Math.max(11, Math.round((13 / Whiteboard.VH) * (this._cssH || 150)));
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.font = `italic ${fs}px 'Manrope',sans-serif`;
ctx.textBaseline = 'middle';
// Clip text to box so it doesn't overflow
ctx.save();
ctx.beginPath();
ctx.rect(cx + 4, cy, cw - 8, ch);
ctx.clip();
ctx.fillText(d.latex, cx + 8, cy + ch / 2);
ctx.restore();
}
_renderFormulaAsync(stroke, cw, ch) {
stroke._formulaImg = 'pending';
if (!window.katex) { stroke._formulaImg = false; return; }
_loadKatexCss(css => {
// Stroke may have been removed while CSS was loading
if (!this._strokes.includes(stroke)) { stroke._formulaImg = false; return; }
try {
const html = window.katex.renderToString(stroke.data.latex, {
throwOnError: false, displayMode: true, output: 'html',
});
const color = stroke.data.color || '#e8e0f7';
const vfs = stroke.data.fontSize || 32;
// Render at 3× for crisp, PowerPoint-quality formulas
const scale = 3;
const W = Math.ceil(cw * scale);
const H = Math.ceil(ch * scale);
const fs = Math.max(18, Math.round((vfs / Whiteboard.VH) * (this._cssH || 150) * 2.8 * scale));
const svgStr = [
`<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; }
setReadOnly(v) { this._readOnly = v; }
setLineStyle(style) { this._lineStyle = style; }
setOpacity(v) { this._opacity = Math.max(0.05, Math.min(1, v)); }
setTemplate(name) { this._template = name || 'blank'; this._staticDirty = true; this.render(); }
setPageNum(n) { this._pageNum = n; }
exportPNG() {
const off = document.createElement('canvas');
off.width = Whiteboard.VW; off.height = Whiteboard.VH;
const ctx = off.getContext('2d');
const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY];
this._cssW = Whiteboard.VW; this._cssH = Whiteboard.VH;
this._zoom = 1; this._panVX = 0; this._panVY = 0;
this._renderBg(ctx);
if (this._template && this._template !== 'blank') this._renderTemplate(ctx);
for (const s of this._strokes) this._renderStroke(ctx, s);
this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy;
off.toBlob(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `whiteboard-p${this._pageNum}.png`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 3000);
}, 'image/png');
}
_renderMinimap() {
if (!this._mmCanvas) return;
const visible = this._zoom > 1.04;
this._mmCanvas.style.display = visible ? 'block' : 'none';
if (!visible) return;
const mm = this._mmCtx;
const MW = 192, MH = 108;
// Render full board at zoom=1 (same approach as renderThumbnail)
const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY];
this._cssW = MW; this._cssH = MH;
this._zoom = 1; this._panVX = 0; this._panVY = 0;
this._renderBg(mm);
if (this._template && this._template !== 'blank') this._renderTemplate(mm);
for (const s of this._strokes) this._renderStroke(mm, s);
// Restore
this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy;
// Viewport indicator
const vpW = MW / sz;
const vpH = MH / sz;
const vpX = (spx / Whiteboard.VW) * MW;
const vpY = (spy / Whiteboard.VH) * MH;
// Dark overlay on non-viewport areas
mm.fillStyle = 'rgba(0,0,0,0.42)';
mm.fillRect(0, 0, MW, vpY);
mm.fillRect(0, vpY + vpH, MW, MH - vpY - vpH);
mm.fillRect(0, vpY, vpX, vpH);
mm.fillRect(vpX + vpW, vpY, MW - vpX - vpW, vpH);
// Viewport border
mm.strokeStyle = 'rgba(155,93,229,0.95)';
mm.lineWidth = 1.5;
mm.strokeRect(vpX, vpY, vpW, vpH);
// Crosshair at viewport center
const cx = vpX + vpW / 2, cy = vpY + vpH / 2;
mm.strokeStyle = 'rgba(155,93,229,0.6)';
mm.lineWidth = 0.8;
mm.beginPath(); mm.moveTo(cx - 5, cy); mm.lineTo(cx + 5, cy); mm.stroke();
mm.beginPath(); mm.moveTo(cx, cy - 5); mm.lineTo(cx, cy + 5); mm.stroke();
}
_mmNavigate(e) {
const rect = this._mmCanvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (192 / rect.width);
const my = (e.clientY - rect.top) * (108 / rect.height);
// Center the viewport on the clicked virtual position
this._panVX = (mx / 192) * Whiteboard.VW - Whiteboard.VW / (2 * this._zoom);
this._panVY = (my / 108) * Whiteboard.VH - Whiteboard.VH / (2 * this._zoom);
this._clampPan();
this._staticDirty = true;
this.render();
}
renderThumbnail(targetCanvas) {
const ctx = targetCanvas.getContext('2d');
const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY];
this._cssW = targetCanvas.width; this._cssH = targetCanvas.height;
this._zoom = 1; this._panVX = 0; this._panVY = 0;
this._renderBg(ctx);
if (this._template && this._template !== 'blank') this._renderTemplate(ctx);
for (const s of this._strokes) this._renderStroke(ctx, s);
this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy;
}
updateOpacity(v) {
if (this._selectedId == null) return;
const sel = this._strokes.find(s => s.id === this._selectedId);
if (!sel) return;
sel.data.opacity = Math.max(0.05, Math.min(1, v));
this._staticDirty = true;
this.render();
if (this._onStrokeUpdated) this._onStrokeUpdated(sel);
}
/* ── formula insert (called by external modal) ──────────────────────── */
insertFormula(vx, vy, latex, fontSize = 32) {
if (!latex) return;
this._editingFormulaStroke = null; // clear edit-mode flag
const W = Math.round(460 * (fontSize / 32));
const H = Math.round(160 * (fontSize / 32));
const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W));
const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H));
const stroke = {
id: this._localIdCounter--, tool: 'formula',
data: { x, y, w: W, h: H, latex, fontSize, color: this._color },
};
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._selectedId = stroke.id;
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
if (this._onObjectCreated) this._onObjectCreated(stroke);
}
/* Called by external modal when editing an existing formula */
updateFormula(stroke, latex, fontSize) {
if (!stroke || !latex) return;
stroke.data.latex = latex;
stroke.data.fontSize = fontSize || stroke.data.fontSize;
stroke._formulaImg = null;
this._editingFormulaStroke = null;
this._staticDirty = true;
this.render();
if (this._onStrokeUpdated) this._onStrokeUpdated(stroke);
}
/* ── Clipboard paste (system clipboard: images & text) ──────────────── */
_onClipboardPaste(e) {
if (this._readOnly) return;
// Don't intercept paste when a real input/textarea is focused
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;
const items = e.clipboardData?.items;
if (!items) return;
// Prefer image over text
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) this._pasteImageBlob(blob);
return;
}
}
for (const item of Array.from(items)) {
if (item.type === 'text/plain') {
e.preventDefault();
item.getAsString(text => {
if (!text.trim()) return;
// Place pasted text at upper-left area of the board
const stroke = {
id: this._localIdCounter--, tool: 'text',
data: { text: text.trim(), x: 80, y: 80, fontSize: 22, color: this._color },
};
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._selectedId = stroke.id;
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
});
return;
}
}
}
_pasteImageBlob(blob) {
const maxPx = 800;
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => {
let pw = img.naturalWidth, ph = img.naturalHeight;
if (pw > maxPx || ph > maxPx) {
if (pw >= ph) { ph = Math.round(ph * maxPx / pw); pw = maxPx; }
else { pw = Math.round(pw * maxPx / ph); ph = maxPx; }
}
const tmp = document.createElement('canvas');
tmp.width = pw; tmp.height = ph;
tmp.getContext('2d').drawImage(img, 0, 0, pw, ph);
const src = tmp.toDataURL('image/jpeg', 0.8);
// Virtual dimensions: fit into ~800×600 vp, maintain aspect ratio
const maxVW = 800, maxVH = 600;
let vw = maxVW, vh = Math.round(maxVW * ph / pw);
if (vh > maxVH) { vh = maxVH; vw = Math.round(maxVH * pw / ph); }
const vx = Math.round((Whiteboard.VW - vw) / 2);
const vy = Math.round((Whiteboard.VH - vh) / 2);
const stroke = {
id: this._localIdCounter--, tool: 'image',
data: { src, x: vx, y: vy, w: vw, h: vh },
};
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._selectedId = stroke.id;
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
if (this._onObjectCreated) this._onObjectCreated(stroke);
};
img.src = ev.target.result;
};
reader.readAsDataURL(blob);
}
/* ── copy / paste / delete / z-order ─────────────────────────────────── */
copy() {
if (this._selectedId == null) return;
const sel = this._strokes.find(s => s.id === this._selectedId);
if (!sel) return;
this._clipboard = { tool: sel.tool, data: JSON.parse(JSON.stringify(sel.data)) };
}
paste() {
if (!this._clipboard) return;
const data = JSON.parse(JSON.stringify(this._clipboard.data));
const OFF = 30;
if (data.x1 != null) {
// shape / connector
data.x1 += OFF; data.y1 += OFF; data.x2 += OFF; data.y2 += OFF;
} else if (data.points) {
// pencil / highlighter
data.points = data.points.map(([px, py]) => [px + OFF, py + OFF]);
} else if (data.x != null && data.w != null) {
// object stroke (image/sticky/formula/table/text with w)
data.x = Math.min(data.x + OFF, Whiteboard.VW - (data.w || 10));
data.y = Math.min(data.y + OFF, Whiteboard.VH - (data.h || 10));
} else if (data.x != null) {
// text (no w)
data.x += OFF; data.y += OFF;
}
const stroke = { id: this._localIdCounter--, tool: this._clipboard.tool, data };
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._redoStack = [];
this._selectedId = stroke.id;
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
}
deleteSelected() {
if (this._selectedIds.size === 0) return;
const ids = [...this._selectedIds];
this._selectedIds.clear();
this._dragState = null;
this._strokes = this._strokes.filter(s => !ids.includes(s.id));
for (const id of ids) {
const ui = this._undoStack.indexOf(id);
if (ui !== -1) this._undoStack.splice(ui, 1);
}
this._staticDirty = true;
this.render();
for (const id of ids) { if (this._onStrokeUndo) this._onStrokeUndo(id); }
}
bringToFront() {
if (this._selectedIds.size === 0) return;
const toMove = this._strokes.filter(s => this._selectedIds.has(s.id));
this._strokes = this._strokes.filter(s => !this._selectedIds.has(s.id));
this._strokes.push(...toMove);
this._staticDirty = true;
this.render();
for (const s of toMove) { if (this._onStrokeUpdated) this._onStrokeUpdated(s); }
}
sendToBack() {
if (this._selectedIds.size === 0) return;
const toMove = this._strokes.filter(s => this._selectedIds.has(s.id));
this._strokes = this._strokes.filter(s => !this._selectedIds.has(s.id));
this._strokes.unshift(...toMove);
this._staticDirty = true;
this.render();
for (const s of toMove) { if (this._onStrokeUpdated) this._onStrokeUpdated(s); }
}
/* ── strokes management ─────────────────────────────────────────────── */
loadStrokes(strokes) {
this._strokes = (strokes || []).map(s => this._normalise(s));
this._liveStrokes.clear();
this._staticDirty = true;
this.render();
}
/* Returns strokes not yet confirmed by server (negative IDs = locally drawn). */
getLocalStrokes() {
return this._strokes.filter(s => s.id < 0);
}
addStrokes(strokes) {
let changed = false;
for (const s of strokes) {
if (this._strokes.some(x => x.id === s.id)) continue;
this._strokes.push(this._normalise(s));
changed = true;
}
if (changed) { this._staticDirty = true; this.render(); }
}
_normalise(s) {
return {
id: s.id,
tool: s.tool,
data: typeof s.data === 'string' ? JSON.parse(s.data) : s.data,
};
}
removeStroke(id) {
this._strokes = this._strokes.filter(s => s.id !== id);
if (this._selectedIds.has(id)) { this._selectedIds.delete(id); this._dragState = null; }
this._staticDirty = true;
this.render();
}
updateStroke(id, data) {
const s = this._strokes.find(x => x.id === id);
if (!s) return;
s.data = typeof data === 'string' ? JSON.parse(data) : { ...data };
s._img = null;
s._formulaImg = null;
this._staticDirty = true;
this.render();
}
confirmStroke(localId, serverId) {
const s = this._strokes.find(x => x.id === localId);
if (s) {
s.id = serverId;
const before = this._strokes.length;
this._strokes = this._strokes.filter(x => x === s || x.id !== serverId);
if (this._strokes.length !== before) { this._staticDirty = true; this.render(); }
}
const i = this._undoStack.indexOf(localId);
if (i !== -1) this._undoStack[i] = serverId;
}
undo() {
if (this._undoStack.length === 0) return;
const id = this._undoStack.pop();
const stroke = this._strokes.find(s => s.id === id);
this._strokes = this._strokes.filter(s => s.id !== id);
if (stroke) this._redoStack.push(stroke);
this._staticDirty = true;
this.render();
if (this._onStrokeUndo) this._onStrokeUndo(id);
}
redo() {
if (this._redoStack.length === 0) return;
const stroke = this._redoStack.pop();
this._strokes.push(stroke);
this._undoStack.push(stroke.id);
this._staticDirty = true;
this.render();
if (this._onStrokeDone) this._onStrokeDone(stroke);
}
clearPage() {
this._strokes = [];
this._undoStack = [];
this._redoStack = [];
this._curPts = [];
this._drawing = false;
this._shapeStart = null;
this._selectedIds.clear();
this._dragState = null;
this._lassoRect = null;
this._snapGuides = [];
this._liveStrokes.clear();
this._removeTextInput();
this._removeObjectInput();
this._staticDirty = true;
this.render();
}
destroy() {
if (this._ro) this._ro.disconnect();
this._removeTextInput();
this._removeObjectInput();
document.removeEventListener('keydown', this._onKeyDown);
document.removeEventListener('keyup', this._onKeyUp);
document.removeEventListener('paste', this._onPaste);
if (this._dynCanvas?.parentElement) this._dynCanvas.parentElement.removeChild(this._dynCanvas);
if (this._mmCanvas?.parentElement) this._mmCanvas.parentElement.removeChild(this._mmCanvas);
}
/* ── Phase 5: Coordinate system stroke ─────────────────────────────── */
_renderCoordinate(ctx, stroke) {
const d = stroke.data;
const [cx, cy] = this._toCanvas(d.x, d.y);
const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);
const xMin = d.xMin ?? -10, xMax = d.xMax ?? 10;
const yMin = d.yMin ?? -10, yMax = d.yMax ?? 10;
const step = d.gridStep || 1;
ctx.save();
if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }
// Background
ctx.fillStyle = 'rgba(18,13,30,0.88)';
ctx.strokeStyle = 'rgba(155,93,229,0.35)';
ctx.lineWidth = 1;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 6);
else ctx.rect(cx, cy, cw, ch);
ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.rect(cx, cy, cw, ch); ctx.clip();
const xRange = xMax - xMin, yRange = yMax - yMin;
const scaleX = cw / xRange, scaleY = ch / yRange;
// Origin in canvas coords (0,0 of math space)
const ox = cx + (-xMin) * scaleX;
const oy = cy + yMax * scaleY;
// Grid lines
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 0.5;
for (let v = Math.ceil(xMin / step) * step; v <= xMax; v += step) {
const px = cx + (v - xMin) * scaleX;
ctx.beginPath(); ctx.moveTo(px, cy); ctx.lineTo(px, cy + ch); ctx.stroke();
}
for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) {
const py = cy + (yMax - v) * scaleY;
ctx.beginPath(); ctx.moveTo(cx, py); ctx.lineTo(cx + cw, py); ctx.stroke();
}
// Axes
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1.5;
// X axis
ctx.beginPath(); ctx.moveTo(cx, oy); ctx.lineTo(cx + cw, oy); ctx.stroke();
// Y axis
ctx.beginPath(); ctx.moveTo(ox, cy); ctx.lineTo(ox, cy + ch); ctx.stroke();
// Tick marks + labels
if (d.showLabels !== false) {
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.font = `${Math.max(8, Math.round(9 * cw / 400))}px Manrope,sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 0.8;
const tickH = 4;
for (let v = Math.ceil(xMin / step) * step; v <= xMax; v += step) {
if (Math.abs(v) < 1e-9) continue;
const px = cx + (v - xMin) * scaleX;
ctx.beginPath(); ctx.moveTo(px, oy - tickH); ctx.lineTo(px, oy + tickH); ctx.stroke();
ctx.fillText(v, px, oy + tickH + 1);
}
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) {
if (Math.abs(v) < 1e-9) continue;
const py = cy + (yMax - v) * scaleY;
ctx.beginPath(); ctx.moveTo(ox - tickH, py); ctx.lineTo(ox + tickH, py); ctx.stroke();
ctx.fillText(v, ox - tickH - 2, py);
}
ctx.fillText('0', ox - tickH - 2, oy + tickH + 1);
}
// Arrow heads on axes
const aw = 6, ah = 4;
ctx.fillStyle = 'rgba(255,255,255,0.55)';
// X arrow right
const xEnd = cx + cw;
ctx.beginPath(); ctx.moveTo(xEnd, oy); ctx.lineTo(xEnd - aw, oy - ah); ctx.lineTo(xEnd - aw, oy + ah); ctx.closePath(); ctx.fill();
// Y arrow up
ctx.beginPath(); ctx.moveTo(ox, cy); ctx.lineTo(ox - ah, cy + aw); ctx.lineTo(ox + ah, cy + aw); ctx.closePath(); ctx.fill();
// Plot functions
for (const fn of (d.functions || [])) {
ctx.strokeStyle = fn.color || '#06D6E0';
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.9;
ctx.beginPath();
let started = false;
for (let px = cx; px <= cx + cw; px += 0.8) {
const mathX = xMin + (px - cx) / scaleX;
let mathY;
try { mathY = this._evalMath(fn.expr, mathX); } catch { continue; }
if (!isFinite(mathY)) { started = false; continue; }
const py = oy - mathY * scaleY;
if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.restore();
}
/* Simple recursive descent math expression evaluator */
_evalMath(expr, x) {
const tokens = this._mathTokenise(expr.replace(/\s+/g, ''));
let pos = 0;
const peek = () => tokens[pos];
const consume = () => tokens[pos++];
const num = () => {
const t = peek();
if (t === '(') { consume(); const v = addSub(); consume(/* ')' */); return v; }
if (t === '-') { consume(); return -num(); }
if (t === 'x') { consume(); return x; }
if (/^[a-z]+$/.test(t)) {
consume();
const arg = peek() === '(' ? (consume(), (() => { const v = addSub(); consume(); return v; })()) : num();
const fns = { sin: Math.sin, cos: Math.cos, tan: Math.tan, sqrt: Math.sqrt,
abs: Math.abs, log: Math.log, exp: Math.exp, asin: Math.asin,
acos: Math.acos, atan: Math.atan, floor: Math.floor, ceil: Math.ceil };
if (fns[t]) return fns[t](arg);
if (t === 'pi') { pos--; return Math.PI; }
if (t === 'e') { pos--; return Math.E; }
return NaN;
}
consume(); return parseFloat(t);
};
const pow = () => { let v = num(); while (peek() === '^') { consume(); v = Math.pow(v, num()); } return v; };
const mulDiv = () => {
let v = pow();
while (peek() === '*' || peek() === '/') {
const op = consume();
v = op === '*' ? v * pow() : v / pow();
}
return v;
};
const addSub = () => {
let v = mulDiv();
while (peek() === '+' || peek() === '-') {
const op = consume();
v = op === '+' ? v + mulDiv() : v - mulDiv();
}
return v;
};
return addSub();
}
_mathTokenise(expr) {
const tokens = [];
let i = 0;
const consts = { pi: Math.PI, e: Math.E };
while (i < expr.length) {
if (/\d|\./.test(expr[i])) {
let n = '';
while (i < expr.length && /[\d.]/.test(expr[i])) n += expr[i++];
tokens.push(n);
} else if (/[a-z]/i.test(expr[i])) {
let w = '';
while (i < expr.length && /[a-z]/i.test(expr[i])) w += expr[i++].toLowerCase();
if (w === 'pi' || w === 'e') tokens.push(parseFloat(consts[w]).toString());
else tokens.push(w);
} else {
tokens.push(expr[i++]);
}
}
tokens.push(null); // EOF sentinel
return tokens;
}
/* ── Phase 5: Ruler/Protractor overlays ────────────────────────────── */
// Overlays are rendered on the dynamic layer and are not saved to DB.
_renderOverlays(ctx) {
for (const ov of this._overlays) {
if (ov.type === 'ruler') this._renderRuler(ctx, ov);
else if (ov.type === 'protractor') this._renderProtractor(ctx, ov);
}
}
_renderRuler(ctx, ov) {
const [cx, cy] = this._toCanvas(ov.x, ov.y);
const rulerW = (ov.width / Whiteboard.VW) * (this._cssW || 300);
const rulerH = Math.max(18, rulerW * 0.07);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(ov.angle || 0);
ctx.fillStyle = 'rgba(255,230,180,0.15)';
ctx.strokeStyle = 'rgba(255,230,180,0.5)';
ctx.lineWidth = 1;
ctx.fillRect(0, 0, rulerW, rulerH);
ctx.strokeRect(0, 0, rulerW, rulerH);
// Tick marks every ~40vp
const tickStep = (40 / Whiteboard.VW) * (this._cssW || 300);
ctx.strokeStyle = 'rgba(255,230,180,0.4)';
ctx.font = `${Math.max(6, Math.round(7 * rulerW / 300))}px Manrope,sans-serif`;
ctx.fillStyle = 'rgba(255,230,180,0.5)';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
for (let t = 0; t * tickStep <= rulerW; t++) {
const tx = t * tickStep;
const th = t % 5 === 0 ? rulerH * 0.6 : rulerH * 0.3;
ctx.beginPath(); ctx.moveTo(tx, rulerH); ctx.lineTo(tx, rulerH - th); ctx.stroke();
if (t % 5 === 0 && t > 0) ctx.fillText(t, tx, rulerH - th - 1);
}
// ── handles ────────────────────────────────────────────────────────────
// Rotation handle — purple circle above ruler center
const rotHx = rulerW / 2, rotHy = -20;
ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1;
ctx.setLineDash([3, 2]);
ctx.beginPath(); ctx.moveTo(rulerW / 2, 0); ctx.lineTo(rotHx, rotHy); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(rotHx, rotHy, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(155,93,229,0.75)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
// ↺ symbol inside
ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('↺', rotHx, rotHy);
// Resize handle — cyan circle at right end
const resHx = rulerW + 10, resHy = rulerH / 2;
ctx.beginPath(); ctx.arc(resHx, resHy, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(6,214,224,0.75)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('↔', resHx, resHy);
ctx.restore();
}
_renderProtractor(ctx, ov) {
const [cx, cy] = this._toCanvas(ov.x, ov.y);
const vR = ov.radius || 80;
const r = Math.max(30, (vR / Whiteboard.VW) * (this._cssW || 300));
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(ov.angle || 0);
ctx.fillStyle = 'rgba(6,214,224,0.08)';
ctx.strokeStyle = 'rgba(6,214,224,0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(0, 0, r, Math.PI, 0, false); // top semicircle
ctx.lineTo(-r, 0); ctx.closePath();
ctx.fill(); ctx.stroke();
// Degree ticks
ctx.font = `${Math.max(6, Math.round(7 * r / 80))}px Manrope,sans-serif`;
ctx.fillStyle = 'rgba(6,214,224,0.6)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (let deg = 0; deg <= 180; deg += 10) {
const rad = (Math.PI - deg * Math.PI / 180);
const major = deg % 30 === 0;
const tl = major ? r * 0.15 : r * 0.08;
ctx.strokeStyle = major ? 'rgba(6,214,224,0.7)' : 'rgba(6,214,224,0.3)';
ctx.beginPath();
ctx.moveTo(Math.cos(rad) * (r - tl), Math.sin(rad) * (r - tl));
ctx.lineTo(Math.cos(rad) * r, Math.sin(rad) * r);
ctx.stroke();
if (major && deg > 0 && deg < 180) {
const lr = r - tl - 5;
ctx.fillText(deg, Math.cos(rad) * lr, Math.sin(rad) * lr - 1);
}
}
// ── handles ────────────────────────────────────────────────────────────
// Rotation handle — above center of flat edge (local y negative = upward)
const pRotHy = -(r + 20);
ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1;
ctx.setLineDash([3, 2]);
ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(0, pRotHy); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(0, pRotHy, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(155,93,229,0.75)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('↺', 0, pRotHy);
// Resize handle — at right end of flat edge
ctx.beginPath(); ctx.arc(r + 10, 0, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(6,214,224,0.75)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('↔', r + 10, 0);
ctx.restore();
}
/* Overlay drag helpers */
_hitTestOverlay(vx, vy) {
const [pcx, pcy] = this._toCanvas(vx, vy);
for (let i = this._overlays.length - 1; i >= 0; i--) {
const ov = this._overlays[i];
const [ox, oy] = this._toCanvas(ov.x, ov.y);
const angle = ov.angle || 0;
const dx = pcx - ox, dy = pcy - oy;
// rotate pointer into overlay local space
const lx = dx * Math.cos(-angle) - dy * Math.sin(-angle);
const ly = dx * Math.sin(-angle) + dy * Math.cos(-angle);
if (ov.type === 'ruler') {
const W = (ov.width / Whiteboard.VW) * (this._cssW || 300);
const H = Math.max(18, W * 0.07);
if (Math.hypot(lx - W / 2, ly + 20) < 10) return { idx: i, zone: 'rot' };
if (Math.hypot(lx - (W + 10), ly - H / 2) < 10) return { idx: i, zone: 'resize' };
if (lx >= -5 && lx <= W + 5 && ly >= -5 && ly <= H + 5) return { idx: i, zone: 'body' };
}
if (ov.type === 'protractor') {
const vR = ov.radius || 80;
const r = Math.max(30, (vR / Whiteboard.VW) * (this._cssW || 300));
if (Math.hypot(lx, ly + r + 20) < 10) return { idx: i, zone: 'rot' };
if (Math.hypot(lx - r - 10, ly) < 10) return { idx: i, zone: 'resize' };
const dist = Math.hypot(lx, ly);
if (dist < r && ly <= 0) return { idx: i, zone: 'body' };
if (Math.abs(ly) < 8 && Math.abs(lx) <= r) return { idx: i, zone: 'body' };
}
}
return null;
}
/* ── Number line stroke ─────────────────────────────────────────────── */
_renderNumberLine(ctx, stroke) {
const d = stroke.data;
const [cx, cy] = this._toCanvas(d.x, d.y);
const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);
const min = d.min ?? -10, max = d.max ?? 10;
const step = d.step || 1;
const range = max - min;
ctx.save();
if (d.rotation) { const [ocx, ocy] = [cx + cw / 2, cy + ch / 2]; ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }
// Background
ctx.fillStyle = 'rgba(18,13,30,0.82)';
ctx.strokeStyle = 'rgba(155,93,229,0.3)';
ctx.lineWidth = 1;
if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 6); else ctx.rect(cx, cy, cw, ch);
ctx.fill(); ctx.stroke();
const axY = cy + ch / 2;
const padX = cw * 0.04;
const axX1 = cx + padX, axX2 = cx + cw - padX;
const scaleX = (axX2 - axX1) / range;
// Main axis line
ctx.strokeStyle = 'rgba(255,255,255,0.75)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(axX1 - 6, axY); ctx.lineTo(axX2 + 8, axY);
ctx.stroke();
// Arrow right
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.beginPath(); ctx.moveTo(axX2 + 8, axY);
ctx.lineTo(axX2 + 2, axY - 5); ctx.lineTo(axX2 + 2, axY + 5); ctx.closePath(); ctx.fill();
// Tick marks + labels
const fs = Math.max(8, Math.round((11 / Whiteboard.VH) * (this._cssH || 150)));
ctx.font = `${fs}px Manrope,sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let v = Math.ceil(min / step) * step; v <= max; v += step) {
const px = axX1 + (v - min) * scaleX;
const major = (v % (step * 5) === 0) || step >= 1;
const th = major ? ch * 0.22 : ch * 0.12;
ctx.strokeStyle = major ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.25)';
ctx.lineWidth = major ? 1.2 : 0.7;
ctx.beginPath(); ctx.moveTo(px, axY - th); ctx.lineTo(px, axY + th); ctx.stroke();
if (major) {
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText(v === 0 ? '0' : v.toString(), px, axY + th + 2);
}
}
// Origin line
if (min <= 0 && 0 <= max) {
const ox = axX1 + (0 - min) * scaleX;
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(ox, axY - ch * 0.3); ctx.lineTo(ox, axY + ch * 0.3); ctx.stroke();
}
// Points
if (d.points) {
for (const pt of d.points) {
const px = axX1 + (pt.val - min) * scaleX;
if (px < cx || px > cx + cw) continue;
const color = pt.color || '#06D6E0';
ctx.fillStyle = pt.open ? 'transparent' : color;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(px, axY, ch * 0.12, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
if (pt.label) {
ctx.fillStyle = color;
ctx.font = `bold ${fs}px Manrope,sans-serif`;
ctx.fillText(pt.label, px, axY - ch * 0.35);
}
}
}
// Intervals (shaded ranges)
if (d.intervals) {
for (const iv of d.intervals) {
const x1 = axX1 + (Math.max(iv.from, min) - min) * scaleX;
const x2 = axX1 + (Math.min(iv.to, max) - min) * scaleX;
ctx.fillStyle = (iv.color || '#9B5DE5') + '33';
ctx.fillRect(x1, axY - ch * 0.1, x2 - x1, ch * 0.2);
}
}
ctx.restore();
}
/* ── Compass stroke ─────────────────────────────────────────────────── */
_renderCompass(ctx, stroke) {
const d = stroke.data;
const [cx, cy] = this._toCanvas(d.x, d.y);
const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);
const r = Math.min(cw, ch) * 0.38;
const ocx = cx + cw / 2, ocy = cy + ch / 2;
ctx.save();
if (d.rotation) { ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }
// Background
ctx.fillStyle = 'rgba(18,13,30,0.82)';
ctx.strokeStyle = 'rgba(6,214,224,0.25)';
ctx.lineWidth = 1;
if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 8); else ctx.rect(cx, cy, cw, ch);
ctx.fill(); ctx.stroke();
// Draw arm of compass (needle)
const angle = (d.angle || 0); // current arc angle
const spread = d.spread || Math.PI / 4; // angle between legs (half = radius)
const legLen = r * 1.15;
// Left leg (pivot) — straight down
ctx.strokeStyle = 'rgba(6,214,224,0.7)';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
const pivotAngle = -Math.PI / 2 - spread / 2;
const drawAngle = -Math.PI / 2 + spread / 2;
ctx.beginPath();
ctx.moveTo(ocx, ocy);
ctx.lineTo(ocx + Math.cos(pivotAngle + angle) * legLen, ocy + Math.sin(pivotAngle + angle) * legLen);
ctx.stroke();
// Right leg (pencil tip)
ctx.strokeStyle = 'rgba(155,93,229,0.8)';
ctx.beginPath();
ctx.moveTo(ocx, ocy);
ctx.lineTo(ocx + Math.cos(drawAngle + angle) * legLen, ocy + Math.sin(drawAngle + angle) * legLen);
ctx.stroke();
// Hinge circle at top
ctx.fillStyle = 'rgba(6,214,224,0.9)';
ctx.beginPath(); ctx.arc(ocx, ocy, 4, 0, Math.PI * 2); ctx.fill();
// Drawn arc
const arcR = (Math.cos(spread / 2) * legLen); // approximate radius
const arcAngle = d.arcAngle || Math.PI * 2;
const arcStart = d.arcStart || 0;
ctx.strokeStyle = (d.color || '#FFE066') + 'cc';
ctx.lineWidth = 1.8;
ctx.setLineDash([]);
ctx.beginPath();
ctx.arc(ocx + Math.cos(pivotAngle + angle) * legLen, ocy + Math.sin(pivotAngle + angle) * legLen,
arcR, arcStart, arcStart + arcAngle);
ctx.stroke();
// Radius label
if (d.showRadius && d.radius) {
const fs = Math.max(9, Math.round((11 / Whiteboard.VH) * (this._cssH || 150)));
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = `${fs}px Manrope,sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(`r = ${d.radius}`, ocx, cy + 6);
}
ctx.restore();
}
/* ── Auto-measurements: display geometric annotations on shapes ─────── */
renderMeasurements(ctx, stroke) {
if (!stroke || stroke.tool !== 'shape') return;
const d = stroke.data;
const fs = Math.max(9, Math.round((10 / Whiteboard.VH) * (this._cssH || 150)));
ctx.save();
ctx.font = `${fs}px Manrope,sans-serif`;
ctx.fillStyle = 'rgba(255,230,100,0.85)';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const [cx1, cy1] = this._toCanvas(d.x1, d.y1);
const [cx2, cy2] = this._toCanvas(d.x2, d.y2);
const midCx = (cx1 + cx2) / 2, midCy = (cy1 + cy2) / 2;
if (d.shape === 'line' || d.shape === 'arrow') {
const len = Math.hypot(d.x2 - d.x1, d.y2 - d.y1);
const ang = Math.atan2(d.y2 - d.y1, d.x2 - d.x1) * 180 / Math.PI;
this._drawMeasLabel(ctx, `${len.toFixed(0)} vp ${ang.toFixed(1)}°`, midCx, midCy - 12);
} else if (d.shape === 'rect' || d.shape === 'roundedrect') {
const vw = Math.abs(d.x2 - d.x1), vh = Math.abs(d.y2 - d.y1);
this._drawMeasLabel(ctx, `${vw.toFixed(0)}`, midCx, Math.min(cy1, cy2) - 8);
this._drawMeasLabel(ctx, `${vh.toFixed(0)}`, Math.max(cx1, cx2) + 8, midCy);
} else if (d.shape === 'ellipse') {
const rx = Math.abs(d.x2 - d.x1) / 2, ry = Math.abs(d.y2 - d.y1) / 2;
this._drawMeasLabel(ctx, `rx=${rx.toFixed(0)} ry=${ry.toFixed(0)}`, midCx, Math.min(cy1, cy2) - 8);
} else if (d.shape === 'triangle') {
const base = Math.abs(d.x2 - d.x1), height = Math.abs(d.y2 - d.y1);
const area = (base * height / 2).toFixed(0);
this._drawMeasLabel(ctx, `A≈${area}`, midCx, Math.min(cy1, cy2) - 8);
}
ctx.restore();
}
_drawMeasLabel(ctx, text, x, y) {
const m = ctx.measureText(text);
const pad = 3;
ctx.fillStyle = 'rgba(26,20,40,0.7)';
ctx.fillRect(x - m.width / 2 - pad, y - 7, m.width + pad * 2, 14);
ctx.fillStyle = 'rgba(255,230,100,0.9)';
ctx.fillText(text, x, y);
}
toggleMeasurements() {
this._showMeasurements = !this._showMeasurements;
this.render();
return this._showMeasurements;
}
toggleRuler() {
const idx = this._overlays.findIndex(o => o.type === 'ruler');
if (idx >= 0) this._overlays.splice(idx, 1);
else this._overlays.push({ type: 'ruler', x: 200, y: 200, angle: 0, width: 400 });
this.render();
}
toggleProtractor() {
const idx = this._overlays.findIndex(o => o.type === 'protractor');
if (idx >= 0) this._overlays.splice(idx, 1);
else this._overlays.push({ type: 'protractor', x: 960, y: 350, radius: 80, angle: 0 });
this.render();
}
}
if (typeof module !== 'undefined') module.exports = Whiteboard;