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