@shelamkoff/rector 1.0.1 → 1.0.3

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 (63) hide show
  1. package/README.md +25 -24
  2. package/dist/core/clipboard/Clipboard.d.ts +2 -0
  3. package/dist/core/clipboard/Clipboard.js +52 -24
  4. package/dist/core/index.js +195 -194
  5. package/dist/core/locale/en.d.ts +1 -0
  6. package/dist/core/locale/en.js +1 -0
  7. package/dist/core/locale/ru.d.ts +1 -0
  8. package/dist/core/locale/ru.js +1 -0
  9. package/dist/core/themes/light.css +1 -1
  10. package/dist/core/themes/variables.css +72 -10
  11. package/dist/locale/en.d.ts +41 -0
  12. package/dist/locale/ru.d.ts +41 -0
  13. package/dist/plugins/attaches/attaches.css +16 -14
  14. package/dist/plugins/attaches/index.js +997 -920
  15. package/dist/plugins/attaches/locale/en.d.ts +9 -0
  16. package/dist/plugins/attaches/locale/en.js +11 -2
  17. package/dist/plugins/attaches/locale/ru.d.ts +9 -0
  18. package/dist/plugins/attaches/locale/ru.js +11 -2
  19. package/dist/plugins/carousel/carousel.css +213 -49
  20. package/dist/plugins/carousel/index.js +918 -858
  21. package/dist/plugins/carousel/locale/en.d.ts +15 -0
  22. package/dist/plugins/carousel/locale/en.js +18 -3
  23. package/dist/plugins/carousel/locale/ru.d.ts +15 -0
  24. package/dist/plugins/carousel/locale/ru.js +18 -3
  25. package/dist/plugins/gallery/README.md +92 -92
  26. package/dist/plugins/gallery/README.ru.md +17 -17
  27. package/dist/plugins/gallery/gallery.css +44 -19
  28. package/dist/plugins/gallery/index.js +375 -354
  29. package/dist/plugins/gallery/locale/en.d.ts +8 -0
  30. package/dist/plugins/gallery/locale/en.js +11 -3
  31. package/dist/plugins/gallery/locale/ru.d.ts +8 -0
  32. package/dist/plugins/gallery/locale/ru.js +11 -3
  33. package/dist/plugins/gallery/settings.js +15 -10
  34. package/dist/plugins/gallery/view-empty.d.ts +2 -0
  35. package/dist/plugins/gallery/view-empty.js +21 -15
  36. package/dist/plugins/gallery/view-filled.d.ts +2 -2
  37. package/dist/plugins/gallery/view-filled.js +84 -72
  38. package/dist/plugins/image/image.css +7 -2
  39. package/dist/plugins/image/index.js +365 -345
  40. package/dist/plugins/image/locale/en.d.ts +8 -0
  41. package/dist/plugins/image/locale/en.js +11 -3
  42. package/dist/plugins/image/locale/ru.d.ts +8 -0
  43. package/dist/plugins/image/locale/ru.js +11 -3
  44. package/dist/plugins/image/view-empty.d.ts +2 -0
  45. package/dist/plugins/image/view-empty.js +21 -15
  46. package/dist/plugins/image/view-filled.d.ts +2 -2
  47. package/dist/plugins/image/view-filled.js +77 -72
  48. package/dist/plugins/shared/dropzone.d.ts +6 -0
  49. package/dist/plugins/shared/dropzone.js +47 -36
  50. package/dist/plugins/shared/layer.d.ts +13 -0
  51. package/dist/plugins/shared/layer.js +45 -0
  52. package/dist/plugins/shared/sourceEditor.css +165 -0
  53. package/dist/plugins/shared/sourceEditor.d.ts +97 -0
  54. package/dist/plugins/shared/sourceEditor.js +260 -0
  55. package/dist/renderer/renderers/carousel/index.js +24 -23
  56. package/dist/renderer/renderers/carousel/styles.css +8 -2
  57. package/dist/renderer/renderers/gallery/README.md +41 -41
  58. package/dist/renderer/renderers/gallery/README.ru.md +41 -41
  59. package/dist/renderer/renderers/gallery/index.js +100 -85
  60. package/dist/renderer/renderers/gallery/styles.css +417 -401
  61. package/dist/shared/galleryMasonry.d.ts +34 -0
  62. package/dist/shared/galleryMasonry.js +152 -0
  63. package/package.json +29 -28
