@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.
- package/README.md +6 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.js +470 -298
- package/dist/nile-chip/nile-chip.css.cjs.js +1 -1
- package/dist/nile-chip/nile-chip.css.cjs.js.map +1 -1
- package/dist/nile-chip/nile-chip.css.esm.js +8 -1
- package/dist/nile-code-editor/nile-code-editor.cjs.js +1 -1
- package/dist/nile-code-editor/nile-code-editor.cjs.js.map +1 -1
- package/dist/nile-code-editor/nile-code-editor.esm.js +3 -3
- package/dist/nile-hero/nile-hero.cjs.js +1 -1
- package/dist/nile-hero/nile-hero.cjs.js.map +1 -1
- package/dist/nile-hero/nile-hero.css.cjs.js +1 -1
- package/dist/nile-hero/nile-hero.css.cjs.js.map +1 -1
- package/dist/nile-hero/nile-hero.css.esm.js +14 -14
- package/dist/nile-hero/nile-hero.esm.js +1 -1
- package/dist/nile-hero-header/nile-hero-header.cjs.js +1 -1
- package/dist/nile-hero-header/nile-hero-header.cjs.js.map +1 -1
- package/dist/nile-hero-header/nile-hero-header.css.cjs.js +1 -1
- package/dist/nile-hero-header/nile-hero-header.css.cjs.js.map +1 -1
- package/dist/nile-hero-header/nile-hero-header.css.esm.js +11 -11
- package/dist/nile-hero-header/nile-hero-header.esm.js +3 -3
- package/dist/nile-rich-text-editor/index.cjs.js +1 -1
- package/dist/nile-rich-text-editor/index.esm.js +1 -1
- package/dist/nile-rich-text-editor/nile-rich-text-editor.cjs.js +1 -1
- package/dist/nile-rich-text-editor/nile-rich-text-editor.cjs.js.map +1 -1
- package/dist/nile-rich-text-editor/nile-rich-text-editor.css.cjs.js +1 -1
- package/dist/nile-rich-text-editor/nile-rich-text-editor.css.cjs.js.map +1 -1
- package/dist/nile-rich-text-editor/nile-rich-text-editor.css.esm.js +70 -0
- package/dist/nile-rich-text-editor/nile-rich-text-editor.esm.js +1 -1
- package/dist/nile-rich-text-editor/nile-rte-image.cjs.js +3 -0
- package/dist/nile-rich-text-editor/nile-rte-image.cjs.js.map +1 -0
- package/dist/nile-rich-text-editor/nile-rte-image.esm.js +96 -0
- package/dist/src/nile-chip/nile-chip.css.js +8 -1
- package/dist/src/nile-chip/nile-chip.css.js.map +1 -1
- package/dist/src/nile-code-editor/nile-code-editor.js +3 -3
- package/dist/src/nile-code-editor/nile-code-editor.js.map +1 -1
- package/dist/src/nile-hero/nile-hero.css.js +12 -12
- package/dist/src/nile-hero/nile-hero.css.js.map +1 -1
- package/dist/src/nile-hero/nile-hero.js +1 -1
- package/dist/src/nile-hero/nile-hero.js.map +1 -1
- package/dist/src/nile-hero-header/nile-hero-header.css.js +9 -9
- package/dist/src/nile-hero-header/nile-hero-header.css.js.map +1 -1
- package/dist/src/nile-hero-header/nile-hero-header.js +1 -1
- package/dist/src/nile-hero-header/nile-hero-header.js.map +1 -1
- package/dist/src/nile-rich-text-editor/nile-rich-text-editor.css.js +70 -0
- package/dist/src/nile-rich-text-editor/nile-rich-text-editor.css.js.map +1 -1
- package/dist/src/nile-rich-text-editor/nile-rich-text-editor.d.ts +3 -0
- package/dist/src/nile-rich-text-editor/nile-rich-text-editor.js +81 -3
- package/dist/src/nile-rich-text-editor/nile-rich-text-editor.js.map +1 -1
- package/dist/src/nile-rich-text-editor/nile-rte-image.d.ts +87 -0
- package/dist/src/nile-rich-text-editor/nile-rte-image.js +1073 -0
- package/dist/src/nile-rich-text-editor/nile-rte-image.js.map +1 -0
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/nile-chip/nile-chip.css.ts +8 -1
- package/src/nile-code-editor/nile-code-editor.ts +3 -3
- package/src/nile-hero/nile-hero.css.ts +12 -12
- package/src/nile-hero/nile-hero.ts +1 -1
- package/src/nile-hero-header/nile-hero-header.css.ts +9 -9
- package/src/nile-hero-header/nile-hero-header.ts +1 -1
- package/src/nile-rich-text-editor/nile-rich-text-editor.css.ts +70 -0
- package/src/nile-rich-text-editor/nile-rich-text-editor.ts +89 -3
- package/src/nile-rich-text-editor/nile-rte-image.ts +1132 -0
- 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
|
+
}
|