@aquera/nile-elements 1.9.5 → 1.9.6

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.
Files changed (67) hide show
  1. package/README.md +6 -0
  2. package/dist/index.cjs.js +1 -1
  3. package/dist/index.esm.js +1 -1
  4. package/dist/index.js +470 -298
  5. package/dist/nile-chip/nile-chip.css.cjs.js +1 -1
  6. package/dist/nile-chip/nile-chip.css.cjs.js.map +1 -1
  7. package/dist/nile-chip/nile-chip.css.esm.js +8 -1
  8. package/dist/nile-code-editor/nile-code-editor.cjs.js +1 -1
  9. package/dist/nile-code-editor/nile-code-editor.cjs.js.map +1 -1
  10. package/dist/nile-code-editor/nile-code-editor.esm.js +3 -3
  11. package/dist/nile-hero/nile-hero.cjs.js +1 -1
  12. package/dist/nile-hero/nile-hero.cjs.js.map +1 -1
  13. package/dist/nile-hero/nile-hero.css.cjs.js +1 -1
  14. package/dist/nile-hero/nile-hero.css.cjs.js.map +1 -1
  15. package/dist/nile-hero/nile-hero.css.esm.js +14 -14
  16. package/dist/nile-hero/nile-hero.esm.js +1 -1
  17. package/dist/nile-hero-header/nile-hero-header.cjs.js +1 -1
  18. package/dist/nile-hero-header/nile-hero-header.cjs.js.map +1 -1
  19. package/dist/nile-hero-header/nile-hero-header.css.cjs.js +1 -1
  20. package/dist/nile-hero-header/nile-hero-header.css.cjs.js.map +1 -1
  21. package/dist/nile-hero-header/nile-hero-header.css.esm.js +11 -11
  22. package/dist/nile-hero-header/nile-hero-header.esm.js +3 -3
  23. package/dist/nile-rich-text-editor/index.cjs.js +1 -1
  24. package/dist/nile-rich-text-editor/index.esm.js +1 -1
  25. package/dist/nile-rich-text-editor/nile-rich-text-editor.cjs.js +1 -1
  26. package/dist/nile-rich-text-editor/nile-rich-text-editor.cjs.js.map +1 -1
  27. package/dist/nile-rich-text-editor/nile-rich-text-editor.css.cjs.js +1 -1
  28. package/dist/nile-rich-text-editor/nile-rich-text-editor.css.cjs.js.map +1 -1
  29. package/dist/nile-rich-text-editor/nile-rich-text-editor.css.esm.js +70 -0
  30. package/dist/nile-rich-text-editor/nile-rich-text-editor.esm.js +1 -1
  31. package/dist/nile-rich-text-editor/nile-rte-image.cjs.js +3 -0
  32. package/dist/nile-rich-text-editor/nile-rte-image.cjs.js.map +1 -0
  33. package/dist/nile-rich-text-editor/nile-rte-image.esm.js +96 -0
  34. package/dist/src/nile-chip/nile-chip.css.js +8 -1
  35. package/dist/src/nile-chip/nile-chip.css.js.map +1 -1
  36. package/dist/src/nile-code-editor/nile-code-editor.js +3 -3
  37. package/dist/src/nile-code-editor/nile-code-editor.js.map +1 -1
  38. package/dist/src/nile-hero/nile-hero.css.js +12 -12
  39. package/dist/src/nile-hero/nile-hero.css.js.map +1 -1
  40. package/dist/src/nile-hero/nile-hero.js +1 -1
  41. package/dist/src/nile-hero/nile-hero.js.map +1 -1
  42. package/dist/src/nile-hero-header/nile-hero-header.css.js +9 -9
  43. package/dist/src/nile-hero-header/nile-hero-header.css.js.map +1 -1
  44. package/dist/src/nile-hero-header/nile-hero-header.js +1 -1
  45. package/dist/src/nile-hero-header/nile-hero-header.js.map +1 -1
  46. package/dist/src/nile-rich-text-editor/nile-rich-text-editor.css.js +70 -0
  47. package/dist/src/nile-rich-text-editor/nile-rich-text-editor.css.js.map +1 -1
  48. package/dist/src/nile-rich-text-editor/nile-rich-text-editor.d.ts +3 -0
  49. package/dist/src/nile-rich-text-editor/nile-rich-text-editor.js +81 -3
  50. package/dist/src/nile-rich-text-editor/nile-rich-text-editor.js.map +1 -1
  51. package/dist/src/nile-rich-text-editor/nile-rte-image.d.ts +87 -0
  52. package/dist/src/nile-rich-text-editor/nile-rte-image.js +1073 -0
  53. package/dist/src/nile-rich-text-editor/nile-rte-image.js.map +1 -0
  54. package/dist/src/version.js +1 -1
  55. package/dist/src/version.js.map +1 -1
  56. package/dist/tsconfig.tsbuildinfo +1 -1
  57. package/package.json +1 -1
  58. package/src/nile-chip/nile-chip.css.ts +8 -1
  59. package/src/nile-code-editor/nile-code-editor.ts +3 -3
  60. package/src/nile-hero/nile-hero.css.ts +12 -12
  61. package/src/nile-hero/nile-hero.ts +1 -1
  62. package/src/nile-hero-header/nile-hero-header.css.ts +9 -9
  63. package/src/nile-hero-header/nile-hero-header.ts +1 -1
  64. package/src/nile-rich-text-editor/nile-rich-text-editor.css.ts +70 -0
  65. package/src/nile-rich-text-editor/nile-rich-text-editor.ts +89 -3
  66. package/src/nile-rich-text-editor/nile-rte-image.ts +1132 -0
  67. package/vscode-html-custom-data.json +41 -1
