@shelamkoff/rector 1.0.1 → 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 (54) 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/gallery.css +6 -1
  26. package/dist/plugins/gallery/index.js +375 -354
  27. package/dist/plugins/gallery/locale/en.d.ts +8 -0
  28. package/dist/plugins/gallery/locale/en.js +11 -3
  29. package/dist/plugins/gallery/locale/ru.d.ts +8 -0
  30. package/dist/plugins/gallery/locale/ru.js +11 -3
  31. package/dist/plugins/gallery/view-empty.d.ts +2 -0
  32. package/dist/plugins/gallery/view-empty.js +21 -15
  33. package/dist/plugins/gallery/view-filled.d.ts +2 -2
  34. package/dist/plugins/gallery/view-filled.js +61 -56
  35. package/dist/plugins/image/image.css +7 -2
  36. package/dist/plugins/image/index.js +365 -345
  37. package/dist/plugins/image/locale/en.d.ts +8 -0
  38. package/dist/plugins/image/locale/en.js +11 -3
  39. package/dist/plugins/image/locale/ru.d.ts +8 -0
  40. package/dist/plugins/image/locale/ru.js +11 -3
  41. package/dist/plugins/image/view-empty.d.ts +2 -0
  42. package/dist/plugins/image/view-empty.js +21 -15
  43. package/dist/plugins/image/view-filled.d.ts +2 -2
  44. package/dist/plugins/image/view-filled.js +77 -72
  45. package/dist/plugins/shared/dropzone.d.ts +6 -0
  46. package/dist/plugins/shared/dropzone.js +47 -36
  47. package/dist/plugins/shared/layer.d.ts +13 -0
  48. package/dist/plugins/shared/layer.js +45 -0
  49. package/dist/plugins/shared/sourceEditor.css +165 -0
  50. package/dist/plugins/shared/sourceEditor.d.ts +97 -0
  51. package/dist/plugins/shared/sourceEditor.js +260 -0
  52. package/dist/renderer/renderers/carousel/index.js +24 -23
  53. package/dist/renderer/renderers/carousel/styles.css +8 -2
  54. package/package.json +32 -32
