@teachinglab/omd 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canvas/core/canvasConfig.js +3 -3
- package/canvas/core/omdCanvas.js +479 -479
- package/canvas/events/eventManager.js +14 -4
- package/canvas/features/focusFrameManager.js +284 -286
- package/canvas/features/resizeHandleManager.js +482 -0
- package/canvas/index.js +2 -1
- package/canvas/tools/EraserTool.js +321 -322
- package/canvas/tools/PencilTool.js +321 -324
- package/canvas/tools/PointerTool.js +71 -0
- package/canvas/tools/SelectTool.js +902 -457
- package/canvas/tools/toolManager.js +389 -393
- package/canvas/ui/cursor.js +462 -437
- package/canvas/ui/toolbar.js +291 -290
- package/docs/omd-objects.md +258 -0
- package/jsvg/jsvg.js +898 -0
- package/jsvg/jsvgComponents.js +359 -0
- package/omd/nodes/omdEquationSequenceNode.js +1280 -1246
- package/package.json +1 -1
- package/src/json-schemas.md +546 -78
- package/src/omd.js +212 -109
- package/src/omdEquation.js +188 -156
- package/src/omdExpression.js +7 -0
- package/src/omdFunction.js +5 -3
- package/src/omdProblem.js +216 -11
- package/src/omdString.js +12 -1
- package/src/omdUtils.js +84 -0
- package/src/omdVariable.js +1 -0
package/canvas/core/omdCanvas.js
CHANGED
|
@@ -1,480 +1,480 @@
|
|
|
1
|
-
import { CanvasConfig } from './canvasConfig.js';
|
|
2
|
-
import { EventManager } from '../events/eventManager.js';
|
|
3
|
-
import { ToolManager } from '../tools/toolManager.js';
|
|
4
|
-
import { PencilTool } from '../tools/PencilTool.js';
|
|
5
|
-
import { EraserTool } from '../tools/EraserTool.js';
|
|
6
|
-
import { SelectTool } from '../tools/SelectTool.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* @param {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
this.
|
|
39
|
-
this.
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
this.
|
|
48
|
-
this.
|
|
49
|
-
this.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Create the main SVG container
|
|
56
|
-
* @private
|
|
57
|
-
*/
|
|
58
|
-
_createSVGContainer() {
|
|
59
|
-
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
60
|
-
this.svg.setAttribute('width', this.config.width);
|
|
61
|
-
this.svg.setAttribute('height', this.config.height);
|
|
62
|
-
this.svg.setAttribute('viewBox', `0 0 ${this.config.width} ${this.config.height}`);
|
|
63
|
-
this.svg.style.cssText = `
|
|
64
|
-
display: block;
|
|
65
|
-
background: ${this.config.backgroundColor};
|
|
66
|
-
cursor: none;
|
|
67
|
-
touch-action: none;
|
|
68
|
-
user-select: none;
|
|
69
|
-
`;
|
|
70
|
-
|
|
71
|
-
this.container.appendChild(this.svg);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Create SVG layers for different canvas elements
|
|
76
|
-
* @private
|
|
77
|
-
*/
|
|
78
|
-
_createLayers() {
|
|
79
|
-
// Background layer (grid, etc.)
|
|
80
|
-
this.backgroundLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
81
|
-
this.backgroundLayer.setAttribute('class', 'background-layer');
|
|
82
|
-
this.svg.appendChild(this.backgroundLayer);
|
|
83
|
-
|
|
84
|
-
// Drawing layer (strokes)
|
|
85
|
-
this.drawingLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
86
|
-
this.drawingLayer.setAttribute('class', 'drawing-layer');
|
|
87
|
-
this.svg.appendChild(this.drawingLayer);
|
|
88
|
-
|
|
89
|
-
// UI layer (selection boxes, etc.)
|
|
90
|
-
this.uiLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
91
|
-
this.uiLayer.setAttribute('class', 'ui-layer');
|
|
92
|
-
this.svg.appendChild(this.uiLayer);
|
|
93
|
-
|
|
94
|
-
// Focus frame layer
|
|
95
|
-
this.focusFrameLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
96
|
-
this.focusFrameLayer.setAttribute('class', 'focus-frame-layer');
|
|
97
|
-
this.svg.appendChild(this.focusFrameLayer);
|
|
98
|
-
|
|
99
|
-
// Add grid if enabled
|
|
100
|
-
if (this.config.showGrid) {
|
|
101
|
-
this._createGrid();
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Initialize managers
|
|
107
|
-
* @private
|
|
108
|
-
*/
|
|
109
|
-
_initializeManagers() {
|
|
110
|
-
// Tool manager
|
|
111
|
-
this.toolManager = new ToolManager(this);
|
|
112
|
-
|
|
113
|
-
// Register default tools
|
|
114
|
-
if (this.config.enabledTools.includes('
|
|
115
|
-
this.toolManager.registerTool('
|
|
116
|
-
}
|
|
117
|
-
if (this.config.enabledTools.includes('
|
|
118
|
-
this.toolManager.registerTool('
|
|
119
|
-
}
|
|
120
|
-
if (this.config.enabledTools.includes('
|
|
121
|
-
this.toolManager.registerTool('
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
pattern.
|
|
175
|
-
pattern.setAttribute('
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
path.
|
|
181
|
-
path.setAttribute('
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
rect.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
this.
|
|
206
|
-
|
|
207
|
-
this.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
this.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
this.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
this.emit('cleared');
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Select strokes by IDs
|
|
254
|
-
* @param {Array<string>} strokeIds - Array of stroke IDs
|
|
255
|
-
*/
|
|
256
|
-
selectStrokes(strokeIds) {
|
|
257
|
-
this.selectedStrokes.clear();
|
|
258
|
-
strokeIds.forEach(id => {
|
|
259
|
-
if (this.strokes.has(id)) {
|
|
260
|
-
this.selectedStrokes.add(id);
|
|
261
|
-
}
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
this._updateStrokeSelection();
|
|
265
|
-
this.emit('selectionChanged', { selected: Array.from(this.selectedStrokes) });
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Update visual selection state of strokes
|
|
270
|
-
* @private
|
|
271
|
-
*/
|
|
272
|
-
_updateStrokeSelection() {
|
|
273
|
-
this.strokes.forEach((stroke, id) => {
|
|
274
|
-
const isSelected = this.selectedStrokes.has(id);
|
|
275
|
-
if (stroke.setSelected) {
|
|
276
|
-
stroke.setSelected(isSelected);
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Convert client coordinates to SVG coordinates
|
|
283
|
-
* @param {number} clientX - Client X coordinate
|
|
284
|
-
* @param {number} clientY - Client Y coordinate
|
|
285
|
-
* @returns {Object} {x, y} SVG coordinates
|
|
286
|
-
*/
|
|
287
|
-
clientToSVG(clientX, clientY) {
|
|
288
|
-
const rect = this.svg.getBoundingClientRect();
|
|
289
|
-
const scaleX = this.config.width / rect.width;
|
|
290
|
-
const scaleY = this.config.height / rect.height;
|
|
291
|
-
|
|
292
|
-
return {
|
|
293
|
-
x: (clientX - rect.left) * scaleX,
|
|
294
|
-
y: (clientY - rect.top) * scaleY
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Export canvas as SVG string
|
|
300
|
-
* @returns {string} SVG content
|
|
301
|
-
*/
|
|
302
|
-
exportSVG() {
|
|
303
|
-
const svgClone = this.svg.cloneNode(true);
|
|
304
|
-
|
|
305
|
-
// Remove UI elements from export
|
|
306
|
-
const uiLayer = svgClone.querySelector('.ui-layer');
|
|
307
|
-
if (uiLayer) uiLayer.remove();
|
|
308
|
-
|
|
309
|
-
const focusFrameLayer = svgClone.querySelector('.focus-frame-layer');
|
|
310
|
-
if (focusFrameLayer) focusFrameLayer.remove();
|
|
311
|
-
|
|
312
|
-
return new XMLSerializer().serializeToString(svgClone);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Export canvas as image
|
|
317
|
-
* @param {string} format - Image format (png, jpeg, webp)
|
|
318
|
-
* @param {number} quality - Image quality (0-1)
|
|
319
|
-
* @returns {Promise<Blob>} Image blob
|
|
320
|
-
*/
|
|
321
|
-
async exportImage(format = 'png', quality = 1) {
|
|
322
|
-
const svgData = this.exportSVG();
|
|
323
|
-
const canvas = document.createElement('canvas');
|
|
324
|
-
canvas.width = this.config.width;
|
|
325
|
-
canvas.height = this.config.height;
|
|
326
|
-
|
|
327
|
-
const ctx = canvas.getContext('2d');
|
|
328
|
-
const img = new Image();
|
|
329
|
-
|
|
330
|
-
const svgBlob = new Blob([svgData], { type: 'image/svg+xml' });
|
|
331
|
-
const url = URL.createObjectURL(svgBlob);
|
|
332
|
-
|
|
333
|
-
try {
|
|
334
|
-
await new Promise((resolve, reject) => {
|
|
335
|
-
img.onload = resolve;
|
|
336
|
-
img.onerror = reject;
|
|
337
|
-
img.src = url;
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
ctx.drawImage(img, 0, 0);
|
|
341
|
-
|
|
342
|
-
return new Promise(resolve => {
|
|
343
|
-
canvas.toBlob(resolve, `image/${format}`, quality);
|
|
344
|
-
});
|
|
345
|
-
} finally {
|
|
346
|
-
URL.revokeObjectURL(url);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Resize canvas
|
|
352
|
-
* @param {number} width - New width
|
|
353
|
-
* @param {number} height - New height
|
|
354
|
-
*/
|
|
355
|
-
resize(width, height) {
|
|
356
|
-
this.config.width = width;
|
|
357
|
-
this.config.height = height;
|
|
358
|
-
|
|
359
|
-
this.svg.setAttribute('width', width);
|
|
360
|
-
this.svg.setAttribute('height', height);
|
|
361
|
-
this.svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
362
|
-
|
|
363
|
-
this.emit('resized', { width, height });
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Handle window resize
|
|
368
|
-
* @private
|
|
369
|
-
*/
|
|
370
|
-
_handleResize() {
|
|
371
|
-
// Optional: implement responsive behavior
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Toggle grid visibility
|
|
376
|
-
*/
|
|
377
|
-
toggleGrid() {
|
|
378
|
-
if (this.backgroundLayer.querySelector('rect[fill="url(#grid)"]')) {
|
|
379
|
-
// Remove grid
|
|
380
|
-
const gridRect = this.backgroundLayer.querySelector('rect[fill="url(#grid)"]');
|
|
381
|
-
if (gridRect) gridRect.remove();
|
|
382
|
-
const defs = this.svg.querySelector('defs');
|
|
383
|
-
if (defs) defs.remove();
|
|
384
|
-
} else {
|
|
385
|
-
// Add grid
|
|
386
|
-
this._createGrid();
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Add event listener
|
|
392
|
-
* @param {string} event - Event name
|
|
393
|
-
* @param {Function} callback - Event callback
|
|
394
|
-
*/
|
|
395
|
-
on(event, callback) {
|
|
396
|
-
if (!this.listeners.has(event)) {
|
|
397
|
-
this.listeners.set(event, []);
|
|
398
|
-
}
|
|
399
|
-
this.listeners.get(event).push(callback);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Remove event listener
|
|
404
|
-
* @param {string} event - Event name
|
|
405
|
-
* @param {Function} callback - Event callback
|
|
406
|
-
*/
|
|
407
|
-
off(event, callback) {
|
|
408
|
-
const callbacks = this.listeners.get(event);
|
|
409
|
-
if (callbacks) {
|
|
410
|
-
const index = callbacks.indexOf(callback);
|
|
411
|
-
if (index !== -1) {
|
|
412
|
-
callbacks.splice(index, 1);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Emit event
|
|
419
|
-
* @param {string} event - Event name
|
|
420
|
-
* @param {Object} data - Event data
|
|
421
|
-
*/
|
|
422
|
-
emit(event, data = {}) {
|
|
423
|
-
const callbacks = this.listeners.get(event);
|
|
424
|
-
if (callbacks) {
|
|
425
|
-
callbacks.forEach(callback => {
|
|
426
|
-
try {
|
|
427
|
-
callback({ type: event, detail: data });
|
|
428
|
-
} catch (error) {
|
|
429
|
-
console.error(`Error in event listener for ${event}:`, error);
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Get canvas information
|
|
437
|
-
* @returns {Object} Canvas information
|
|
438
|
-
*/
|
|
439
|
-
getInfo() {
|
|
440
|
-
return {
|
|
441
|
-
width: this.config.width,
|
|
442
|
-
height: this.config.height,
|
|
443
|
-
strokeCount: this.strokes.size,
|
|
444
|
-
selectedStrokeCount: this.selectedStrokes.size,
|
|
445
|
-
activeTool: this.toolManager.getActiveTool()?.name,
|
|
446
|
-
availableTools: this.toolManager.getToolNames(),
|
|
447
|
-
isDestroyed: this.isDestroyed
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Destroy the canvas and clean up resources
|
|
453
|
-
*/
|
|
454
|
-
destroy() {
|
|
455
|
-
if (this.isDestroyed) return;
|
|
456
|
-
|
|
457
|
-
// Clean up event listeners
|
|
458
|
-
window.removeEventListener('resize', this._resizeHandler);
|
|
459
|
-
|
|
460
|
-
// Destroy managers
|
|
461
|
-
if (this.eventManager) this.eventManager.destroy();
|
|
462
|
-
if (this.toolManager) this.toolManager.destroy();
|
|
463
|
-
if (this.focusFrameManager) this.focusFrameManager.destroy();
|
|
464
|
-
if (this.toolbar) this.toolbar.destroy();
|
|
465
|
-
if (this.cursor) this.cursor.destroy();
|
|
466
|
-
|
|
467
|
-
// Remove DOM elements
|
|
468
|
-
if (this.svg.parentNode) {
|
|
469
|
-
this.svg.parentNode.removeChild(this.svg);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Clear state
|
|
473
|
-
this.strokes.clear();
|
|
474
|
-
this.selectedStrokes.clear();
|
|
475
|
-
this.listeners.clear();
|
|
476
|
-
|
|
477
|
-
this.isDestroyed = true;
|
|
478
|
-
this.emit('destroyed');
|
|
479
|
-
}
|
|
1
|
+
import { CanvasConfig } from './canvasConfig.js';
|
|
2
|
+
import { EventManager } from '../events/eventManager.js';
|
|
3
|
+
import { ToolManager } from '../tools/toolManager.js';
|
|
4
|
+
import { PencilTool } from '../tools/PencilTool.js';
|
|
5
|
+
import { EraserTool } from '../tools/EraserTool.js';
|
|
6
|
+
import { SelectTool } from '../tools/SelectTool.js';
|
|
7
|
+
import { PointerTool } from '../tools/PointerTool.js';
|
|
8
|
+
import { Cursor } from '../ui/cursor.js';
|
|
9
|
+
import { Toolbar } from '../ui/toolbar.js';
|
|
10
|
+
import { FocusFrameManager } from '../features/focusFrameManager.js';
|
|
11
|
+
import {jsvgGroup} from '@teachinglab/jsvg'
|
|
12
|
+
/**
|
|
13
|
+
* Main OMD Canvas class
|
|
14
|
+
* Provides the primary interface for creating and managing a drawing canvas
|
|
15
|
+
*/
|
|
16
|
+
export class omdCanvas {
|
|
17
|
+
/**
|
|
18
|
+
* @param {HTMLElement|string} container - Container element or selector
|
|
19
|
+
* @param {Object} options - Configuration options
|
|
20
|
+
*/
|
|
21
|
+
constructor(container, options = {}) {
|
|
22
|
+
// Resolve container element
|
|
23
|
+
this.container = typeof container === 'string'
|
|
24
|
+
? document.querySelector(container)
|
|
25
|
+
: container;
|
|
26
|
+
|
|
27
|
+
if (!this.container) {
|
|
28
|
+
throw new Error('Container element not found');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Set container to relative positioning for absolute positioned children
|
|
32
|
+
this.container.style.position = 'relative';
|
|
33
|
+
|
|
34
|
+
// Initialize configuration
|
|
35
|
+
this.config = new CanvasConfig(options);
|
|
36
|
+
|
|
37
|
+
// Initialize state
|
|
38
|
+
this.strokes = new Map();
|
|
39
|
+
this.selectedStrokes = new Set();
|
|
40
|
+
this.isDestroyed = false;
|
|
41
|
+
this.strokeCounter = 0;
|
|
42
|
+
|
|
43
|
+
// Event system
|
|
44
|
+
this.listeners = new Map();
|
|
45
|
+
|
|
46
|
+
// Initialize canvas
|
|
47
|
+
this._createSVGContainer();
|
|
48
|
+
this._createLayers();
|
|
49
|
+
this._initializeManagers();
|
|
50
|
+
this._setupEventListeners();
|
|
51
|
+
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create the main SVG container
|
|
56
|
+
* @private
|
|
57
|
+
*/
|
|
58
|
+
_createSVGContainer() {
|
|
59
|
+
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
60
|
+
this.svg.setAttribute('width', this.config.width);
|
|
61
|
+
this.svg.setAttribute('height', this.config.height);
|
|
62
|
+
this.svg.setAttribute('viewBox', `0 0 ${this.config.width} ${this.config.height}`);
|
|
63
|
+
this.svg.style.cssText = `
|
|
64
|
+
display: block;
|
|
65
|
+
background: ${this.config.backgroundColor};
|
|
66
|
+
cursor: none;
|
|
67
|
+
touch-action: none;
|
|
68
|
+
user-select: none;
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
this.container.appendChild(this.svg);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create SVG layers for different canvas elements
|
|
76
|
+
* @private
|
|
77
|
+
*/
|
|
78
|
+
_createLayers() {
|
|
79
|
+
// Background layer (grid, etc.)
|
|
80
|
+
this.backgroundLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
81
|
+
this.backgroundLayer.setAttribute('class', 'background-layer');
|
|
82
|
+
this.svg.appendChild(this.backgroundLayer);
|
|
83
|
+
|
|
84
|
+
// Drawing layer (strokes)
|
|
85
|
+
this.drawingLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
86
|
+
this.drawingLayer.setAttribute('class', 'drawing-layer');
|
|
87
|
+
this.svg.appendChild(this.drawingLayer);
|
|
88
|
+
|
|
89
|
+
// UI layer (selection boxes, etc.)
|
|
90
|
+
this.uiLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
91
|
+
this.uiLayer.setAttribute('class', 'ui-layer');
|
|
92
|
+
this.svg.appendChild(this.uiLayer);
|
|
93
|
+
|
|
94
|
+
// Focus frame layer
|
|
95
|
+
this.focusFrameLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
96
|
+
this.focusFrameLayer.setAttribute('class', 'focus-frame-layer');
|
|
97
|
+
this.svg.appendChild(this.focusFrameLayer);
|
|
98
|
+
|
|
99
|
+
// Add grid if enabled
|
|
100
|
+
if (this.config.showGrid) {
|
|
101
|
+
this._createGrid();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Initialize managers
|
|
107
|
+
* @private
|
|
108
|
+
*/
|
|
109
|
+
_initializeManagers() {
|
|
110
|
+
// Tool manager
|
|
111
|
+
this.toolManager = new ToolManager(this);
|
|
112
|
+
|
|
113
|
+
// Register default tools
|
|
114
|
+
if (this.config.enabledTools.includes('pointer')) {
|
|
115
|
+
this.toolManager.registerTool('pointer', new PointerTool(this));
|
|
116
|
+
}
|
|
117
|
+
if (this.config.enabledTools.includes('pencil')) {
|
|
118
|
+
this.toolManager.registerTool('pencil', new PencilTool(this));
|
|
119
|
+
}
|
|
120
|
+
if (this.config.enabledTools.includes('eraser')) {
|
|
121
|
+
this.toolManager.registerTool('eraser', new EraserTool(this));
|
|
122
|
+
}
|
|
123
|
+
if (this.config.enabledTools.includes('select')) {
|
|
124
|
+
this.toolManager.registerTool('select', new SelectTool(this));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Set default tool
|
|
128
|
+
if (this.config.defaultTool && this.config.enabledTools.includes(this.config.defaultTool)) {
|
|
129
|
+
this.toolManager.setActiveTool(this.config.defaultTool);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Event manager
|
|
133
|
+
this.eventManager = new EventManager(this);
|
|
134
|
+
this.eventManager.initialize();
|
|
135
|
+
|
|
136
|
+
// Cursor
|
|
137
|
+
this.cursor = new Cursor(this);
|
|
138
|
+
|
|
139
|
+
// Toolbar
|
|
140
|
+
if (this.config.showToolbar) {
|
|
141
|
+
this.toolbar = new Toolbar(this);
|
|
142
|
+
// Toolbar is now added directly to container in its constructor
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Cursor will be shown/hidden based on mouse enter/leave events
|
|
146
|
+
|
|
147
|
+
// Focus frame manager
|
|
148
|
+
if (this.config.enableFocusFrames) {
|
|
149
|
+
this.focusFrameManager = new FocusFrameManager(this);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Setup event listeners
|
|
155
|
+
* @private
|
|
156
|
+
*/
|
|
157
|
+
_setupEventListeners() {
|
|
158
|
+
// Listen for window resize
|
|
159
|
+
this._resizeHandler = () => this._handleResize();
|
|
160
|
+
window.addEventListener('resize', this._resizeHandler);
|
|
161
|
+
|
|
162
|
+
// Hide cursor initially - EventManager will handle show/hide
|
|
163
|
+
if (this.cursor) {
|
|
164
|
+
this.cursor.hide();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create grid background
|
|
170
|
+
* @private
|
|
171
|
+
*/
|
|
172
|
+
_createGrid() {
|
|
173
|
+
const gridSize = 20;
|
|
174
|
+
const pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern');
|
|
175
|
+
pattern.setAttribute('id', 'grid');
|
|
176
|
+
pattern.setAttribute('width', gridSize);
|
|
177
|
+
pattern.setAttribute('height', gridSize);
|
|
178
|
+
pattern.setAttribute('patternUnits', 'userSpaceOnUse');
|
|
179
|
+
|
|
180
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
181
|
+
path.setAttribute('d', `M ${gridSize} 0 L 0 0 0 ${gridSize}`);
|
|
182
|
+
path.setAttribute('fill', 'none');
|
|
183
|
+
path.setAttribute('stroke', '#ddd');
|
|
184
|
+
path.setAttribute('stroke-width', '1');
|
|
185
|
+
|
|
186
|
+
pattern.appendChild(path);
|
|
187
|
+
|
|
188
|
+
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
189
|
+
defs.appendChild(pattern);
|
|
190
|
+
this.svg.insertBefore(defs, this.backgroundLayer);
|
|
191
|
+
|
|
192
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
193
|
+
rect.setAttribute('width', '100%');
|
|
194
|
+
rect.setAttribute('height', '100%');
|
|
195
|
+
rect.setAttribute('fill', 'url(#grid)');
|
|
196
|
+
this.backgroundLayer.appendChild(rect);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Add a stroke to the canvas
|
|
201
|
+
* @param {Stroke} stroke - Stroke to add
|
|
202
|
+
* @returns {string} Stroke ID
|
|
203
|
+
*/
|
|
204
|
+
addStroke(stroke) {
|
|
205
|
+
const id = `stroke_${++this.strokeCounter}`;
|
|
206
|
+
stroke.id = id;
|
|
207
|
+
this.strokes.set(id, stroke);
|
|
208
|
+
this.drawingLayer.appendChild(stroke.element);
|
|
209
|
+
|
|
210
|
+
this.emit('strokeAdded', { id, stroke });
|
|
211
|
+
return id;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Remove a stroke from the canvas
|
|
216
|
+
* @param {string} strokeId - ID of stroke to remove
|
|
217
|
+
* @returns {boolean} True if stroke was removed
|
|
218
|
+
*/
|
|
219
|
+
removeStroke(strokeId) {
|
|
220
|
+
const stroke = this.strokes.get(strokeId);
|
|
221
|
+
if (!stroke) return false;
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if (stroke.element.parentNode) {
|
|
225
|
+
stroke.element.parentNode.removeChild(stroke.element);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.strokes.delete(strokeId);
|
|
229
|
+
this.selectedStrokes.delete(strokeId);
|
|
230
|
+
|
|
231
|
+
this.emit('strokeRemoved', { strokeId });
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Clear all strokes
|
|
237
|
+
*/
|
|
238
|
+
clear() {
|
|
239
|
+
|
|
240
|
+
this.strokes.forEach((stroke, id) => {
|
|
241
|
+
if (stroke.element.parentNode) {
|
|
242
|
+
stroke.element.parentNode.removeChild(stroke.element);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
this.strokes.clear();
|
|
247
|
+
this.selectedStrokes.clear();
|
|
248
|
+
|
|
249
|
+
this.emit('cleared');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Select strokes by IDs
|
|
254
|
+
* @param {Array<string>} strokeIds - Array of stroke IDs
|
|
255
|
+
*/
|
|
256
|
+
selectStrokes(strokeIds) {
|
|
257
|
+
this.selectedStrokes.clear();
|
|
258
|
+
strokeIds.forEach(id => {
|
|
259
|
+
if (this.strokes.has(id)) {
|
|
260
|
+
this.selectedStrokes.add(id);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
this._updateStrokeSelection();
|
|
265
|
+
this.emit('selectionChanged', { selected: Array.from(this.selectedStrokes) });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Update visual selection state of strokes
|
|
270
|
+
* @private
|
|
271
|
+
*/
|
|
272
|
+
_updateStrokeSelection() {
|
|
273
|
+
this.strokes.forEach((stroke, id) => {
|
|
274
|
+
const isSelected = this.selectedStrokes.has(id);
|
|
275
|
+
if (stroke.setSelected) {
|
|
276
|
+
stroke.setSelected(isSelected);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Convert client coordinates to SVG coordinates
|
|
283
|
+
* @param {number} clientX - Client X coordinate
|
|
284
|
+
* @param {number} clientY - Client Y coordinate
|
|
285
|
+
* @returns {Object} {x, y} SVG coordinates
|
|
286
|
+
*/
|
|
287
|
+
clientToSVG(clientX, clientY) {
|
|
288
|
+
const rect = this.svg.getBoundingClientRect();
|
|
289
|
+
const scaleX = this.config.width / rect.width;
|
|
290
|
+
const scaleY = this.config.height / rect.height;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
x: (clientX - rect.left) * scaleX,
|
|
294
|
+
y: (clientY - rect.top) * scaleY
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Export canvas as SVG string
|
|
300
|
+
* @returns {string} SVG content
|
|
301
|
+
*/
|
|
302
|
+
exportSVG() {
|
|
303
|
+
const svgClone = this.svg.cloneNode(true);
|
|
304
|
+
|
|
305
|
+
// Remove UI elements from export
|
|
306
|
+
const uiLayer = svgClone.querySelector('.ui-layer');
|
|
307
|
+
if (uiLayer) uiLayer.remove();
|
|
308
|
+
|
|
309
|
+
const focusFrameLayer = svgClone.querySelector('.focus-frame-layer');
|
|
310
|
+
if (focusFrameLayer) focusFrameLayer.remove();
|
|
311
|
+
|
|
312
|
+
return new XMLSerializer().serializeToString(svgClone);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Export canvas as image
|
|
317
|
+
* @param {string} format - Image format (png, jpeg, webp)
|
|
318
|
+
* @param {number} quality - Image quality (0-1)
|
|
319
|
+
* @returns {Promise<Blob>} Image blob
|
|
320
|
+
*/
|
|
321
|
+
async exportImage(format = 'png', quality = 1) {
|
|
322
|
+
const svgData = this.exportSVG();
|
|
323
|
+
const canvas = document.createElement('canvas');
|
|
324
|
+
canvas.width = this.config.width;
|
|
325
|
+
canvas.height = this.config.height;
|
|
326
|
+
|
|
327
|
+
const ctx = canvas.getContext('2d');
|
|
328
|
+
const img = new Image();
|
|
329
|
+
|
|
330
|
+
const svgBlob = new Blob([svgData], { type: 'image/svg+xml' });
|
|
331
|
+
const url = URL.createObjectURL(svgBlob);
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
await new Promise((resolve, reject) => {
|
|
335
|
+
img.onload = resolve;
|
|
336
|
+
img.onerror = reject;
|
|
337
|
+
img.src = url;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
ctx.drawImage(img, 0, 0);
|
|
341
|
+
|
|
342
|
+
return new Promise(resolve => {
|
|
343
|
+
canvas.toBlob(resolve, `image/${format}`, quality);
|
|
344
|
+
});
|
|
345
|
+
} finally {
|
|
346
|
+
URL.revokeObjectURL(url);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Resize canvas
|
|
352
|
+
* @param {number} width - New width
|
|
353
|
+
* @param {number} height - New height
|
|
354
|
+
*/
|
|
355
|
+
resize(width, height) {
|
|
356
|
+
this.config.width = width;
|
|
357
|
+
this.config.height = height;
|
|
358
|
+
|
|
359
|
+
this.svg.setAttribute('width', width);
|
|
360
|
+
this.svg.setAttribute('height', height);
|
|
361
|
+
this.svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
362
|
+
|
|
363
|
+
this.emit('resized', { width, height });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Handle window resize
|
|
368
|
+
* @private
|
|
369
|
+
*/
|
|
370
|
+
_handleResize() {
|
|
371
|
+
// Optional: implement responsive behavior
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Toggle grid visibility
|
|
376
|
+
*/
|
|
377
|
+
toggleGrid() {
|
|
378
|
+
if (this.backgroundLayer.querySelector('rect[fill="url(#grid)"]')) {
|
|
379
|
+
// Remove grid
|
|
380
|
+
const gridRect = this.backgroundLayer.querySelector('rect[fill="url(#grid)"]');
|
|
381
|
+
if (gridRect) gridRect.remove();
|
|
382
|
+
const defs = this.svg.querySelector('defs');
|
|
383
|
+
if (defs) defs.remove();
|
|
384
|
+
} else {
|
|
385
|
+
// Add grid
|
|
386
|
+
this._createGrid();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Add event listener
|
|
392
|
+
* @param {string} event - Event name
|
|
393
|
+
* @param {Function} callback - Event callback
|
|
394
|
+
*/
|
|
395
|
+
on(event, callback) {
|
|
396
|
+
if (!this.listeners.has(event)) {
|
|
397
|
+
this.listeners.set(event, []);
|
|
398
|
+
}
|
|
399
|
+
this.listeners.get(event).push(callback);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Remove event listener
|
|
404
|
+
* @param {string} event - Event name
|
|
405
|
+
* @param {Function} callback - Event callback
|
|
406
|
+
*/
|
|
407
|
+
off(event, callback) {
|
|
408
|
+
const callbacks = this.listeners.get(event);
|
|
409
|
+
if (callbacks) {
|
|
410
|
+
const index = callbacks.indexOf(callback);
|
|
411
|
+
if (index !== -1) {
|
|
412
|
+
callbacks.splice(index, 1);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Emit event
|
|
419
|
+
* @param {string} event - Event name
|
|
420
|
+
* @param {Object} data - Event data
|
|
421
|
+
*/
|
|
422
|
+
emit(event, data = {}) {
|
|
423
|
+
const callbacks = this.listeners.get(event);
|
|
424
|
+
if (callbacks) {
|
|
425
|
+
callbacks.forEach(callback => {
|
|
426
|
+
try {
|
|
427
|
+
callback({ type: event, detail: data });
|
|
428
|
+
} catch (error) {
|
|
429
|
+
console.error(`Error in event listener for ${event}:`, error);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Get canvas information
|
|
437
|
+
* @returns {Object} Canvas information
|
|
438
|
+
*/
|
|
439
|
+
getInfo() {
|
|
440
|
+
return {
|
|
441
|
+
width: this.config.width,
|
|
442
|
+
height: this.config.height,
|
|
443
|
+
strokeCount: this.strokes.size,
|
|
444
|
+
selectedStrokeCount: this.selectedStrokes.size,
|
|
445
|
+
activeTool: this.toolManager.getActiveTool()?.name,
|
|
446
|
+
availableTools: this.toolManager.getToolNames(),
|
|
447
|
+
isDestroyed: this.isDestroyed
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Destroy the canvas and clean up resources
|
|
453
|
+
*/
|
|
454
|
+
destroy() {
|
|
455
|
+
if (this.isDestroyed) return;
|
|
456
|
+
|
|
457
|
+
// Clean up event listeners
|
|
458
|
+
window.removeEventListener('resize', this._resizeHandler);
|
|
459
|
+
|
|
460
|
+
// Destroy managers
|
|
461
|
+
if (this.eventManager) this.eventManager.destroy();
|
|
462
|
+
if (this.toolManager) this.toolManager.destroy();
|
|
463
|
+
if (this.focusFrameManager) this.focusFrameManager.destroy();
|
|
464
|
+
if (this.toolbar) this.toolbar.destroy();
|
|
465
|
+
if (this.cursor) this.cursor.destroy();
|
|
466
|
+
|
|
467
|
+
// Remove DOM elements
|
|
468
|
+
if (this.svg.parentNode) {
|
|
469
|
+
this.svg.parentNode.removeChild(this.svg);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Clear state
|
|
473
|
+
this.strokes.clear();
|
|
474
|
+
this.selectedStrokes.clear();
|
|
475
|
+
this.listeners.clear();
|
|
476
|
+
|
|
477
|
+
this.isDestroyed = true;
|
|
478
|
+
this.emit('destroyed');
|
|
479
|
+
}
|
|
480
480
|
}
|