@@ -0,0 +1,1132 @@
1
+ import { LitElement, html } from 'lit';
2
+ import { customElement, property, state } from 'lit/decorators.js';
3
+
4
+ @customElement('nile-rte-image')
5
+ export class NileRteImage extends LitElement {
6
+ protected createRenderRoot() {
7
+ return this;
8
+ }
9
+
10
+ @property({ attribute: false }) editorEl!: HTMLElement;
11
+ @property({ type: Boolean, reflect: true, attribute: true }) disabled = false;
12
+ @property({ type: String, reflect: true, attribute: true }) label = 'Image';
13
+ @property({ type: String, reflect: true, attribute: true }) placeholder = 'Paste image link here';
14
+ @property({ type: String, reflect: true, attribute: true }) altPlaceholder = 'Describe the image';
15
+ @property({ type: String, reflect: true, attribute: true }) captionPlaceholder = 'Add a caption (optional)';
16
+ @property({ type: Number, reflect: true, attribute: 'maxfilesize' }) maxFileSize = 2 * 1024 * 1024;
17
+ @property({ type: Boolean, reflect: true, attribute: 'uploadfromcomputer' }) uploadFromComputer = false;
18
+
19
+ @state() private srcValue = '';
20
+ @state() private altValue = '';
21
+ @state() private captionValue = '';
22
+ @state() private hasActiveImage = false;
23
+ @state() private uploadedFileName = '';
24
+ @state() private errorMessage = '';
25
+ @state() private popoverStyle = '';
26
+
27
+ private selectionRange: Range | null = null;
28
+ private activeFigure: HTMLElement | null = null;
29
+
30
+ private ignoreBlur = false;
31
+ private isApplying = false;
32
+
33
+ private selectedFigure: HTMLElement | null = null;
34
+ private resizeOverlay: HTMLElement | null = null;
35
+ private dragStartX = 0;
36
+ private dragStartWidth = 0;
37
+ private dragDir = '';
38
+ private isDragging = false;
39
+ private trackingRaf = 0;
40
+
41
+ connectedCallback() {
42
+ super.connectedCallback();
43
+
44
+ this.injectCss(`
45
+ nile-popover.rte-image-popover::part(popover) {
46
+ min-width: 340px;
47
+ max-width: 420px;
48
+ background: white;
49
+ border: 1px solid var(--nile-colors-neutral-400, var(--ng-componentcolors-utility-gray-400));
50
+ border-radius: 8px;
51
+ padding: 0;
52
+ gap: 0;
53
+ overflow: hidden;
54
+ height: auto;
55
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
56
+ }
57
+
58
+ .image-popup-wrap {
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: 12px;
62
+ padding: 12px;
63
+ }
64
+
65
+ .image-popup-wrap nile-input {
66
+ width: 100%;
67
+ }
68
+
69
+ .image-popup-wrap .image-upload-row {
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ gap: 8px;
74
+ width: 100%;
75
+ }
76
+
77
+ .image-popup-wrap .image-upload-row nile-button {
78
+ flex-shrink: 0;
79
+ }
80
+
81
+ .image-popup-wrap .image-upload-filename {
82
+ font-size: 12px;
83
+ color: var(--nile-colors-neutral-600, var(--ng-colors-text-secondary));
84
+ overflow: hidden;
85
+ text-overflow: ellipsis;
86
+ white-space: nowrap;
87
+ min-width: 0;
88
+ }
89
+
90
+ .image-popup-wrap .image-error {
91
+ font-size: 12px;
92
+ color: var(--nile-colors-error-600, var(--ng-colors-text-error-primary-600));
93
+ }
94
+
95
+ .image-popup-wrap .image-divider-row {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 8px;
99
+ color: var(--nile-colors-neutral-500, var(--ng-colors-text-secondary));
100
+ font-size: 12px;
101
+ }
102
+
103
+ .image-popup-wrap .image-divider-row::before,
104
+ .image-popup-wrap .image-divider-row::after {
105
+ content: '';
106
+ flex: 1;
107
+ height: 1px;
108
+ background: var(--nile-colors-neutral-400, var(--ng-colors-border-neutral));
109
+ }
110
+
111
+ .image-popup-wrap .image-actions-row {
112
+ display: flex;
113
+ justify-content: flex-end;
114
+ gap: 8px;
115
+ width: 100%;
116
+ }
117
+
118
+ nile-button.rte-image-button::part(base) {
119
+ border: none;
120
+ height: 32px;
121
+ width: 32px;
122
+ }
123
+
124
+ nile-rte-image input[type='file'] {
125
+ display: none;
126
+ }
127
+
128
+ .rte-image-resize-overlay {
129
+ position: fixed;
130
+ z-index: 999;
131
+ pointer-events: none;
132
+ border: 2px solid var(--nile-colors-primary-500, var(--ng-componentcolors-utility-blue-500));
133
+ border-radius: var(--nile-radius-radius-lg, var(--ng-radius-md));
134
+ box-sizing: border-box;
135
+ }
136
+
137
+ .rte-image-resize-overlay .rte-resize-handle {
138
+ position: absolute;
139
+ width: 12px;
140
+ height: 12px;
141
+ background: var(--nile-colors-white-base, #fff);
142
+ border: 2px solid var(--nile-colors-primary-500, var(--ng-componentcolors-utility-blue-500));
143
+ border-radius: 50%;
144
+ pointer-events: auto;
145
+ box-sizing: border-box;
146
+ }
147
+
148
+ .rte-image-resize-overlay .rte-resize-handle[data-dir='nw'] { top: -7px; left: -7px; cursor: nwse-resize; }
149
+ .rte-image-resize-overlay .rte-resize-handle[data-dir='ne'] { top: -7px; right: -7px; cursor: nesw-resize; }
150
+ .rte-image-resize-overlay .rte-resize-handle[data-dir='sw'] { bottom: -7px; left: -7px; cursor: nesw-resize; }
151
+ .rte-image-resize-overlay .rte-resize-handle[data-dir='se'] { bottom: -7px; right: -7px; cursor: nwse-resize; }
152
+
153
+ .rte-image-resize-overlay .rte-image-align-bar {
154
+ position: absolute;
155
+ top: -44px;
156
+ left: 50%;
157
+ transform: translateX(-50%);
158
+ display: flex;
159
+ gap: 2px;
160
+ padding: 4px;
161
+ background: var(--nile-colors-white-base, #fff);
162
+ border: 1px solid var(--nile-colors-neutral-400, var(--ng-colors-border-neutral));
163
+ border-radius: 8px;
164
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
165
+ pointer-events: auto;
166
+ }
167
+
168
+ .rte-image-resize-overlay .rte-image-align-bar button {
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ width: 28px;
173
+ height: 28px;
174
+ border: none;
175
+ background: transparent;
176
+ border-radius: 6px;
177
+ cursor: pointer;
178
+ padding: 0;
179
+ }
180
+
181
+ .rte-image-resize-overlay .rte-image-align-bar button:hover {
182
+ background: var(--nile-colors-neutral-200, var(--ng-componentcolors-utility-gray-200));
183
+ }
184
+
185
+ .rte-image-resize-overlay .rte-image-align-bar button[data-active] {
186
+ background: var(--nile-colors-primary-400, var(--ng-componentcolors-utility-blue-400));
187
+ }
188
+
189
+ .rte-image-resize-overlay .rte-image-align-bar button nile-icon {
190
+ pointer-events: none;
191
+ }
192
+
193
+ .rte-image-resize-overlay .rte-resize-size-label {
194
+ position: absolute;
195
+ bottom: -26px;
196
+ right: 0;
197
+ background: var(--nile-colors-dark-900, #101828);
198
+ color: #fff;
199
+ font-size: 11px;
200
+ line-height: 1;
201
+ padding: 4px 6px;
202
+ border-radius: 4px;
203
+ white-space: nowrap;
204
+ pointer-events: none;
205
+ }
206
+ `);
207
+ }
208
+
209
+ private injectCss(cssText: string) {
210
+ if (this.querySelector('style[data-rte-image-style]')) return;
211
+ const style = document.createElement('style');
212
+ style.setAttribute('data-rte-image-style', 'true');
213
+ style.textContent = cssText;
214
+ this.insertBefore(style, this.firstChild);
215
+ }
216
+
217
+ protected updated(changed: Map<string, any>) {
218
+ if (changed.has('editorEl')) {
219
+ const prev = changed.get('editorEl') as HTMLElement | undefined;
220
+ prev?.removeEventListener('click', this.onEditorImageClick);
221
+ prev?.removeEventListener('dblclick', this.onEditorImageDblClick);
222
+ prev?.removeEventListener('input', this.onEditorInput);
223
+ prev?.removeEventListener('paste', this.onEditorImagePaste);
224
+ this.editorEl?.addEventListener('click', this.onEditorImageClick);
225
+ this.editorEl?.addEventListener('dblclick', this.onEditorImageDblClick);
226
+ this.editorEl?.addEventListener('input', this.onEditorInput);
227
+ this.editorEl?.addEventListener('paste', this.onEditorImagePaste);
228
+ }
229
+ if (changed.has('disabled') && this.disabled) {
230
+ this.deselectImage();
231
+ }
232
+ }
233
+
234
+ disconnectedCallback(): void {
235
+ this.editorEl?.removeEventListener('click', this.onEditorImageClick);
236
+ this.editorEl?.removeEventListener('dblclick', this.onEditorImageDblClick);
237
+ this.editorEl?.removeEventListener('input', this.onEditorInput);
238
+ this.editorEl?.removeEventListener('paste', this.onEditorImagePaste);
239
+ window.removeEventListener('scroll', this.onScroll, true);
240
+ this.deselectImage();
241
+ super.disconnectedCallback();
242
+ }
243
+
244
+ private onEditorInput = () => {
245
+ // content edits can remove or reflow the figure
246
+ if (this.selectedFigure && !this.selectedFigure.isConnected) {
247
+ this.deselectImage();
248
+ } else {
249
+ this.updateOverlayPosition();
250
+ }
251
+ };
252
+
253
+ private onEditorImagePaste = (e: ClipboardEvent) => {
254
+ if (this.disabled) return;
255
+
256
+ const items = Array.from(e.clipboardData?.items ?? []);
257
+ const imageFiles = items
258
+ .filter(i => i.kind === 'file' && i.type.startsWith('image/'))
259
+ .map(i => i.getAsFile())
260
+ .filter((f): f is File => !!f);
261
+ if (!imageFiles.length) return;
262
+
263
+ e.preventDefault();
264
+ e.stopPropagation();
265
+
266
+ for (const file of imageFiles) {
267
+ if (this.maxFileSize > 0 && file.size > this.maxFileSize) {
268
+ console.warn(
269
+ `nile-rte-image: pasted image "${file.name}" skipped — exceeds the ${this.maxFileSize} byte limit`
270
+ );
271
+ continue;
272
+ }
273
+ const reader = new FileReader();
274
+ reader.onload = () => {
275
+ this.insertPastedImage(
276
+ String(reader.result || ''),
277
+ file.name.replace(/\.[^.]+$/, '')
278
+ );
279
+ };
280
+ reader.readAsDataURL(file);
281
+ }
282
+ };
283
+
284
+ private insertPastedImage(src: string, alt: string) {
285
+ if (!src.startsWith('data:image/')) return;
286
+
287
+ let range: Range;
288
+ const sel = document.getSelection();
289
+ if (
290
+ sel &&
291
+ sel.rangeCount > 0 &&
292
+ this.editorEl.contains(sel.getRangeAt(0).commonAncestorContainer)
293
+ ) {
294
+ range = sel.getRangeAt(0).cloneRange();
295
+ } else {
296
+ range = document.createRange();
297
+ range.selectNodeContents(this.editorEl);
298
+ range.collapse(false);
299
+ }
300
+
301
+ const figure = this.buildFigure(src, alt);
302
+ this.insertFigureAt(range, figure);
303
+ this.emit(src);
304
+ }
305
+
306
+ private isSafeSrc(src: string): boolean {
307
+ return /^(https?:\/\/|data:image\/|blob:|\/)/i.test(src);
308
+ }
309
+
310
+ private normalizeSrc(src: string): string {
311
+ if (this.isSafeSrc(src)) return src;
312
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src)) return '';
313
+ return `https://${src}`;
314
+ }
315
+
316
+ private getSelectionRect(): DOMRect | null {
317
+ if (!this.selectionRange) return null;
318
+
319
+ let rect = this.selectionRange.getBoundingClientRect();
320
+ if (rect.width === 0 && rect.height === 0) {
321
+ const span = document.createElement('span');
322
+ span.textContent = '\u200b';
323
+ const tempRange = this.selectionRange.cloneRange();
324
+ tempRange.insertNode(span);
325
+ rect = span.getBoundingClientRect();
326
+ span.remove();
327
+ }
328
+ return rect;
329
+ }
330
+
331
+ private positionPopover(anchorRect?: DOMRect | null) {
332
+ const rect = anchorRect ?? this.getSelectionRect();
333
+ if (!rect) return;
334
+
335
+ const half = 170;
336
+ const centerX = Math.min(
337
+ Math.max(rect.left + rect.width / 2, half + 8),
338
+ window.innerWidth - half - 8
339
+ );
340
+
341
+ this.popoverStyle = `
342
+ position: fixed;
343
+ top: ${rect.bottom + 8}px;
344
+ left: ${centerX}px;
345
+ transform: translateX(-50%);
346
+ z-index: 1000;
347
+ `;
348
+ }
349
+
350
+ private onScroll = () => {
351
+ this.closePopover();
352
+ };
353
+
354
+ private openPopover() {
355
+ requestAnimationFrame(() => {
356
+ const pop = this.querySelector('nile-popover') as any;
357
+ pop && (pop.isShow = true);
358
+ window.addEventListener('scroll', this.onScroll, true);
359
+ });
360
+ }
361
+
362
+ private closePopover() {
363
+ const pop = this.querySelector('nile-popover') as any;
364
+ pop && (pop.isShow = false);
365
+
366
+ window.removeEventListener('scroll', this.onScroll, true);
367
+
368
+ this.srcValue = '';
369
+ this.altValue = '';
370
+ this.captionValue = '';
371
+ this.uploadedFileName = '';
372
+ this.errorMessage = '';
373
+ this.selectionRange = null;
374
+ this.activeFigure = null;
375
+ this.hasActiveImage = false;
376
+ this.popoverStyle = '';
377
+ }
378
+
379
+ private onEditorImageClick = (e: MouseEvent) => {
380
+ if (this.disabled) return;
381
+ const target = e.target as HTMLElement;
382
+ const figure = target.closest?.('figure.rte-image') as HTMLElement | null;
383
+ if (!figure || !this.editorEl.contains(figure)) {
384
+ this.deselectImage();
385
+ return;
386
+ }
387
+
388
+ e.preventDefault();
389
+ this.selectImage(figure);
390
+ };
391
+
392
+ private onEditorImageDblClick = (e: MouseEvent) => {
393
+ if (this.disabled) return;
394
+ const target = e.target as HTMLElement;
395
+ const figure = target.closest?.('figure.rte-image') as HTMLElement | null;
396
+ if (!figure || !this.editorEl.contains(figure)) return;
397
+
398
+ e.preventDefault();
399
+ this.openEditFor(figure);
400
+ };
401
+
402
+ private openEditFor(figure: HTMLElement, anchorRect?: DOMRect | null) {
403
+ const range = document.createRange();
404
+ range.selectNode(figure);
405
+ this.selectionRange = range;
406
+
407
+ this.activeFigure = figure;
408
+ this.hasActiveImage = true;
409
+
410
+ const img = figure.querySelector('img');
411
+ const figcaption = figure.querySelector('figcaption');
412
+ this.srcValue = img?.getAttribute('src') || '';
413
+ this.altValue = img?.getAttribute('alt') || '';
414
+ this.captionValue = figcaption?.textContent || '';
415
+ this.uploadedFileName = '';
416
+ this.errorMessage = '';
417
+
418
+ this.positionPopover(anchorRect ?? figure.getBoundingClientRect());
419
+ this.openPopover();
420
+ }
421
+
422
+ private getToolbarButtonRect(): DOMRect | null {
423
+ const btn = this.querySelector('nile-button.rte-image-button');
424
+ return btn ? btn.getBoundingClientRect() : null;
425
+ }
426
+
427
+ // ---------- resize ----------
428
+
429
+ private selectImage(figure: HTMLElement) {
430
+ if (this.selectedFigure === figure) {
431
+ this.updateOverlayPosition();
432
+ return;
433
+ }
434
+ this.deselectImage();
435
+ this.selectedFigure = figure;
436
+
437
+ const img = figure.querySelector('img');
438
+ if (img && !img.complete) {
439
+ img.addEventListener('load', this.onSelectedImageLoad, { once: true });
440
+ }
441
+
442
+ this.buildOverlay();
443
+ this.syncAlignBar();
444
+ this.updateOverlayPosition();
445
+ this.startOverlayTracking();
446
+
447
+ document.addEventListener('mousedown', this.onDocMousedown, true);
448
+ document.addEventListener('keydown', this.onDocKeydown, true);
449
+ }
450
+
451
+ private deselectImage() {
452
+ if (!this.selectedFigure && !this.resizeOverlay) return;
453
+ this.selectedFigure = null;
454
+ this.resizeOverlay?.remove();
455
+ this.resizeOverlay = null;
456
+ this.isDragging = false;
457
+ this.stopOverlayTracking();
458
+
459
+ document.removeEventListener('mousedown', this.onDocMousedown, true);
460
+ document.removeEventListener('keydown', this.onDocKeydown, true);
461
+ document.removeEventListener('mousemove', this.onDragMove);
462
+ document.removeEventListener('mouseup', this.onDragEnd);
463
+ }
464
+
465
+ private onDocKeydown = (e: KeyboardEvent) => {
466
+ if (!this.selectedFigure) return;
467
+
468
+ if (e.key === 'Escape') {
469
+ this.deselectImage();
470
+ return;
471
+ }
472
+ if (e.key !== 'Backspace' && e.key !== 'Delete') return;
473
+
474
+ // don't hijack deletions typed into inputs (e.g. the edit popover)
475
+ const target = e.target as HTMLElement;
476
+ if (target?.closest?.('input, textarea, nile-input, nile-popover')) return;
477
+
478
+ e.preventDefault();
479
+ e.stopPropagation();
480
+
481
+ const figure = this.selectedFigure;
482
+ this.deselectImage();
483
+ const parent = figure.parentNode;
484
+ figure.remove();
485
+ if (parent instanceof HTMLElement && !parent.hasChildNodes()) {
486
+ parent.innerHTML = '<br>';
487
+ }
488
+ this.emit('');
489
+ };
490
+
491
+ private onSelectedImageLoad = () => {
492
+ this.updateOverlayPosition();
493
+ };
494
+
495
+ // re-sync the overlay to the image every frame while selected — this keeps
496
+ // it glued to the image through scrolling (any container), layout shifts,
497
+ // and image loads without needing to enumerate those events
498
+ private startOverlayTracking() {
499
+ this.stopOverlayTracking();
500
+ const tick = () => {
501
+ if (!this.selectedFigure || !this.resizeOverlay) return;
502
+ if (!this.selectedFigure.isConnected) {
503
+ this.deselectImage();
504
+ return;
505
+ }
506
+ this.updateOverlayPosition();
507
+ this.trackingRaf = requestAnimationFrame(tick);
508
+ };
509
+ this.trackingRaf = requestAnimationFrame(tick);
510
+ }
511
+
512
+ private stopOverlayTracking() {
513
+ if (this.trackingRaf !== 0) {
514
+ cancelAnimationFrame(this.trackingRaf);
515
+ this.trackingRaf = 0;
516
+ }
517
+ }
518
+
519
+ private onDocMousedown = (e: MouseEvent) => {
520
+ const target = e.target as HTMLElement;
521
+ if (this.resizeOverlay?.contains(target)) return;
522
+ if (this.selectedFigure?.contains(target)) return;
523
+ // clicks on our own UI (toolbar image button, popover) must not clear the
524
+ // selection, otherwise pressing the toolbar button while an image is
525
+ // selected would drop into insert mode instead of edit mode
526
+ if (this.contains(target)) return;
527
+ this.deselectImage();
528
+ };
529
+
530
+ private buildOverlay() {
531
+ const overlay = document.createElement('div');
532
+ overlay.className = 'rte-image-resize-overlay';
533
+
534
+ for (const dir of ['nw', 'ne', 'sw', 'se']) {
535
+ const handle = document.createElement('div');
536
+ handle.className = 'rte-resize-handle';
537
+ handle.dataset.dir = dir;
538
+ handle.addEventListener('mousedown', this.onHandleMousedown);
539
+ overlay.appendChild(handle);
540
+ }
541
+
542
+ const label = document.createElement('span');
543
+ label.className = 'rte-resize-size-label';
544
+ overlay.appendChild(label);
545
+
546
+ const alignBar = document.createElement('div');
547
+ alignBar.className = 'rte-image-align-bar';
548
+ const alignments: Array<{ dir: string; icon: string; title: string }> = [
549
+ { dir: 'left', icon: 'format_align_left', title: 'Align left' },
550
+ { dir: 'center', icon: 'format_align_middle', title: 'Align center' },
551
+ { dir: 'right', icon: 'format_align_right', title: 'Align right' },
552
+ { dir: 'justify', icon: 'format_align_justify', title: 'Full width' },
553
+ ];
554
+ for (const { dir, icon, title } of alignments) {
555
+ const btn = document.createElement('button');
556
+ btn.type = 'button';
557
+ btn.dataset.dir = dir;
558
+ btn.title = title;
559
+ btn.setAttribute('aria-label', title);
560
+ btn.innerHTML = `<nile-icon name="${icon}" size="18"></nile-icon>`;
561
+ btn.addEventListener('mousedown', e => e.preventDefault());
562
+ btn.addEventListener('click', this.onAlignClick);
563
+ alignBar.appendChild(btn);
564
+ }
565
+ overlay.appendChild(alignBar);
566
+
567
+ // append to <body>: a transformed/filtered ancestor (drawer, modal,
568
+ // animated panel) would otherwise become the containing block for
569
+ // position:fixed and the overlay would render far from the image
570
+ document.body.appendChild(overlay);
571
+ this.resizeOverlay = overlay;
572
+ }
573
+
574
+ private updateOverlayPosition() {
575
+ if (!this.resizeOverlay || !this.selectedFigure) return;
576
+ const img = this.selectedFigure.querySelector('img');
577
+ if (!img) {
578
+ this.deselectImage();
579
+ return;
580
+ }
581
+ const rect = img.getBoundingClientRect();
582
+ this.resizeOverlay.style.top = `${rect.top}px`;
583
+ this.resizeOverlay.style.left = `${rect.left}px`;
584
+ this.resizeOverlay.style.width = `${rect.width}px`;
585
+ this.resizeOverlay.style.height = `${rect.height}px`;
586
+
587
+ // keep the align bar reachable: above the image by default, flipped to
588
+ // just inside the top edge when the image touches the viewport top
589
+ const bar = this.resizeOverlay.querySelector(
590
+ '.rte-image-align-bar'
591
+ ) as HTMLElement | null;
592
+ if (bar) {
593
+ if (rect.top < 56) {
594
+ bar.style.top = '8px';
595
+ } else {
596
+ bar.style.top = '-44px';
597
+ }
598
+ }
599
+
600
+ const label = this.resizeOverlay.querySelector(
601
+ '.rte-resize-size-label'
602
+ ) as HTMLElement | null;
603
+ if (label) {
604
+ label.textContent = `${Math.round(rect.width)} × ${Math.round(rect.height)}`;
605
+ label.style.display = this.isDragging ? '' : 'none';
606
+ }
607
+ }
608
+
609
+ private onAlignClick = (e: Event) => {
610
+ if (!this.selectedFigure) return;
611
+ const dir = (e.currentTarget as HTMLElement).dataset.dir || 'left';
612
+
613
+ if (dir === 'left') {
614
+ this.selectedFigure.removeAttribute('data-align');
615
+ } else {
616
+ this.selectedFigure.setAttribute('data-align', dir);
617
+ }
618
+ this.syncAlignBar();
619
+ this.updateOverlayPosition();
620
+
621
+ const img = this.selectedFigure.querySelector('img');
622
+ this.emit(img?.getAttribute('src') || '');
623
+ };
624
+
625
+ private syncAlignBar() {
626
+ if (!this.resizeOverlay || !this.selectedFigure) return;
627
+ const current = this.selectedFigure.getAttribute('data-align') || 'left';
628
+ this.resizeOverlay
629
+ .querySelectorAll<HTMLElement>('.rte-image-align-bar button')
630
+ .forEach(btn => {
631
+ btn.toggleAttribute('data-active', btn.dataset.dir === current);
632
+ });
633
+ }
634
+
635
+ private onHandleMousedown = (e: MouseEvent) => {
636
+ if (this.disabled || !this.selectedFigure) return;
637
+ e.preventDefault();
638
+ e.stopPropagation();
639
+
640
+ const img = this.selectedFigure.querySelector('img');
641
+ if (!img) return;
642
+
643
+ // a full-width image can't be hand-resized — dragging implies a custom
644
+ // width, so drop back to default alignment first
645
+ if (this.selectedFigure.getAttribute('data-align') === 'justify') {
646
+ this.selectedFigure.removeAttribute('data-align');
647
+ this.syncAlignBar();
648
+ }
649
+
650
+ this.isDragging = true;
651
+ this.dragDir = (e.currentTarget as HTMLElement).dataset.dir || 'se';
652
+ this.dragStartX = e.clientX;
653
+ this.dragStartWidth = img.getBoundingClientRect().width;
654
+
655
+ document.addEventListener('mousemove', this.onDragMove);
656
+ document.addEventListener('mouseup', this.onDragEnd);
657
+ };
658
+
659
+ private onDragMove = (e: MouseEvent) => {
660
+ if (!this.isDragging || !this.selectedFigure) return;
661
+ const img = this.selectedFigure.querySelector('img');
662
+ if (!img) return;
663
+
664
+ const dx = e.clientX - this.dragStartX;
665
+ const growsRight = this.dragDir === 'ne' || this.dragDir === 'se';
666
+ let width = this.dragStartWidth + (growsRight ? dx : -dx);
667
+
668
+ const maxWidth = this.getEditorContentWidth();
669
+ width = Math.max(48, Math.min(width, maxWidth));
670
+
671
+ img.setAttribute('width', String(Math.round(width)));
672
+ this.updateOverlayPosition();
673
+ };
674
+
675
+ private onDragEnd = () => {
676
+ if (!this.isDragging) return;
677
+ this.isDragging = false;
678
+ document.removeEventListener('mousemove', this.onDragMove);
679
+ document.removeEventListener('mouseup', this.onDragEnd);
680
+ this.updateOverlayPosition();
681
+
682
+ const img = this.selectedFigure?.querySelector('img');
683
+ this.dispatchEvent(
684
+ new CustomEvent('nile-image-changed', {
685
+ detail: {
686
+ src: img?.getAttribute('src') || '',
687
+ alt: img?.getAttribute('alt') || '',
688
+ caption:
689
+ this.selectedFigure?.querySelector('figcaption')?.textContent || '',
690
+ width: img ? Number(img.getAttribute('width')) || null : null,
691
+ },
692
+ bubbles: true,
693
+ composed: true,
694
+ })
695
+ );
696
+ };
697
+
698
+ private getEditorContentWidth(): number {
699
+ const style = getComputedStyle(this.editorEl);
700
+ return (
701
+ this.editorEl.clientWidth -
702
+ parseFloat(style.paddingLeft || '0') -
703
+ parseFloat(style.paddingRight || '0') || Infinity
704
+ );
705
+ }
706
+
707
+ private onOpen = () => {
708
+ if (this.disabled) return;
709
+
710
+ if (this.selectedFigure) {
711
+ this.openEditFor(this.selectedFigure, this.getToolbarButtonRect());
712
+ return;
713
+ }
714
+
715
+ const sel = document.getSelection();
716
+ if (
717
+ sel &&
718
+ sel.rangeCount > 0 &&
719
+ this.editorEl.contains(sel.getRangeAt(0).commonAncestorContainer)
720
+ ) {
721
+ this.selectionRange = sel.getRangeAt(0).cloneRange();
722
+ } else {
723
+ const range = document.createRange();
724
+ range.selectNodeContents(this.editorEl);
725
+ range.collapse(false);
726
+ this.selectionRange = range;
727
+ }
728
+
729
+ this.activeFigure = null;
730
+ this.hasActiveImage = false;
731
+ this.srcValue = '';
732
+ this.altValue = '';
733
+ this.captionValue = '';
734
+ this.uploadedFileName = '';
735
+ this.errorMessage = '';
736
+ this.positionPopover(this.getToolbarButtonRect());
737
+ this.openPopover();
738
+
739
+ requestAnimationFrame(() => {
740
+ const input = this.querySelector('nile-input');
741
+ (input as any)?.focus();
742
+ });
743
+ };
744
+
745
+ private onInputKeydown = (e: KeyboardEvent) => {
746
+ if (e.key !== 'Escape' && e.key !== 'Enter') return;
747
+ e.preventDefault();
748
+ e.stopPropagation();
749
+ if (e.key === 'Escape') {
750
+ this.closePopover();
751
+ return;
752
+ }
753
+ this.isApplying = true;
754
+ try {
755
+ this.applyImage();
756
+ } finally {
757
+ this.isApplying = false;
758
+ }
759
+ };
760
+
761
+ private onInputBlur = () => {
762
+ if (this.ignoreBlur || this.isApplying) return;
763
+ requestAnimationFrame(() => {
764
+ if (this.ignoreBlur || this.isApplying) return;
765
+ const pop = this.querySelector('nile-popover');
766
+ if (pop?.matches(':focus-within')) return;
767
+ this.closePopover();
768
+ });
769
+ };
770
+
771
+ private onSrcInput = (e: CustomEvent) => {
772
+ this.srcValue = e.detail.value;
773
+ this.uploadedFileName = '';
774
+ this.errorMessage = '';
775
+ };
776
+
777
+ private onAltInput = (e: CustomEvent) => {
778
+ this.altValue = e.detail.value;
779
+ };
780
+
781
+ private onCaptionInput = (e: CustomEvent) => {
782
+ this.captionValue = e.detail.value;
783
+ };
784
+
785
+ private onPopoverMousedown = () => {
786
+ this.ignoreBlur = true;
787
+ };
788
+
789
+ private onPopoverMouseup = () => {
790
+ this.ignoreBlur = false;
791
+ };
792
+
793
+ private onToolbarButtonMousedown = (e: Event) => {
794
+ e.preventDefault();
795
+ };
796
+
797
+ private onUploadClick = () => {
798
+ // keep the popover open while the OS file dialog steals focus
799
+ this.ignoreBlur = true;
800
+ const fileInput = this.querySelector(
801
+ 'input[type="file"]'
802
+ ) as HTMLInputElement | null;
803
+ fileInput?.click();
804
+ window.addEventListener(
805
+ 'focus',
806
+ () => {
807
+ setTimeout(() => {
808
+ this.ignoreBlur = false;
809
+ }, 200);
810
+ },
811
+ { once: true }
812
+ );
813
+ };
814
+
815
+ private onFileChange = (e: Event) => {
816
+ const input = e.target as HTMLInputElement;
817
+ const file = input.files?.[0];
818
+ input.value = '';
819
+ if (!file) return;
820
+
821
+ if (!file.type.startsWith('image/')) {
822
+ this.errorMessage = 'Only image files are supported';
823
+ return;
824
+ }
825
+ if (this.maxFileSize > 0 && file.size > this.maxFileSize) {
826
+ const mb = (this.maxFileSize / (1024 * 1024)).toFixed(1);
827
+ this.errorMessage = `File is too large. Maximum size is ${mb} MB`;
828
+ return;
829
+ }
830
+
831
+ const reader = new FileReader();
832
+ reader.onload = () => {
833
+ this.srcValue = String(reader.result || '');
834
+ this.uploadedFileName = file.name;
835
+ this.errorMessage = '';
836
+ if (!this.altValue) {
837
+ this.altValue = file.name.replace(/\.[^.]+$/, '');
838
+ }
839
+ };
840
+ reader.onerror = () => {
841
+ this.errorMessage = 'Could not read the selected file';
842
+ };
843
+ reader.readAsDataURL(file);
844
+ };
845
+
846
+ private onApplyClick = () => {
847
+ this.isApplying = true;
848
+ this.applyImage();
849
+ this.isApplying = false;
850
+ this.ignoreBlur = false;
851
+ };
852
+
853
+ private onRemoveClick = () => {
854
+ this.isApplying = true;
855
+ this.removeImage();
856
+ this.isApplying = false;
857
+ this.ignoreBlur = false;
858
+ };
859
+
860
+ private buildFigure(src: string, alt = '', caption = ''): HTMLElement {
861
+ const figure = document.createElement('figure');
862
+ figure.className = 'rte-image';
863
+ figure.setAttribute('contenteditable', 'false');
864
+
865
+ const img = document.createElement('img');
866
+ img.src = src;
867
+ if (alt.trim()) img.alt = alt.trim();
868
+ figure.appendChild(img);
869
+
870
+ if (caption.trim()) {
871
+ const figcaption = document.createElement('figcaption');
872
+ figcaption.textContent = caption.trim();
873
+ figure.appendChild(figcaption);
874
+ }
875
+ return figure;
876
+ }
877
+
878
+ private insertFigureAt(insertRange: Range, figure: HTMLElement) {
879
+ const sel = document.getSelection();
880
+ if (!sel) return;
881
+
882
+ // a <figure> is not valid inside <p>/<h*>, so place it after the
883
+ // block that holds the caret instead of splitting it at the range
884
+ let block: HTMLElement | null = null;
885
+ let n: Node | null = insertRange.startContainer;
886
+ while (n && n !== this.editorEl) {
887
+ if (
888
+ n instanceof HTMLElement &&
889
+ /^(P|H[1-6]|UL|OL|BLOCKQUOTE|PRE|TABLE|FIGURE|DIV)$/.test(n.tagName)
890
+ ) {
891
+ block = n;
892
+ break;
893
+ }
894
+ n = n.parentNode;
895
+ }
896
+ // an empty paragraph (the default '<p><br></p>' of a fresh editor) would
897
+ // otherwise remain above the image as a blank gap — take its place instead
898
+ const isEmptyBlock =
899
+ block &&
900
+ /^(P|H[1-6])$/.test(block.tagName) &&
901
+ (block.textContent ?? '').replace(/\u200B/g, '').trim() === '' &&
902
+ !block.querySelector('img');
903
+
904
+ if (block?.parentNode) {
905
+ if (isEmptyBlock) {
906
+ block.parentNode.insertBefore(figure, block);
907
+ block.remove();
908
+ } else {
909
+ block.parentNode.insertBefore(figure, block.nextSibling);
910
+ }
911
+ } else {
912
+ this.editorEl.appendChild(figure);
913
+ }
914
+
915
+ // keep a paragraph after the image so typing can continue below it
916
+ let after = figure.nextElementSibling;
917
+ if (!after) {
918
+ const p = document.createElement('p');
919
+ p.innerHTML = '<br>';
920
+ figure.parentNode?.insertBefore(p, figure.nextSibling);
921
+ after = p;
922
+ }
923
+
924
+ const range = document.createRange();
925
+ range.setStart(after, 0);
926
+ range.collapse(true);
927
+ sel.removeAllRanges();
928
+ sel.addRange(range);
929
+ }
930
+
931
+ private applyImage() {
932
+ const raw = this.srcValue.trim();
933
+ if (!raw) {
934
+ if (this.uploadFromComputer) {
935
+ this.errorMessage = 'Enter an image link or upload a file';
936
+ }
937
+ return;
938
+ }
939
+
940
+ const src = this.normalizeSrc(raw);
941
+ if (!src) {
942
+ this.errorMessage = 'Enter a valid image link';
943
+ return;
944
+ }
945
+
946
+ if (this.activeFigure) {
947
+ const img = this.activeFigure.querySelector('img');
948
+ if (img) {
949
+ img.src = src;
950
+ if (this.altValue.trim()) {
951
+ img.alt = this.altValue.trim();
952
+ } else {
953
+ img.removeAttribute('alt');
954
+ }
955
+ }
956
+
957
+ let figcaption = this.activeFigure.querySelector('figcaption');
958
+ if (this.captionValue.trim()) {
959
+ if (!figcaption) {
960
+ figcaption = document.createElement('figcaption');
961
+ this.activeFigure.appendChild(figcaption);
962
+ }
963
+ figcaption.textContent = this.captionValue.trim();
964
+ } else {
965
+ figcaption?.remove();
966
+ }
967
+
968
+ this.emit(src);
969
+ this.closePopover();
970
+ requestAnimationFrame(() => this.updateOverlayPosition());
971
+ return;
972
+ }
973
+
974
+ if (!(this.selectionRange instanceof Range)) return;
975
+
976
+ this.editorEl.focus();
977
+
978
+ const sel = document.getSelection();
979
+ if (!sel) return;
980
+ sel.removeAllRanges();
981
+ sel.addRange(this.selectionRange);
982
+
983
+ const figure = this.buildFigure(src, this.altValue, this.captionValue);
984
+ this.insertFigureAt(this.selectionRange, figure);
985
+
986
+ this.emit(src);
987
+ this.closePopover();
988
+ }
989
+
990
+ private removeImage() {
991
+ if (!this.activeFigure) return;
992
+ this.deselectImage();
993
+ const parent = this.activeFigure.parentNode;
994
+ this.activeFigure.remove();
995
+ if (parent instanceof HTMLElement && !parent.hasChildNodes()) {
996
+ parent.innerHTML = '<br>';
997
+ }
998
+ this.emit('');
999
+ this.closePopover();
1000
+ }
1001
+
1002
+ private emit(src: string) {
1003
+ this.dispatchEvent(
1004
+ new CustomEvent('nile-image-changed', {
1005
+ detail: {
1006
+ src,
1007
+ alt: this.altValue.trim(),
1008
+ caption: this.captionValue.trim(),
1009
+ },
1010
+ bubbles: true,
1011
+ composed: true,
1012
+ })
1013
+ );
1014
+ }
1015
+
1016
+ render() {
1017
+ const iconColor = this.disabled
1018
+ ? 'var(--nile-colors-neutral-500, var(--ng-colors-fg-disabled-subtle))'
1019
+ : 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900))';
1020
+
1021
+ return html`
1022
+ <nile-lite-tooltip content=${this.label}>
1023
+ <nile-button
1024
+ variant="tertiary"
1025
+ size="small"
1026
+ ?disabled=${this.disabled}
1027
+ @mousedown=${this.onToolbarButtonMousedown}
1028
+ @click=${this.onOpen}
1029
+ class="rte-image-button"
1030
+ >
1031
+ <nile-glyph name="image" color=${iconColor}></nile-glyph>
1032
+ </nile-button>
1033
+ </nile-lite-tooltip>
1034
+
1035
+ <nile-popover
1036
+ class="rte-image-popover"
1037
+ .arrow=${false}
1038
+ style=${this.popoverStyle}
1039
+ @mousedown=${this.onPopoverMousedown}
1040
+ @mouseup=${this.onPopoverMouseup}
1041
+ >
1042
+ <div class="image-popup-wrap">
1043
+ <nile-input
1044
+ label="Image URL"
1045
+ placeholder=${this.placeholder}
1046
+ .value=${this.srcValue}
1047
+ @nile-input=${this.onSrcInput}
1048
+ @keydown=${this.onInputKeydown}
1049
+ @nile-blur=${this.onInputBlur}
1050
+ ></nile-input>
1051
+
1052
+ ${this.uploadFromComputer
1053
+ ? html`
1054
+ <div class="image-divider-row" part="divider">or</div>
1055
+
1056
+ <div class="image-upload-row" part="upload-row">
1057
+ <nile-button
1058
+ part="upload-button"
1059
+ size="small"
1060
+ variant="secondary"
1061
+ @click=${this.onUploadClick}
1062
+ >
1063
+ <nile-glyph name="upload" slot="prefix"></nile-glyph>
1064
+ Upload from computer
1065
+ </nile-button>
1066
+ ${this.uploadedFileName
1067
+ ? html`<span class="image-upload-filename" part="upload-filename"
1068
+ >${this.uploadedFileName}</span
1069
+ >`
1070
+ : null}
1071
+ </div>
1072
+ <input
1073
+ part="upload-input"
1074
+ type="file"
1075
+ accept="image/*"
1076
+ @change=${this.onFileChange}
1077
+ />
1078
+ `
1079
+ : null}
1080
+
1081
+ ${this.errorMessage
1082
+ ? html`<span class="image-error" part="error">${this.errorMessage}</span>`
1083
+ : null}
1084
+
1085
+ <nile-input
1086
+ label="Alt text"
1087
+ placeholder=${this.altPlaceholder}
1088
+ .value=${this.altValue}
1089
+ @nile-input=${this.onAltInput}
1090
+ @keydown=${this.onInputKeydown}
1091
+ @nile-blur=${this.onInputBlur}
1092
+ part="alt-input"
1093
+ ></nile-input>
1094
+
1095
+ <nile-input
1096
+ label="Caption"
1097
+ placeholder=${this.captionPlaceholder}
1098
+ .value=${this.captionValue}
1099
+ @nile-input=${this.onCaptionInput}
1100
+ @keydown=${this.onInputKeydown}
1101
+ @nile-blur=${this.onInputBlur}
1102
+ part="caption-input"
1103
+ ></nile-input>
1104
+
1105
+ <div class="image-actions-row" part="actions-row">
1106
+ ${this.hasActiveImage
1107
+ ? html`
1108
+ <nile-button
1109
+ part="remove-button"
1110
+ size="small"
1111
+ variant="secondary"
1112
+ @click=${this.onRemoveClick}
1113
+ >
1114
+ Remove
1115
+ </nile-button>
1116
+ `
1117
+ : null}
1118
+ <nile-button part="apply-button" size="small" variant="primary" @click=${this.onApplyClick}>
1119
+ ${this.hasActiveImage ? 'Update' : 'Insert'}
1120
+ </nile-button>
1121
+ </div>
1122
+ </div>
1123
+ </nile-popover>
1124
+ `;
1125
+ }
1126
+ }
1127
+
1128
+ declare global {
1129
+ interface HTMLElementTagNameMap {
1130
+ 'nile-rte-image': NileRteImage;
1131
+ }
1132
+ }