@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.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Manages resize handles for OMD visuals
3
+ * Provides functionality for creating, positioning, and handling resize operations
4
+ */
5
+ export class ResizeHandleManager {
6
+ constructor(canvas) {
7
+ this.canvas = canvas;
8
+ this.selectedElement = null;
9
+ this.handles = [];
10
+ this.isResizing = false;
11
+ this.resizeData = null;
12
+
13
+ // Handle configuration
14
+ this.handleSize = 8;
15
+ this.handleColor = '#007bff';
16
+ this.handleStrokeColor = '#ffffff';
17
+ this.handleStrokeWidth = 1;
18
+
19
+ // Selection border config
20
+ this.selectionBorderColor = '#007bff';
21
+ this.selectionBorderWidth = 2;
22
+ this.selectionBorder = null;
23
+
24
+ // Resize constraints
25
+ this.minSize = 20;
26
+ this.maxSize = 800;
27
+ this.maintainAspectRatio = false; // Can be toggled with shift key
28
+ }
29
+
30
+ /**
31
+ * Select an OMD visual element and show resize handles
32
+ * @param {SVGElement} element - The OMD wrapper element to select
33
+ */
34
+ selectElement(element) {
35
+ this.clearSelection();
36
+
37
+ if (!element || !element.classList.contains('omd-item')) {
38
+ return;
39
+ }
40
+
41
+ this.selectedElement = element;
42
+ this._createSelectionBorder();
43
+ this._createResizeHandles();
44
+ this._updateHandlePositions();
45
+
46
+ // Add selected class for CSS styling
47
+ element.classList.add('omd-selected');
48
+
49
+ // Emit selection event
50
+ this.canvas.emit('omdElementSelected', { element });
51
+ }
52
+
53
+ /**
54
+ * Clear current selection and remove all handles
55
+ */
56
+ clearSelection() {
57
+ if (this.selectedElement) {
58
+ this.selectedElement.classList.remove('omd-selected');
59
+ this.selectedElement = null;
60
+ }
61
+
62
+ this._removeSelectionBorder();
63
+ this._removeResizeHandles();
64
+ this.isResizing = false;
65
+ this.resizeData = null;
66
+
67
+ // Emit deselection event
68
+ this.canvas.emit('omdElementDeselected');
69
+ }
70
+
71
+ /**
72
+ * Check if a point is over a resize handle
73
+ * @param {number} x - X coordinate in canvas coordinates
74
+ * @param {number} y - Y coordinate in canvas coordinates
75
+ * @returns {Object|null} Handle data if hit, null otherwise
76
+ */
77
+ getHandleAtPoint(x, y) {
78
+ const tolerance = this.handleSize / 2 + 3; // Slightly larger hit area
79
+
80
+ for (const handle of this.handles) {
81
+ // Get handle position from its attributes
82
+ const handleX = parseFloat(handle.element.getAttribute('x')) + this.handleSize / 2;
83
+ const handleY = parseFloat(handle.element.getAttribute('y')) + this.handleSize / 2;
84
+
85
+ const distance = Math.hypot(x - handleX, y - handleY);
86
+ if (distance <= tolerance) {
87
+ return handle;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Start resize operation
95
+ * @param {Object} handle - Handle data
96
+ * @param {number} startX - Starting X coordinate
97
+ * @param {number} startY - Starting Y coordinate
98
+ * @param {boolean} maintainAspectRatio - Whether to maintain aspect ratio
99
+ */
100
+ startResize(handle, startX, startY, maintainAspectRatio = false) {
101
+ if (!this.selectedElement || !handle) return;
102
+
103
+ this.isResizing = true;
104
+ this.maintainAspectRatio = maintainAspectRatio;
105
+
106
+ // Get current transform and bounds
107
+ const transform = this.selectedElement.getAttribute('transform') || 'translate(0,0)';
108
+ const match = transform.match(/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/);
109
+ const currentX = match ? parseFloat(match[1]) : 0;
110
+ const currentY = match ? parseFloat(match[2]) : 0;
111
+
112
+ // Get current scale
113
+ const scaleMatch = transform.match(/scale\(\s*([^,)]+)(?:\s*,\s*([^)]+))?\s*\)/);
114
+ const currentScaleX = scaleMatch ? parseFloat(scaleMatch[1]) : 1;
115
+ const currentScaleY = scaleMatch ? (scaleMatch[2] ? parseFloat(scaleMatch[2]) : currentScaleX) : 1;
116
+
117
+ // Get bounding box of the content
118
+ const bbox = this._getElementBounds();
119
+
120
+ this.resizeData = {
121
+ handle,
122
+ startX,
123
+ startY,
124
+ originalTransform: transform,
125
+ currentX,
126
+ currentY,
127
+ currentScaleX,
128
+ currentScaleY,
129
+ originalBounds: bbox,
130
+ startWidth: bbox.width * currentScaleX,
131
+ startHeight: bbox.height * currentScaleY
132
+ };
133
+
134
+ // Add resizing class
135
+ this.selectedElement.classList.add('omd-resizing');
136
+ }
137
+
138
+ /**
139
+ * Update resize operation
140
+ * @param {number} currentX - Current X coordinate
141
+ * @param {number} currentY - Current Y coordinate
142
+ */
143
+ updateResize(currentX, currentY) {
144
+ if (!this.isResizing || !this.resizeData) return;
145
+
146
+ const { handle, startX, startY, originalBounds, startWidth, startHeight } = this.resizeData;
147
+
148
+ const deltaX = currentX - startX;
149
+ const deltaY = currentY - startY;
150
+
151
+ let newWidth = startWidth;
152
+ let newHeight = startHeight;
153
+ let offsetX = 0;
154
+ let offsetY = 0;
155
+
156
+ // Calculate new dimensions based on handle type
157
+ switch (handle.type) {
158
+ case 'nw': // Top-left corner
159
+ newWidth = startWidth - deltaX;
160
+ newHeight = startHeight - deltaY;
161
+ offsetX = deltaX;
162
+ offsetY = deltaY;
163
+ break;
164
+ case 'ne': // Top-right corner
165
+ newWidth = startWidth + deltaX;
166
+ newHeight = startHeight - deltaY;
167
+ offsetY = deltaY;
168
+ break;
169
+ case 'sw': // Bottom-left corner
170
+ newWidth = startWidth - deltaX;
171
+ newHeight = startHeight + deltaY;
172
+ offsetX = deltaX;
173
+ break;
174
+ case 'se': // Bottom-right corner
175
+ newWidth = startWidth + deltaX;
176
+ newHeight = startHeight + deltaY;
177
+ break;
178
+ case 'n': // Top edge
179
+ newHeight = startHeight - deltaY;
180
+ offsetY = deltaY;
181
+ break;
182
+ case 's': // Bottom edge
183
+ newHeight = startHeight + deltaY;
184
+ break;
185
+ case 'w': // Left edge
186
+ newWidth = startWidth - deltaX;
187
+ offsetX = deltaX;
188
+ break;
189
+ case 'e': // Right edge
190
+ newWidth = startWidth + deltaX;
191
+ break;
192
+ }
193
+
194
+ // Maintain aspect ratio if requested
195
+ if (this.maintainAspectRatio) {
196
+ const aspectRatio = startWidth / startHeight;
197
+
198
+ if (handle.type.includes('e') || handle.type.includes('w')) {
199
+ // Width-based resize
200
+ newHeight = newWidth / aspectRatio;
201
+ } else if (handle.type.includes('n') || handle.type.includes('s')) {
202
+ // Height-based resize
203
+ newWidth = newHeight * aspectRatio;
204
+ } else {
205
+ // Corner resize - use the dimension with larger change
206
+ const widthChange = Math.abs(newWidth - startWidth);
207
+ const heightChange = Math.abs(newHeight - startHeight);
208
+
209
+ if (widthChange > heightChange) {
210
+ newHeight = newWidth / aspectRatio;
211
+ } else {
212
+ newWidth = newHeight * aspectRatio;
213
+ }
214
+ }
215
+ }
216
+
217
+ // Apply size constraints
218
+ newWidth = Math.max(this.minSize, Math.min(this.maxSize, newWidth));
219
+ newHeight = Math.max(this.minSize, Math.min(this.maxSize, newHeight));
220
+
221
+ // Calculate scale factors
222
+ const scaleX = newWidth / originalBounds.width;
223
+ const scaleY = newHeight / originalBounds.height;
224
+
225
+ // Calculate new position
226
+ const newX = this.resizeData.currentX + offsetX;
227
+ const newY = this.resizeData.currentY + offsetY;
228
+
229
+ // Apply transform
230
+ this.selectedElement.setAttribute('transform',
231
+ `translate(${newX}, ${newY}) scale(${scaleX}, ${scaleY})`);
232
+
233
+ // Update handle positions
234
+ this._updateHandlePositions();
235
+ this._updateSelectionBorder();
236
+ }
237
+
238
+ /**
239
+ * Finish resize operation
240
+ */
241
+ finishResize() {
242
+ if (!this.isResizing) return;
243
+
244
+ this.isResizing = false;
245
+
246
+ if (this.selectedElement) {
247
+ this.selectedElement.classList.remove('omd-resizing');
248
+ }
249
+
250
+ // Update handle positions after resize is complete
251
+ this._updateHandlePositions();
252
+ this._updateSelectionBorder();
253
+
254
+ // Emit resize complete event
255
+ this.canvas.emit('omdElementResized', {
256
+ element: this.selectedElement,
257
+ transform: this.selectedElement.getAttribute('transform')
258
+ });
259
+
260
+ this.resizeData = null;
261
+ }
262
+
263
+ /**
264
+ * Update handle positions for currently selected element (called externally)
265
+ */
266
+ updateIfSelected(element) {
267
+ if (this.selectedElement && this.selectedElement === element) {
268
+ this._updateHandlePositions();
269
+ this._updateSelectionBorder();
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Get cursor for handle type
275
+ * @param {string} handleType - Type of handle
276
+ * @returns {string} CSS cursor value
277
+ */
278
+ getCursorForHandle(handleType) {
279
+ const cursors = {
280
+ 'nw': 'nw-resize',
281
+ 'n': 'n-resize',
282
+ 'ne': 'ne-resize',
283
+ 'e': 'e-resize',
284
+ 'se': 'se-resize',
285
+ 's': 's-resize',
286
+ 'sw': 'sw-resize',
287
+ 'w': 'w-resize'
288
+ };
289
+ return cursors[handleType] || 'default';
290
+ }
291
+
292
+ /**
293
+ * Create selection border around element
294
+ * @private
295
+ */
296
+ _createSelectionBorder() {
297
+ if (!this.selectedElement) return;
298
+
299
+ this.selectionBorder = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
300
+ this.selectionBorder.setAttribute('fill', 'none');
301
+ this.selectionBorder.setAttribute('stroke', this.selectionBorderColor);
302
+ this.selectionBorder.setAttribute('stroke-width', this.selectionBorderWidth);
303
+ this.selectionBorder.setAttribute('stroke-dasharray', '4,2');
304
+ this.selectionBorder.style.pointerEvents = 'none';
305
+ this.selectionBorder.classList.add('omd-selection-border');
306
+
307
+ this.canvas.uiLayer.appendChild(this.selectionBorder);
308
+ this._updateSelectionBorder();
309
+ }
310
+
311
+ /**
312
+ * Update selection border position and size
313
+ * @private
314
+ */
315
+ _updateSelectionBorder() {
316
+ if (!this.selectionBorder || !this.selectedElement) return;
317
+
318
+ const bounds = this._getTransformedBounds();
319
+ const padding = 3;
320
+
321
+ this.selectionBorder.setAttribute('x', bounds.x - padding);
322
+ this.selectionBorder.setAttribute('y', bounds.y - padding);
323
+ this.selectionBorder.setAttribute('width', bounds.width + padding * 2);
324
+ this.selectionBorder.setAttribute('height', bounds.height + padding * 2);
325
+ }
326
+
327
+ /**
328
+ * Remove selection border
329
+ * @private
330
+ */
331
+ _removeSelectionBorder() {
332
+ if (this.selectionBorder) {
333
+ this.selectionBorder.remove();
334
+ this.selectionBorder = null;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Create resize handles around the selected element
340
+ * @private
341
+ */
342
+ _createResizeHandles() {
343
+ if (!this.selectedElement) return;
344
+
345
+ const handleTypes = [
346
+ { type: 'nw', pos: 'top-left' },
347
+ { type: 'n', pos: 'top-center' },
348
+ { type: 'ne', pos: 'top-right' },
349
+ { type: 'e', pos: 'middle-right' },
350
+ { type: 'se', pos: 'bottom-right' },
351
+ { type: 's', pos: 'bottom-center' },
352
+ { type: 'sw', pos: 'bottom-left' },
353
+ { type: 'w', pos: 'middle-left' }
354
+ ];
355
+
356
+ handleTypes.forEach(handleDef => {
357
+ const handle = this._createHandle(handleDef.type, handleDef.pos);
358
+ this.handles.push(handle);
359
+ this.canvas.uiLayer.appendChild(handle.element);
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Create individual resize handle
365
+ * @private
366
+ */
367
+ _createHandle(type, position) {
368
+ const handle = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
369
+
370
+ handle.setAttribute('width', this.handleSize);
371
+ handle.setAttribute('height', this.handleSize);
372
+ handle.setAttribute('fill', this.handleColor);
373
+ handle.setAttribute('stroke', this.handleStrokeColor);
374
+ handle.setAttribute('stroke-width', this.handleStrokeWidth);
375
+ handle.setAttribute('rx', 1);
376
+ handle.style.cursor = this.getCursorForHandle(type);
377
+ handle.classList.add('resize-handle', `resize-handle-${type}`);
378
+
379
+ // Add hover effects
380
+ handle.addEventListener('mouseenter', () => {
381
+ handle.setAttribute('fill', '#0056b3');
382
+ });
383
+
384
+ handle.addEventListener('mouseleave', () => {
385
+ handle.setAttribute('fill', this.handleColor);
386
+ });
387
+
388
+ return {
389
+ element: handle,
390
+ type,
391
+ position
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Update positions of all resize handles
397
+ * @private
398
+ */
399
+ _updateHandlePositions() {
400
+ if (!this.selectedElement || this.handles.length === 0) return;
401
+
402
+ const bounds = this._getTransformedBounds();
403
+ const halfHandle = this.handleSize / 2;
404
+
405
+ const positions = {
406
+ 'nw': { x: bounds.x - halfHandle, y: bounds.y - halfHandle },
407
+ 'n': { x: bounds.x + bounds.width/2 - halfHandle, y: bounds.y - halfHandle },
408
+ 'ne': { x: bounds.x + bounds.width - halfHandle, y: bounds.y - halfHandle },
409
+ 'e': { x: bounds.x + bounds.width - halfHandle, y: bounds.y + bounds.height/2 - halfHandle },
410
+ 'se': { x: bounds.x + bounds.width - halfHandle, y: bounds.y + bounds.height - halfHandle },
411
+ 's': { x: bounds.x + bounds.width/2 - halfHandle, y: bounds.y + bounds.height - halfHandle },
412
+ 'sw': { x: bounds.x - halfHandle, y: bounds.y + bounds.height - halfHandle },
413
+ 'w': { x: bounds.x - halfHandle, y: bounds.y + bounds.height/2 - halfHandle }
414
+ };
415
+
416
+ this.handles.forEach(handle => {
417
+ const pos = positions[handle.type];
418
+ if (pos) {
419
+ handle.element.setAttribute('x', pos.x);
420
+ handle.element.setAttribute('y', pos.y);
421
+ }
422
+ });
423
+ }
424
+
425
+ /**
426
+ * Remove all resize handles
427
+ * @private
428
+ */
429
+ _removeResizeHandles() {
430
+ this.handles.forEach(handle => {
431
+ handle.element.remove();
432
+ });
433
+ this.handles = [];
434
+ }
435
+
436
+ /**
437
+ * Get element bounds without transform
438
+ * @private
439
+ */
440
+ _getElementBounds() {
441
+ if (!this.selectedElement) return { x: 0, y: 0, width: 0, height: 0 };
442
+
443
+ try {
444
+ // Get bounding box of the content inside the wrapper
445
+ const content = this.selectedElement.firstElementChild;
446
+ if (content) {
447
+ return content.getBBox();
448
+ } else {
449
+ return this.selectedElement.getBBox();
450
+ }
451
+ } catch (error) {
452
+ // Fallback if getBBox fails
453
+ return { x: 0, y: 0, width: 100, height: 100 };
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Get element bounds with current transform applied
459
+ * @private
460
+ */
461
+ _getTransformedBounds() {
462
+ if (!this.selectedElement) return { x: 0, y: 0, width: 0, height: 0 };
463
+
464
+ const transform = this.selectedElement.getAttribute('transform') || '';
465
+ const translateMatch = transform.match(/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/);
466
+ const scaleMatch = transform.match(/scale\(\s*([^,)]+)(?:\s*,\s*([^)]+))?\s*\)/);
467
+
468
+ const translateX = translateMatch ? parseFloat(translateMatch[1]) : 0;
469
+ const translateY = translateMatch ? parseFloat(translateMatch[2]) : 0;
470
+ const scaleX = scaleMatch ? parseFloat(scaleMatch[1]) : 1;
471
+ const scaleY = scaleMatch ? (scaleMatch[2] ? parseFloat(scaleMatch[2]) : scaleX) : 1;
472
+
473
+ const bounds = this._getElementBounds();
474
+
475
+ return {
476
+ x: translateX + (bounds.x * scaleX),
477
+ y: translateY + (bounds.y * scaleY),
478
+ width: bounds.width * scaleX,
479
+ height: bounds.height * scaleY
480
+ };
481
+ }
482
+ }
package/canvas/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Core canvas system
2
- export { omdCanvas } from './core/omdCanvas.js';
2
+ import { omdCanvas } from './core/omdCanvas.js';
3
+ export { omdCanvas };
3
4
  export { CanvasConfig as canvasConfig } from './core/canvasConfig.js';
4
5
 
5
6
  // Event handling