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