@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,1583 @@
1
+ // editor/properties.js — Properties panel: read and write CSS on selected elements
2
+ //
3
+ // Exports:
4
+ // initProperties(iframe, iframeDoc)
5
+ // readProperty(element, cssProp) — returns { value, locked }
6
+ // applyStyleProperty(element, hcId, cssProp, newValue) — creates + pushes Command
7
+ //
8
+ // Approach:
9
+ // - Two-source read: inline style (raw) for var() detection, computed for displayed value
10
+ // - var()-backed properties are locked (read-only) with a visible warning
11
+ // - Writes go through Command Pattern (history.js) for full undo/redo support
12
+ // - Panel sections are collapsible; controls are grayed out when nothing is selected
13
+
14
+ import { hasVarReference } from './cssVars.js';
15
+ import { push } from './history.js';
16
+ import { recordChange } from './serializer.js';
17
+ import { getSelectedElement, getSelectedHcId } from './selection.js';
18
+ import { isTextEditActive, applyStyleToSelection } from './textEdit.js';
19
+ import { startCrop } from './crop.js';
20
+
21
+ // ── Recent colors (persisted in session) ────────────────────────────────────
22
+ const MAX_RECENT_COLORS = 12;
23
+ let _recentColors = [];
24
+
25
+ function addRecentColor(hex) {
26
+ if (!hex || hex === '#000000') return;
27
+ _recentColors = [hex, ..._recentColors.filter(c => c !== hex)].slice(0, MAX_RECENT_COLORS);
28
+ _renderRecentColors();
29
+ }
30
+
31
+ function _renderRecentColors() {
32
+ document.querySelectorAll('.prop-recent-colors').forEach(container => {
33
+ container.innerHTML = '';
34
+ const cssProp = container.dataset.cssProp;
35
+ for (const color of _recentColors) {
36
+ const btn = document.createElement('button');
37
+ btn.className = 'prop-recent-swatch';
38
+ btn.style.background = color;
39
+ btn.title = color;
40
+ btn.addEventListener('click', () => {
41
+ applyColorProp(cssProp || 'color', color);
42
+ // Sync visible swatch/hex inputs
43
+ const section = container.closest('.prop-section-body');
44
+ if (section) {
45
+ const swatch = section.querySelector('.prop-color-swatch');
46
+ const hex = section.querySelector('.prop-hex-input');
47
+ if (swatch) swatch.value = color;
48
+ if (hex) hex.value = color;
49
+ }
50
+ });
51
+ container.appendChild(btn);
52
+ }
53
+ });
54
+ }
55
+
56
+ // ── Debounce helper for input events ─────────────────────────────────────────
57
+ const _debounceTimers = new Map();
58
+ function debounceApply(key, fn, delay = 300) {
59
+ if (_debounceTimers.has(key)) clearTimeout(_debounceTimers.get(key));
60
+ _debounceTimers.set(key, setTimeout(() => {
61
+ _debounceTimers.delete(key);
62
+ fn();
63
+ }, delay));
64
+ }
65
+
66
+ // ── Aspect ratio lock state ──────────────────────────────────────────────────
67
+ let _aspectLocked = false;
68
+ let _aspectRatio = 1;
69
+
70
+ // ── SVG shape helpers ────────────────────────────────────────────────────────
71
+
72
+ const SVG_SHAPE_TAGS = new Set(['rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline', 'path']);
73
+
74
+ /**
75
+ * If the element is an <svg>, returns the first child shape element.
76
+ * Otherwise returns null.
77
+ */
78
+ function getSvgShape(element) {
79
+ if (!element || element.tagName.toLowerCase() !== 'svg') return null;
80
+ for (const child of element.children) {
81
+ if (SVG_SHAPE_TAGS.has(child.tagName.toLowerCase())) return child;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Reads an SVG attribute from a shape element and returns a hex color.
88
+ */
89
+ function readSvgColor(shape, attr) {
90
+ if (!shape) return null;
91
+ const val = shape.getAttribute(attr);
92
+ if (!val || val === 'none') return val === 'none' ? 'none' : null;
93
+ // Could be a named color, hex, rgb()
94
+ if (/^#[0-9a-fA-F]{3,6}$/.test(val)) return val;
95
+ const hex = rgbToHex(val);
96
+ if (hex) return hex;
97
+ // Named color: create a temp element to resolve
98
+ const tmp = document.createElement('div');
99
+ tmp.style.color = val;
100
+ document.body.appendChild(tmp);
101
+ const resolved = getComputedStyle(tmp).color;
102
+ document.body.removeChild(tmp);
103
+ return rgbToHex(resolved) || val;
104
+ }
105
+
106
+ // ── DOM refs ──────────────────────────────────────────────────────────────────
107
+
108
+ const panelBody = document.querySelector('.panel-properties .panel-body-properties');
109
+
110
+ // ── readProperty ──────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Reads a CSS property from an element using two sources:
114
+ * 1. element.style (inline) — checked for var() references (locked protection)
115
+ * 2. getComputedStyle — actual resolved display value
116
+ *
117
+ * @param {Element} element - Live iframe DOM element
118
+ * @param {string} cssProp - CSS property name (e.g. 'color', 'font-size')
119
+ * @returns {{ value: string, locked: boolean }}
120
+ */
121
+ export function readProperty(element, cssProp) {
122
+ const inlineVal = element.style.getPropertyValue(cssProp) || '';
123
+ const locked = hasVarReference(inlineVal);
124
+
125
+ const computedVal = getComputedStyle(element).getPropertyValue(cssProp).trim();
126
+ return { value: computedVal, locked };
127
+ }
128
+
129
+ // ── applyStyleProperty ────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Writes a CSS property to an element via the Command Pattern.
133
+ * Returns null (no-op) if the existing inline value contains var().
134
+ *
135
+ * @param {Element} element - Live iframe DOM element
136
+ * @param {string} hcId - data-hc-id of the element
137
+ * @param {string} cssProp - CSS property to write
138
+ * @param {string} newValue - New value to apply
139
+ * @returns {object|null} The created Command, or null if locked
140
+ */
141
+ export function applyStyleProperty(element, hcId, cssProp, newValue) {
142
+ const existingInline = element.style.getPropertyValue(cssProp) || '';
143
+ if (hasVarReference(existingInline)) return null;
144
+
145
+ const beforeVal = existingInline;
146
+
147
+ const command = {
148
+ description: `Set ${cssProp}`,
149
+
150
+ execute() {
151
+ element.style.setProperty(cssProp, newValue);
152
+ recordChange(hcId, element.outerHTML);
153
+ },
154
+
155
+ undo() {
156
+ if (beforeVal === '') {
157
+ element.style.removeProperty(cssProp);
158
+ } else {
159
+ element.style.setProperty(cssProp, beforeVal);
160
+ }
161
+ recordChange(hcId, element.outerHTML);
162
+ },
163
+ };
164
+
165
+ push(command);
166
+ return command;
167
+ }
168
+
169
+ // ── Panel population ──────────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Reads a property and populates the corresponding input/warning UI.
173
+ *
174
+ * @param {Element} element
175
+ * @param {string} cssProp
176
+ * @param {HTMLElement} input - Text/number input to set value on
177
+ * @param {HTMLElement} [lockEl] - .prop-var-lock warning element (optional)
178
+ * @param {HTMLElement} [swatch] - color swatch <input type="color"> (optional)
179
+ */
180
+ function populateControl(element, cssProp, input, lockEl, swatch) {
181
+ const { value, locked } = readProperty(element, cssProp);
182
+
183
+ if (locked) {
184
+ input.disabled = true;
185
+ input.classList.add('prop-locked');
186
+ if (swatch) { swatch.disabled = true; swatch.classList.add('prop-locked'); }
187
+ if (lockEl) lockEl.style.display = '';
188
+ } else {
189
+ input.disabled = false;
190
+ input.classList.remove('prop-locked');
191
+ if (swatch) { swatch.disabled = false; swatch.classList.remove('prop-locked'); }
192
+ if (lockEl) lockEl.style.display = 'none';
193
+ }
194
+
195
+ // Set display value
196
+ if (input.type === 'color') {
197
+ // color inputs need a valid hex; fall back silently
198
+ const hex = rgbToHex(value);
199
+ if (hex) input.value = hex;
200
+ } else if (input.type === 'number') {
201
+ const num = parseFloat(value);
202
+ if (!isNaN(num)) input.value = num;
203
+ else input.value = '';
204
+ } else {
205
+ input.value = value;
206
+ }
207
+
208
+ if (swatch && swatch.type === 'color') {
209
+ const hex = rgbToHex(value);
210
+ if (hex) swatch.value = hex;
211
+ }
212
+ }
213
+
214
+ // ── updatePanel ────────────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Populates all panel controls with the selected element's computed styles.
218
+ * @param {Element} element
219
+ * @param {Document} iframeDoc
220
+ */
221
+ function updatePanel(element, iframeDoc) {
222
+ if (!panelBody) return;
223
+
224
+ // Enable all sections
225
+ panelBody.querySelectorAll('.prop-section').forEach(s => s.classList.remove('prop-disabled'));
226
+ // Remove placeholder
227
+ const placeholder = panelBody.querySelector('.panel-placeholder');
228
+ if (placeholder) placeholder.style.display = 'none';
229
+ // Show sections
230
+ panelBody.querySelectorAll('.prop-section').forEach(s => s.style.display = '');
231
+
232
+ // ── Detect element type ──
233
+ const tag = element.tagName.toLowerCase();
234
+ const isSvg = tag === 'svg';
235
+ const isImg = tag === 'img';
236
+ const svgShape = getSvgShape(element);
237
+
238
+ // ── Context-sensitive action buttons ──
239
+ const actionsSection = panelBody.querySelector('.prop-section-actions');
240
+ const editTextBtn = document.getElementById('prop-edit-text');
241
+ const replaceImgBtn = document.getElementById('prop-replace-image');
242
+ const cropImgBtn = document.getElementById('prop-crop-image');
243
+ if (actionsSection) {
244
+ actionsSection.style.display = '';
245
+ if (replaceImgBtn) replaceImgBtn.style.display = isImg ? '' : 'none';
246
+ if (cropImgBtn) cropImgBtn.style.display = isImg ? '' : 'none';
247
+ if (editTextBtn) editTextBtn.style.display = (isImg || isSvg) ? 'none' : '';
248
+ }
249
+
250
+ // ── Dimensions ──
251
+ const readPx = (prop) => {
252
+ const val = getComputedStyle(element).getPropertyValue(prop).trim();
253
+ const num = parseFloat(val);
254
+ return isNaN(num) ? '' : Math.round(num);
255
+ };
256
+
257
+ const inpX = document.getElementById('prop-x');
258
+ const inpY = document.getElementById('prop-y');
259
+ const inpW = document.getElementById('prop-w');
260
+ const inpH = document.getElementById('prop-h');
261
+ if (inpX) inpX.value = readPx('left');
262
+ if (inpY) inpY.value = readPx('top');
263
+ if (inpW) inpW.value = readPx('width');
264
+ if (inpH) inpH.value = readPx('height');
265
+
266
+ // Rotation: parse transform rotate()
267
+ const inpR = document.getElementById('prop-rotation');
268
+ if (inpR) {
269
+ const transform = getComputedStyle(element).transform;
270
+ inpR.value = extractRotation(transform);
271
+ }
272
+
273
+ // ── Text ──
274
+ const fontPicker = document.getElementById('prop-font-family');
275
+ if (fontPicker) {
276
+ const { value: fontVal, locked: fontLocked } = readProperty(element, 'font-family');
277
+ const firstFont = fontVal.split(',')[0].trim().replace(/['"]/g, '');
278
+ updateFontPicker(fontPicker, firstFont, iframeDoc);
279
+ fontPicker.classList.toggle('prop-locked', fontLocked);
280
+ }
281
+
282
+ const inpFontSize = document.getElementById('prop-font-size');
283
+ const inpLineHeight = document.getElementById('prop-line-height');
284
+ const selWeight = document.getElementById('prop-font-weight');
285
+
286
+ if (inpFontSize) populateControl(element, 'font-size', inpFontSize);
287
+ if (inpLineHeight) populateControl(element, 'line-height', inpLineHeight);
288
+ if (selWeight) {
289
+ const { value: wVal, locked: wLocked } = readProperty(element, 'font-weight');
290
+ setSelectValue(selWeight, Math.round(parseFloat(wVal)).toString());
291
+ selWeight.disabled = wLocked;
292
+ selWeight.classList.toggle('prop-locked', wLocked);
293
+ }
294
+
295
+ // ── Color (text) ──
296
+ const swatchColor = document.getElementById('prop-color');
297
+ const hexColor = document.getElementById('prop-color-hex');
298
+ const lockColor = document.querySelector('#prop-color-hex ~ .prop-var-lock');
299
+ if (swatchColor && hexColor) {
300
+ const { value: colorVal, locked: colorLocked } = readProperty(element, 'color');
301
+ const hex = rgbToHex(colorVal);
302
+ if (hex) { swatchColor.value = hex; hexColor.value = hex; }
303
+ swatchColor.disabled = colorLocked;
304
+ hexColor.disabled = colorLocked;
305
+ swatchColor.classList.toggle('prop-locked', colorLocked);
306
+ hexColor.classList.toggle('prop-locked', colorLocked);
307
+ const colorLockEl = document.getElementById('prop-color-lock');
308
+ if (colorLockEl) colorLockEl.style.display = colorLocked ? '' : 'none';
309
+ }
310
+
311
+ // ── Fill (background or SVG fill) ──
312
+ const swatchBg = document.getElementById('prop-bg-color');
313
+ const hexBg = document.getElementById('prop-bg-hex');
314
+ if (swatchBg && hexBg) {
315
+ if (svgShape) {
316
+ // SVG shape: read fill attribute
317
+ const fillColor = readSvgColor(svgShape, 'fill') || '#E87420';
318
+ if (fillColor !== 'none') {
319
+ swatchBg.value = fillColor;
320
+ hexBg.value = fillColor;
321
+ } else {
322
+ swatchBg.value = '#ffffff';
323
+ hexBg.value = 'none';
324
+ }
325
+ swatchBg.disabled = false;
326
+ hexBg.disabled = false;
327
+ swatchBg.classList.remove('prop-locked');
328
+ hexBg.classList.remove('prop-locked');
329
+ const bgLockEl = document.getElementById('prop-bg-lock');
330
+ if (bgLockEl) bgLockEl.style.display = 'none';
331
+ } else {
332
+ const { value: bgVal, locked: bgLocked } = readProperty(element, 'background-color');
333
+ const hex = rgbToHex(bgVal);
334
+ if (hex) { swatchBg.value = hex; hexBg.value = hex; }
335
+ else { hexBg.value = bgVal; }
336
+ swatchBg.disabled = bgLocked;
337
+ hexBg.disabled = bgLocked;
338
+ swatchBg.classList.toggle('prop-locked', bgLocked);
339
+ hexBg.classList.toggle('prop-locked', bgLocked);
340
+ const bgLockEl = document.getElementById('prop-bg-lock');
341
+ if (bgLockEl) bgLockEl.style.display = bgLocked ? '' : 'none';
342
+ }
343
+ }
344
+
345
+ // ── Fill chip state ──
346
+ {
347
+ const chipColor = document.getElementById('prop-fill-chip-color');
348
+ const chipNone = document.getElementById('prop-fill-chip-none');
349
+ const colorRow = document.getElementById('prop-fill-color-row');
350
+ if (chipColor && chipNone && colorRow) {
351
+ let hasFill;
352
+ if (svgShape) {
353
+ const f = readSvgColor(svgShape, 'fill');
354
+ hasFill = f !== 'none' && f !== '';
355
+ } else {
356
+ const { value: bgVal } = readProperty(element, 'background-color');
357
+ hasFill = bgVal && bgVal !== 'transparent' && bgVal !== 'rgba(0, 0, 0, 0)';
358
+ }
359
+ chipColor.classList.toggle('active', !!hasFill);
360
+ chipNone.classList.toggle('active', !hasFill);
361
+ colorRow.style.display = hasFill ? '' : 'none';
362
+ }
363
+ }
364
+
365
+ // ── Typography extras ──
366
+ const inpLetterSpacing = document.getElementById('prop-letter-spacing');
367
+ if (inpLetterSpacing) {
368
+ const { value: lsVal } = readProperty(element, 'letter-spacing');
369
+ const lsNum = parseFloat(lsVal);
370
+ inpLetterSpacing.value = (!isNaN(lsNum) && lsVal !== 'normal') ? Math.round(lsNum * 10) / 10 : '';
371
+ }
372
+
373
+ // Text align buttons
374
+ const textAlignGroup = document.getElementById('prop-text-align');
375
+ if (textAlignGroup) {
376
+ const { value: taVal } = readProperty(element, 'text-align');
377
+ const align = taVal.replace('start', 'left').replace('end', 'right');
378
+ textAlignGroup.querySelectorAll('.prop-btn-toggle').forEach(btn => {
379
+ btn.classList.toggle('active', btn.dataset.value === align);
380
+ });
381
+ }
382
+
383
+ // V Align buttons — reads justify-content (column flex main-axis = vertical)
384
+ const textVAlignGroup = document.getElementById('prop-text-valign');
385
+ if (textVAlignGroup) {
386
+ const display = getComputedStyle(element).display;
387
+ const justifyContent = getComputedStyle(element).justifyContent;
388
+ let valign = 'flex-start'; // default: top
389
+ if (display === 'flex' || display === 'inline-flex') {
390
+ if (justifyContent === 'center') valign = 'center';
391
+ else if (justifyContent === 'flex-end') valign = 'flex-end';
392
+ else valign = 'flex-start';
393
+ }
394
+ textVAlignGroup.querySelectorAll('.prop-btn-toggle').forEach(btn => {
395
+ btn.classList.toggle('active', btn.dataset.value === valign);
396
+ });
397
+ }
398
+
399
+ // Text style buttons (underline, uppercase, italic)
400
+ const textStyleGroup = document.getElementById('prop-text-style');
401
+ if (textStyleGroup) {
402
+ const { value: tdVal } = readProperty(element, 'text-decoration');
403
+ const { value: ttVal } = readProperty(element, 'text-transform');
404
+ const { value: fsVal } = readProperty(element, 'font-style');
405
+ textStyleGroup.querySelectorAll('.prop-btn-toggle').forEach(btn => {
406
+ const prop = btn.dataset.prop;
407
+ const val = btn.dataset.value;
408
+ if (prop === 'text-decoration') btn.classList.toggle('active', tdVal.includes('underline'));
409
+ else if (prop === 'text-transform') btn.classList.toggle('active', ttVal === 'uppercase');
410
+ else if (prop === 'font-style') btn.classList.toggle('active', fsVal === 'italic');
411
+ });
412
+ }
413
+
414
+ // ── Border (CSS border or SVG stroke) ──
415
+ const inpBorderWidth = document.getElementById('prop-border-width');
416
+ const selBorderStyle = document.getElementById('prop-border-style');
417
+ const swatchBorder = document.getElementById('prop-border-color');
418
+ const hexBorder = document.getElementById('prop-border-hex');
419
+ const inpBorderRadius = document.getElementById('prop-border-radius');
420
+
421
+ if (svgShape) {
422
+ // SVG: read stroke/stroke-width attributes
423
+ const strokeColor = readSvgColor(svgShape, 'stroke');
424
+ const strokeWidth = svgShape.getAttribute('stroke-width');
425
+ if (inpBorderWidth) {
426
+ inpBorderWidth.value = strokeWidth ? parseFloat(strokeWidth) : 0;
427
+ inpBorderWidth.disabled = false;
428
+ inpBorderWidth.classList.remove('prop-locked');
429
+ }
430
+ if (selBorderStyle) {
431
+ setSelectValue(selBorderStyle, strokeColor && strokeColor !== 'none' ? 'solid' : 'none');
432
+ selBorderStyle.disabled = false;
433
+ }
434
+ if (swatchBorder && hexBorder) {
435
+ const hex = (strokeColor && strokeColor !== 'none') ? strokeColor : '#000000';
436
+ swatchBorder.value = hex;
437
+ hexBorder.value = hex;
438
+ swatchBorder.disabled = false;
439
+ hexBorder.disabled = false;
440
+ }
441
+ if (inpBorderRadius) {
442
+ const rx = svgShape.getAttribute('rx');
443
+ inpBorderRadius.value = rx ? parseFloat(rx) : 0;
444
+ inpBorderRadius.disabled = false;
445
+ inpBorderRadius.classList.remove('prop-locked');
446
+ }
447
+ } else {
448
+ if (inpBorderWidth) populateControl(element, 'border-top-width', inpBorderWidth);
449
+ if (inpBorderRadius) populateControl(element, 'border-radius', inpBorderRadius);
450
+ if (selBorderStyle) {
451
+ const { value: bsVal, locked: bsLocked } = readProperty(element, 'border-top-style');
452
+ setSelectValue(selBorderStyle, bsVal.trim());
453
+ selBorderStyle.disabled = bsLocked;
454
+ }
455
+ if (swatchBorder && hexBorder) {
456
+ const { value: bcVal, locked: bcLocked } = readProperty(element, 'border-top-color');
457
+ const hex = rgbToHex(bcVal);
458
+ if (hex) { swatchBorder.value = hex; hexBorder.value = hex; }
459
+ swatchBorder.disabled = bcLocked;
460
+ hexBorder.disabled = bcLocked;
461
+ }
462
+ }
463
+
464
+ // ── Border chip state ──
465
+ {
466
+ const chipOn = document.getElementById('prop-border-chip-on');
467
+ const chipNone = document.getElementById('prop-border-chip-none');
468
+ const borderCtrls = document.getElementById('prop-border-controls');
469
+ if (chipOn && chipNone && borderCtrls) {
470
+ let hasBorder;
471
+ if (svgShape) {
472
+ const s = readSvgColor(svgShape, 'stroke');
473
+ hasBorder = s && s !== 'none' && s !== '';
474
+ } else {
475
+ const { value: bsVal } = readProperty(element, 'border-top-style');
476
+ hasBorder = bsVal && bsVal !== 'none';
477
+ }
478
+ chipOn.classList.toggle('active', !!hasBorder);
479
+ chipNone.classList.toggle('active', !hasBorder);
480
+ borderCtrls.style.display = hasBorder ? '' : 'none';
481
+ }
482
+ }
483
+
484
+ // ── Opacity ──
485
+ const inpOpacity = document.getElementById('prop-opacity');
486
+ const inpOpacityVal = document.getElementById('prop-opacity-val');
487
+ if (inpOpacity && inpOpacityVal) {
488
+ const { value: opVal } = readProperty(element, 'opacity');
489
+ const op = parseFloat(opVal);
490
+ if (!isNaN(op)) {
491
+ inpOpacity.value = op;
492
+ inpOpacityVal.value = Math.round(op * 100);
493
+ }
494
+ }
495
+
496
+ // ── Shadow ──
497
+ const computed = getComputedStyle(element);
498
+ const shadow = computed.boxShadow;
499
+ if (shadow && shadow !== 'none') {
500
+ const sm = shadow.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px\s+([-\d.]+)px/);
501
+ if (sm) {
502
+ const sX = document.getElementById('prop-shadow-x');
503
+ const sY = document.getElementById('prop-shadow-y');
504
+ const sBlur = document.getElementById('prop-shadow-blur');
505
+ const sSpread = document.getElementById('prop-shadow-spread');
506
+ const sColor = document.getElementById('prop-shadow-color');
507
+ const sHex = document.getElementById('prop-shadow-hex');
508
+ if (sX) sX.value = parseFloat(sm[5]);
509
+ if (sY) sY.value = parseFloat(sm[6]);
510
+ if (sBlur) sBlur.value = parseFloat(sm[7]);
511
+ if (sSpread) sSpread.value = parseFloat(sm[8]);
512
+ const hex = rgbToHex(`rgb(${sm[1]}, ${sm[2]}, ${sm[3]})`);
513
+ if (hex && sColor) sColor.value = hex;
514
+ if (hex && sHex) sHex.value = hex;
515
+ }
516
+ }
517
+
518
+ // ── Spacing (padding/margin) ──
519
+ ['padding-top', 'padding-right', 'padding-bottom', 'padding-left',
520
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left'].forEach(prop => {
521
+ const id = 'prop-' + prop;
522
+ const inp = document.getElementById(id);
523
+ if (inp) {
524
+ const val = computed.getPropertyValue(prop);
525
+ const num = parseFloat(val);
526
+ inp.value = isNaN(num) ? '' : Math.round(num);
527
+ }
528
+ });
529
+
530
+ // ── Aspect ratio ──
531
+ const w = parseFloat(computed.width);
532
+ const h = parseFloat(computed.height);
533
+ if (w > 0 && h > 0) _aspectRatio = w / h;
534
+ }
535
+
536
+ // ── updatePanelMulti ──────────────────────────────────────────────────────────
537
+
538
+ /**
539
+ * Populates the panel for a multi-selection. Shows shared values where all
540
+ * selected elements agree; shows empty inputs where values differ.
541
+ *
542
+ * @param {Element[]} elements
543
+ * @param {Document} iframeDoc
544
+ */
545
+ function updatePanelMulti(elements, iframeDoc) {
546
+ if (!panelBody || !elements || elements.length === 0) return;
547
+
548
+ // Enable all sections and show them
549
+ panelBody.querySelectorAll('.prop-section').forEach(s => {
550
+ s.classList.remove('prop-disabled');
551
+ s.style.display = '';
552
+ });
553
+ const placeholder = panelBody.querySelector('.panel-placeholder');
554
+ if (placeholder) placeholder.style.display = 'none';
555
+
556
+ // Helper: get shared value across all elements, or '' if they differ
557
+ const sharedPx = (prop) => {
558
+ const vals = elements.map(el => {
559
+ const v = getComputedStyle(el).getPropertyValue(prop).trim();
560
+ return isNaN(parseFloat(v)) ? '' : Math.round(parseFloat(v));
561
+ });
562
+ const allSame = vals.every(v => v === vals[0]);
563
+ return allSame ? vals[0] : '';
564
+ };
565
+
566
+ const inpX = document.getElementById('prop-x');
567
+ const inpY = document.getElementById('prop-y');
568
+ const inpW = document.getElementById('prop-w');
569
+ const inpH = document.getElementById('prop-h');
570
+ const inpR = document.getElementById('prop-rotation');
571
+ if (inpX) inpX.value = sharedPx('left');
572
+ if (inpY) inpY.value = sharedPx('top');
573
+ if (inpW) inpW.value = sharedPx('width');
574
+ if (inpH) inpH.value = sharedPx('height');
575
+ if (inpR) inpR.value = '';
576
+
577
+ // Text: show shared font-size
578
+ const inpFontSize = document.getElementById('prop-font-size');
579
+ if (inpFontSize) {
580
+ const vals = elements.map(el => Math.round(parseFloat(getComputedStyle(el).fontSize)));
581
+ inpFontSize.value = vals.every(v => v === vals[0]) ? vals[0] : '';
582
+ }
583
+
584
+ // Gray out inputs that have mixed values (placeholder shows they differ)
585
+ panelBody.querySelectorAll('.prop-input').forEach(inp => {
586
+ if (inp.value === '') inp.placeholder = 'Mixed';
587
+ });
588
+ }
589
+
590
+ // ── clearPanel ────────────────────────────────────────────────────────────────
591
+
592
+ /**
593
+ * Resets all controls to empty/defaults and grays out all sections.
594
+ * Skips clearing if text editing is active (panel should stay populated).
595
+ */
596
+ export function clearPanel() {
597
+ if (!panelBody) return;
598
+ if (isTextEditActive()) return;
599
+
600
+ panelBody.querySelectorAll('.prop-section').forEach(s => {
601
+ s.classList.add('prop-disabled');
602
+ s.style.display = 'none';
603
+ });
604
+
605
+ const placeholder = panelBody.querySelector('.panel-placeholder');
606
+ if (placeholder) placeholder.style.display = '';
607
+
608
+ // Reset inputs
609
+ panelBody.querySelectorAll('.prop-input, .prop-select').forEach(el => {
610
+ if (el.type === 'color') el.value = '#000000';
611
+ else el.value = '';
612
+ });
613
+ }
614
+
615
+ // ── Stored event handler references (module-level for proper removal) ─────────
616
+
617
+ let _onSelectionChanged = null;
618
+ let _onSelectionCleared = null;
619
+ let _onMultiSelectionChanged = null;
620
+ let _wired = false;
621
+
622
+ // ── initProperties ────────────────────────────────────────────────────────────
623
+
624
+ /**
625
+ * Initialises (or re-initialises) the properties panel for a new iframe.
626
+ * Wires up all input change events and listens for selection events.
627
+ *
628
+ * @param {HTMLIFrameElement} iframe
629
+ * @param {Document} iframeDoc
630
+ */
631
+ export function initProperties(iframe, iframeDoc) {
632
+ // Start with panel cleared (force clear even during text edit by calling inner logic)
633
+ if (panelBody) {
634
+ panelBody.querySelectorAll('.prop-section').forEach(s => {
635
+ s.classList.add('prop-disabled');
636
+ s.style.display = 'none';
637
+ });
638
+ const placeholder = panelBody.querySelector('.panel-placeholder');
639
+ if (placeholder) placeholder.style.display = '';
640
+ panelBody.querySelectorAll('.prop-input, .prop-select').forEach(el => {
641
+ if (el.type === 'color') el.value = '#000000';
642
+ else el.value = '';
643
+ });
644
+ }
645
+
646
+ // ── Selection event listeners (properly removable via module-level refs) ──
647
+
648
+ // Remove previous listeners if they exist
649
+ if (_onSelectionChanged) window.removeEventListener('hc:selection-changed', _onSelectionChanged);
650
+ if (_onSelectionCleared) window.removeEventListener('hc:selection-cleared', _onSelectionCleared);
651
+ if (_onMultiSelectionChanged) window.removeEventListener('hc:multi-selection-changed', _onMultiSelectionChanged);
652
+
653
+ _onSelectionChanged = (e) => {
654
+ const { element } = e.detail || {};
655
+ if (element) updatePanel(element, iframeDoc);
656
+ };
657
+
658
+ _onSelectionCleared = () => {
659
+ clearPanel();
660
+ };
661
+
662
+ _onMultiSelectionChanged = (e) => {
663
+ const { elements } = e.detail || {};
664
+ if (!elements || elements.length === 0) {
665
+ clearPanel();
666
+ return;
667
+ }
668
+ if (elements.length === 1) {
669
+ updatePanel(elements[0], iframeDoc);
670
+ return;
671
+ }
672
+ updatePanelMulti(elements, iframeDoc);
673
+ };
674
+
675
+ window.addEventListener('hc:selection-changed', _onSelectionChanged);
676
+ window.addEventListener('hc:selection-cleared', _onSelectionCleared);
677
+ window.addEventListener('hc:multi-selection-changed', _onMultiSelectionChanged);
678
+
679
+ // ── Wire all change events (only once — input elements persist across iframe reloads) ──
680
+
681
+ if (_wired) return;
682
+ _wired = true;
683
+
684
+ // ── Shape mask picker ──────────────────────────────────────────────────────
685
+ const MASK_SHAPES = [
686
+ { id: 'circle', label: 'Circle', icon: '<circle cx="8" cy="8" r="6"/>', clipPath: 'circle(50% at 50% 50%)' },
687
+ { id: 'ellipse', label: 'Ellipse', icon: '<ellipse cx="8" cy="8" rx="7" ry="4.5"/>', clipPath: 'ellipse(50% 35% at 50% 50%)' },
688
+ { id: 'round', label: 'Rounded', icon: '<rect x="1" y="1" width="14" height="14" rx="4"/>', clipPath: 'inset(0% round 15%)' },
689
+ { id: 'pill', label: 'Pill', icon: '<rect x="1" y="3" width="14" height="10" rx="5"/>', clipPath: 'inset(0% round 50%)' },
690
+ { id: 'triangle', label: 'Triangle', icon: '<polygon points="8,1 15,15 1,15"/>', clipPath: 'polygon(50% 0%, 0% 100%, 100% 100%)' },
691
+ { id: 'rtriangle', label: 'Right Triangle', icon: '<polygon points="1,1 15,15 1,15"/>', clipPath: 'polygon(0% 0%, 100% 100%, 0% 100%)' },
692
+ { id: 'diamond', label: 'Diamond', icon: '<polygon points="8,1 15,8 8,15 1,8"/>', clipPath: 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)' },
693
+ { id: 'pentagon', label: 'Pentagon', icon: '<polygon points="8,1 15,6 13,14 3,14 1,6"/>', clipPath: 'polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)' },
694
+ { id: 'hexagon', label: 'Hexagon', icon: '<polygon points="4,1 12,1 15,8 12,15 4,15 1,8"/>', clipPath: 'polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)' },
695
+ { id: 'star', label: 'Star', icon: '<polygon points="8,1 9.8,5.8 15,5.8 10.9,9.2 12.4,14.2 8,11.2 3.6,14.2 5.1,9.2 1,5.8 6.2,5.8"/>', clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)' },
696
+ { id: 'arrow', label: 'Arrow', icon: '<polygon points="1,5 9,5 9,1 15,8 9,15 9,11 1,11"/>', clipPath: 'polygon(0% 20%, 60% 20%, 60% 0%, 100% 50%, 60% 100%, 60% 80%, 0% 80%)' },
697
+ { id: 'chevron', label: 'Chevron', icon: '<polygon points="1,1 11,1 15,8 11,15 1,15 5,8"/>', clipPath: 'polygon(0% 0%, 75% 0%, 100% 50%, 75% 100%, 0% 100%, 25% 50%)' },
698
+ ];
699
+
700
+ (function initMaskPicker() {
701
+ const btn = document.getElementById('prop-mask-shape');
702
+ const picker = document.getElementById('mask-picker');
703
+ if (!btn || !picker) return;
704
+
705
+ // Build grid
706
+ const grid = document.createElement('div');
707
+ grid.className = 'mask-picker-grid';
708
+ for (const shape of MASK_SHAPES) {
709
+ const item = document.createElement('button');
710
+ item.className = 'mask-picker-item';
711
+ item.title = shape.label;
712
+ item.dataset.maskId = shape.id;
713
+ item.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3">${shape.icon}</svg>`;
714
+ item.addEventListener('click', () => {
715
+ const target = getSelectedElement();
716
+ const hcId = getSelectedHcId();
717
+ if (!target || !hcId) return;
718
+ const old = target.style.clipPath || '';
719
+ const cmd = {
720
+ description: `Mask to ${shape.label}`,
721
+ execute() { target.style.clipPath = shape.clipPath; recordChange(hcId, target.outerHTML); window.dispatchEvent(new CustomEvent('hc:zoom-changed')); },
722
+ undo() { target.style.clipPath = old; recordChange(hcId, target.outerHTML); window.dispatchEvent(new CustomEvent('hc:zoom-changed')); },
723
+ };
724
+ push(cmd);
725
+ // Mark active
726
+ grid.querySelectorAll('.mask-picker-item').forEach(i => i.classList.remove('active'));
727
+ item.classList.add('active');
728
+ picker.style.display = 'none';
729
+ });
730
+ grid.appendChild(item);
731
+ }
732
+ picker.appendChild(grid);
733
+
734
+ // Remove mask button
735
+ const removeBtn = document.createElement('button');
736
+ removeBtn.className = 'mask-picker-remove';
737
+ removeBtn.textContent = 'Remove mask';
738
+ removeBtn.addEventListener('click', () => {
739
+ const target = getSelectedElement();
740
+ const hcId = getSelectedHcId();
741
+ if (!target || !hcId) return;
742
+ const old = target.style.clipPath || '';
743
+ if (!old) { picker.style.display = 'none'; return; }
744
+ const cmd = {
745
+ description: 'Remove mask',
746
+ execute() { target.style.removeProperty('clip-path'); recordChange(hcId, target.outerHTML); window.dispatchEvent(new CustomEvent('hc:zoom-changed')); },
747
+ undo() { target.style.clipPath = old; recordChange(hcId, target.outerHTML); window.dispatchEvent(new CustomEvent('hc:zoom-changed')); },
748
+ };
749
+ push(cmd);
750
+ grid.querySelectorAll('.mask-picker-item').forEach(i => i.classList.remove('active'));
751
+ picker.style.display = 'none';
752
+ });
753
+ picker.appendChild(removeBtn);
754
+
755
+ // Toggle open/close
756
+ let _open = false;
757
+ btn.addEventListener('click', (e) => {
758
+ e.stopPropagation();
759
+ _open = !_open;
760
+ picker.style.display = _open ? 'block' : 'none';
761
+ if (_open) {
762
+ // Highlight current mask
763
+ const target = getSelectedElement();
764
+ const cur = target?.style.clipPath || '';
765
+ grid.querySelectorAll('.mask-picker-item').forEach(item => {
766
+ const shape = MASK_SHAPES.find(s => s.id === item.dataset.maskId);
767
+ item.classList.toggle('active', !!shape && shape.clipPath === cur);
768
+ });
769
+ }
770
+ });
771
+ document.addEventListener('mousedown', (e) => {
772
+ if (_open && !picker.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
773
+ _open = false;
774
+ picker.style.display = 'none';
775
+ }
776
+ });
777
+ document.addEventListener('keydown', (e) => {
778
+ if (e.key === 'Escape' && _open) { _open = false; picker.style.display = 'none'; }
779
+ });
780
+ })();
781
+
782
+ // Text-related CSS properties that can apply to inline selections
783
+ const INLINE_TEXT_PROPS = new Set([
784
+ 'font-family', 'font-size', 'font-weight', 'line-height', 'color',
785
+ 'letter-spacing', 'text-decoration', 'text-transform', 'font-style',
786
+ ]);
787
+
788
+ // Helper: wire a single input to applyStyleProperty (or inline selection if text editing)
789
+ function wire(id, cssProp, transform) {
790
+ const el = document.getElementById(id);
791
+ if (!el) return;
792
+ el.addEventListener('change', () => {
793
+ const target = getSelectedElement();
794
+ const hcId = getSelectedHcId();
795
+ if (!target || !hcId) return;
796
+ const val = transform ? transform(el.value) : el.value;
797
+
798
+ // During text editing with a selection, apply to the selected text only
799
+ if (isTextEditActive() && INLINE_TEXT_PROPS.has(cssProp)) {
800
+ if (applyStyleToSelection(cssProp, val)) return;
801
+ }
802
+
803
+ applyStyleProperty(target, hcId, cssProp, val);
804
+ });
805
+ }
806
+
807
+ // Helper: wire input that checks for SVG shape and applies SVG attribute instead
808
+ function wireSvgAware(id, cssProp, svgAttr, cssTransform, svgTransform) {
809
+ const el = document.getElementById(id);
810
+ if (!el) return;
811
+ el.addEventListener('change', () => {
812
+ const target = getSelectedElement();
813
+ const hcId = getSelectedHcId();
814
+ if (!target || !hcId) return;
815
+ const shape = getSvgShape(target);
816
+ if (shape) {
817
+ const val = svgTransform ? svgTransform(el.value) : el.value;
818
+ applySvgAttribute(target, hcId, shape, svgAttr, val);
819
+ } else {
820
+ const val = cssTransform ? cssTransform(el.value) : el.value;
821
+ applyStyleProperty(target, hcId, cssProp, val);
822
+ }
823
+ });
824
+ }
825
+
826
+ // Dimensions
827
+ wire('prop-x', 'left', v => v + 'px');
828
+ wire('prop-y', 'top', v => v + 'px');
829
+ wire('prop-w', 'width', v => v + 'px');
830
+ wire('prop-h', 'height', v => v + 'px');
831
+
832
+ // Rotation (writes transform)
833
+ const inpR = document.getElementById('prop-rotation');
834
+ if (inpR) {
835
+ inpR.addEventListener('change', () => {
836
+ const target = getSelectedElement();
837
+ const hcId = getSelectedHcId();
838
+ if (!target || !hcId) return;
839
+ applyStyleProperty(target, hcId, 'transform', `rotate(${inpR.value}deg)`);
840
+ });
841
+ }
842
+
843
+ // Text properties — font picker (custom searchable dropdown)
844
+ initFontPicker();
845
+
846
+ wire('prop-font-size', 'font-size', v => v + 'px');
847
+ wire('prop-font-weight', 'font-weight');
848
+ wire('prop-line-height', 'line-height');
849
+
850
+ // Text color: swatch + hex text sync
851
+ wireColorPair('prop-color', 'prop-color-hex', 'color');
852
+
853
+ // Fill chip: Color / None toggle
854
+ {
855
+ const chipColor = document.getElementById('prop-fill-chip-color');
856
+ const chipNone = document.getElementById('prop-fill-chip-none');
857
+ const colorRow = document.getElementById('prop-fill-color-row');
858
+ if (chipColor && chipNone) {
859
+ chipColor.addEventListener('click', () => {
860
+ const target = getSelectedElement();
861
+ const hcId = getSelectedHcId();
862
+ if (!target || !hcId) return;
863
+ const shape = getSvgShape(target);
864
+ const color = document.getElementById('prop-bg-color')?.value || '#E87420';
865
+ chipColor.classList.add('active');
866
+ chipNone.classList.remove('active');
867
+ if (colorRow) colorRow.style.display = '';
868
+ if (shape) applySvgAttribute(target, hcId, shape, 'fill', color);
869
+ else applyStyleProperty(target, hcId, 'background-color', color);
870
+ });
871
+ chipNone.addEventListener('click', () => {
872
+ const target = getSelectedElement();
873
+ const hcId = getSelectedHcId();
874
+ if (!target || !hcId) return;
875
+ const shape = getSvgShape(target);
876
+ chipNone.classList.add('active');
877
+ chipColor.classList.remove('active');
878
+ if (colorRow) colorRow.style.display = 'none';
879
+ if (shape) applySvgAttribute(target, hcId, shape, 'fill', 'none');
880
+ else applyStyleProperty(target, hcId, 'background-color', 'transparent');
881
+ });
882
+ }
883
+ }
884
+
885
+ // Fill: background color (or SVG fill)
886
+ wireColorPair('prop-bg-color', 'prop-bg-hex', 'background-color');
887
+
888
+ // Typography extras
889
+ wire('prop-letter-spacing', 'letter-spacing', v => v + 'px');
890
+
891
+ // Text align toggle buttons
892
+ const textAlignGroup = document.getElementById('prop-text-align');
893
+ if (textAlignGroup) {
894
+ textAlignGroup.querySelectorAll('.prop-btn-toggle').forEach(btn => {
895
+ btn.addEventListener('click', () => {
896
+ const target = getSelectedElement();
897
+ const hcId = getSelectedHcId();
898
+ if (!target || !hcId) return;
899
+ applyStyleProperty(target, hcId, 'text-align', btn.dataset.value);
900
+ // Update active state
901
+ textAlignGroup.querySelectorAll('.prop-btn-toggle').forEach(b => b.classList.remove('active'));
902
+ btn.classList.add('active');
903
+ });
904
+ });
905
+ }
906
+
907
+ // Text style toggle buttons (underline, uppercase, italic)
908
+ const textStyleGroup = document.getElementById('prop-text-style');
909
+ if (textStyleGroup) {
910
+ textStyleGroup.querySelectorAll('.prop-btn-toggle').forEach(btn => {
911
+ btn.addEventListener('click', () => {
912
+ const target = getSelectedElement();
913
+ const hcId = getSelectedHcId();
914
+ if (!target || !hcId) return;
915
+ const prop = btn.dataset.prop;
916
+ const val = btn.dataset.value;
917
+ const isActive = btn.classList.contains('active');
918
+
919
+ let newVal;
920
+ if (prop === 'text-decoration') newVal = isActive ? 'none' : val;
921
+ else if (prop === 'text-transform') newVal = isActive ? 'none' : val;
922
+ else if (prop === 'font-style') newVal = isActive ? 'normal' : val;
923
+ else newVal = val;
924
+
925
+ if (isTextEditActive() && INLINE_TEXT_PROPS.has(prop)) {
926
+ if (applyStyleToSelection(prop, newVal)) {
927
+ btn.classList.toggle('active');
928
+ return;
929
+ }
930
+ }
931
+ applyStyleProperty(target, hcId, prop, newVal);
932
+ btn.classList.toggle('active');
933
+ });
934
+ });
935
+ }
936
+
937
+ // V Align toggle buttons — uses justify-content (column flex main-axis = vertical).
938
+ //
939
+ // Why a single atomic command instead of multiple applyStyleProperty calls:
940
+ // - Each applyStyleProperty pushes a separate history entry. Intermediate states
941
+ // (e.g. display:flex without flex-direction yet) can cause brief layout glitches.
942
+ // - A single command means one clean undo step.
943
+ //
944
+ // Why we do NOT touch height / width / box-sizing:
945
+ // Switching display from block → flex does not change the CSS box model for
946
+ // the element itself — height/padding/border are resolved identically in both
947
+ // modes. Leaving those properties untouched means the element's rendered size
948
+ // is guaranteed to stay the same, and text boxes can still grow while typing.
949
+ const textVAlignGroup = document.getElementById('prop-text-valign');
950
+ if (textVAlignGroup) {
951
+ textVAlignGroup.querySelectorAll('.prop-btn-toggle').forEach(btn => {
952
+ btn.addEventListener('click', () => {
953
+ const target = getSelectedElement();
954
+ const hcId = getSelectedHcId();
955
+ if (!target || !hcId) return;
956
+ const val = btn.dataset.value;
957
+ const display = getComputedStyle(target).display;
958
+ const isAlreadyFlex = display === 'flex' || display === 'inline-flex';
959
+
960
+ if (isAlreadyFlex) {
961
+ // Already flex — just update justify-content (single command, no size change)
962
+ applyStyleProperty(target, hcId, 'justify-content', val);
963
+ } else {
964
+ // Capture only the flex-related before-state for clean single undo.
965
+ // We intentionally do NOT touch height, width, or box-sizing —
966
+ // switching display from block→flex does not change the box model
967
+ // interpretation of those properties, so dimensions stay stable.
968
+ const before = {
969
+ display: target.style.display || '',
970
+ flexDirection: target.style.flexDirection || '',
971
+ justifyContent: target.style.justifyContent || '',
972
+ };
973
+
974
+ const cmd = {
975
+ description: 'Set vertical alignment',
976
+ execute() {
977
+ target.style.display = 'flex';
978
+ target.style.flexDirection = 'column';
979
+ target.style.justifyContent = val;
980
+ recordChange(hcId, target.outerHTML);
981
+ },
982
+ undo() {
983
+ const set = (cssProp, jsKey, v) => {
984
+ if (v) target.style[jsKey] = v;
985
+ else target.style.removeProperty(cssProp);
986
+ };
987
+ set('display', 'display', before.display);
988
+ set('flex-direction', 'flexDirection', before.flexDirection);
989
+ set('justify-content', 'justifyContent',before.justifyContent);
990
+ recordChange(hcId, target.outerHTML);
991
+ },
992
+ };
993
+ push(cmd);
994
+ }
995
+
996
+ textVAlignGroup.querySelectorAll('.prop-btn-toggle').forEach(b => b.classList.remove('active'));
997
+ btn.classList.add('active');
998
+ });
999
+ });
1000
+ }
1001
+
1002
+ // Border chip: On / None toggle
1003
+ {
1004
+ const chipOn = document.getElementById('prop-border-chip-on');
1005
+ const chipNone = document.getElementById('prop-border-chip-none');
1006
+ const borderCtrls = document.getElementById('prop-border-controls');
1007
+ if (chipOn && chipNone) {
1008
+ chipOn.addEventListener('click', () => {
1009
+ const target = getSelectedElement();
1010
+ const hcId = getSelectedHcId();
1011
+ if (!target || !hcId) return;
1012
+ const shape = getSvgShape(target);
1013
+ chipOn.classList.add('active');
1014
+ chipNone.classList.remove('active');
1015
+ if (borderCtrls) borderCtrls.style.display = '';
1016
+ const width = parseInt(document.getElementById('prop-border-width')?.value) || 2;
1017
+ const color = document.getElementById('prop-border-color')?.value || '#000000';
1018
+ if (shape) {
1019
+ applySvgAttribute(target, hcId, shape, 'stroke', color);
1020
+ applySvgAttribute(target, hcId, shape, 'stroke-width', String(width));
1021
+ } else {
1022
+ applyStyleProperty(target, hcId, 'border-style', 'solid');
1023
+ applyStyleProperty(target, hcId, 'border-width', width + 'px');
1024
+ applyStyleProperty(target, hcId, 'border-color', color);
1025
+ }
1026
+ });
1027
+ chipNone.addEventListener('click', () => {
1028
+ const target = getSelectedElement();
1029
+ const hcId = getSelectedHcId();
1030
+ if (!target || !hcId) return;
1031
+ const shape = getSvgShape(target);
1032
+ chipNone.classList.add('active');
1033
+ chipOn.classList.remove('active');
1034
+ if (borderCtrls) borderCtrls.style.display = 'none';
1035
+ if (shape) {
1036
+ applySvgAttribute(target, hcId, shape, 'stroke', 'none');
1037
+ } else {
1038
+ applyStyleProperty(target, hcId, 'border-style', 'none');
1039
+ }
1040
+ });
1041
+ }
1042
+ }
1043
+
1044
+ // Border (CSS or SVG stroke)
1045
+ wireSvgAware('prop-border-width', 'border-width', 'stroke-width', v => v + 'px', v => v);
1046
+ wire('prop-border-style', 'border-style');
1047
+ wireSvgAware('prop-border-radius', 'border-radius', 'rx', v => v + 'px', v => v);
1048
+ wireColorPair('prop-border-color', 'prop-border-hex', 'border-color');
1049
+
1050
+ // Opacity: range slider + number input sync
1051
+ const inpOpacity = document.getElementById('prop-opacity');
1052
+ const inpOpacityVal = document.getElementById('prop-opacity-val');
1053
+ if (inpOpacity && inpOpacityVal) {
1054
+ inpOpacity.addEventListener('input', () => {
1055
+ inpOpacityVal.value = Math.round(inpOpacity.value * 100);
1056
+ const target = getSelectedElement();
1057
+ const hcId = getSelectedHcId();
1058
+ if (!target || !hcId) return;
1059
+ debounceApply('opacity', () => applyStyleProperty(target, hcId, 'opacity', inpOpacity.value));
1060
+ });
1061
+ inpOpacityVal.addEventListener('change', () => {
1062
+ const v = Math.max(0, Math.min(100, parseInt(inpOpacityVal.value) || 100));
1063
+ inpOpacity.value = v / 100;
1064
+ inpOpacityVal.value = v;
1065
+ const target = getSelectedElement();
1066
+ const hcId = getSelectedHcId();
1067
+ if (!target || !hcId) return;
1068
+ applyStyleProperty(target, hcId, 'opacity', String(v / 100));
1069
+ });
1070
+ }
1071
+
1072
+ // Shadow: composite box-shadow from individual inputs
1073
+ function applyShadow() {
1074
+ const target = getSelectedElement();
1075
+ const hcId = getSelectedHcId();
1076
+ if (!target || !hcId) return;
1077
+ const sx = document.getElementById('prop-shadow-x')?.value || '0';
1078
+ const sy = document.getElementById('prop-shadow-y')?.value || '4';
1079
+ const sb = document.getElementById('prop-shadow-blur')?.value || '8';
1080
+ const ss = document.getElementById('prop-shadow-spread')?.value || '0';
1081
+ const sc = document.getElementById('prop-shadow-hex')?.value || document.getElementById('prop-shadow-color')?.value || '#00000040';
1082
+ applyStyleProperty(target, hcId, 'box-shadow', `${sx}px ${sy}px ${sb}px ${ss}px ${sc}`);
1083
+ }
1084
+
1085
+ ['prop-shadow-x', 'prop-shadow-y', 'prop-shadow-blur', 'prop-shadow-spread'].forEach(id => {
1086
+ const el = document.getElementById(id);
1087
+ if (el) el.addEventListener('change', applyShadow);
1088
+ });
1089
+
1090
+ const shadowColor = document.getElementById('prop-shadow-color');
1091
+ const shadowHex = document.getElementById('prop-shadow-hex');
1092
+ let _shadowStartValue = null;
1093
+
1094
+ // Helper: build shadow string from current inputs
1095
+ function buildShadowValue() {
1096
+ const sx = document.getElementById('prop-shadow-x')?.value || '0';
1097
+ const sy = document.getElementById('prop-shadow-y')?.value || '4';
1098
+ const sb = document.getElementById('prop-shadow-blur')?.value || '8';
1099
+ const ss = document.getElementById('prop-shadow-spread')?.value || '0';
1100
+ const sc = shadowHex?.value || shadowColor?.value || '#00000040';
1101
+ return `${sx}px ${sy}px ${sb}px ${ss}px ${sc}`;
1102
+ }
1103
+
1104
+ if (shadowColor) {
1105
+ // Capture before-value on mousedown (same pattern as wireColorPair)
1106
+ shadowColor.addEventListener('mousedown', () => {
1107
+ const target = getSelectedElement();
1108
+ if (!target) { _shadowStartValue = null; return; }
1109
+ _shadowStartValue = target.style.getPropertyValue('box-shadow') || '';
1110
+ });
1111
+
1112
+ // Live preview: apply directly to DOM, no history push
1113
+ shadowColor.addEventListener('input', () => {
1114
+ if (shadowHex) shadowHex.value = shadowColor.value;
1115
+ const target = getSelectedElement();
1116
+ if (target) target.style.setProperty('box-shadow', buildShadowValue());
1117
+ });
1118
+
1119
+ // Commit to history on release (single entry)
1120
+ shadowColor.addEventListener('change', () => {
1121
+ applyShadow();
1122
+ _shadowStartValue = null;
1123
+ });
1124
+ }
1125
+ if (shadowHex) {
1126
+ shadowHex.addEventListener('change', () => {
1127
+ const clean = shadowHex.value.trim();
1128
+ if (/^#[0-9a-fA-F]{3,8}$/.test(clean)) {
1129
+ if (shadowColor) shadowColor.value = clean.slice(0, 7);
1130
+ applyShadow();
1131
+ }
1132
+ });
1133
+ }
1134
+
1135
+ const removeShadowBtn = document.getElementById('prop-shadow-remove');
1136
+ if (removeShadowBtn) {
1137
+ removeShadowBtn.addEventListener('click', () => {
1138
+ const target = getSelectedElement();
1139
+ const hcId = getSelectedHcId();
1140
+ if (!target || !hcId) return;
1141
+ applyStyleProperty(target, hcId, 'box-shadow', 'none');
1142
+ });
1143
+ }
1144
+
1145
+ // Spacing (padding/margin)
1146
+ ['padding-top', 'padding-right', 'padding-bottom', 'padding-left',
1147
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left'].forEach(prop => {
1148
+ wire('prop-' + prop, prop, v => v + 'px');
1149
+ });
1150
+
1151
+ // Aspect ratio lock toggle
1152
+ const aspectLockBtn = document.getElementById('prop-aspect-lock');
1153
+ if (aspectLockBtn) {
1154
+ aspectLockBtn.addEventListener('click', () => {
1155
+ _aspectLocked = !_aspectLocked;
1156
+ aspectLockBtn.classList.toggle('active', _aspectLocked);
1157
+ // Capture current ratio
1158
+ const target = getSelectedElement();
1159
+ if (target) {
1160
+ const w = parseFloat(getComputedStyle(target).width);
1161
+ const h = parseFloat(getComputedStyle(target).height);
1162
+ if (w > 0 && h > 0) _aspectRatio = w / h;
1163
+ }
1164
+ });
1165
+ }
1166
+
1167
+ // W/H linked updates when aspect locked
1168
+ const inpW = document.getElementById('prop-w');
1169
+ const inpH = document.getElementById('prop-h');
1170
+ if (inpW) {
1171
+ inpW.addEventListener('input', () => {
1172
+ if (_aspectLocked && _aspectRatio > 0) {
1173
+ const w = parseFloat(inpW.value);
1174
+ if (!isNaN(w)) inpH.value = Math.round(w / _aspectRatio);
1175
+ }
1176
+ });
1177
+ }
1178
+ if (inpH) {
1179
+ inpH.addEventListener('input', () => {
1180
+ if (_aspectLocked && _aspectRatio > 0) {
1181
+ const h = parseFloat(inpH.value);
1182
+ if (!isNaN(h)) inpW.value = Math.round(h * _aspectRatio);
1183
+ }
1184
+ });
1185
+ }
1186
+
1187
+ // ── Action buttons ──
1188
+ const editTextBtn = document.getElementById('prop-edit-text');
1189
+ if (editTextBtn) {
1190
+ editTextBtn.addEventListener('click', () => {
1191
+ // Dispatch a custom event that textEdit.js can listen for
1192
+ window.dispatchEvent(new CustomEvent('hc:request-text-edit'));
1193
+ });
1194
+ }
1195
+
1196
+ const replaceImgBtn = document.getElementById('prop-replace-image');
1197
+ if (replaceImgBtn) {
1198
+ replaceImgBtn.addEventListener('click', () => {
1199
+ if (window._hcReplaceImage) window._hcReplaceImage();
1200
+ });
1201
+ }
1202
+
1203
+ const cropImgBtn = document.getElementById('prop-crop-image');
1204
+ if (cropImgBtn) {
1205
+ cropImgBtn.addEventListener('click', () => {
1206
+ startCrop();
1207
+ });
1208
+ }
1209
+
1210
+ // ── Collapsible section headers ──
1211
+ panelBody.querySelectorAll('.prop-section-header').forEach(header => {
1212
+ header.addEventListener('click', () => {
1213
+ const section = header.closest('.prop-section');
1214
+ if (section) section.classList.toggle('prop-section-collapsed');
1215
+ });
1216
+ });
1217
+ }
1218
+
1219
+ // ── Helper: wire color swatch + hex text pair ─────────────────────────────────
1220
+ //
1221
+ // Strategy: separate live preview from history commitment.
1222
+ // - mousedown: capture the value before the user starts dragging
1223
+ // - input: apply directly to DOM for smooth live preview (no history push)
1224
+ // - change: push exactly ONE command to history with correct before/after values
1225
+ //
1226
+ // This prevents the history stack from being flooded with intermediary states
1227
+ // and ensures undo always jumps back to the value before the drag started.
1228
+
1229
+ function wireColorPair(swatchId, hexId, cssProp) {
1230
+ const swatch = document.getElementById(swatchId);
1231
+ const hexInput = document.getElementById(hexId);
1232
+ if (!swatch || !hexInput) return;
1233
+
1234
+ // Value captured just before the user starts dragging the color picker
1235
+ let _startValue = null;
1236
+
1237
+ swatch.addEventListener('mousedown', () => {
1238
+ const target = getSelectedElement();
1239
+ if (!target) { _startValue = null; return; }
1240
+ const shape = getSvgShape(target);
1241
+ if (shape) {
1242
+ const attr = cssProp === 'background-color' ? 'fill' : cssProp === 'border-color' ? 'stroke' : cssProp;
1243
+ _startValue = shape.getAttribute(attr) || '';
1244
+ } else {
1245
+ _startValue = target.style.getPropertyValue(cssProp) || '';
1246
+ }
1247
+ });
1248
+
1249
+ // Live preview while dragging — apply directly to DOM, no history entry
1250
+ swatch.addEventListener('input', () => {
1251
+ hexInput.value = swatch.value;
1252
+ const target = getSelectedElement();
1253
+ if (!target) return;
1254
+ const shape = getSvgShape(target);
1255
+ if (shape) {
1256
+ const attr = cssProp === 'background-color' ? 'fill' : cssProp === 'border-color' ? 'stroke' : cssProp;
1257
+ shape.setAttribute(attr, swatch.value);
1258
+ } else if (isTextEditActive() && cssProp === 'color') {
1259
+ // In text edit mode, apply preview only to selected text via a span.
1260
+ // NEVER fall through to whole-element setProperty here:
1261
+ // - It runs without a history entry, so it can't be undone
1262
+ // - It leaves an inline color on the element that subsequent span
1263
+ // operations (e.g. font-size via applyStyleToSelection) will inherit,
1264
+ // making the text appear in the wrong color
1265
+ // If no text is selected the preview is skipped; change fires on release.
1266
+ applyStyleToSelection('color', swatch.value);
1267
+ } else {
1268
+ target.style.setProperty(cssProp, swatch.value);
1269
+ }
1270
+ });
1271
+
1272
+ // Commit to history when the user finishes picking (change fires once on release)
1273
+ swatch.addEventListener('change', () => {
1274
+ const finalValue = swatch.value;
1275
+ const target = getSelectedElement();
1276
+ const hcId = getSelectedHcId();
1277
+ if (!target || !hcId) { _startValue = null; return; }
1278
+
1279
+ if (/^#[0-9a-fA-F]{3,6}$/.test(finalValue)) addRecentColor(finalValue);
1280
+
1281
+ const startVal = _startValue ?? '';
1282
+ _startValue = null;
1283
+
1284
+ // No-op guard: if nothing changed don't pollute history
1285
+ if (startVal === finalValue) return;
1286
+
1287
+ const shape = getSvgShape(target);
1288
+ if (shape) {
1289
+ const attr = cssProp === 'background-color' ? 'fill' : cssProp === 'border-color' ? 'stroke' : cssProp;
1290
+ // DOM is already at finalValue from live preview. Build command manually
1291
+ // so undo() knows the correct start value.
1292
+ const cmd = {
1293
+ description: `Set SVG ${attr}`,
1294
+ execute() { shape.setAttribute(attr, finalValue); recordChange(hcId, target.outerHTML); },
1295
+ undo() { if (startVal) shape.setAttribute(attr, startVal); else shape.removeAttribute(attr); recordChange(hcId, target.outerHTML); },
1296
+ };
1297
+ // push() calls execute() — that re-applies finalValue and records. Fine.
1298
+ push(cmd);
1299
+ } else {
1300
+ // Handle text-edit inline selection
1301
+ if (isTextEditActive() && cssProp === 'color') {
1302
+ if (applyStyleToSelection('color', finalValue)) return;
1303
+ }
1304
+ const cmd = {
1305
+ description: `Set ${cssProp}`,
1306
+ execute() { target.style.setProperty(cssProp, finalValue); recordChange(hcId, target.outerHTML); },
1307
+ undo() { if (startVal === '') target.style.removeProperty(cssProp); else target.style.setProperty(cssProp, startVal); recordChange(hcId, target.outerHTML); },
1308
+ };
1309
+ push(cmd);
1310
+ }
1311
+ });
1312
+
1313
+ hexInput.addEventListener('change', () => {
1314
+ const clean = hexInput.value.trim();
1315
+ if (/^#[0-9a-fA-F]{3,6}$/.test(clean)) {
1316
+ swatch.value = clean;
1317
+ applyColorProp(cssProp, clean);
1318
+ }
1319
+ });
1320
+ }
1321
+
1322
+ function applyColorProp(cssProp, value) {
1323
+ const target = getSelectedElement();
1324
+ const hcId = getSelectedHcId();
1325
+ if (!target || !hcId) return;
1326
+
1327
+ // Track recent colors
1328
+ if (/^#[0-9a-fA-F]{3,6}$/.test(value)) addRecentColor(value);
1329
+
1330
+ // During text editing, apply color to selected text only
1331
+ if (isTextEditActive() && cssProp === 'color') {
1332
+ if (applyStyleToSelection('color', value)) return;
1333
+ }
1334
+
1335
+ // SVG shape: map CSS props to SVG attributes
1336
+ const shape = getSvgShape(target);
1337
+ if (shape) {
1338
+ if (cssProp === 'background-color') {
1339
+ applySvgAttribute(target, hcId, shape, 'fill', value);
1340
+ return;
1341
+ }
1342
+ if (cssProp === 'border-color') {
1343
+ applySvgAttribute(target, hcId, shape, 'stroke', value);
1344
+ return;
1345
+ }
1346
+ }
1347
+
1348
+ applyStyleProperty(target, hcId, cssProp, value);
1349
+ }
1350
+
1351
+ /**
1352
+ * Applies an SVG attribute change via the Command Pattern.
1353
+ */
1354
+ function applySvgAttribute(svgElement, hcId, shape, attr, newValue) {
1355
+ const oldValue = shape.getAttribute(attr) || '';
1356
+ const command = {
1357
+ description: `Set SVG ${attr}`,
1358
+ execute() {
1359
+ shape.setAttribute(attr, newValue);
1360
+ recordChange(hcId, svgElement.outerHTML);
1361
+ },
1362
+ undo() {
1363
+ if (oldValue) shape.setAttribute(attr, oldValue);
1364
+ else shape.removeAttribute(attr);
1365
+ recordChange(hcId, svgElement.outerHTML);
1366
+ },
1367
+ };
1368
+ push(command);
1369
+ return command;
1370
+ }
1371
+
1372
+ // ── Utilities ─────────────────────────────────────────────────────────────────
1373
+
1374
+ /**
1375
+ * Convert rgb(r, g, b) or rgba(r, g, b, a) to #rrggbb hex.
1376
+ * Returns null if conversion fails.
1377
+ */
1378
+ function rgbToHex(value) {
1379
+ if (!value) return null;
1380
+ if (/^#[0-9a-fA-F]{3,6}$/.test(value.trim())) return value.trim();
1381
+
1382
+ const m = value.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
1383
+ if (!m) return null;
1384
+
1385
+ const r = parseInt(m[1]).toString(16).padStart(2, '0');
1386
+ const g = parseInt(m[2]).toString(16).padStart(2, '0');
1387
+ const b = parseInt(m[3]).toString(16).padStart(2, '0');
1388
+ return `#${r}${g}${b}`;
1389
+ }
1390
+
1391
+ /**
1392
+ * Extract rotation degrees from a CSS transform matrix string.
1393
+ * Returns 0 if no rotation found.
1394
+ */
1395
+ function extractRotation(transform) {
1396
+ if (!transform || transform === 'none') return 0;
1397
+
1398
+ // matrix(a, b, c, d, tx, ty) — rotation = atan2(b, a)
1399
+ const m = transform.match(/matrix\(([^)]+)\)/);
1400
+ if (m) {
1401
+ const parts = m[1].split(',').map(Number);
1402
+ const angle = Math.round(Math.atan2(parts[1], parts[0]) * (180 / Math.PI));
1403
+ return angle;
1404
+ }
1405
+
1406
+ // rotate(Xdeg) shorthand
1407
+ const r = transform.match(/rotate\(([-\d.]+)deg\)/);
1408
+ if (r) return parseFloat(r[1]);
1409
+
1410
+ return 0;
1411
+ }
1412
+
1413
+ /**
1414
+ * Set a <select> element's value, case-insensitively.
1415
+ */
1416
+ function setSelectValue(select, value) {
1417
+ if (!value) return;
1418
+ const lower = value.toLowerCase();
1419
+ for (const opt of select.options) {
1420
+ if (opt.value.toLowerCase() === lower || opt.text.toLowerCase() === lower) {
1421
+ select.value = opt.value;
1422
+ return;
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ // ── Custom font picker (searchable dropdown) ─────────────────────────────────
1428
+
1429
+ const DEFAULT_FONTS = [
1430
+ 'DM Sans', 'Inter', 'Fraunces', 'Roboto', 'Open Sans', 'Lato',
1431
+ 'Montserrat', 'Poppins', 'Raleway', 'Playfair Display', 'Source Sans 3',
1432
+ 'Merriweather', 'Nunito', 'PT Sans', 'Fira Sans',
1433
+ 'Georgia', 'Arial', 'Helvetica', 'Times New Roman', 'Courier New',
1434
+ ];
1435
+
1436
+ let _fontPickerWired = false;
1437
+ let _fontPickerOpen = false;
1438
+
1439
+ /**
1440
+ * Updates the font picker display value and rebuilds options with document
1441
+ * fonts prioritized at the top.
1442
+ */
1443
+ function updateFontPicker(picker, currentFont, iframeDoc) {
1444
+ if (!picker) return;
1445
+ const valueEl = picker.querySelector('.font-picker-value');
1446
+ if (valueEl) valueEl.textContent = currentFont || 'DM Sans';
1447
+ picker._currentValue = currentFont || 'DM Sans';
1448
+
1449
+ // Detect fonts used in the iframe document
1450
+ const usedFonts = new Set();
1451
+ if (iframeDoc) {
1452
+ try {
1453
+ iframeDoc.fonts.forEach(f => usedFonts.add(f.family.replace(/['"]/g, '').trim()));
1454
+ } catch {}
1455
+ }
1456
+
1457
+ // Build ordered font list: used fonts first, separator, then rest
1458
+ const used = DEFAULT_FONTS.filter(f => usedFonts.has(f));
1459
+ const rest = DEFAULT_FONTS.filter(f => !usedFonts.has(f));
1460
+ picker._fonts = used.length > 0 ? [...used, '---', ...rest] : [...DEFAULT_FONTS];
1461
+ }
1462
+
1463
+ /**
1464
+ * Wires the font picker trigger, search, and option selection. Called once.
1465
+ */
1466
+ function initFontPicker() {
1467
+ if (_fontPickerWired) return;
1468
+ _fontPickerWired = true;
1469
+
1470
+ const picker = document.getElementById('prop-font-family');
1471
+ if (!picker) return;
1472
+
1473
+ const trigger = picker.querySelector('.font-picker-trigger');
1474
+ const dropdown = picker.querySelector('.font-picker-dropdown');
1475
+ const search = picker.querySelector('.font-picker-search');
1476
+ const list = picker.querySelector('.font-picker-list');
1477
+ if (!trigger || !dropdown || !search || !list) return;
1478
+
1479
+ picker._currentValue = 'DM Sans';
1480
+ picker._fonts = [...DEFAULT_FONTS];
1481
+
1482
+ function openDropdown() {
1483
+ if (_fontPickerOpen) return;
1484
+ _fontPickerOpen = true;
1485
+ picker.classList.add('open');
1486
+
1487
+ // Position dropdown below the trigger, using fixed positioning
1488
+ const rect = trigger.getBoundingClientRect();
1489
+ dropdown.style.left = rect.left + 'px';
1490
+ dropdown.style.top = (rect.bottom + 2) + 'px';
1491
+ dropdown.style.width = Math.max(rect.width, 200) + 'px';
1492
+ dropdown.style.display = 'flex';
1493
+
1494
+ search.value = '';
1495
+ renderFontList('');
1496
+ search.focus();
1497
+ }
1498
+
1499
+ function closeDropdown() {
1500
+ if (!_fontPickerOpen) return;
1501
+ _fontPickerOpen = false;
1502
+ picker.classList.remove('open');
1503
+ dropdown.style.display = 'none';
1504
+ }
1505
+
1506
+ function selectFont(fontName) {
1507
+ picker._currentValue = fontName;
1508
+ const valueEl = picker.querySelector('.font-picker-value');
1509
+ if (valueEl) valueEl.textContent = fontName;
1510
+ closeDropdown();
1511
+
1512
+ // Apply to element or inline selection
1513
+ const target = getSelectedElement();
1514
+ const hcId = getSelectedHcId();
1515
+ if (!target || !hcId) return;
1516
+
1517
+ if (isTextEditActive() && applyStyleToSelection('font-family', fontName)) return;
1518
+ applyStyleProperty(target, hcId, 'font-family', fontName);
1519
+ }
1520
+
1521
+ function renderFontList(filter) {
1522
+ list.innerHTML = '';
1523
+ const lower = filter.toLowerCase();
1524
+ const fonts = (picker._fonts || DEFAULT_FONTS);
1525
+
1526
+ for (const font of fonts) {
1527
+ if (font === '---') {
1528
+ if (!filter) {
1529
+ const sep = document.createElement('div');
1530
+ sep.className = 'font-picker-separator';
1531
+ list.appendChild(sep);
1532
+ }
1533
+ continue;
1534
+ }
1535
+
1536
+ if (lower && !font.toLowerCase().includes(lower)) continue;
1537
+
1538
+ const option = document.createElement('div');
1539
+ option.className = 'font-picker-option';
1540
+ option.textContent = font;
1541
+ option.style.fontFamily = `'${font}', sans-serif`;
1542
+ if (font === picker._currentValue) option.classList.add('active');
1543
+
1544
+ option.addEventListener('mousedown', (e) => {
1545
+ e.preventDefault(); // prevent search blur
1546
+ selectFont(font);
1547
+ });
1548
+
1549
+ list.appendChild(option);
1550
+ }
1551
+ }
1552
+
1553
+ // Toggle on trigger click
1554
+ trigger.addEventListener('click', () => {
1555
+ if (_fontPickerOpen) closeDropdown();
1556
+ else openDropdown();
1557
+ });
1558
+
1559
+ // Filter as you type
1560
+ search.addEventListener('input', () => {
1561
+ renderFontList(search.value);
1562
+ });
1563
+
1564
+ // Keyboard navigation
1565
+ search.addEventListener('keydown', (e) => {
1566
+ if (e.key === 'Escape') {
1567
+ e.preventDefault();
1568
+ e.stopPropagation();
1569
+ closeDropdown();
1570
+ } else if (e.key === 'Enter') {
1571
+ e.preventDefault();
1572
+ const first = list.querySelector('.font-picker-option');
1573
+ if (first) selectFont(first.textContent);
1574
+ }
1575
+ });
1576
+
1577
+ // Close on click outside
1578
+ document.addEventListener('mousedown', (e) => {
1579
+ if (_fontPickerOpen && !picker.contains(e.target)) {
1580
+ closeDropdown();
1581
+ }
1582
+ });
1583
+ }