@@ -1,345 +1,365 @@
1
- import { isSupportedImageFile, triggerFileInput } from '../shared/fileInput.js'
2
- import { BlockPluginAbstract } from '../BlockPluginAbstract.js'
3
- import { validateImageData } from '../../shared/blockDataValidators.js'
4
- import { CSS } from './css.js'
5
- import { ICON } from './icons.js'
6
- import { ImageState, normalizeImageData, emptyImageData } from './state.js'
7
- import { ImageUploader } from './uploader.js'
8
- import { renderEmptyView } from './view-empty.js'
9
- import { renderFilledView } from './view-filled.js'
10
- import { sanitizeMediaUrl } from '../../shared/sanitize/index.js'
11
-
12
- const editorStyles = new URL('./image.css', import.meta.url).href
13
-
14
- /**
15
- * @typedef {{ url: string, alt?: string }} ImageSourceResult
16
- * @typedef {(file: File, context: { signal: AbortSignal }) => Promise<ImageSourceResult>} ImageUpload
17
- * @typedef {(context: { signal: AbortSignal }) => Promise<ImageSourceResult | null>} ImageSourceHandler
18
- * @typedef {{ icon?: string, label: string, handler: ImageSourceHandler }} ImageSourceAction
19
- *
20
- * @typedef {Object} ImageConfig
21
- * @property {ImageUpload} [uploadFile] Uploads a browser file. Without this callback the plugin reads the file into a data URL stored in the document.
22
- * @property {ImageSourceAction[]} [actions] Additional application-owned image sources. `icon` is trusted application markup; never pass user-authored HTML.
23
- * @property {boolean} [injectStyles=true] Whether the editor should load the built-in image stylesheet.
24
- * @property {string} [css] Additional stylesheet URL, or the replacement URL when `injectStyles` is `false`.
25
- */
26
-
27
- /**
28
- * Block plugin for images. Public surface implements `BlockPlugin`.
29
- * Internal logic is split across:
30
- * - `state.js` per-block state container (replaces module WeakMap)
31
- * - `uploader.js` file upload pipeline (HTTP or data-URL fallback)
32
- * - `view-empty.js`/`view-filled.js` — DOM rendering for the two states
33
- * - `settings.js` — settings dropdown form
34
- * - `styles.js` — inline style application
35
- *
36
- * @extends {BlockPluginAbstract<ImageConfig>}
37
- */
38
- export class Image extends BlockPluginAbstract {
39
- static isTextBlock = false
40
- static styles = [editorStyles]
41
-
42
- type = 'image'
43
- icon = ICON
44
- inlineTools = false
45
-
46
- pasteConfig = {
47
- files: ['image/*'],
48
- patterns: [/https?:\/\/\S+\.(gif|jpe?g|png|svg|webp)(\?\S*)?$/i],
49
- }
50
-
51
- #uploader
52
- /** @type {ImageConfig} */
53
- #config
54
- /** Per-block state, keyed by wrapper element. Encapsulated to this instance. */
55
- #states = /** @type {WeakMap<HTMLElement, ImageState>} */ (new WeakMap())
56
- /** @type {WeakMap<HTMLElement, import('../../core/types').BlockMutationContext>} */
57
- #contexts = new WeakMap()
58
- /**
59
- * Create an Image instance with the supplied consumer configuration.
60
- * @param {ImageConfig} [config]
61
- */
62
- constructor(config) {
63
- super(config)
64
- this.#config = /** @type {ImageConfig} */ (this.getPluginConfig())
65
- this.#uploader = new ImageUploader(this.#config)
66
- }
67
-
68
- /**
69
- * Return the localized toolbox label for this block.
70
- * @returns {string}
71
- */
72
- get title() {
73
- return this._t('title', 'Image')
74
- }
75
-
76
- // ── BlockPlugin contract ───────────────────────────────────────────────────
77
-
78
- /**
79
- * Create the editable DOM owned by this block instance.
80
- * @param {Record<string, unknown>} data
81
- * @param {import('../../core/types').BlockMutationContext} context
82
- * @returns {HTMLElement}
83
- */
84
- render(data, context) {
85
- const blockData = normalizeImageData(data)
86
- const pendingFile = /** @type {File | null} */ (/** @type {any} */ (data)?._pendingFile || null)
87
-
88
- const wrapper = document.createElement('div')
89
- wrapper.classList.add(CSS.wrapper)
90
- wrapper.contentEditable = 'false'
91
- wrapper.tabIndex = -1
92
-
93
- const state = new ImageState(blockData, pendingFile)
94
- this.#states.set(wrapper, state)
95
- this.#contexts.set(wrapper, context)
96
-
97
- if (blockData.withBorder) wrapper.classList.add(CSS.withBorder)
98
- if (blockData.expanded) wrapper.classList.add(CSS.expanded)
99
- if (blockData.withBackground) wrapper.classList.add(CSS.withBackground)
100
-
101
- if (blockData.file.url) {
102
- this.#renderFilled(wrapper)
103
- } else {
104
- this.#renderEmpty(wrapper)
105
- }
106
-
107
- // Drain pending file from paste (async; renders after upload completes).
108
- if (state.pendingFile && !context.readOnly) {
109
- const file = state.pendingFile
110
- state.pendingFile = null
111
- state.pendingUpload = this.#handleFile(wrapper, file).finally(() => {
112
- if (this.#states.get(wrapper) === state) state.pendingUpload = null
113
- })
114
- }
115
-
116
- return wrapper
117
- }
118
-
119
- /**
120
- * Serialize the current block DOM into document data.
121
- * @param {HTMLElement} element
122
- * @returns {Record<string, unknown>}
123
- */
124
- save(element) {
125
- const state = this.#states.get(element)
126
- if (!state) return emptyImageData()
127
- return { ...state.data, styles: { ...state.data.styles } }
128
- }
129
-
130
- /**
131
- * Check whether serialized data satisfies this block's schema.
132
- * @param {Record<string, unknown>} data
133
- * @returns {boolean}
134
- */
135
- validate(data) {
136
- return validateImageData(data)
137
- }
138
-
139
- /**
140
- * Check whether the block has no meaningful user content.
141
- * @param {HTMLElement} element
142
- * @returns {boolean}
143
- */
144
- isEmpty(element) {
145
- const state = this.#states.get(element)
146
- return !state?.data.file.url
147
- }
148
-
149
- /**
150
- * Extract neutral text that can initialize another block type.
151
- * @param {HTMLElement} element
152
- * @returns {Record<string, unknown>}
153
- */
154
- exportData(element) {
155
- const state = this.#states.get(element)
156
- return { text: state?.data.caption || '' }
157
- }
158
-
159
- /**
160
- * Handle supported pasted content for this block.
161
- * @param {import('../../types').PasteEvent} event
162
- * @returns {Record<string, unknown> | null}
163
- */
164
- onPaste(event) {
165
- if (event.type === 'file') {
166
- // Pass the file through the _pendingFile marker; render() will pick it up.
167
- const data = /** @type {any} */ (emptyImageData())
168
- data._pendingFile = event.file
169
- return data
170
- }
171
- if (event.type === 'pattern') {
172
- const url = String(event.data)
173
- if (url && /^https?:\/\/.+/i.test(url)) {
174
- return { ...emptyImageData(), file: { url } }
175
- }
176
- }
177
- return null
178
- }
179
-
180
- /**
181
- * Keep a file paste inside one undo transaction until its upload finishes.
182
- * @param {HTMLElement} element
183
- * @returns {Promise<void>}
184
- */
185
- waitForPaste(element) {
186
- return this.#states.get(element)?.pendingUpload ?? Promise.resolve()
187
- }
188
-
189
- /**
190
- * Release listeners and resources owned by this block element.
191
- * @param {HTMLElement} element
192
- * @returns {void}
193
- */
194
- destroy(element) {
195
- const state = this.#states.get(element)
196
- if (!state) return
197
- state.dispose()
198
- this.#states.delete(element)
199
- this.#contexts.delete(element)
200
- }
201
-
202
- // ── Internal coordination ──────────────────────────────────────────────────
203
-
204
- /**
205
- * Resolve a localized image-plugin message.
206
- * @param {string} key Translation key scoped to the image plugin.
207
- * @param {string} fallback Message returned when the key is unavailable.
208
- * @returns {string}
209
- */
210
- #t = (key, fallback) => this._t(key, fallback)
211
-
212
- /**
213
- * Execute one completed block mutation through the editor command context.
214
- * @param {HTMLElement} wrapper Image block wrapper that owns the context.
215
- * @param {() => void} operation Synchronous DOM and state mutation.
216
- * @returns {void}
217
- */
218
- #mutate = (wrapper, operation) => {
219
- this.#contexts.get(wrapper)?.mutate(operation)
220
- }
221
-
222
- /** @param {HTMLElement} wrapper @returns {void} */
223
- #renderEmpty(wrapper) {
224
- const state = this.#states.get(wrapper)
225
- if (!state) return
226
- renderEmptyView(wrapper, state, {
227
- t: this.#t,
228
- readOnly: Boolean(this.#contexts.get(wrapper)?.readOnly),
229
- onUploadClick: () => this.#triggerFileInput(wrapper),
230
- onFileDropped: (file) => { void this.#handleFile(wrapper, file) },
231
- customActions: this.#config.actions || [],
232
- runCustomAction: async (handler) => this.#runCustomAction(wrapper, handler),
233
- })
234
- }
235
-
236
- /** @param {HTMLElement} wrapper @returns {void} */
237
- #renderFilled(wrapper) {
238
- const state = this.#states.get(wrapper)
239
- if (!state) return
240
- renderFilledView(wrapper, state, {
241
- t: this.#t,
242
- readOnly: Boolean(this.#contexts.get(wrapper)?.readOnly),
243
- mutate: (operation) => this.#mutate(wrapper, operation),
244
- customActions: this.#config.actions || [],
245
- onTriggerFileInput: () => this.#triggerFileInput(wrapper),
246
- onPromptUrl: () => this.#promptUrl(wrapper),
247
- onDelete: () => this.#deleteImage(wrapper),
248
- runCustomAction: async (handler) => this.#runCustomAction(wrapper, handler),
249
- })
250
- }
251
-
252
- /** @param {HTMLElement} wrapper @returns {void} */
253
- #triggerFileInput(wrapper) {
254
- if (this.#contexts.get(wrapper)?.readOnly) return
255
- triggerFileInput({
256
- accept: 'image/*',
257
- signal: this.#states.get(wrapper)?.abortController?.signal,
258
- onFiles: (files) => {
259
- if (files[0]) void this.#handleFile(wrapper, files[0])
260
- },
261
- })
262
- }
263
-
264
- /**
265
- * @param {HTMLElement} wrapper
266
- * @param {File} file
267
- * @returns {Promise<void>}
268
- */
269
- async #handleFile(wrapper, file) {
270
- const state = this.#states.get(wrapper)
271
- const context = this.#contexts.get(wrapper)
272
- if (!state || !context || context.readOnly || !isSupportedImageFile(file)) return
273
- const controller = state.beginTask()
274
- wrapper.classList.add(CSS.loading)
275
- try {
276
- await this.#uploader.handle(file, (result) => {
277
- if (controller.signal.aborted || this.#states.get(wrapper) !== state) return
278
- this.#mutate(wrapper, () => {
279
- state.data.file = { url: result.url }
280
- if (result.alt && !state.data.caption) state.data.caption = result.alt
281
- this.#renderFilled(wrapper)
282
- })
283
- }, controller.signal)
284
- } finally {
285
- if (state.finishTask(controller)) wrapper.classList.remove(CSS.loading)
286
- }
287
- }
288
-
289
- /**
290
- * @param {HTMLElement} wrapper
291
- * @param {(context: { signal: AbortSignal }) => Promise<{url: string, alt?: string} | null>} handler
292
- * @returns {Promise<void>}
293
- */
294
- async #runCustomAction(wrapper, handler) {
295
- const state = this.#states.get(wrapper)
296
- if (!state || this.#contexts.get(wrapper)?.readOnly) return
297
- const controller = state.beginTask()
298
- wrapper.classList.add(CSS.loading)
299
- try {
300
- const result = await handler({ signal: controller.signal })
301
- const url = sanitizeMediaUrl(result?.url || '')
302
- if (!controller.signal.aborted && this.#states.get(wrapper) === state && url) {
303
- this.#mutate(wrapper, () => {
304
- state.data.file = { url }
305
- if (typeof result?.alt === 'string' && result.alt && !state.data.caption) {
306
- state.data.caption = result.alt
307
- }
308
- this.#renderFilled(wrapper)
309
- })
310
- }
311
- } catch {
312
- // Action was canceled or failed.
313
- } finally {
314
- if (state.finishTask(controller)) wrapper.classList.remove(CSS.loading)
315
- }
316
- }
317
-
318
- /** @param {HTMLElement} wrapper @returns {void} */
319
- #promptUrl(wrapper) {
320
- const state = this.#states.get(wrapper)
321
- if (!state || this.#contexts.get(wrapper)?.readOnly) return
322
- const url = sanitizeMediaUrl(prompt(this.#t('urlPrompt', 'Image URL:')) || '')
323
- if (url) {
324
- state.cancelTask()
325
- wrapper.classList.remove(CSS.loading)
326
- this.#mutate(wrapper, () => {
327
- state.data.file = { url }
328
- this.#renderFilled(wrapper)
329
- })
330
- }
331
- }
332
-
333
- /** @param {HTMLElement} wrapper @returns {void} */
334
- #deleteImage(wrapper) {
335
- const state = this.#states.get(wrapper)
336
- if (!state) return
337
- state.cancelTask()
338
- wrapper.classList.remove(CSS.loading)
339
- this.#mutate(wrapper, () => {
340
- state.data.file = { url: '' }
341
- state.data.styles = {}
342
- this.#renderEmpty(wrapper)
343
- })
344
- }
345
- }
1
+ import { isSupportedImageFile, triggerFileInput } from '../shared/fileInput.js'
2
+ import { BlockPluginAbstract } from '../BlockPluginAbstract.js'
3
+ import { validateImageData } from '../../shared/blockDataValidators.js'
4
+ import { CSS } from './css.js'
5
+ import { ICON } from './icons.js'
6
+ import { ImageState, normalizeImageData, emptyImageData } from './state.js'
7
+ import { ImageUploader } from './uploader.js'
8
+ import { renderEmptyView } from './view-empty.js'
9
+ import { renderFilledView } from './view-filled.js'
10
+ import { sanitizeMediaUrl } from '../../shared/sanitize/index.js'
11
+ import { openSourceEditor, preloadSourceEditor } from '../shared/sourceEditor.js'
12
+
13
+ const editorStyles = new URL('./image.css', import.meta.url).href
14
+ const sourceEditorStyles = new URL('../shared/sourceEditor.css', import.meta.url).href
15
+
16
+ /**
17
+ * @typedef {{ url: string, alt?: string }} ImageSourceResult
18
+ * @typedef {(file: File, context: { signal: AbortSignal }) => Promise<ImageSourceResult>} ImageUpload
19
+ * @typedef {(context: { signal: AbortSignal }) => Promise<ImageSourceResult | null>} ImageSourceHandler
20
+ * @typedef {{ icon?: string, label: string, handler: ImageSourceHandler }} ImageSourceAction
21
+ *
22
+ * @typedef {Object} ImageConfig
23
+ * @property {ImageUpload} [uploadFile] Uploads a browser file. Without this callback the plugin reads the file into a data URL stored in the document.
24
+ * @property {ImageSourceAction[]} [actions] Additional application-owned image sources. `icon` is trusted application markup; never pass user-authored HTML.
25
+ * @property {boolean} [injectStyles=true] Whether the editor should load the built-in image stylesheet.
26
+ * @property {string} [css] Additional stylesheet URL, or the replacement URL when `injectStyles` is `false`.
27
+ */
28
+
29
+ /**
30
+ * Block plugin for images. Public surface implements `BlockPlugin`.
31
+ * Internal logic is split across:
32
+ * - `state.js` — per-block state container (replaces module WeakMap)
33
+ * - `uploader.js` — file upload pipeline (HTTP or data-URL fallback)
34
+ * - `view-empty.js`/`view-filled.js` — DOM rendering for the two states
35
+ * - `settings.js` — settings dropdown form
36
+ * - `styles.js` — inline style application
37
+ *
38
+ * @extends {BlockPluginAbstract<ImageConfig>}
39
+ */
40
+ export class Image extends BlockPluginAbstract {
41
+ static isTextBlock = false
42
+ static styles = [editorStyles, sourceEditorStyles]
43
+
44
+ type = 'image'
45
+ icon = ICON
46
+ inlineTools = false
47
+
48
+ pasteConfig = {
49
+ files: ['image/*'],
50
+ patterns: [/https?:\/\/\S+\.(gif|jpe?g|png|svg|webp)(\?\S*)?$/i],
51
+ }
52
+
53
+ #uploader
54
+ /** @type {ImageConfig} */
55
+ #config
56
+ /** Per-block state, keyed by wrapper element. Encapsulated to this instance. */
57
+ #states = /** @type {WeakMap<HTMLElement, ImageState>} */ (new WeakMap())
58
+ /** @type {WeakMap<HTMLElement, import('../../core/types').BlockMutationContext>} */
59
+ #contexts = new WeakMap()
60
+ /**
61
+ * Create an Image instance with the supplied consumer configuration.
62
+ * @param {ImageConfig} [config]
63
+ */
64
+ constructor(config) {
65
+ super(config)
66
+ this.#config = /** @type {ImageConfig} */ (this.getPluginConfig())
67
+ this.#uploader = new ImageUploader(this.#config)
68
+ }
69
+
70
+ /**
71
+ * Return the localized toolbox label for this block.
72
+ * @returns {string}
73
+ */
74
+ get title() {
75
+ return this._t('title', 'Image')
76
+ }
77
+
78
+ // ── BlockPlugin contract ───────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Create the editable DOM owned by this block instance.
82
+ * @param {Record<string, unknown>} data
83
+ * @param {import('../../core/types').BlockMutationContext} context
84
+ * @returns {HTMLElement}
85
+ */
86
+ render(data, context) {
87
+ const blockData = normalizeImageData(data)
88
+ const pendingFile = /** @type {File | null} */ (/** @type {any} */ (data)?._pendingFile || null)
89
+
90
+ const wrapper = document.createElement('div')
91
+ wrapper.classList.add(CSS.wrapper)
92
+ wrapper.contentEditable = 'false'
93
+ wrapper.tabIndex = -1
94
+
95
+ const state = new ImageState(blockData, pendingFile)
96
+ this.#states.set(wrapper, state)
97
+ this.#contexts.set(wrapper, context)
98
+
99
+ if (blockData.withBorder) wrapper.classList.add(CSS.withBorder)
100
+ if (blockData.expanded) wrapper.classList.add(CSS.expanded)
101
+ if (blockData.withBackground) wrapper.classList.add(CSS.withBackground)
102
+
103
+ if (blockData.file.url) {
104
+ this.#renderFilled(wrapper)
105
+ } else {
106
+ this.#renderEmpty(wrapper)
107
+ }
108
+
109
+ // Drain pending file from paste (async; renders after upload completes).
110
+ if (state.pendingFile && !context.readOnly) {
111
+ const file = state.pendingFile
112
+ state.pendingFile = null
113
+ state.pendingUpload = this.#handleFile(wrapper, file).finally(() => {
114
+ if (this.#states.get(wrapper) === state) state.pendingUpload = null
115
+ })
116
+ }
117
+
118
+ return wrapper
119
+ }
120
+
121
+ /**
122
+ * Serialize the current block DOM into document data.
123
+ * @param {HTMLElement} element
124
+ * @returns {Record<string, unknown>}
125
+ */
126
+ save(element) {
127
+ const state = this.#states.get(element)
128
+ if (!state) return emptyImageData()
129
+ return { ...state.data, styles: { ...state.data.styles } }
130
+ }
131
+
132
+ /**
133
+ * Check whether serialized data satisfies this block's schema.
134
+ * @param {Record<string, unknown>} data
135
+ * @returns {boolean}
136
+ */
137
+ validate(data) {
138
+ return validateImageData(data)
139
+ }
140
+
141
+ /**
142
+ * Check whether the block has no meaningful user content.
143
+ * @param {HTMLElement} element
144
+ * @returns {boolean}
145
+ */
146
+ isEmpty(element) {
147
+ const state = this.#states.get(element)
148
+ return !state?.data.file.url
149
+ }
150
+
151
+ /**
152
+ * Extract neutral text that can initialize another block type.
153
+ * @param {HTMLElement} element
154
+ * @returns {Record<string, unknown>}
155
+ */
156
+ exportData(element) {
157
+ const state = this.#states.get(element)
158
+ return { text: state?.data.caption || '' }
159
+ }
160
+
161
+ /**
162
+ * Handle supported pasted content for this block.
163
+ * @param {import('../../types').PasteEvent} event
164
+ * @returns {Record<string, unknown> | null}
165
+ */
166
+ onPaste(event) {
167
+ if (event.type === 'file') {
168
+ // Pass the file through the _pendingFile marker; render() will pick it up.
169
+ const data = /** @type {any} */ (emptyImageData())
170
+ data._pendingFile = event.file
171
+ return data
172
+ }
173
+ if (event.type === 'pattern') {
174
+ const url = String(event.data)
175
+ if (url && /^https?:\/\/.+/i.test(url)) {
176
+ return { ...emptyImageData(), file: { url } }
177
+ }
178
+ }
179
+ return null
180
+ }
181
+
182
+ /**
183
+ * Keep a file paste inside one undo transaction until its upload finishes.
184
+ * @param {HTMLElement} element
185
+ * @returns {Promise<void>}
186
+ */
187
+ waitForPaste(element) {
188
+ return this.#states.get(element)?.pendingUpload ?? Promise.resolve()
189
+ }
190
+
191
+ /**
192
+ * Release listeners and resources owned by this block element.
193
+ * @param {HTMLElement} element
194
+ * @returns {void}
195
+ */
196
+ destroy(element) {
197
+ const state = this.#states.get(element)
198
+ if (!state) return
199
+ state.dispose()
200
+ this.#states.delete(element)
201
+ this.#contexts.delete(element)
202
+ }
203
+
204
+ // ── Internal coordination ──────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Resolve a localized image-plugin message.
208
+ * @param {string} key Translation key scoped to the image plugin.
209
+ * @param {string} fallback Message returned when the key is unavailable.
210
+ * @returns {string}
211
+ */
212
+ #t = (key, fallback) => this._t(key, fallback)
213
+
214
+ /**
215
+ * Execute one completed block mutation through the editor command context.
216
+ * @param {HTMLElement} wrapper Image block wrapper that owns the context.
217
+ * @param {() => void} operation Synchronous DOM and state mutation.
218
+ * @returns {void}
219
+ */
220
+ #mutate = (wrapper, operation) => {
221
+ this.#contexts.get(wrapper)?.mutate(operation)
222
+ }
223
+
224
+ /** @param {HTMLElement} wrapper @returns {void} */
225
+ #renderEmpty(wrapper) {
226
+ const state = this.#states.get(wrapper)
227
+ if (!state) return
228
+ const readOnly = Boolean(this.#contexts.get(wrapper)?.readOnly)
229
+ renderEmptyView(wrapper, state, {
230
+ t: this.#t,
231
+ readOnly,
232
+ onUploadClick: () => this.#triggerFileInput(wrapper),
233
+ onOpenUrlEditor: () => this.#openUrlEditor(wrapper),
234
+ onFileDropped: (file) => { void this.#handleFile(wrapper, file) },
235
+ customActions: this.#config.actions || [],
236
+ runCustomAction: async (handler) => this.#runCustomAction(wrapper, handler),
237
+ })
238
+ if (!readOnly) preloadSourceEditor(wrapper, state.abortController.signal, ['url'])
239
+ }
240
+
241
+ /** @param {HTMLElement} wrapper @returns {void} */
242
+ #renderFilled(wrapper) {
243
+ const state = this.#states.get(wrapper)
244
+ if (!state) return
245
+ const readOnly = Boolean(this.#contexts.get(wrapper)?.readOnly)
246
+ renderFilledView(wrapper, state, {
247
+ t: this.#t,
248
+ readOnly,
249
+ mutate: (operation) => this.#mutate(wrapper, operation),
250
+ customActions: this.#config.actions || [],
251
+ onTriggerFileInput: () => this.#triggerFileInput(wrapper),
252
+ onOpenUrlEditor: () => this.#openUrlEditor(wrapper),
253
+ onDelete: () => this.#deleteImage(wrapper),
254
+ runCustomAction: async (handler) => this.#runCustomAction(wrapper, handler),
255
+ })
256
+ if (!readOnly) preloadSourceEditor(wrapper, state.abortController.signal, ['url'])
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
+ signal: this.#states.get(wrapper)?.abortController?.signal,
265
+ onFiles: (files) => {
266
+ if (files[0]) void this.#handleFile(wrapper, files[0])
267
+ },
268
+ })
269
+ }
270
+
271
+ /**
272
+ * @param {HTMLElement} wrapper
273
+ * @param {File} file
274
+ * @returns {Promise<void>}
275
+ */
276
+ async #handleFile(wrapper, file) {
277
+ const state = this.#states.get(wrapper)
278
+ const context = this.#contexts.get(wrapper)
279
+ if (!state || !context || context.readOnly || !isSupportedImageFile(file)) return
280
+ const controller = state.beginTask()
281
+ wrapper.classList.add(CSS.loading)
282
+ try {
283
+ await this.#uploader.handle(file, (result) => {
284
+ if (controller.signal.aborted || this.#states.get(wrapper) !== state) return
285
+ this.#mutate(wrapper, () => {
286
+ state.data.file = { url: result.url }
287
+ if (result.alt && !state.data.caption) state.data.caption = result.alt
288
+ this.#renderFilled(wrapper)
289
+ })
290
+ }, controller.signal)
291
+ } finally {
292
+ if (state.finishTask(controller)) wrapper.classList.remove(CSS.loading)
293
+ }
294
+ }
295
+
296
+ /**
297
+ * @param {HTMLElement} wrapper
298
+ * @param {(context: { signal: AbortSignal }) => Promise<{url: string, alt?: string} | null>} handler
299
+ * @returns {Promise<void>}
300
+ */
301
+ async #runCustomAction(wrapper, handler) {
302
+ const state = this.#states.get(wrapper)
303
+ if (!state || this.#contexts.get(wrapper)?.readOnly) return
304
+ const controller = state.beginTask()
305
+ wrapper.classList.add(CSS.loading)
306
+ try {
307
+ const result = await handler({ signal: controller.signal })
308
+ const url = sanitizeMediaUrl(result?.url || '')
309
+ if (!controller.signal.aborted && this.#states.get(wrapper) === state && url) {
310
+ this.#mutate(wrapper, () => {
311
+ state.data.file = { url }
312
+ if (typeof result?.alt === 'string' && result.alt && !state.data.caption) {
313
+ state.data.caption = result.alt
314
+ }
315
+ this.#renderFilled(wrapper)
316
+ })
317
+ }
318
+ } catch {
319
+ // Action was canceled or failed.
320
+ } finally {
321
+ if (state.finishTask(controller)) wrapper.classList.remove(CSS.loading)
322
+ }
323
+ }
324
+
325
+ /** @param {HTMLElement} wrapper @returns {void} */
326
+ #openUrlEditor(wrapper) {
327
+ const state = this.#states.get(wrapper)
328
+ if (!state || this.#contexts.get(wrapper)?.readOnly) return
329
+ openSourceEditor({
330
+ wrapper,
331
+ signal: state.abortController.signal,
332
+ kind: 'url',
333
+ title: this.#t('urlEditorTitle', 'Insert image by URL'),
334
+ label: this.#t('urlEditorLabel', 'Image URL'),
335
+ placeholder: this.#t('urlEditorPlaceholder', 'https://example.com/image.jpg'),
336
+ submitText: this.#t('sourceSubmit', 'Insert'),
337
+ cancelText: this.#t('sourceCancel', 'Cancel'),
338
+ invalidText: this.#t('invalidUrl', 'Enter a valid image URL.'),
339
+ normalize: sanitizeMediaUrl,
340
+ onSubmit: (url) => {
341
+ const current = this.#states.get(wrapper)
342
+ if (current !== state || this.#contexts.get(wrapper)?.readOnly) return
343
+ state.cancelTask()
344
+ wrapper.classList.remove(CSS.loading)
345
+ this.#mutate(wrapper, () => {
346
+ state.data.file = { url }
347
+ this.#renderFilled(wrapper)
348
+ })
349
+ },
350
+ })
351
+ }
352
+
353
+ /** @param {HTMLElement} wrapper @returns {void} */
354
+ #deleteImage(wrapper) {
355
+ const state = this.#states.get(wrapper)
356
+ if (!state) return
357
+ state.cancelTask()
358
+ wrapper.classList.remove(CSS.loading)
359
+ this.#mutate(wrapper, () => {
360
+ state.data.file = { url: '' }
361
+ state.data.styles = {}
362
+ this.#renderEmpty(wrapper)
363
+ })
364
+ }
365
+ }