@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,538 @@
|
|
|
1
|
+
// editor/textEdit.js — Inline text editing via contenteditable
|
|
2
|
+
//
|
|
3
|
+
// Exports:
|
|
4
|
+
// initTextEdit(iframe, iframeDoc, canvasArea) — attach click listener on selection overlay
|
|
5
|
+
// isTextEditActive() — returns true while a text edit session is open
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle:
|
|
8
|
+
// 1. User clicks the selected text element (via drag surface click with no drag)
|
|
9
|
+
// 2. enterTextMode() suspends shortcuts, makes element contenteditable, focuses it
|
|
10
|
+
// 3. Cursor is positioned at the click location (not select-all)
|
|
11
|
+
// 4. User types; existing shortcuts (Delete, Ctrl+Z) operate on text content
|
|
12
|
+
// 5. blur or Escape calls exitTextMode()
|
|
13
|
+
// 6. If text changed, a Command is pushed to history (with recordChange for serializer)
|
|
14
|
+
// 7. If text unchanged, no Command is created (no pollution)
|
|
15
|
+
|
|
16
|
+
import { suspendShortcuts, resumeShortcuts } from './shortcuts.js';
|
|
17
|
+
import { push } from './history.js';
|
|
18
|
+
import { recordChange } from './serializer.js';
|
|
19
|
+
import { getSelectedElement, getSelectedHcId } from './selection.js';
|
|
20
|
+
|
|
21
|
+
// ── Module state ──────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
let _textEditActive = false;
|
|
24
|
+
let _currentElement = null;
|
|
25
|
+
let _currentHcId = null;
|
|
26
|
+
let _beforeHTML = null;
|
|
27
|
+
|
|
28
|
+
// Stored listener references for clean removal
|
|
29
|
+
let _blurHandler = null;
|
|
30
|
+
let _keydownHandler = null;
|
|
31
|
+
let _mouseupHandler = null;
|
|
32
|
+
|
|
33
|
+
// Blur suppression: when user clicks on the properties panel, we suppress
|
|
34
|
+
// the blur event so contenteditable stays active, then re-focus the element.
|
|
35
|
+
let _suppressBlur = false;
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
// Saved selection range: preserved when element loses focus so properties
|
|
39
|
+
// panel controls (color picker, font picker) can still apply inline styles.
|
|
40
|
+
let _savedRange = null;
|
|
41
|
+
|
|
42
|
+
// ── Exported state query ──────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns true while an inline text edit session is active.
|
|
46
|
+
* Used by editor.js to guard selection clicks during text editing.
|
|
47
|
+
*
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function isTextEditActive() {
|
|
51
|
+
return _textEditActive;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Core lifecycle ────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Enters text editing mode for the given element.
|
|
58
|
+
* - Suspends all keyboard shortcuts so Delete/Ctrl+Z operate on text
|
|
59
|
+
* - Makes the element contenteditable and focuses it
|
|
60
|
+
* - Positions cursor at click location (or selects all if no coords given)
|
|
61
|
+
* - Hides the selection overlay (avoids pointer-events interference)
|
|
62
|
+
*
|
|
63
|
+
* @param {Element} element - The DOM element inside the iframe to edit
|
|
64
|
+
* @param {string} hcId - Its data-hc-id (for Command / serializer)
|
|
65
|
+
* @param {{ clientX: number, clientY: number }|null} [clickCoords] - Click position for cursor placement
|
|
66
|
+
*/
|
|
67
|
+
function enterTextMode(element, hcId, clickCoords) {
|
|
68
|
+
if (_textEditActive) return; // already in a session
|
|
69
|
+
|
|
70
|
+
_textEditActive = true;
|
|
71
|
+
_currentElement = element;
|
|
72
|
+
_currentHcId = hcId;
|
|
73
|
+
_beforeHTML = element.innerHTML;
|
|
74
|
+
|
|
75
|
+
// Suspend editor shortcuts so keys operate on text content
|
|
76
|
+
suspendShortcuts();
|
|
77
|
+
|
|
78
|
+
// Hide the selection overlay to remove its pointer-events layer
|
|
79
|
+
const overlay = document.getElementById('selection-overlay');
|
|
80
|
+
if (overlay) overlay.style.display = 'none';
|
|
81
|
+
|
|
82
|
+
// Make the element editable and focus it
|
|
83
|
+
element.contentEditable = 'true';
|
|
84
|
+
element.focus();
|
|
85
|
+
|
|
86
|
+
// Position cursor at click location, or select all if no coords given
|
|
87
|
+
try {
|
|
88
|
+
const doc = element.ownerDocument;
|
|
89
|
+
const sel = doc.getSelection();
|
|
90
|
+
|
|
91
|
+
if (clickCoords && sel) {
|
|
92
|
+
// Convert canvas-area coords to iframe-document coords
|
|
93
|
+
const iframe = document.getElementById('render-iframe');
|
|
94
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
95
|
+
const iframeX = clickCoords.clientX - iframeRect.left;
|
|
96
|
+
const iframeY = clickCoords.clientY - iframeRect.top;
|
|
97
|
+
|
|
98
|
+
// Use caretRangeFromPoint to find the exact text position
|
|
99
|
+
let range = null;
|
|
100
|
+
if (doc.caretRangeFromPoint) {
|
|
101
|
+
range = doc.caretRangeFromPoint(iframeX, iframeY);
|
|
102
|
+
} else if (doc.caretPositionFromPoint) {
|
|
103
|
+
const pos = doc.caretPositionFromPoint(iframeX, iframeY);
|
|
104
|
+
if (pos) {
|
|
105
|
+
range = doc.createRange();
|
|
106
|
+
range.setStart(pos.offsetNode, pos.offset);
|
|
107
|
+
range.collapse(true);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (range) {
|
|
112
|
+
sel.removeAllRanges();
|
|
113
|
+
sel.addRange(range);
|
|
114
|
+
} else {
|
|
115
|
+
// Fallback: place cursor at end
|
|
116
|
+
const fallbackRange = doc.createRange();
|
|
117
|
+
fallbackRange.selectNodeContents(element);
|
|
118
|
+
fallbackRange.collapse(false);
|
|
119
|
+
sel.removeAllRanges();
|
|
120
|
+
sel.addRange(fallbackRange);
|
|
121
|
+
}
|
|
122
|
+
} else if (sel) {
|
|
123
|
+
// No click coords — select all (e.g. triggered from properties panel button)
|
|
124
|
+
const range = doc.createRange();
|
|
125
|
+
range.selectNodeContents(element);
|
|
126
|
+
sel.removeAllRanges();
|
|
127
|
+
sel.addRange(range);
|
|
128
|
+
}
|
|
129
|
+
} catch (_) {
|
|
130
|
+
// Non-critical — editing still works if selection fails
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Exit on blur (click away) — unless the user clicked the properties panel
|
|
134
|
+
_blurHandler = () => {
|
|
135
|
+
if (_suppressBlur) {
|
|
136
|
+
_suppressBlur = false;
|
|
137
|
+
// Save the current text selection before it gets cleared by blur
|
|
138
|
+
try {
|
|
139
|
+
const doc = _currentElement.ownerDocument;
|
|
140
|
+
const sel = doc.getSelection();
|
|
141
|
+
if (sel && sel.rangeCount > 0) {
|
|
142
|
+
_savedRange = sel.getRangeAt(0).cloneRange();
|
|
143
|
+
}
|
|
144
|
+
} catch (_) {}
|
|
145
|
+
// Check if focus moved to a control inside the properties panel.
|
|
146
|
+
// If so, don't steal focus back — let the user interact with the control.
|
|
147
|
+
setTimeout(() => {
|
|
148
|
+
if (!_textEditActive || !_currentElement) return;
|
|
149
|
+
const active = document.activeElement;
|
|
150
|
+
const propsPanel = document.querySelector('.panel-properties');
|
|
151
|
+
if (propsPanel && propsPanel.contains(active)) return; // dropdown/input has focus
|
|
152
|
+
_currentElement.focus();
|
|
153
|
+
_restoreSavedRange();
|
|
154
|
+
}, 0);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
exitTextMode();
|
|
158
|
+
};
|
|
159
|
+
element.addEventListener('blur', _blurHandler);
|
|
160
|
+
|
|
161
|
+
// Handle special keys during text editing
|
|
162
|
+
_keydownHandler = (e) => {
|
|
163
|
+
if (e.key === 'Escape') {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
e.stopPropagation();
|
|
166
|
+
exitTextMode();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Insert <br> on Enter instead of letting the browser create new <div> elements
|
|
170
|
+
// (new divs would become separately selectable items in the editor)
|
|
171
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
const doc = element.ownerDocument;
|
|
174
|
+
const sel = doc.getSelection();
|
|
175
|
+
if (sel && sel.rangeCount > 0) {
|
|
176
|
+
const range = sel.getRangeAt(0);
|
|
177
|
+
range.deleteContents();
|
|
178
|
+
const br = doc.createElement('br');
|
|
179
|
+
range.insertNode(br);
|
|
180
|
+
// Move cursor after the <br>
|
|
181
|
+
range.setStartAfter(br);
|
|
182
|
+
range.setEndAfter(br);
|
|
183
|
+
sel.removeAllRanges();
|
|
184
|
+
sel.addRange(range);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// After any key that changes the selection (Ctrl+A, Shift+arrows, etc.),
|
|
189
|
+
// update _savedRange so panel operations have the latest selection.
|
|
190
|
+
// Use a microtask so the browser processes the key first.
|
|
191
|
+
if ((e.ctrlKey || e.metaKey || e.shiftKey) && !e.altKey) {
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
if (!_textEditActive || !_currentElement) return;
|
|
194
|
+
try {
|
|
195
|
+
const doc = _currentElement.ownerDocument;
|
|
196
|
+
const sel = doc.getSelection();
|
|
197
|
+
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
|
|
198
|
+
_savedRange = sel.getRangeAt(0).cloneRange();
|
|
199
|
+
}
|
|
200
|
+
} catch (_) {}
|
|
201
|
+
}, 0);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
element.addEventListener('keydown', _keydownHandler);
|
|
205
|
+
|
|
206
|
+
// Save selection on mouseup (captures click-drag text selections)
|
|
207
|
+
_mouseupHandler = () => {
|
|
208
|
+
try {
|
|
209
|
+
const doc = element.ownerDocument;
|
|
210
|
+
const sel = doc.getSelection();
|
|
211
|
+
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
|
|
212
|
+
_savedRange = sel.getRangeAt(0).cloneRange();
|
|
213
|
+
}
|
|
214
|
+
} catch (_) {}
|
|
215
|
+
};
|
|
216
|
+
element.addEventListener('mouseup', _mouseupHandler);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Exits text editing mode and commits any changes as a single undoable Command.
|
|
221
|
+
* - Re-enables keyboard shortcuts
|
|
222
|
+
* - Creates a Command only if text content changed
|
|
223
|
+
* - Calls recordChange so the serializer can patch the source file
|
|
224
|
+
* - Restores the selection overlay
|
|
225
|
+
*/
|
|
226
|
+
function exitTextMode() {
|
|
227
|
+
if (!_textEditActive) return; // prevent double-commit
|
|
228
|
+
|
|
229
|
+
_textEditActive = false;
|
|
230
|
+
|
|
231
|
+
// Remove event listeners before we lose the references
|
|
232
|
+
if (_currentElement) {
|
|
233
|
+
if (_blurHandler) _currentElement.removeEventListener('blur', _blurHandler);
|
|
234
|
+
if (_keydownHandler) _currentElement.removeEventListener('keydown', _keydownHandler);
|
|
235
|
+
if (_mouseupHandler) _currentElement.removeEventListener('mouseup', _mouseupHandler);
|
|
236
|
+
}
|
|
237
|
+
_blurHandler = null;
|
|
238
|
+
_keydownHandler = null;
|
|
239
|
+
_mouseupHandler = null;
|
|
240
|
+
|
|
241
|
+
// Remove contenteditable
|
|
242
|
+
if (_currentElement) {
|
|
243
|
+
_currentElement.contentEditable = 'false';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Re-enable shortcuts
|
|
247
|
+
resumeShortcuts();
|
|
248
|
+
|
|
249
|
+
// Capture current text and compare
|
|
250
|
+
const afterHTML = _currentElement ? _currentElement.innerHTML : null;
|
|
251
|
+
|
|
252
|
+
if (afterHTML !== null && afterHTML !== _beforeHTML) {
|
|
253
|
+
// Capture into locals so Command closures don't reference module state
|
|
254
|
+
const el = _currentElement;
|
|
255
|
+
const hcId = _currentHcId;
|
|
256
|
+
const before = _beforeHTML;
|
|
257
|
+
const after = afterHTML;
|
|
258
|
+
|
|
259
|
+
// Push to history BEFORE recording change — push() calls execute() which
|
|
260
|
+
// would double-apply the change. Since the text is already applied, we push
|
|
261
|
+
// a command whose execute() re-applies it (for redo) and undo() restores it.
|
|
262
|
+
//
|
|
263
|
+
// We do NOT call push(cmd) here (that would call execute() again).
|
|
264
|
+
// Instead we directly add to the undo stack via a manual approach.
|
|
265
|
+
// However, history.js's push() always calls execute(). We must work around
|
|
266
|
+
// this: the Command's execute() should be idempotent when text is already applied.
|
|
267
|
+
//
|
|
268
|
+
// Solution: execute() sets innerHTML = after (already the case, harmless no-op on
|
|
269
|
+
// first call); undo() sets innerHTML = before. This is safe.
|
|
270
|
+
|
|
271
|
+
const cmd = {
|
|
272
|
+
description: 'Edit text',
|
|
273
|
+
execute() {
|
|
274
|
+
el.innerHTML = after;
|
|
275
|
+
recordChange(hcId, el.outerHTML);
|
|
276
|
+
},
|
|
277
|
+
undo() {
|
|
278
|
+
el.innerHTML = before;
|
|
279
|
+
recordChange(hcId, el.outerHTML);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// push() calls cmd.execute() which re-applies innerHTML = after (harmless no-op
|
|
284
|
+
// since the element already has `after` as its innerHTML from user typing).
|
|
285
|
+
push(cmd);
|
|
286
|
+
|
|
287
|
+
}
|
|
288
|
+
// No else needed — if text unchanged, we intentionally create no Command
|
|
289
|
+
|
|
290
|
+
// Capture element/hcId before clearing (needed for events below)
|
|
291
|
+
const exitElement = _currentElement;
|
|
292
|
+
const exitHcId = _currentHcId;
|
|
293
|
+
|
|
294
|
+
// Clear references
|
|
295
|
+
_currentElement = null;
|
|
296
|
+
_currentHcId = null;
|
|
297
|
+
_beforeHTML = null;
|
|
298
|
+
_savedRange = null;
|
|
299
|
+
|
|
300
|
+
// Restore the selection overlay and re-populate properties panel
|
|
301
|
+
window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
|
|
302
|
+
if (exitElement && exitHcId) {
|
|
303
|
+
window.dispatchEvent(new CustomEvent('hc:selection-changed', {
|
|
304
|
+
detail: { hcId: exitHcId, element: exitElement }
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Selection range helpers ───────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Restores a previously saved text selection range inside the current element.
|
|
313
|
+
*/
|
|
314
|
+
function _restoreSavedRange() {
|
|
315
|
+
if (!_savedRange || !_currentElement) return;
|
|
316
|
+
try {
|
|
317
|
+
const doc = _currentElement.ownerDocument;
|
|
318
|
+
const sel = doc.getSelection();
|
|
319
|
+
if (sel) {
|
|
320
|
+
sel.removeAllRanges();
|
|
321
|
+
sel.addRange(_savedRange);
|
|
322
|
+
}
|
|
323
|
+
} catch (_) {}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Inline style application ─────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Applies a CSS style to the currently selected text within the contenteditable
|
|
330
|
+
* element. Wraps the selection in a <span> with the given inline style.
|
|
331
|
+
*
|
|
332
|
+
* If the selection is collapsed (no text highlighted), does nothing.
|
|
333
|
+
* If the selection covers the entire element, applies to the element directly
|
|
334
|
+
* (or to a single wrapper span if one exists) to avoid unnecessary nesting.
|
|
335
|
+
* If the selection is within an existing <span>, modifies that span's style.
|
|
336
|
+
*
|
|
337
|
+
* @param {string} cssProp - CSS property name (e.g. 'color', 'font-size')
|
|
338
|
+
* @param {string} value - CSS value to apply (e.g. '#ff0000', '24px')
|
|
339
|
+
* @returns {boolean} true if style was applied
|
|
340
|
+
*/
|
|
341
|
+
export function applyStyleToSelection(cssProp, value) {
|
|
342
|
+
if (!_textEditActive || !_currentElement) return false;
|
|
343
|
+
|
|
344
|
+
const doc = _currentElement.ownerDocument;
|
|
345
|
+
const sel = doc.getSelection();
|
|
346
|
+
|
|
347
|
+
// If the live selection is collapsed (lost focus to color picker etc.),
|
|
348
|
+
// refocus the element first so the iframe's selection API works, then
|
|
349
|
+
// restore from the saved range.
|
|
350
|
+
if (sel && (sel.rangeCount === 0 || sel.isCollapsed) && _savedRange) {
|
|
351
|
+
_currentElement.focus();
|
|
352
|
+
try {
|
|
353
|
+
sel.removeAllRanges();
|
|
354
|
+
sel.addRange(_savedRange);
|
|
355
|
+
} catch (_) {}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return false;
|
|
359
|
+
|
|
360
|
+
const range = sel.getRangeAt(0);
|
|
361
|
+
|
|
362
|
+
// ── Check if selection covers the entire element content ──
|
|
363
|
+
// If so, apply style directly to the element (or its single wrapper span)
|
|
364
|
+
// to avoid creating unnecessary nested spans on every "select all" + style change.
|
|
365
|
+
const coversAll = _selectionCoversAll(range, _currentElement);
|
|
366
|
+
if (coversAll) {
|
|
367
|
+
// If the element has exactly one child and it's a <span>, modify that span
|
|
368
|
+
const kids = _currentElement.childNodes;
|
|
369
|
+
if (kids.length === 1 && kids[0].nodeType === 1 && kids[0].tagName === 'SPAN') {
|
|
370
|
+
kids[0].style.setProperty(cssProp, value);
|
|
371
|
+
} else {
|
|
372
|
+
// Apply directly to the element itself
|
|
373
|
+
_currentElement.style.setProperty(cssProp, value);
|
|
374
|
+
}
|
|
375
|
+
// Keep the full selection active
|
|
376
|
+
_savedRange = range.cloneRange();
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Check if selection is within a single existing <span> ──
|
|
381
|
+
const container = range.commonAncestorContainer;
|
|
382
|
+
const parentSpan = container.nodeType === 3
|
|
383
|
+
? container.parentElement
|
|
384
|
+
: container;
|
|
385
|
+
|
|
386
|
+
if (parentSpan && parentSpan.tagName === 'SPAN' && parentSpan !== _currentElement) {
|
|
387
|
+
// Modify existing span's style instead of double-wrapping
|
|
388
|
+
parentSpan.style.setProperty(cssProp, value);
|
|
389
|
+
} else {
|
|
390
|
+
// Wrap selection in a new <span>
|
|
391
|
+
const span = doc.createElement('span');
|
|
392
|
+
span.style.setProperty(cssProp, value);
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const fragment = range.extractContents();
|
|
396
|
+
span.appendChild(fragment);
|
|
397
|
+
range.insertNode(span);
|
|
398
|
+
|
|
399
|
+
// Re-select the span contents so the user sees the selection preserved
|
|
400
|
+
sel.removeAllRanges();
|
|
401
|
+
const newRange = doc.createRange();
|
|
402
|
+
newRange.selectNodeContents(span);
|
|
403
|
+
sel.addRange(newRange);
|
|
404
|
+
} catch (_) {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Update saved range so subsequent panel operations still have a valid range
|
|
410
|
+
try { _savedRange = sel.getRangeAt(0).cloneRange(); } catch (_) {}
|
|
411
|
+
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Returns true if the given Range covers the entire content of the element.
|
|
417
|
+
* Handles both text-only and mixed content (spans, br, etc.).
|
|
418
|
+
*/
|
|
419
|
+
function _selectionCoversAll(range, element) {
|
|
420
|
+
try {
|
|
421
|
+
// Quick check: does the range start at the beginning and end at the end?
|
|
422
|
+
const testRange = element.ownerDocument.createRange();
|
|
423
|
+
testRange.selectNodeContents(element);
|
|
424
|
+
|
|
425
|
+
// compareBoundaryPoints: 0 = START_TO_START, 2 = END_TO_END
|
|
426
|
+
const startsAtBeginning = range.compareBoundaryPoints(Range.START_TO_START, testRange) <= 0;
|
|
427
|
+
const endsAtEnd = range.compareBoundaryPoints(Range.END_TO_END, testRange) >= 0;
|
|
428
|
+
return startsAtBeginning && endsAtEnd;
|
|
429
|
+
} catch (_) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Initialisation ────────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
// Non-text tags that should NOT enter text editing mode on click
|
|
437
|
+
const NON_TEXT_TAGS = new Set(['img', 'svg', 'video', 'canvas', 'iframe', 'hr']);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Returns true if the element is text-editable (not an image, SVG, video, etc.)
|
|
441
|
+
*/
|
|
442
|
+
function isTextElement(el) {
|
|
443
|
+
return !NON_TEXT_TAGS.has(el.tagName.toLowerCase());
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Initialises the text editing module.
|
|
448
|
+
* Listens for single-click on the drag surface (via hc:drag-surface-click from
|
|
449
|
+
* manipulation.js) to enter text edit mode for text elements. Also supports
|
|
450
|
+
* double-click as fallback and the properties panel "Edit Text" button.
|
|
451
|
+
*
|
|
452
|
+
* @param {HTMLIFrameElement} _iframe - The render iframe (unused, reserved for future use)
|
|
453
|
+
* @param {Document} _iframeDoc - The iframe's contentDocument (unused, reserved)
|
|
454
|
+
* @param {HTMLElement} _canvasArea - The canvas wrapper element (unused, reserved)
|
|
455
|
+
*/
|
|
456
|
+
export function initTextEdit(_iframe, _iframeDoc, _canvasArea) {
|
|
457
|
+
const dragSurface = document.querySelector('.sel-drag-surface');
|
|
458
|
+
if (!dragSurface) {
|
|
459
|
+
console.warn('textEdit: .sel-drag-surface not found — text editing unavailable');
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Suppress blur when clicking on the properties panel (so text edit stays active)
|
|
464
|
+
const propsPanel = document.querySelector('.panel-properties');
|
|
465
|
+
if (propsPanel) {
|
|
466
|
+
propsPanel.addEventListener('mousedown', () => {
|
|
467
|
+
if (!_textEditActive || !_currentElement) return;
|
|
468
|
+
_suppressBlur = true;
|
|
469
|
+
// Save the selection NOW (while the element still has focus) as a backup.
|
|
470
|
+
// The blur handler also saves, but by blur-time the iframe's selection
|
|
471
|
+
// may already be cleared in some browsers.
|
|
472
|
+
try {
|
|
473
|
+
const doc = _currentElement.ownerDocument;
|
|
474
|
+
const sel = doc.getSelection();
|
|
475
|
+
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
|
|
476
|
+
_savedRange = sel.getRangeAt(0).cloneRange();
|
|
477
|
+
}
|
|
478
|
+
} catch (_) {}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// When focus leaves the properties panel entirely (e.g. after closing the OS
|
|
482
|
+
// color picker), exit text mode if focus didn't return to the text element.
|
|
483
|
+
// Without this, shortcuts (Ctrl+Z) stay suspended indefinitely.
|
|
484
|
+
propsPanel.addEventListener('focusout', (e) => {
|
|
485
|
+
if (!_textEditActive || !_currentElement) return;
|
|
486
|
+
// relatedTarget is where focus is going. If it's still inside the panel, stay active.
|
|
487
|
+
if (e.relatedTarget && propsPanel.contains(e.relatedTarget)) return;
|
|
488
|
+
// Give focus a tick to potentially land back on the text element
|
|
489
|
+
setTimeout(() => {
|
|
490
|
+
if (!_textEditActive || !_currentElement) return;
|
|
491
|
+
const active = document.activeElement;
|
|
492
|
+
// If active element is not the text element and not in the panel, exit
|
|
493
|
+
if (active !== _currentElement && !(propsPanel.contains(active))) {
|
|
494
|
+
exitTextMode();
|
|
495
|
+
}
|
|
496
|
+
}, 100);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Exit text mode when selection is cleared (canvas background click).
|
|
501
|
+
// Prevents shortcuts from staying suspended after the user clicks away.
|
|
502
|
+
window.addEventListener('hc:selection-cleared', () => {
|
|
503
|
+
if (_textEditActive) exitTextMode();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Single click on drag surface (no drag movement) — enter text edit for text elements
|
|
507
|
+
window.addEventListener('hc:drag-surface-click', (e) => {
|
|
508
|
+
const el = getSelectedElement();
|
|
509
|
+
const hcId = getSelectedHcId();
|
|
510
|
+
if (!el || !hcId) return;
|
|
511
|
+
|
|
512
|
+
if (isTextElement(el)) {
|
|
513
|
+
enterTextMode(el, hcId, e.detail);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Double-click on drag surface — enter text edit for text elements,
|
|
518
|
+
// or escalate to parent for non-text elements (handled by selection.js)
|
|
519
|
+
dragSurface.addEventListener('dblclick', (e) => {
|
|
520
|
+
const el = getSelectedElement();
|
|
521
|
+
const hcId = getSelectedHcId();
|
|
522
|
+
if (!el || !hcId) return;
|
|
523
|
+
|
|
524
|
+
if (isTextElement(el)) {
|
|
525
|
+
e.stopImmediatePropagation();
|
|
526
|
+
enterTextMode(el, hcId);
|
|
527
|
+
}
|
|
528
|
+
// For non-text elements, let the event propagate to selection.js parent escalation
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Listen for "Edit Text" button click from properties panel
|
|
532
|
+
window.addEventListener('hc:request-text-edit', () => {
|
|
533
|
+
const el = getSelectedElement();
|
|
534
|
+
const hcId = getSelectedHcId();
|
|
535
|
+
if (!el || !hcId) return;
|
|
536
|
+
enterTextMode(el, hcId);
|
|
537
|
+
});
|
|
538
|
+
}
|
package/editor/zoom.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// editor/zoom.js — Zoom controls for the canvas
|
|
2
|
+
|
|
3
|
+
const ZOOM_MIN = 0.25;
|
|
4
|
+
const ZOOM_MAX = 10.0;
|
|
5
|
+
const ZOOM_STEP = 0.1;
|
|
6
|
+
const ZOOM_DEFAULT = 1.0;
|
|
7
|
+
|
|
8
|
+
let _zoom = ZOOM_DEFAULT;
|
|
9
|
+
let _iframeContainer = null;
|
|
10
|
+
let _onZoomChange = null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initializes the zoom module.
|
|
14
|
+
*
|
|
15
|
+
* @param {HTMLElement} iframeContainer - The #iframe-container element
|
|
16
|
+
* @param {function} onZoomChange - Callback called with the new zoom level
|
|
17
|
+
*/
|
|
18
|
+
export function initZoom(iframeContainer, onZoomChange) {
|
|
19
|
+
_iframeContainer = iframeContainer;
|
|
20
|
+
_onZoomChange = onZoomChange;
|
|
21
|
+
applyZoom();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the current zoom level (1.0 = 100%).
|
|
26
|
+
*/
|
|
27
|
+
export function getZoom() {
|
|
28
|
+
return _zoom;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sets the zoom level to an exact value.
|
|
33
|
+
* Clamps to [ZOOM_MIN, ZOOM_MAX].
|
|
34
|
+
*
|
|
35
|
+
* @param {number} level - The desired zoom level (e.g., 1.5 for 150%)
|
|
36
|
+
*/
|
|
37
|
+
export function setZoom(level) {
|
|
38
|
+
_zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, level));
|
|
39
|
+
applyZoom();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Zooms in by one step (ZOOM_STEP).
|
|
44
|
+
*/
|
|
45
|
+
export function zoomIn() {
|
|
46
|
+
setZoom(_zoom + ZOOM_STEP);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Zooms out by one step (ZOOM_STEP).
|
|
51
|
+
*/
|
|
52
|
+
export function zoomOut() {
|
|
53
|
+
setZoom(_zoom - ZOOM_STEP);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resets zoom to 100%.
|
|
58
|
+
*/
|
|
59
|
+
export function zoomReset() {
|
|
60
|
+
setZoom(ZOOM_DEFAULT);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Zooms to fit the content width in the canvas area.
|
|
65
|
+
*
|
|
66
|
+
* @param {number} contentWidth - The natural width of the iframe content (e.g., 794 for A4)
|
|
67
|
+
* @param {number} canvasWidth - The visible width of the canvas area
|
|
68
|
+
*/
|
|
69
|
+
export function zoomToFit(contentWidth, canvasWidth) {
|
|
70
|
+
const padding = 80; // 40px padding each side
|
|
71
|
+
const fitZoom = (canvasWidth - padding) / contentWidth;
|
|
72
|
+
setZoom(fitZoom);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Applies the current zoom level to the iframe container via CSS transform.
|
|
77
|
+
*
|
|
78
|
+
* Uses `scale()` with `transform-origin: top center` (set in CSS).
|
|
79
|
+
* Container is centered via `margin: auto`.
|
|
80
|
+
*/
|
|
81
|
+
function applyZoom() {
|
|
82
|
+
if (!_iframeContainer) return;
|
|
83
|
+
_iframeContainer.style.transform = `scale(${_zoom})`;
|
|
84
|
+
|
|
85
|
+
// CSS scale doesn't change layout box size — adjust margins so
|
|
86
|
+
// the canvas area scrolls to reveal the full scaled content
|
|
87
|
+
const naturalW = _iframeContainer.offsetWidth;
|
|
88
|
+
const naturalH = _iframeContainer.offsetHeight;
|
|
89
|
+
const extraW = naturalW * _zoom - naturalW;
|
|
90
|
+
const extraH = naturalH * _zoom - naturalH;
|
|
91
|
+
_iframeContainer.style.marginRight = extraW + 'px';
|
|
92
|
+
_iframeContainer.style.marginBottom = (24 + extraH) + 'px';
|
|
93
|
+
|
|
94
|
+
if (_onZoomChange) _onZoomChange(_zoom);
|
|
95
|
+
window.dispatchEvent(new CustomEvent('hc:zoom-changed', { detail: { zoom: _zoom } }));
|
|
96
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ashraf_mizo/htmlcanvas",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Browser-based HTML canvas editor for designing slide decks and pages",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"htmlcanvas": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"editor/*.js",
|
|
12
|
+
"editor/*.css",
|
|
13
|
+
"editor/*.html",
|
|
14
|
+
"editor/*.svg",
|
|
15
|
+
"server.js"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node server.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"chokidar": "^5.0.0",
|
|
22
|
+
"express": "^5.2.1",
|
|
23
|
+
"multer": "^2.1.1",
|
|
24
|
+
"open": "^11.0.0",
|
|
25
|
+
"playwright": "^1.58.2",
|
|
26
|
+
"ws": "^8.19.0"
|
|
27
|
+
}
|
|
28
|
+
}
|