@shelamkoff/rector 1.0.0 → 1.0.2

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