@@ -1,354 +1,375 @@
1
- import { isSupportedImageFile, triggerFileInput } from '../shared/fileInput.js'
2
- import { BlockPluginAbstract } from '../BlockPluginAbstract.js'
3
- import { validateGalleryData } from '../../shared/blockDataValidators.js'
4
- import { CSS } from './css.js'
5
- import { ICON } from './icons.js'
6
- import { GalleryState, normalizeGalleryData, emptyGalleryData } from './state.js'
7
- import { GalleryUploader } from './uploader.js'
8
- import { renderEmptyView } from './view-empty.js'
9
- import { renderFilledView } from './view-filled.js'
10
- import { sanitizeMediaUrl } from '../../shared/sanitize/sanitizeUrl.js'
11
-
12
- const editorStyles = new URL('./gallery.css', import.meta.url).href
13
-
14
- /**
15
- * @typedef {(file: File, context: { signal: AbortSignal }) => Promise<{ url: string, alt?: string }>} UploadFn
16
- *
17
- * @typedef {Object} GalleryConfig
18
- * @property {UploadFn} [uploadFile] Uploads one browser file. Without this callback the plugin reads each file into a data URL stored in the document.
19
- * @property {Array<{ icon?: string, label: string, handler: (context: { signal: AbortSignal }) => Promise<Array<{url: string, alt?: string}> | null> }>} [actions] Additional application-owned image sources. `icon` is trusted application markup; never pass user-authored HTML.
20
- * @property {boolean} [injectStyles=true] Whether the editor should load the built-in gallery stylesheet.
21
- * @property {string} [css] Additional stylesheet URL, or the replacement URL when `injectStyles` is `false`.
22
- */
23
-
24
- /**
25
- * Editable image gallery with upload-source extensions, ordering, and layout controls.
26
- * Internal logic is split across:
27
- * - `state.js` — per-block state container (replaces module WeakMap)
28
- * - `uploader.js` — multi-file upload pipeline
29
- * - `layout.js` layout selection algorithms
30
- * - `slot.js` slot/overflow item DOM builders + drag handling
31
- * - `view-empty.js`/ `view-filled.js` DOM rendering for the two states
32
- * - `settings.js` settings dropdown form
33
- * - `styles.js` gallery-level inline style application
34
- * @extends {BlockPluginAbstract<GalleryConfig>}
35
- */
36
- export class Gallery extends BlockPluginAbstract {
37
- static isTextBlock = false
38
- static styles = [editorStyles]
39
-
40
- type = 'gallery'
41
- icon = ICON
42
- inlineTools = false
43
-
44
- pasteConfig = {
45
- files: ['image/*'],
46
- }
47
-
48
- #uploader
49
- /** Per-block state, encapsulated to this plugin instance. */
50
- #states = /** @type {WeakMap<HTMLElement, GalleryState>} */ (new WeakMap())
51
- /** @type {WeakMap<HTMLElement, import('../../core/types').BlockMutationContext>} */
52
- #contexts = new WeakMap()
53
- /**
54
- * Create a Gallery instance with the supplied consumer configuration.
55
- * @param {GalleryConfig} [config]
56
- */
57
- constructor(config) {
58
- super(config)
59
- this.#uploader = new GalleryUploader(this._config)
60
- }
61
-
62
- /**
63
- * Return the localized toolbox label for this block.
64
- * @returns {string}
65
- */
66
- get title() {
67
- return this._t('title', 'Gallery')
68
- }
69
-
70
- // ── BlockPlugin contract ───────────────────────────────────────────────────
71
-
72
- /**
73
- * Create the editable DOM owned by this block instance.
74
- * @param {Record<string, unknown>} data
75
- * @param {import('../../core/types').BlockMutationContext} context
76
- * @returns {HTMLElement}
77
- */
78
- render(data, context) {
79
- const blockData = normalizeGalleryData(data)
80
- const pendingFile = /** @type {File | null} */ (/** @type {any} */ (data)?._pendingFile || null)
81
-
82
- const wrapper = document.createElement('div')
83
- wrapper.classList.add(CSS.wrapper)
84
- wrapper.contentEditable = 'false'
85
- wrapper.tabIndex = -1
86
-
87
- const state = new GalleryState(blockData, pendingFile ? [pendingFile] : [])
88
- this.#states.set(wrapper, state)
89
- this.#contexts.set(wrapper, context)
90
-
91
- if (blockData.images.length > 0) {
92
- this.#renderFilled(wrapper)
93
- } else {
94
- this.#renderEmpty(wrapper)
95
- }
96
-
97
- if (state.pendingFiles.length > 0 && !context.readOnly) {
98
- const files = state.pendingFiles
99
- state.pendingFiles = []
100
- state.pendingUpload = this.#handleFiles(wrapper, files).finally(() => {
101
- if (this.#states.get(wrapper) === state) state.pendingUpload = null
102
- })
103
- }
104
-
105
- return wrapper
106
- }
107
-
108
- /**
109
- * Serialize the current block DOM into document data.
110
- * @param {HTMLElement} element
111
- * @returns {Record<string, unknown>}
112
- */
113
- save(element) {
114
- const state = this.#states.get(element)
115
- if (!state) return emptyGalleryData()
116
- this.#syncCaptions(element)
117
- return {
118
- images: state.data.images.map((img) => ({ ...img })),
119
- layout: state.data.layout,
120
- styles: { ...state.data.styles },
121
- options: { ...state.data.options },
122
- }
123
- }
124
-
125
- /**
126
- * Check whether serialized data satisfies this block's schema.
127
- * @param {Record<string, unknown>} data
128
- * @returns {boolean}
129
- */
130
- validate(data) {
131
- return validateGalleryData(data)
132
- }
133
-
134
- /**
135
- * Check whether the block has no meaningful user content.
136
- * @param {HTMLElement} element
137
- * @returns {boolean}
138
- */
139
- isEmpty(element) {
140
- const state = this.#states.get(element)
141
- return !state || state.data.images.length === 0
142
- }
143
-
144
- /**
145
- * Extract neutral text that can initialize another block type.
146
- * @param {HTMLElement} element
147
- * @returns {Record<string, unknown>}
148
- */
149
- exportData(element) {
150
- const state = this.#states.get(element)
151
- return { text: state?.data.images.map(image => image.caption).filter(Boolean).join(' ') || '' }
152
- }
153
-
154
- /**
155
- * Handle supported pasted content for this block.
156
- * @param {import('../../types').PasteEvent} event
157
- * @returns {Record<string, unknown> | null}
158
- */
159
- onPaste(event) {
160
- if (event.type === 'file') {
161
- // Files dropped via paste — push immediately into the new block.
162
- // Render path will then upload via the wrapper bound to this default data.
163
- const data = /** @type {any} */ (emptyGalleryData())
164
- data._pendingFile = event.file
165
- return data
166
- }
167
- return null
168
- }
169
-
170
- /**
171
- * Keep pasted files inside one undo transaction until upload completes.
172
- * @param {HTMLElement} element
173
- * @returns {Promise<void>}
174
- */
175
- waitForPaste(element) {
176
- return this.#states.get(element)?.pendingUpload ?? Promise.resolve()
177
- }
178
-
179
- /**
180
- * Release listeners and resources owned by this block element.
181
- * @param {HTMLElement} element
182
- * @returns {void}
183
- */
184
- destroy(element) {
185
- const state = this.#states.get(element)
186
- if (!state) return
187
- state.dispose()
188
- this.#states.delete(element)
189
- this.#contexts.delete(element)
190
- }
191
-
192
- // ── Internal coordination ──────────────────────────────────────────────────
193
-
194
- /** @param {string} key @param {string} fallback @returns {string} */
195
- #t = (key, fallback) => this._t(key, fallback)
196
-
197
- /** @param {HTMLElement} wrapper @param {() => void} operation @returns {void} */
198
- #mutate = (wrapper, operation) => {
199
- this.#contexts.get(wrapper)?.mutate(operation)
200
- }
201
-
202
- /**
203
- * Flush in-flight contenteditable caption text into state before any
204
- * mutation that would re-render and tear down the live caption nodes.
205
- *
206
- * @param {HTMLElement} wrapper
207
- * @returns {void}
208
- */
209
- #syncCaptions(wrapper) {
210
- const state = this.#states.get(wrapper)
211
- if (!state) return
212
- const slots = wrapper.querySelectorAll(`.${CSS.slot}.${CSS.slotFilled}`)
213
- slots.forEach((slotEl) => {
214
- const slot = /** @type {HTMLElement} */ (slotEl)
215
- const idx = parseInt(slot.dataset.slot || slot.dataset.index || '0', 10)
216
- const img = state.data.images[idx]
217
- if (img) {
218
- const caption = slot.querySelector(`.${CSS.slotCaption}`)
219
- img.caption = caption?.textContent?.trim() || ''
220
- }
221
- })
222
- }
223
-
224
- /** @param {HTMLElement} wrapper @returns {void} */
225
- #renderEmpty(wrapper) {
226
- const state = this.#states.get(wrapper)
227
- if (!state) return
228
- renderEmptyView(wrapper, state, {
229
- t: this.#t,
230
- readOnly: Boolean(this.#contexts.get(wrapper)?.readOnly),
231
- onUploadClick: () => this.#triggerFileInput(wrapper),
232
- onFilesDropped: (files) => { void this.#handleFiles(wrapper, files) },
233
- customActions: this._config.actions || [],
234
- runCustomAction: async (handler) => this.#runCustomAction(wrapper, handler),
235
- })
236
- }
237
-
238
- /** @param {HTMLElement} wrapper @returns {void} */
239
- #renderFilled(wrapper) {
240
- const state = this.#states.get(wrapper)
241
- if (!state) return
242
- renderFilledView(wrapper, state, {
243
- t: this.#t,
244
- readOnly: Boolean(this.#contexts.get(wrapper)?.readOnly),
245
- syncCaptions: () => this.#syncCaptions(wrapper),
246
- getState: () => this.#states.get(wrapper),
247
- reRender: () => this.#renderFilled(wrapper),
248
- renderEmpty: () => this.#renderEmpty(wrapper),
249
- mutate: (operation) => this.#mutate(wrapper, operation),
250
- onFilesDropped: (files) => { void this.#handleFiles(wrapper, files) },
251
- onTriggerFileInput: () => this.#triggerFileInput(wrapper),
252
- onPromptUrl: () => this.#promptUrl(wrapper),
253
- onDeleteAll: () => this.#deleteAll(wrapper),
254
- customActions: this._config.actions || [],
255
- runCustomAction: async (handler) => this.#runCustomAction(wrapper, handler),
256
- })
257
- }
258
-
259
- /** @param {HTMLElement} wrapper @returns {void} */
260
- #triggerFileInput(wrapper) {
261
- if (this.#contexts.get(wrapper)?.readOnly) return
262
- triggerFileInput({
263
- accept: 'image/*',
264
- multiple: true,
265
- signal: this.#states.get(wrapper)?.abortController?.signal,
266
- onFiles: (files) => void this.#handleFiles(wrapper, files),
267
- })
268
- }
269
-
270
- /**
271
- * @param {HTMLElement} wrapper
272
- * @param {File[]} files
273
- * @returns {Promise<void>}
274
- */
275
- async #handleFiles(wrapper, files) {
276
- const state = this.#states.get(wrapper)
277
- const context = this.#contexts.get(wrapper)
278
- if (!state || !context || context.readOnly) return
279
- const accepted = files.filter(isSupportedImageFile)
280
- if (accepted.length === 0) return
281
- const controller = state.beginTask()
282
- wrapper.classList.add(CSS.loading)
283
- try {
284
- await this.#uploader.handle(accepted, (added) => {
285
- if (controller.signal.aborted || this.#states.get(wrapper) !== state) return
286
- this.#mutate(wrapper, () => {
287
- this.#syncCaptions(wrapper)
288
- state.data.images.push(...added)
289
- this.#renderFilled(wrapper)
290
- })
291
- }, controller.signal)
292
- } finally {
293
- if (state.finishTask(controller)) wrapper.classList.remove(CSS.loading)
294
- }
295
- }
296
-
297
- /**
298
- * @param {HTMLElement} wrapper
299
- * @param {(context: { signal: AbortSignal }) => Promise<Array<{url: string, alt?: string}> | null>} handler
300
- * @returns {Promise<void>}
301
- */
302
- async #runCustomAction(wrapper, handler) {
303
- const state = this.#states.get(wrapper)
304
- if (!state || this.#contexts.get(wrapper)?.readOnly) return
305
- const controller = state.beginTask()
306
- wrapper.classList.add(CSS.loading)
307
- try {
308
- const result = await handler({ signal: controller.signal })
309
- if (!controller.signal.aborted && this.#states.get(wrapper) === state && Array.isArray(result) && result.length > 0) {
310
- const added = result.flatMap(item => {
311
- const url = sanitizeMediaUrl(item?.url || '')
312
- if (!url) return []
313
- return [{ url, caption: typeof item?.alt === 'string' ? item.alt : '' }]
314
- })
315
- if (!added.length) return
316
- this.#mutate(wrapper, () => {
317
- this.#syncCaptions(wrapper)
318
- state.data.images.push(...added)
319
- this.#renderFilled(wrapper)
320
- })
321
- }
322
- } catch {
323
- // Action cancelled or failed.
324
- } finally {
325
- if (state.finishTask(controller)) wrapper.classList.remove(CSS.loading)
326
- }
327
- }
328
-
329
- /** @param {HTMLElement} wrapper @returns {void} */
330
- #promptUrl(wrapper) {
331
- const state = this.#states.get(wrapper)
332
- if (!state || this.#contexts.get(wrapper)?.readOnly) return
333
- const url = sanitizeMediaUrl(prompt(this.#t('urlPrompt', 'Image URL:')) || '')
334
- if (url) {
335
- this.#mutate(wrapper, () => {
336
- this.#syncCaptions(wrapper)
337
- state.data.images.push({ url, caption: '' })
338
- this.#renderFilled(wrapper)
339
- })
340
- }
341
- }
342
-
343
- /** @param {HTMLElement} wrapper @returns {void} */
344
- #deleteAll(wrapper) {
345
- const state = this.#states.get(wrapper)
346
- if (!state) return
347
- state.cancelTasks()
348
- wrapper.classList.remove(CSS.loading)
349
- this.#mutate(wrapper, () => {
350
- state.data.images = []
351
- this.#renderEmpty(wrapper)
352
- })
353
- }
354
- }
1
+ import { isSupportedImageFile, triggerFileInput } from '../shared/fileInput.js'
2
+ import { BlockPluginAbstract } from '../BlockPluginAbstract.js'
3
+ import { validateGalleryData } from '../../shared/blockDataValidators.js'
4
+ import { CSS } from './css.js'
5
+ import { ICON } from './icons.js'
6
+ import { GalleryState, normalizeGalleryData, emptyGalleryData } from './state.js'
7
+ import { GalleryUploader } from './uploader.js'
8
+ import { renderEmptyView } from './view-empty.js'
9
+ import { renderFilledView } from './view-filled.js'
10
+ import { sanitizeMediaUrl } from '../../shared/sanitize/sanitizeUrl.js'
11
+ import { openSourceEditor, preloadSourceEditor } from '../shared/sourceEditor.js'
12
+
13
+ const editorStyles = new URL('./gallery.css', import.meta.url).href
14
+ const sourceEditorStyles = new URL('../shared/sourceEditor.css', import.meta.url).href
15
+
16
+ /**
17
+ * @typedef {(file: File, context: { signal: AbortSignal }) => Promise<{ url: string, alt?: string }>} UploadFn
18
+ *
19
+ * @typedef {Object} GalleryConfig
20
+ * @property {UploadFn} [uploadFile] Uploads one browser file. Without this callback the plugin reads each file into a data URL stored in the document.
21
+ * @property {Array<{ icon?: string, label: string, handler: (context: { signal: AbortSignal }) => Promise<Array<{url: string, alt?: string}> | null> }>} [actions] Additional application-owned image sources. `icon` is trusted application markup; never pass user-authored HTML.
22
+ * @property {boolean} [injectStyles=true] Whether the editor should load the built-in gallery stylesheet.
23
+ * @property {string} [css] Additional stylesheet URL, or the replacement URL when `injectStyles` is `false`.
24
+ */
25
+
26
+ /**
27
+ * Editable image gallery with upload-source extensions, ordering, and layout controls.
28
+ * Internal logic is split across:
29
+ * - `state.js` per-block state container (replaces module WeakMap)
30
+ * - `uploader.js` multi-file upload pipeline
31
+ * - `layout.js` layout selection algorithms
32
+ * - `slot.js` slot/overflow item DOM builders + drag handling
33
+ * - `view-empty.js`/ `view-filled.js` DOM rendering for the two states
34
+ * - `settings.js` — settings dropdown form
35
+ * - `styles.js` — gallery-level inline style application
36
+ * @extends {BlockPluginAbstract<GalleryConfig>}
37
+ */
38
+ export class Gallery extends BlockPluginAbstract {
39
+ static isTextBlock = false
40
+ static styles = [editorStyles, sourceEditorStyles]
41
+
42
+ type = 'gallery'
43
+ icon = ICON
44
+ inlineTools = false
45
+
46
+ pasteConfig = {
47
+ files: ['image/*'],
48
+ }
49
+
50
+ #uploader
51
+ /** Per-block state, encapsulated to this plugin instance. */
52
+ #states = /** @type {WeakMap<HTMLElement, GalleryState>} */ (new WeakMap())
53
+ /** @type {WeakMap<HTMLElement, import('../../core/types').BlockMutationContext>} */
54
+ #contexts = new WeakMap()
55
+ /**
56
+ * Create a Gallery instance with the supplied consumer configuration.
57
+ * @param {GalleryConfig} [config]
58
+ */
59
+ constructor(config) {
60
+ super(config)
61
+ this.#uploader = new GalleryUploader(this._config)
62
+ }
63
+
64
+ /**
65
+ * Return the localized toolbox label for this block.
66
+ * @returns {string}
67
+ */
68
+ get title() {
69
+ return this._t('title', 'Gallery')
70
+ }
71
+
72
+ // ── BlockPlugin contract ───────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Create the editable DOM owned by this block instance.
76
+ * @param {Record<string, unknown>} data
77
+ * @param {import('../../core/types').BlockMutationContext} context
78
+ * @returns {HTMLElement}
79
+ */
80
+ render(data, context) {
81
+ const blockData = normalizeGalleryData(data)
82
+ const pendingFile = /** @type {File | null} */ (/** @type {any} */ (data)?._pendingFile || null)
83
+
84
+ const wrapper = document.createElement('div')
85
+ wrapper.classList.add(CSS.wrapper)
86
+ wrapper.contentEditable = 'false'
87
+ wrapper.tabIndex = -1
88
+
89
+ const state = new GalleryState(blockData, pendingFile ? [pendingFile] : [])
90
+ this.#states.set(wrapper, state)
91
+ this.#contexts.set(wrapper, context)
92
+
93
+ if (blockData.images.length > 0) {
94
+ this.#renderFilled(wrapper)
95
+ } else {
96
+ this.#renderEmpty(wrapper)
97
+ }
98
+
99
+ if (state.pendingFiles.length > 0 && !context.readOnly) {
100
+ const files = state.pendingFiles
101
+ state.pendingFiles = []
102
+ state.pendingUpload = this.#handleFiles(wrapper, files).finally(() => {
103
+ if (this.#states.get(wrapper) === state) state.pendingUpload = null
104
+ })
105
+ }
106
+
107
+ return wrapper
108
+ }
109
+
110
+ /**
111
+ * Serialize the current block DOM into document data.
112
+ * @param {HTMLElement} element
113
+ * @returns {Record<string, unknown>}
114
+ */
115
+ save(element) {
116
+ const state = this.#states.get(element)
117
+ if (!state) return emptyGalleryData()
118
+ this.#syncCaptions(element)
119
+ return {
120
+ images: state.data.images.map((img) => ({ ...img })),
121
+ layout: state.data.layout,
122
+ styles: { ...state.data.styles },
123
+ options: { ...state.data.options },
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Check whether serialized data satisfies this block's schema.
129
+ * @param {Record<string, unknown>} data
130
+ * @returns {boolean}
131
+ */
132
+ validate(data) {
133
+ return validateGalleryData(data)
134
+ }
135
+
136
+ /**
137
+ * Check whether the block has no meaningful user content.
138
+ * @param {HTMLElement} element
139
+ * @returns {boolean}
140
+ */
141
+ isEmpty(element) {
142
+ const state = this.#states.get(element)
143
+ return !state || state.data.images.length === 0
144
+ }
145
+
146
+ /**
147
+ * Extract neutral text that can initialize another block type.
148
+ * @param {HTMLElement} element
149
+ * @returns {Record<string, unknown>}
150
+ */
151
+ exportData(element) {
152
+ const state = this.#states.get(element)
153
+ return { text: state?.data.images.map(image => image.caption).filter(Boolean).join(' ') || '' }
154
+ }
155
+
156
+ /**
157
+ * Handle supported pasted content for this block.
158
+ * @param {import('../../types').PasteEvent} event
159
+ * @returns {Record<string, unknown> | null}
160
+ */
161
+ onPaste(event) {
162
+ if (event.type === 'file') {
163
+ // Files dropped via paste push immediately into the new block.
164
+ // Render path will then upload via the wrapper bound to this default data.
165
+ const data = /** @type {any} */ (emptyGalleryData())
166
+ data._pendingFile = event.file
167
+ return data
168
+ }
169
+ return null
170
+ }
171
+
172
+ /**
173
+ * Keep pasted files inside one undo transaction until upload completes.
174
+ * @param {HTMLElement} element
175
+ * @returns {Promise<void>}
176
+ */
177
+ waitForPaste(element) {
178
+ return this.#states.get(element)?.pendingUpload ?? Promise.resolve()
179
+ }
180
+
181
+ /**
182
+ * Release listeners and resources owned by this block element.
183
+ * @param {HTMLElement} element
184
+ * @returns {void}
185
+ */
186
+ destroy(element) {
187
+ const state = this.#states.get(element)
188
+ if (!state) return
189
+ state.dispose()
190
+ this.#states.delete(element)
191
+ this.#contexts.delete(element)
192
+ }
193
+
194
+ // ── Internal coordination ──────────────────────────────────────────────────
195
+
196
+ /** @param {string} key @param {string} fallback @returns {string} */
197
+ #t = (key, fallback) => this._t(key, fallback)
198
+
199
+ /** @param {HTMLElement} wrapper @param {() => void} operation @returns {void} */
200
+ #mutate = (wrapper, operation) => {
201
+ this.#contexts.get(wrapper)?.mutate(operation)
202
+ }
203
+
204
+ /**
205
+ * Flush in-flight contenteditable caption text into state before any
206
+ * mutation that would re-render and tear down the live caption nodes.
207
+ *
208
+ * @param {HTMLElement} wrapper
209
+ * @returns {void}
210
+ */
211
+ #syncCaptions(wrapper) {
212
+ const state = this.#states.get(wrapper)
213
+ if (!state) return
214
+ const slots = wrapper.querySelectorAll(`.${CSS.slot}.${CSS.slotFilled}`)
215
+ slots.forEach((slotEl) => {
216
+ const slot = /** @type {HTMLElement} */ (slotEl)
217
+ const idx = parseInt(slot.dataset.slot || slot.dataset.index || '0', 10)
218
+ const img = state.data.images[idx]
219
+ if (img) {
220
+ const caption = slot.querySelector(`.${CSS.slotCaption}`)
221
+ img.caption = caption?.textContent?.trim() || ''
222
+ }
223
+ })
224
+ }
225
+
226
+ /** @param {HTMLElement} wrapper @returns {void} */
227
+ #renderEmpty(wrapper) {
228
+ const state = this.#states.get(wrapper)
229
+ if (!state) return
230
+ const readOnly = Boolean(this.#contexts.get(wrapper)?.readOnly)
231
+ renderEmptyView(wrapper, state, {
232
+ t: this.#t,
233
+ readOnly,
234
+ onUploadClick: () => this.#triggerFileInput(wrapper),
235
+ onOpenUrlEditor: () => this.#openUrlEditor(wrapper),
236
+ onFilesDropped: (files) => { void this.#handleFiles(wrapper, files) },
237
+ customActions: this._config.actions || [],
238
+ runCustomAction: async (handler) => this.#runCustomAction(wrapper, handler),
239
+ })
240
+ if (!readOnly) preloadSourceEditor(wrapper, state.abortController.signal, ['url'])
241
+ }
242
+
243
+ /** @param {HTMLElement} wrapper @returns {void} */
244
+ #renderFilled(wrapper) {
245
+ const state = this.#states.get(wrapper)
246
+ if (!state) return
247
+ const readOnly = Boolean(this.#contexts.get(wrapper)?.readOnly)
248
+ renderFilledView(wrapper, state, {
249
+ t: this.#t,
250
+ readOnly,
251
+ syncCaptions: () => this.#syncCaptions(wrapper),
252
+ getState: () => this.#states.get(wrapper),
253
+ reRender: () => this.#renderFilled(wrapper),
254
+ renderEmpty: () => this.#renderEmpty(wrapper),
255
+ mutate: (operation) => this.#mutate(wrapper, operation),
256
+ onFilesDropped: (files) => { void this.#handleFiles(wrapper, files) },
257
+ onTriggerFileInput: () => this.#triggerFileInput(wrapper),
258
+ onOpenUrlEditor: () => this.#openUrlEditor(wrapper),
259
+ onDeleteAll: () => this.#deleteAll(wrapper),
260
+ customActions: this._config.actions || [],
261
+ runCustomAction: async (handler) => this.#runCustomAction(wrapper, handler),
262
+ })
263
+ if (!readOnly) preloadSourceEditor(wrapper, state.abortController.signal, ['url'])
264
+ }
265
+
266
+ /** @param {HTMLElement} wrapper @returns {void} */
267
+ #triggerFileInput(wrapper) {
268
+ if (this.#contexts.get(wrapper)?.readOnly) return
269
+ triggerFileInput({
270
+ accept: 'image/*',
271
+ multiple: true,
272
+ signal: this.#states.get(wrapper)?.abortController?.signal,
273
+ onFiles: (files) => void this.#handleFiles(wrapper, files),
274
+ })
275
+ }
276
+
277
+ /**
278
+ * @param {HTMLElement} wrapper
279
+ * @param {File[]} files
280
+ * @returns {Promise<void>}
281
+ */
282
+ async #handleFiles(wrapper, files) {
283
+ const state = this.#states.get(wrapper)
284
+ const context = this.#contexts.get(wrapper)
285
+ if (!state || !context || context.readOnly) return
286
+ const accepted = files.filter(isSupportedImageFile)
287
+ if (accepted.length === 0) return
288
+ const controller = state.beginTask()
289
+ wrapper.classList.add(CSS.loading)
290
+ try {
291
+ await this.#uploader.handle(accepted, (added) => {
292
+ if (controller.signal.aborted || this.#states.get(wrapper) !== state) return
293
+ this.#mutate(wrapper, () => {
294
+ this.#syncCaptions(wrapper)
295
+ state.data.images.push(...added)
296
+ this.#renderFilled(wrapper)
297
+ })
298
+ }, controller.signal)
299
+ } finally {
300
+ if (state.finishTask(controller)) wrapper.classList.remove(CSS.loading)
301
+ }
302
+ }
303
+
304
+ /**
305
+ * @param {HTMLElement} wrapper
306
+ * @param {(context: { signal: AbortSignal }) => Promise<Array<{url: string, alt?: string}> | null>} handler
307
+ * @returns {Promise<void>}
308
+ */
309
+ async #runCustomAction(wrapper, handler) {
310
+ const state = this.#states.get(wrapper)
311
+ if (!state || this.#contexts.get(wrapper)?.readOnly) return
312
+ const controller = state.beginTask()
313
+ wrapper.classList.add(CSS.loading)
314
+ try {
315
+ const result = await handler({ signal: controller.signal })
316
+ if (!controller.signal.aborted && this.#states.get(wrapper) === state && Array.isArray(result) && result.length > 0) {
317
+ const added = result.flatMap(item => {
318
+ const url = sanitizeMediaUrl(item?.url || '')
319
+ if (!url) return []
320
+ return [{ url, caption: typeof item?.alt === 'string' ? item.alt : '' }]
321
+ })
322
+ if (!added.length) return
323
+ this.#mutate(wrapper, () => {
324
+ this.#syncCaptions(wrapper)
325
+ state.data.images.push(...added)
326
+ this.#renderFilled(wrapper)
327
+ })
328
+ }
329
+ } catch {
330
+ // Action cancelled or failed.
331
+ } finally {
332
+ if (state.finishTask(controller)) wrapper.classList.remove(CSS.loading)
333
+ }
334
+ }
335
+
336
+ /** @param {HTMLElement} wrapper @returns {void} */
337
+ #openUrlEditor(wrapper) {
338
+ const state = this.#states.get(wrapper)
339
+ const signal = state?.abortController?.signal
340
+ if (!state || !signal || this.#contexts.get(wrapper)?.readOnly) return
341
+ openSourceEditor({
342
+ wrapper,
343
+ signal,
344
+ kind: 'url',
345
+ title: this.#t('urlEditorTitle', 'Insert image by URL'),
346
+ label: this.#t('urlEditorLabel', 'Image URL'),
347
+ placeholder: this.#t('urlEditorPlaceholder', 'https://example.com/image.jpg'),
348
+ submitText: this.#t('sourceSubmit', 'Insert'),
349
+ cancelText: this.#t('sourceCancel', 'Cancel'),
350
+ invalidText: this.#t('invalidUrl', 'Enter a valid image URL.'),
351
+ normalize: sanitizeMediaUrl,
352
+ onSubmit: (url) => {
353
+ const current = this.#states.get(wrapper)
354
+ if (current !== state || this.#contexts.get(wrapper)?.readOnly) return
355
+ this.#mutate(wrapper, () => {
356
+ this.#syncCaptions(wrapper)
357
+ state.data.images.push({ url, caption: '' })
358
+ this.#renderFilled(wrapper)
359
+ })
360
+ },
361
+ })
362
+ }
363
+
364
+ /** @param {HTMLElement} wrapper @returns {void} */
365
+ #deleteAll(wrapper) {
366
+ const state = this.#states.get(wrapper)
367
+ if (!state) return
368
+ state.cancelTasks()
369
+ wrapper.classList.remove(CSS.loading)
370
+ this.#mutate(wrapper, () => {
371
+ state.data.images = []
372
+ this.#renderEmpty(wrapper)
373
+ })
374
+ }
375
+ }