@ashraf_mizo/htmlcanvas 1.0.0

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,432 @@
1
+ // editor/selection.js — Hover highlight, click-to-select, selection overlay, hit-testing
2
+ //
3
+ // Exports:
4
+ // initSelection(iframe, iframeDoc, canvasArea) — attach listeners on iframe-ready
5
+ // getSelectedElement() — returns selected DOM element or null
6
+ // getSelectedHcId() — returns selected data-hc-id or null
7
+ // clearSelection() — hides overlay, nulls state
8
+ // resolveSelectable(element) — exported for unit tests
9
+
10
+ import { getElementCanvasRect } from './coords.js';
11
+ import { addToSelection, removeFromSelection, getSelectedElements, clearMultiSelection } from './multiSelect.js';
12
+ import { isLocked } from './layers.js';
13
+ import { getZoom } from './zoom.js';
14
+
15
+ // ── Exclusion list ────────────────────────────────────────────────────────────
16
+
17
+ export const EXCLUDED_TAGS = new Set(['script', 'style', 'link', 'meta', 'head', 'html', 'body']);
18
+
19
+ // SVG child elements that should bubble up to their container
20
+ const SVG_INTERNALS = new Set([
21
+ 'path', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
22
+ 'rect', 'g', 'text', 'tspan', 'use', 'defs', 'clippath',
23
+ 'mask', 'pattern', 'symbol', 'marker', 'foreignobject',
24
+ ]);
25
+
26
+ // Pure inline formatting tags that should bubble up to their block parent.
27
+ // Note: 'span' and 'a' are excluded — they're often used as standalone
28
+ // positioned elements (slide numbers, brand labels, links) and must stay selectable.
29
+ const INLINE_TAGS = new Set([
30
+ 'strong', 'em', 'b', 'i', 'u', 'small', 'sub', 'sup',
31
+ 'abbr', 'code', 'mark', 'del', 'ins', 'br', 'wbr',
32
+ ]);
33
+
34
+ // ── Module state ──────────────────────────────────────────────────────────────
35
+
36
+ let _iframe = null;
37
+ let _iframeDoc = null;
38
+ let _canvasArea = null;
39
+
40
+ let _selectedElement = null;
41
+ let _selectedHcId = null;
42
+ let _hoveredElement = null;
43
+ let _rafPending = false;
44
+
45
+ // Stored handler references for clean re-attachment on iframe reload
46
+ let _onMouseMove = null;
47
+ let _onClick = null;
48
+ let _onMouseLeave = null;
49
+
50
+ // ── DOM refs (queried once at module load, guarded for Node.js test environments) ────
51
+
52
+ const hoverOverlay = typeof document !== 'undefined' ? document.getElementById('hover-overlay') : null;
53
+ const selectionOverlay = typeof document !== 'undefined' ? document.getElementById('selection-overlay') : null;
54
+ const dimLabel = selectionOverlay ? selectionOverlay.querySelector('.sel-dim-label') : null;
55
+
56
+ // ── Hit-testing ───────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Resolves the best selectable element from a given element.
60
+ *
61
+ * Bubbling rules:
62
+ * - SVG internals (path, circle, etc.) → bubble up to <svg> and stop there
63
+ * - Inline text (strong, em, span, etc.) → bubble up to block parent
64
+ * - Everything else → select directly
65
+ *
66
+ * @param {Element} element
67
+ * @returns {Element|null}
68
+ */
69
+ export function resolveSelectable(element) {
70
+ if (!element) return null;
71
+
72
+ let el = element;
73
+
74
+ while (el) {
75
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
76
+
77
+ if (EXCLUDED_TAGS.has(tag)) return null;
78
+ if (el.classList && el.classList.contains('page')) return null;
79
+
80
+ // SVG internals → bubble up to the <svg> element
81
+ if (SVG_INTERNALS.has(tag)) {
82
+ el = el.parentElement;
83
+ continue;
84
+ }
85
+
86
+ // <svg> itself → stop here (individually selectable and scalable)
87
+ if (tag === 'svg') break;
88
+
89
+ // Inline text → bubble up to block parent
90
+ if (INLINE_TAGS.has(tag)) {
91
+ el = el.parentElement;
92
+ continue;
93
+ }
94
+
95
+ // Block-level or other element → stop here
96
+ break;
97
+ }
98
+
99
+ if (!el) return null;
100
+
101
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
102
+ if (EXCLUDED_TAGS.has(tag)) return null;
103
+ if (el.classList && el.classList.contains('page')) return null;
104
+
105
+ if (el.closest) {
106
+ const parentPage = el.closest('.page');
107
+ if (parentPage) return el;
108
+
109
+ const parentBody = el.closest('body');
110
+ if (parentBody && tag !== 'body') return el;
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ // ── Overlay positioning ───────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Positions the hover overlay over the given element.
120
+ * @param {Element} element
121
+ */
122
+ function positionHoverOverlay(element) {
123
+ const rect = getElementCanvasRect(element, _iframe, _canvasArea);
124
+ hoverOverlay.style.left = rect.x + 'px';
125
+ hoverOverlay.style.top = rect.y + 'px';
126
+ hoverOverlay.style.width = rect.width + 'px';
127
+ hoverOverlay.style.height = rect.height + 'px';
128
+ hoverOverlay.style.display = '';
129
+ }
130
+
131
+ /**
132
+ * Updates the selection overlay to track the currently selected element.
133
+ * Uses the element's un-scaled getBoundingClientRect for the dimension label.
134
+ */
135
+ function updateSelectionOverlay() {
136
+ if (!_selectedElement) {
137
+ selectionOverlay.style.display = 'none';
138
+ return;
139
+ }
140
+
141
+ const rect = getElementCanvasRect(_selectedElement, _iframe, _canvasArea);
142
+ selectionOverlay.style.left = rect.x + 'px';
143
+ selectionOverlay.style.top = rect.y + 'px';
144
+ selectionOverlay.style.width = rect.width + 'px';
145
+ selectionOverlay.style.height = rect.height + 'px';
146
+ selectionOverlay.style.display = '';
147
+
148
+ // Dimension label: use visible dimensions (cropped if clip-path is set), un-scaled by zoom
149
+ const zoom = getZoom();
150
+ dimLabel.textContent = `${Math.round(rect.width / zoom)}x${Math.round(rect.height / zoom)} px`;
151
+
152
+ // Text-edit cursor hint: show text cursor on drag surface for text-editable elements
153
+ const dragSurface = selectionOverlay.querySelector('.sel-drag-surface');
154
+ if (dragSurface) {
155
+ const tag = _selectedElement.tagName.toLowerCase();
156
+ const isImg = tag === 'img' || tag === 'svg' || tag === 'video' || tag === 'canvas';
157
+ dragSurface.classList.toggle('text-cursor', !isImg);
158
+ }
159
+ }
160
+
161
+ // ── iframe event handlers ─────────────────────────────────────────────────────
162
+
163
+ function onIframeMouseMove(e) {
164
+ const target = _iframeDoc.elementFromPoint(e.clientX, e.clientY);
165
+ const resolved = resolveSelectable(target);
166
+
167
+ // Avoid redundant rect calculations when hovering the same element
168
+ if (resolved === _hoveredElement) return;
169
+ _hoveredElement = resolved;
170
+
171
+ // Hide hover overlay if nothing valid or hovering the selected element
172
+ if (!resolved || resolved === _selectedElement) {
173
+ hoverOverlay.style.display = 'none';
174
+ return;
175
+ }
176
+
177
+ // Throttle with rAF
178
+ if (_rafPending) return;
179
+ _rafPending = true;
180
+ requestAnimationFrame(() => {
181
+ _rafPending = false;
182
+ if (_hoveredElement && _hoveredElement !== _selectedElement) {
183
+ positionHoverOverlay(_hoveredElement);
184
+ } else {
185
+ hoverOverlay.style.display = 'none';
186
+ }
187
+ });
188
+ }
189
+
190
+ function onIframeClick(e) {
191
+ const target = _iframeDoc.elementFromPoint(e.clientX, e.clientY);
192
+ let resolved = resolveSelectable(target);
193
+
194
+ // Skip locked elements (can't select by clicking canvas)
195
+ if (resolved) {
196
+ const hcId = resolved.getAttribute('data-hc-id');
197
+ if (hcId && isLocked(hcId)) resolved = null;
198
+ }
199
+
200
+ // ── Ctrl/Shift-click: toggle element in multi-selection ─────────────────────
201
+ if ((e.shiftKey || e.ctrlKey || e.metaKey) && resolved) {
202
+ const multiSelected = getSelectedElements();
203
+
204
+ // If transitioning from single selection to multi, add the current single selection too
205
+ if (_selectedElement && multiSelected.length === 0) {
206
+ addToSelection(_selectedElement);
207
+ }
208
+
209
+ if (multiSelected.includes(resolved)) {
210
+ removeFromSelection(resolved);
211
+ } else {
212
+ addToSelection(resolved);
213
+ }
214
+
215
+ // Hide hover overlay
216
+ hoverOverlay.style.display = 'none';
217
+ _hoveredElement = null;
218
+ return;
219
+ }
220
+
221
+ // ── Normal click: clear multi-selection, do single select ─────────────────
222
+ clearMultiSelection();
223
+
224
+ if (!resolved) {
225
+ // Clicked empty .page space or excluded element
226
+ clearSelection();
227
+ return;
228
+ }
229
+
230
+ // Already selected — try to drill into a child element under the cursor.
231
+ // This allows clicking the orange strip inside a card even when the card is selected.
232
+ if (resolved === _selectedElement) {
233
+ const allUnderCursor = _iframeDoc.elementsFromPoint(e.clientX, e.clientY);
234
+ for (const candidate of allUnderCursor) {
235
+ // Stop searching once we reach the currently selected element
236
+ if (candidate === _selectedElement) break;
237
+ if (!candidate.hasAttribute('data-hc-id')) continue;
238
+ const tag = candidate.tagName ? candidate.tagName.toLowerCase() : '';
239
+ if (EXCLUDED_TAGS.has(tag)) continue;
240
+ if (candidate.classList && candidate.classList.contains('page')) continue;
241
+ // Found a selectable child — select it
242
+ _selectedElement = candidate;
243
+ _selectedHcId = candidate.getAttribute('data-hc-id');
244
+ hoverOverlay.style.display = 'none';
245
+ _hoveredElement = null;
246
+ updateSelectionOverlay();
247
+ window.dispatchEvent(new CustomEvent('hc:selection-changed', {
248
+ detail: { hcId: _selectedHcId, element: _selectedElement }
249
+ }));
250
+ return;
251
+ }
252
+ return;
253
+ }
254
+
255
+ _selectedElement = resolved;
256
+ _selectedHcId = resolved.getAttribute('data-hc-id') || null;
257
+
258
+ // Hide hover overlay (selection takes visual priority)
259
+ hoverOverlay.style.display = 'none';
260
+ _hoveredElement = null;
261
+
262
+ updateSelectionOverlay();
263
+
264
+ // Notify other modules (layer panel, manipulation) that selection changed
265
+ window.dispatchEvent(new CustomEvent('hc:selection-changed', {
266
+ detail: { hcId: _selectedHcId, element: _selectedElement }
267
+ }));
268
+ }
269
+
270
+ function clearHover() {
271
+ hoverOverlay.style.display = 'none';
272
+ _hoveredElement = null;
273
+ }
274
+
275
+ // ── Exported API ──────────────────────────────────────────────────────────────
276
+
277
+ /**
278
+ * Clears the current selection. Hides overlays and nulls all state.
279
+ * Called on Escape, canvas-background click, iframe reload, and DeleteCommand.
280
+ */
281
+ export function clearSelection() {
282
+ const hadSelection = !!_selectedElement;
283
+ _selectedElement = null;
284
+ _selectedHcId = null;
285
+ selectionOverlay.style.display = 'none';
286
+ hoverOverlay.style.display = 'none';
287
+ _hoveredElement = null;
288
+
289
+ // Notify manipulation + other modules so they clear stale targets
290
+ if (hadSelection) {
291
+ window.dispatchEvent(new CustomEvent('hc:selection-cleared'));
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Programmatically selects an element (e.g. from the layer panel).
297
+ * Clears multi-selection, updates overlay, and dispatches selection-changed.
298
+ *
299
+ * @param {Element} element - The iframe DOM element to select
300
+ */
301
+ export function selectElement(element) {
302
+ if (!element) return;
303
+
304
+ const resolved = resolveSelectable(element) || element;
305
+ if (resolved === _selectedElement) return;
306
+
307
+ clearMultiSelection();
308
+
309
+ _selectedElement = resolved;
310
+ _selectedHcId = resolved.getAttribute('data-hc-id') || null;
311
+
312
+ hoverOverlay.style.display = 'none';
313
+ _hoveredElement = null;
314
+
315
+ updateSelectionOverlay();
316
+
317
+ window.dispatchEvent(new CustomEvent('hc:selection-changed', {
318
+ detail: { hcId: _selectedHcId, element: _selectedElement }
319
+ }));
320
+ }
321
+
322
+ /**
323
+ * Returns the currently selected DOM element (inside the iframe), or null.
324
+ * @returns {Element|null}
325
+ */
326
+ export function getSelectedElement() {
327
+ return _selectedElement;
328
+ }
329
+
330
+ /**
331
+ * Returns the data-hc-id of the currently selected element, or null.
332
+ * @returns {string|null}
333
+ */
334
+ export function getSelectedHcId() {
335
+ return _selectedHcId;
336
+ }
337
+
338
+ /**
339
+ * Initialises (or re-initialises) the selection module for a new iframe load.
340
+ * Removes stale listeners from the previous iframeDoc, attaches fresh ones.
341
+ *
342
+ * @param {HTMLIFrameElement} iframe
343
+ * @param {Document} iframeDoc
344
+ * @param {HTMLElement} canvasArea
345
+ */
346
+ export function initSelection(iframe, iframeDoc, canvasArea) {
347
+ // Remove old listeners if we have a previous iframeDoc
348
+ if (_iframeDoc && _onMouseMove) {
349
+ _iframeDoc.removeEventListener('mousemove', _onMouseMove);
350
+ _iframeDoc.removeEventListener('click', _onClick);
351
+ _iframeDoc.removeEventListener('mouseleave', _onMouseLeave);
352
+ }
353
+
354
+ _iframe = iframe;
355
+ _iframeDoc = iframeDoc;
356
+ _canvasArea = canvasArea;
357
+
358
+ // Clear stale selection from any previously opened file
359
+ clearSelection();
360
+
361
+ // Create stable handler references for later removal
362
+ _onMouseMove = onIframeMouseMove;
363
+ _onClick = onIframeClick;
364
+ _onMouseLeave = clearHover;
365
+
366
+ iframeDoc.addEventListener('mousemove', _onMouseMove);
367
+ iframeDoc.addEventListener('click', _onClick);
368
+ iframeDoc.addEventListener('mouseleave', _onMouseLeave);
369
+ }
370
+
371
+ // ── Browser-only top-level wiring (skipped in Node.js test environments) ─────
372
+
373
+ if (typeof document !== 'undefined') {
374
+ // ── Parent escalation (double-click on drag surface) ─────────────────────
375
+
376
+ // Double-click the selection overlay body to select the parent element.
377
+ // (Handles are now used for resize, so single-click escalation is removed.)
378
+ const dragSurfaceEl = selectionOverlay ? selectionOverlay.querySelector('.sel-drag-surface') : null;
379
+ if (dragSurfaceEl) {
380
+ dragSurfaceEl.addEventListener('dblclick', (e) => {
381
+ if (!_selectedElement || !_selectedElement.parentElement) return;
382
+
383
+ const parent = resolveSelectable(_selectedElement.parentElement);
384
+ if (parent) {
385
+ _selectedElement = parent;
386
+ _selectedHcId = parent.getAttribute('data-hc-id') || null;
387
+ updateSelectionOverlay();
388
+
389
+ window.dispatchEvent(new CustomEvent('hc:selection-changed', {
390
+ detail: { hcId: _selectedHcId, element: _selectedElement }
391
+ }));
392
+ }
393
+ });
394
+ }
395
+
396
+ // ── Canvas background deselect ──────────────────────────────────────────
397
+
398
+ // Query canvasArea now (it exists at module load time in the static HTML)
399
+ const canvasArea = document.getElementById('canvas-area');
400
+
401
+ if (canvasArea) {
402
+ canvasArea.addEventListener('click', (e) => {
403
+ // Only deselect if clicking directly on the canvas background or empty-state
404
+ // (not on the iframe, not on overlay handles)
405
+ if (e.target === canvasArea || e.target.id === 'canvas-empty-state') {
406
+ clearSelection();
407
+ }
408
+ });
409
+
410
+ // ── Scroll / zoom re-sync ─────────────────────────────────────────────
411
+
412
+ canvasArea.addEventListener('scroll', () => {
413
+ if (_selectedElement) {
414
+ requestAnimationFrame(() => updateSelectionOverlay());
415
+ }
416
+ // Hover overlay does not need scroll sync (mouse has moved away)
417
+ });
418
+ }
419
+
420
+ window.addEventListener('hc:zoom-changed', () => {
421
+ if (_selectedElement) updateSelectionOverlay();
422
+ if (_hoveredElement && _hoveredElement !== _selectedElement) {
423
+ positionHoverOverlay(_hoveredElement);
424
+ }
425
+ });
426
+
427
+ // ── hc:selection-cleared (fired by DeleteCommand.execute()) ───────────────
428
+
429
+ window.addEventListener('hc:selection-cleared', () => {
430
+ clearSelection();
431
+ });
432
+ }
@@ -0,0 +1,160 @@
1
+ // editor/serializer.js — Patch-based HTML serializer
2
+ //
3
+ // Tracks changes made to elements by their data-hc-id. On save, patches
4
+ // only the changed elements in the original source text. When no changes
5
+ // exist, returns the original rawHTML verbatim (byte-identical round-trip).
6
+ //
7
+ // CRITICAL: This module NEVER re-serializes the whole document.
8
+
9
+ import { getModel } from './domModel.js';
10
+
11
+ // ── Change tracking ─────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Pending changes keyed by hc-id.
15
+ * ChangeRecord shape:
16
+ * {
17
+ * id: string, // hc-id of the changed element
18
+ * type: 'attribute' | 'content' | 'delete' | 'insert',
19
+ * newOuterHTML: string | null, // cleaned HTML (data-hc-id stripped)
20
+ * }
21
+ * @type {Map<string, ChangeRecord>}
22
+ */
23
+ const _pendingChanges = new Map();
24
+
25
+ /**
26
+ * Records a change to an element. Called by editing operations in later phases.
27
+ *
28
+ * @param {string} hcId - The data-hc-id of the changed element
29
+ * @param {string} newOuterHTML - The new outerHTML (may contain data-hc-id)
30
+ */
31
+ export function recordChange(hcId, newOuterHTML) {
32
+ const cleaned = stripHcIds(newOuterHTML);
33
+ _pendingChanges.set(hcId, {
34
+ id: hcId,
35
+ type: 'attribute',
36
+ newOuterHTML: cleaned,
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Records that an element has been deleted.
42
+ *
43
+ * @param {string} hcId - The data-hc-id of the deleted element
44
+ */
45
+ export function recordDeletion(hcId) {
46
+ _pendingChanges.set(hcId, {
47
+ id: hcId,
48
+ type: 'delete',
49
+ newOuterHTML: null,
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Cancels a pending deletion. Called by DeleteCommand.undo() to reverse
55
+ * a recordDeletion when the user undoes a delete operation.
56
+ *
57
+ * @param {string} hcId - The data-hc-id of the element whose deletion to cancel
58
+ */
59
+ export function cancelDeletion(hcId) {
60
+ _pendingChanges.delete(hcId);
61
+ }
62
+
63
+ /**
64
+ * Clears all pending changes. Called after a successful save.
65
+ */
66
+ export function clearChanges() {
67
+ _pendingChanges.clear();
68
+ }
69
+
70
+ /**
71
+ * Returns true if there are unsaved changes.
72
+ */
73
+ export function hasChanges() {
74
+ return _pendingChanges.size > 0;
75
+ }
76
+
77
+ // ── ID stripping ────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Strips all data-hc-id attributes from an HTML string.
81
+ * Handles double quotes, single quotes, and unquoted attribute values.
82
+ * Also removes the leading whitespace before the attribute.
83
+ *
84
+ * @param {string} html - HTML string that may contain data-hc-id attributes
85
+ * @returns {string} HTML with all data-hc-id attributes removed
86
+ */
87
+ export function stripHcIds(html) {
88
+ return html
89
+ .replace(/\s+data-hc-id=(?:"[^"]*"|'[^']*'|[^\s>]*)/g, '')
90
+ .replace(/\s*data-hc-group(?:=(?:"[^"]*"|'[^']*'|[^\s>]*))?/g, '');
91
+ }
92
+
93
+ // ── Serialization ───────────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Produces the final HTML output by patching changes into the original source.
97
+ *
98
+ * Algorithm:
99
+ * 1. Start with the original raw HTML (before any data-hc-id injection).
100
+ * 2. If there are no pending changes, return the original HTML verbatim
101
+ * (byte-identical round-trip guarantee).
102
+ * 3. For each pending change, find the element's position in the original
103
+ * source using sourceIndex and sourceLength from the element map.
104
+ * 4. Replace only the changed portions. Work backwards (highest sourceIndex
105
+ * first) so earlier indices remain valid after replacements.
106
+ *
107
+ * @returns {string} The final HTML ready to be saved to disk
108
+ */
109
+ export function serialize() {
110
+ const { elementMap, rawHTML } = getModel();
111
+
112
+ if (!rawHTML || !elementMap) {
113
+ throw new Error('No DOM model loaded. Open a file first.');
114
+ }
115
+
116
+ // No changes — return original verbatim (byte-identical guarantee)
117
+ if (_pendingChanges.size === 0) {
118
+ return rawHTML;
119
+ }
120
+
121
+ // Collect changes with their source positions
122
+ const patches = [];
123
+ for (const [hcId, change] of _pendingChanges) {
124
+ const record = elementMap.get(hcId);
125
+ if (!record) {
126
+ console.warn(`serializer: no element record for hc-id "${hcId}", skipping`);
127
+ continue;
128
+ }
129
+
130
+ // For Phase 2, we track opening tag positions only.
131
+ // Full element replacement (opening + content + closing) will be
132
+ // computed by walking the annotated DOM in Phase 3.
133
+ patches.push({
134
+ ...change,
135
+ sourceIndex: record.sourceIndex,
136
+ sourceLength: record.sourceLength,
137
+ });
138
+ }
139
+
140
+ // Sort patches by sourceIndex descending (patch from end to start)
141
+ patches.sort((a, b) => b.sourceIndex - a.sourceIndex);
142
+
143
+ let result = rawHTML;
144
+ for (const patch of patches) {
145
+ if (patch.type === 'delete') {
146
+ // Remove the opening tag from the source.
147
+ // Full element deletion (content + closing tag) will be refined in Phase 3.
148
+ result = result.slice(0, patch.sourceIndex) +
149
+ result.slice(patch.sourceIndex + patch.sourceLength);
150
+ } else if (patch.newOuterHTML !== null) {
151
+ // Replace the element's opening tag portion.
152
+ // Full replacement logic will be refined in Phase 3.
153
+ result = result.slice(0, patch.sourceIndex) +
154
+ patch.newOuterHTML +
155
+ result.slice(patch.sourceIndex + patch.sourceLength);
156
+ }
157
+ }
158
+
159
+ return result;
160
+ }