f4eee2af3f
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>
3250 lines
126 KiB
JavaScript
3250 lines
126 KiB
JavaScript
/* ── 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;
|