@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.
- package/bin/cli.js +28 -0
- package/editor/alignment.js +211 -0
- package/editor/assets.js +724 -0
- package/editor/clipboard.js +177 -0
- package/editor/coords.js +121 -0
- package/editor/crop.js +325 -0
- package/editor/cssVars.js +134 -0
- package/editor/domModel.js +161 -0
- package/editor/editor.css +1996 -0
- package/editor/editor.js +833 -0
- package/editor/guides.js +513 -0
- package/editor/history.js +135 -0
- package/editor/index.html +540 -0
- package/editor/layers.js +389 -0
- package/editor/logo-final.svg +21 -0
- package/editor/logo-toolbar.svg +21 -0
- package/editor/manipulation.js +864 -0
- package/editor/multiSelect.js +436 -0
- package/editor/properties.js +1583 -0
- package/editor/selection.js +432 -0
- package/editor/serializer.js +160 -0
- package/editor/shortcuts.js +143 -0
- package/editor/slidePanel.js +361 -0
- package/editor/slides.js +101 -0
- package/editor/snap.js +98 -0
- package/editor/textEdit.js +538 -0
- package/editor/zoom.js +96 -0
- package/package.json +28 -0
- package/server.js +588 -0
|
@@ -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
|
+
}
|