@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,921 +1,997 @@
1
- import { resolvePath } from '../../shared/resolvePath.js'
2
- import { triggerFileInput } from '../shared/fileInput.js'
3
- import { BlockPluginAbstract } from '../BlockPluginAbstract.js'
4
- import { getFileIcon, getExtension, formatSize, EXT_COLORS } from '../../shared/fileUtils.js'
5
- import { validateAttachesData } from '../../shared/blockDataValidators.js'
6
- import { sanitizeUrl } from '../../shared/sanitize/sanitizeUrl.js'
7
- import { normalizeTextValue } from '../../shared/textFormat.js'
8
-
9
- const editorStyles = resolvePath('./attaches.css', import.meta.url)
10
-
11
- const ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"/><path d="M12 11v6"/><path d="M9.5 13.5l2.5-2.5l2.5 2.5"/></svg>'
12
- const ICON_SELECT = '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"/><path d="M12 11v6"/><path d="M9.5 13.5l2.5-2.5l2.5 2.5"/></svg>'
13
- const ICON_UPLOAD = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><path d="M7 9l5-5l5 5"/><path d="M12 4v12"/></svg>'
14
- const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7h16"/><path stroke-linecap="round" stroke-linejoin="round" d="M10 11v6"/><path stroke-linecap="round" stroke-linejoin="round" d="M14 11v6"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12"/><path stroke-linecap="round" stroke-linejoin="round" d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/></svg>'
15
- const ICON_CHEVRON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
16
- const ICON_SETTINGS = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>'
17
- const ICON_FILE_DEFAULT = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/></svg>'
18
- const ICON_FILE_SM = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/></svg>'
19
-
20
- /** @type {string[]} */
21
- const VARIANTS = ['a', 'b', 'f', 'g']
22
-
23
- /** @type {Record<string, { icon: string, label: string }>} */
24
- const VARIANT_META = {
25
- a: { icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"/></svg>', label: 'variantA' },
26
- b: { icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>', label: 'variantB' },
27
- f: { icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18"/><path d="M3 6h18"/><path d="M3 18h18"/></svg>', label: 'variantF' },
28
- g: { icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="3"/></svg>', label: 'variantG' },
29
- }
30
-
31
- /**
32
- * @typedef {{ url: string, name: string, size: number, extension: string }} FileEntry
33
- * @typedef {Object} AttachesAction
34
- * @property {string} label Plain-text label shown on the source button.
35
- * @property {string} [icon] Trusted application-owned icon markup. Never pass user-authored HTML.
36
- * @property {(context: { signal: AbortSignal }) => Promise<Array<{ url: string, name: string, size?: number, extension?: string }> | null>} handler Opens an application-owned source and returns selected files, or `null` when selection is cancelled.
37
- * @typedef {Object} AttachesConfig
38
- * @property {(file: File, context: { signal: AbortSignal }) => Promise<{ url: string, size?: number }>} [uploadFile] Uploads a browser file. Without this callback the plugin creates a temporary object URL valid until the editor is destroyed.
39
- * @property {AttachesAction[]} [actions] Additional file sources such as a media library.
40
- * @property {boolean} [injectStyles=true] Whether the editor should load the built-in plugin stylesheet.
41
- * @property {string} [css] Additional or replacement stylesheet URL, depending on `injectStyles`.
42
- * @typedef {{
43
- * data: { files: FileEntry[], variant: string },
44
- * viewController: AbortController | null,
45
- * taskControllers: Set<AbortController>,
46
- * expanded: boolean,
47
- * context: import('../../core/types').BlockMutationContext,
48
- * }} AttachesState
49
- */
50
-
51
- /** @type {WeakMap<HTMLElement, AttachesState>} */
52
- const stateMap = new WeakMap()
53
- let groupSequence = 0
54
-
55
-
56
- /**
57
- * File attachment block supporting direct uploads and consumer-defined file
58
- * source actions such as a media library.
59
- * @extends {BlockPluginAbstract<AttachesConfig>}
60
- */
61
- export class Attaches extends BlockPluginAbstract {
62
- static isTextBlock = false
63
- static styles = [editorStyles]
64
- type = 'attaches'
65
- icon = ICON
66
- inlineTools = false
67
-
68
-
69
- #objectUrls = new Set()
70
- /**
71
- * Create an Attaches instance with the supplied consumer configuration.
72
- * @param {AttachesConfig} [config]
73
- */
74
- constructor(config) {
75
- super(config)
76
- }
77
-
78
- /** @returns {AttachesConfig} Consumer configuration for this plugin instance. */
79
- get #config() {
80
- return /** @type {AttachesConfig} */ (this._config)
81
- }
82
-
83
- /**
84
- * Return the localized toolbox label for this block.
85
- * @returns {string} Localized toolbox title.
86
- */
87
- get title() { return this._t('title', 'File') }
88
-
89
- /**
90
- * Create the editable DOM owned by this block instance.
91
- * @param {Record<string, unknown>} data
92
- * @param {import('../../core/types').BlockMutationContext} context
93
- * @returns {HTMLElement}
94
- */
95
- render(data, context) {
96
- const normalizeSize = (value) => {
97
- const size = Number(value)
98
- return Number.isFinite(size) && size >= 0 ? size : 0
99
- }
100
- let files
101
- if (data?.file && !data?.files) {
102
- const f = /** @type {any} */ (data.file)
103
- const url = sanitizeUrl(normalizeTextValue(f.url), { policy: 'download', fallback: '' })
104
- const name = normalizeTextValue(f.name)
105
- files = url ? [{
106
- url,
107
- name,
108
- size: normalizeSize(f.size),
109
- extension: normalizeTextValue(f.extension) || getExtension(name),
110
- }] : []
111
- } else {
112
- const inputFiles = Array.isArray(data?.files) ? data.files : []
113
- files = /** @type {any[]} */ (inputFiles).map(f => ({
114
- url: sanitizeUrl(normalizeTextValue(f?.url), { policy: 'download', fallback: '' }),
115
- name: normalizeTextValue(f?.name),
116
- size: normalizeSize(f?.size),
117
- extension: normalizeTextValue(f?.extension) || getExtension(normalizeTextValue(f?.name)),
118
- })).filter(f => f.url)
119
- }
120
- const requestedVariant = normalizeTextValue(data?.variant) || 'f'
121
- const variant = VARIANTS.includes(requestedVariant) ? requestedVariant : 'f'
122
- const wrapper = document.createElement('div')
123
- wrapper.classList.add('oe-attaches')
124
- wrapper.contentEditable = 'false'
125
- wrapper.tabIndex = -1
126
- stateMap.set(wrapper, {
127
- data: { files, variant },
128
- viewController: null,
129
- taskControllers: new Set(),
130
- expanded: false,
131
- context,
132
- })
133
- if (files.length > 0) this.#renderFilled(wrapper)
134
- else this.#renderSelect(wrapper)
135
- return wrapper
136
- }
137
-
138
- /**
139
- * Serialize the current block DOM into document data.
140
- * @param {HTMLElement} el
141
- * @returns {{ files: FileEntry[], variant: string }}
142
- */
143
- save(el) {
144
- const s = stateMap.get(el)
145
- if (!s) return { files: [], variant: 'f' }
146
- this.#syncNames(el)
147
- return { files: s.data.files.map(f => ({ ...f })), variant: s.data.variant }
148
- }
149
-
150
- /**
151
- * Check whether serialized data satisfies this block's schema.
152
- * @param {Record<string, unknown>} data
153
- * @returns {boolean}
154
- */
155
- validate(data) {
156
- return validateAttachesData(data)
157
- }
158
-
159
- /**
160
- * Check whether the block has no meaningful user content.
161
- * @param {HTMLElement} el
162
- * @returns {boolean}
163
- */
164
- isEmpty(el) {
165
- const s = stateMap.get(el)
166
- return !s || s.data.files.length === 0
167
- }
168
-
169
- /**
170
- * Extract neutral text that can initialize another block type.
171
- * @param {HTMLElement} el
172
- * @returns {{ text: string }}
173
- */
174
- exportData(el) {
175
- const s = stateMap.get(el)
176
- return { text: s ? s.data.files.map(f => f.name).join(', ') : '' }
177
- }
178
-
179
- /**
180
- * Abort work and listeners owned by one rendered block.
181
- * @param {HTMLElement} el
182
- * @returns {void}
183
- */
184
- destroy(el) {
185
- const s = stateMap.get(el)
186
- if (s) {
187
- s.viewController?.abort()
188
- for (const controller of s.taskControllers) controller.abort()
189
- s.taskControllers.clear()
190
- stateMap.delete(el)
191
- }
192
- }
193
-
194
- /**
195
- * Release temporary local URLs after all block snapshots and undo history
196
- * owned by this editor have become unreachable.
197
- * @returns {void}
198
- */
199
- dispose() {
200
- for (const url of this.#objectUrls) URL.revokeObjectURL(url)
201
- this.#objectUrls.clear()
202
- }
203
-
204
- // ── sync editable names ────────────────────────────────────────────────────
205
-
206
- /** @param {HTMLElement} wrapper @returns {void} */
207
- #syncNames(wrapper) {
208
- const s = stateMap.get(wrapper)
209
- if (!s) return
210
- wrapper.querySelectorAll('.oe-attaches__name:not(.oe-attaches__name--static)').forEach((el, i) => {
211
- if (s.data.files[i]) {
212
- const t = String(el.textContent ?? '').trim()
213
- if (t) s.data.files[i].name = t
214
- }
215
- })
216
- }
217
-
218
- // ── dropzone ───────────────────────────────────────────────────────────────
219
-
220
- /** @param {HTMLElement} wrapper @returns {void} */
221
- #renderSelect(wrapper) {
222
- const s = stateMap.get(wrapper)
223
- if (!s) return
224
- wrapper.innerHTML = ''
225
- wrapper.classList.remove('oe-attaches--filled')
226
- s.viewController?.abort()
227
- s.viewController = new AbortController()
228
- const { signal } = s.viewController
229
-
230
- const select = document.createElement('div')
231
- select.className = 'oe-attaches__select'
232
-
233
- if (s.context.readOnly) {
234
- select.textContent = this._t('emptyReadonly', 'No files')
235
- wrapper.appendChild(select)
236
- return
237
- }
238
-
239
- const icon = document.createElement('div')
240
- icon.className = 'oe-attaches__select-icon'
241
- icon.innerHTML = ICON_SELECT
242
-
243
- const text = document.createElement('div')
244
- text.className = 'oe-attaches__select-text'
245
- const link = document.createElement('button')
246
- link.type = 'button'
247
- link.className = 'oe-attaches__select-link'
248
- link.textContent = this._t('dropzoneUpload', 'Upload')
249
- link.addEventListener('click', (e) => { e.stopPropagation(); this.#triggerFileInput(wrapper) }, { signal })
250
- text.append(link, document.createTextNode(' ' + this._t('dropzoneText', 'files from your device or drag and drop them here')))
251
- select.append(icon, text)
252
-
253
- const configuredActions = this.#config.actions ?? []
254
- if (configuredActions.length) {
255
- const sources = document.createElement('div')
256
- sources.className = 'oe-attaches__select-actions'
257
- for (const action of configuredActions) {
258
- const button = document.createElement('button')
259
- button.type = 'button'
260
- button.className = 'oe-attaches__select-action'
261
- if (action.icon) button.insertAdjacentHTML('afterbegin', action.icon)
262
- button.append(document.createTextNode(action.label))
263
- button.addEventListener('click', event => {
264
- event.stopPropagation()
265
- void this.#runCustomAction(wrapper, action.handler)
266
- }, { signal })
267
- sources.appendChild(button)
268
- }
269
- select.appendChild(sources)
270
- }
271
-
272
- wrapper.addEventListener('dragover', (e) => { e.preventDefault(); select.classList.add('oe-attaches__select--dragover') }, { signal })
273
- wrapper.addEventListener('dragleave', () => select.classList.remove('oe-attaches__select--dragover'), { signal })
274
- wrapper.addEventListener('drop', (e) => {
275
- e.preventDefault()
276
- select.classList.remove('oe-attaches__select--dragover')
277
- if (e.dataTransfer?.files?.length) void this.#handleFiles(wrapper, e.dataTransfer.files)
278
- }, { signal })
279
-
280
- wrapper.appendChild(select)
281
- }
282
-
283
- // ── filled state (dispatches by variant) ──────────────────────────────────
284
-
285
- /** @param {HTMLElement} wrapper @returns {void} */
286
- #renderFilled(wrapper) {
287
- const s = stateMap.get(wrapper)
288
- if (!s) return
289
- this.#syncNames(wrapper)
290
- wrapper.innerHTML = ''
291
- wrapper.classList.add('oe-attaches--filled')
292
- s.viewController?.abort()
293
- s.viewController = new AbortController()
294
- const { signal } = s.viewController
295
- const files = s.data.files
296
- const v = s.data.variant || 'a'
297
-
298
- switch (v) {
299
- case 'b':
300
- this.#renderPills(wrapper, files, signal)
301
- break
302
- case 'f':
303
- this.#renderNotion(wrapper, files, signal)
304
- break
305
- case 'g':
306
- this.#renderMaterial(wrapper, files, signal)
307
- break
308
- default: // 'a'
309
- if (files.length === 1) wrapper.appendChild(this.#buildCardA(wrapper, files[0], 0, signal))
310
- else wrapper.appendChild(this.#buildGroupA(wrapper, signal))
311
- break
312
- }
313
-
314
- if (!s.context.readOnly) this.#renderActions(wrapper, signal)
315
- }
316
-
317
- // ── Variant A: Minimal ────────────────────────────────────────────────────
318
-
319
- /**
320
- * @param {HTMLElement} w Block wrapper that owns the file state.
321
- * @param {FileEntry} file File represented by this card.
322
- * @param {number} i File index in the block data.
323
- * @param {AbortSignal} sig Current view lifecycle signal.
324
- * @returns {HTMLDivElement}
325
- */
326
- #buildCardA(w, file, i, sig) {
327
- const card = document.createElement('div')
328
- card.className = 'oe-attaches__card'
329
- card.appendChild(this.#buildIconA(file))
330
- const info = document.createElement('div')
331
- info.className = 'oe-attaches__info'
332
- info.appendChild(this.#buildNameEl(w, i, file, sig))
333
- if (file.size) { const m = document.createElement('div'); m.className = 'oe-attaches__meta'; m.textContent = formatSize(file.size); info.appendChild(m) }
334
- card.append(info, this.#buildRemoveBtn(w, i, sig))
335
- return card
336
- }
337
-
338
- /**
339
- * @param {HTMLElement} w Block wrapper that owns the group.
340
- * @param {AbortSignal} sig Current view lifecycle signal.
341
- * @returns {HTMLDivElement}
342
- */
343
- #buildGroupA(w, sig) {
344
- const s = stateMap.get(w)
345
- if (!s) return document.createElement('div')
346
- const files = s.data.files
347
- const group = document.createElement('div')
348
- group.className = 'oe-attaches__group'
349
-
350
- const header = document.createElement('div')
351
- header.className = 'oe-attaches__group-header'
352
- const iconWrap = document.createElement('div')
353
- iconWrap.className = 'oe-attaches__icon'
354
- iconWrap.innerHTML = ICON_FILE_DEFAULT
355
- const headerInfo = document.createElement('div')
356
- headerInfo.className = 'oe-attaches__info'
357
- const countText = document.createElement('div')
358
- countText.className = 'oe-attaches__name oe-attaches__name--static'
359
- countText.textContent = `${files.length} ${this._p('filesCount', files.length, 'files')}`
360
- headerInfo.appendChild(countText)
361
- const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0)
362
- if (totalSize > 0) { const m = document.createElement('span'); m.className = 'oe-attaches__meta'; m.textContent = formatSize(totalSize); headerInfo.appendChild(m) }
363
- const chevron = document.createElement('button')
364
- chevron.type = 'button'
365
- chevron.className = 'oe-attaches__chevron'
366
- chevron.innerHTML = ICON_CHEVRON
367
- chevron.setAttribute('aria-label', this._t('toggleGroup', 'Show or hide files'))
368
- chevron.setAttribute('aria-expanded', String(s.expanded))
369
- header.append(iconWrap, headerInfo, chevron)
370
-
371
- const body = document.createElement('div')
372
- body.className = 'oe-attaches__group-body'
373
- body.id = `rector-editor-attaches-group-${++groupSequence}`
374
- chevron.setAttribute('aria-controls', body.id)
375
- if (s.expanded) { body.classList.add('oe-attaches__group-body--open'); chevron.classList.add('oe-attaches__chevron--open') }
376
- for (let i = 0; i < files.length; i++) {
377
- const row = document.createElement('div')
378
- row.className = 'oe-attaches__row'
379
- row.append(this.#buildNameEl(w, i, files[i], sig))
380
- if (files[i].size) { const sz = document.createElement('span'); sz.className = 'oe-attaches__row-size'; sz.textContent = formatSize(files[i].size); row.appendChild(sz) }
381
- row.appendChild(this.#buildRemoveBtn(w, i, sig))
382
- body.appendChild(row)
383
- }
384
- const toggle = () => {
385
- const st = stateMap.get(w)
386
- if (!st) return
387
- st.expanded = !st.expanded
388
- body.classList.toggle('oe-attaches__group-body--open', st.expanded)
389
- chevron.classList.toggle('oe-attaches__chevron--open', st.expanded)
390
- chevron.setAttribute('aria-expanded', String(st.expanded))
391
- }
392
- header.addEventListener('click', (event) => {
393
- const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null)
394
- if (target?.closest('button, [contenteditable="true"]')) return
395
- toggle()
396
- }, { signal: sig })
397
- chevron.addEventListener('click', (event) => {
398
- event.stopPropagation()
399
- toggle()
400
- }, { signal: sig })
401
- group.append(header, body)
402
- return group
403
- }
404
-
405
- /** Variant A icon: always default file SVG + extension badge. @param {FileEntry} file @returns {HTMLDivElement} */
406
- #buildIconA(file) {
407
- const wrap = document.createElement('div')
408
- wrap.className = 'oe-attaches__icon'
409
- wrap.innerHTML = ICON_FILE_DEFAULT
410
- if (file.extension) {
411
- const badge = document.createElement('span')
412
- badge.className = 'oe-attaches__ext'
413
- badge.textContent = file.extension.toUpperCase()
414
- const color = EXT_COLORS[file.extension.toLowerCase()]
415
- if (color) badge.style.backgroundColor = color
416
- wrap.appendChild(badge)
417
- }
418
- return wrap
419
- }
420
-
421
- // ── Variant B: Pill ────────────────────────────────────────────────────────
422
-
423
- /**
424
- * @param {HTMLElement} w Block wrapper that receives the pill list.
425
- * @param {FileEntry[]} files Files to render.
426
- * @param {AbortSignal} sig Current view lifecycle signal.
427
- * @returns {void}
428
- */
429
- #renderPills(w, files, sig) {
430
- const container = document.createElement('div')
431
- container.className = 'oe-attaches__pills'
432
- for (let i = 0; i < files.length; i++) {
433
- const pill = document.createElement('div')
434
- pill.className = 'oe-attaches__pill'
435
- const ic = document.createElement('div')
436
- ic.className = 'oe-attaches__pill-icon'
437
- ic.innerHTML = ICON_FILE_SM
438
- pill.appendChild(ic)
439
- pill.appendChild(this.#buildNameEl(w, i, files[i], sig))
440
- if (files[i].size) { const sz = document.createElement('span'); sz.className = 'oe-attaches__pill-size'; sz.textContent = formatSize(files[i].size); pill.appendChild(sz) }
441
- pill.appendChild(this.#buildRemoveBtn(w, i, sig))
442
- container.appendChild(pill)
443
- }
444
- w.appendChild(container)
445
- }
446
-
447
- // ── Variant F: Notion ──────────────────────────────────────────────────────
448
-
449
- /**
450
- * @param {HTMLElement} w Block wrapper that receives the table-like list.
451
- * @param {FileEntry[]} files Files to render.
452
- * @param {AbortSignal} sig Current view lifecycle signal.
453
- * @returns {void}
454
- */
455
- #renderNotion(w, files, sig) {
456
- const table = document.createElement('div')
457
- table.className = 'oe-attaches__notion'
458
- for (let i = 0; i < files.length; i++) {
459
- const row = document.createElement('div')
460
- row.className = 'oe-attaches__notion-row'
461
- row.appendChild(this.#buildNameEl(w, i, files[i], sig))
462
- const ext = (files[i].extension || '').toUpperCase()
463
- if (ext) {
464
- const tag = document.createElement('span')
465
- tag.className = 'oe-attaches__notion-tag'
466
- tag.textContent = ext
467
- const color = EXT_COLORS[files[i].extension?.toLowerCase()] || null
468
- if (color) { tag.style.backgroundColor = `${color}20`; tag.style.color = color }
469
- row.appendChild(tag)
470
- }
471
- if (files[i].size) { const sz = document.createElement('span'); sz.className = 'oe-attaches__notion-size'; sz.textContent = formatSize(files[i].size); row.appendChild(sz) }
472
- row.appendChild(this.#buildRemoveBtn(w, i, sig))
473
- table.appendChild(row)
474
- }
475
- w.appendChild(table)
476
- }
477
-
478
- // ── Variant G: Material ────────────────────────────────────────────────────
479
-
480
- /**
481
- * @param {HTMLElement} w Block wrapper that receives the material cards.
482
- * @param {FileEntry[]} files Files to render.
483
- * @param {AbortSignal} sig Current view lifecycle signal.
484
- * @returns {void}
485
- */
486
- #renderMaterial(w, files, sig) {
487
- const stack = document.createElement('div')
488
- stack.className = 'oe-attaches__material'
489
- for (let i = 0; i < files.length; i++) {
490
- const card = document.createElement('div')
491
- card.className = 'oe-attaches__material-card'
492
- card.appendChild(this.#buildIconG(files[i]))
493
- const info = document.createElement('div')
494
- info.className = 'oe-attaches__info'
495
- info.appendChild(this.#buildNameEl(w, i, files[i], sig))
496
- if (files[i].size) { const m = document.createElement('div'); m.className = 'oe-attaches__meta'; m.textContent = formatSize(files[i].size); info.appendChild(m) }
497
- card.append(info, this.#buildRemoveBtn(w, i, sig))
498
- stack.appendChild(card)
499
- }
500
- w.appendChild(stack)
501
- }
502
-
503
- /** Variant G icon: custom type-specific icon, no badge. @param {FileEntry} file @returns {HTMLDivElement} */
504
- #buildIconG(file) {
505
- const wrap = document.createElement('div')
506
- wrap.className = 'oe-attaches__material-icon'
507
- const { svg } = getFileIcon(file.extension)
508
- wrap.innerHTML = svg
509
- return wrap
510
- }
511
-
512
- // ── shared builders ────────────────────────────────────────────────────────
513
-
514
- /**
515
- * @param {HTMLElement} wrapper
516
- * @param {number} index
517
- * @param {FileEntry} file
518
- * @param {AbortSignal} signal
519
- * @returns {HTMLDivElement}
520
- */
521
- #buildNameEl(wrapper, index, file, signal) {
522
- const state = stateMap.get(wrapper)
523
- const editable = !state?.context.readOnly
524
- const el = document.createElement('div')
525
- el.className = `oe-attaches__name${editable ? '' : ' oe-attaches__name--static'}`
526
- el.contentEditable = String(editable)
527
- const fallbackName = this._t('untitled', 'File')
528
- el.textContent = file.name || fallbackName
529
- if (!editable) return el
530
-
531
- el.addEventListener('keydown', (e) => {
532
- if (e.key === 'Enter') { e.preventDefault(); el.blur() }
533
- if (!e.ctrlKey && !e.metaKey) e.stopPropagation()
534
- }, { signal })
535
- el.addEventListener('blur', () => {
536
- const s = stateMap.get(wrapper)
537
- if (s?.data.files[index]) s.data.files[index].name = String(el.textContent ?? '').trim() || fallbackName
538
- }, { signal })
539
- return el
540
- }
541
-
542
- /**
543
- * @param {HTMLElement} wrapper
544
- * @param {number} index
545
- * @param {AbortSignal} signal
546
- * @returns {HTMLButtonElement}
547
- */
548
- #buildRemoveBtn(wrapper, index, signal) {
549
- const btn = document.createElement('button')
550
- btn.type = 'button'
551
- btn.className = 'oe-attaches__remove'
552
- btn.innerHTML = '&times;'
553
- btn.setAttribute('aria-label', this._t('delete', 'Delete'))
554
- const state = stateMap.get(wrapper)
555
- if (state?.context.readOnly) {
556
- btn.hidden = true
557
- btn.disabled = true
558
- return btn
559
- }
560
- btn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
561
- btn.addEventListener('click', (e) => {
562
- e.stopPropagation()
563
- const st = stateMap.get(wrapper)
564
- if (!st) return
565
- st.context.mutate(() => {
566
- st.data.files.splice(index, 1)
567
- if (st.data.files.length > 0) {
568
- this.#renderFilled(wrapper)
569
- } else {
570
- this.#cancelTasks(wrapper)
571
- this.#renderSelect(wrapper)
572
- }
573
- })
574
- }, { signal })
575
- return btn
576
- }
577
-
578
- // ── actions ────────────────────────────────────────────────────────────────
579
-
580
- /**
581
- * @param {HTMLElement} wrapper Block wrapper that owns the action bar.
582
- * @param {AbortSignal} signal Current view lifecycle signal.
583
- * @returns {void}
584
- */
585
- #renderActions(wrapper, signal) {
586
- const actions = document.createElement('div')
587
- actions.className = 'oe-attaches__actions'
588
-
589
- // Settings dropdown (variant selector)
590
- const dropdown = document.createElement('div')
591
- dropdown.className = 'oe-attaches__dropdown'
592
-
593
- const settingsBtn = document.createElement('button')
594
- settingsBtn.type = 'button'
595
- settingsBtn.className = 'oe-attaches__action-btn'
596
- settingsBtn.innerHTML = `${ICON_SETTINGS} ${this._t('settings', 'Settings')}`
597
- settingsBtn.setAttribute('aria-haspopup', 'true')
598
- settingsBtn.setAttribute('aria-expanded', 'false')
599
-
600
- const panel = this.#buildVariantPanel(wrapper, signal)
601
- const setOpen = (open) => {
602
- dropdown.classList.toggle('oe-attaches__dropdown--open', open)
603
- settingsBtn.setAttribute('aria-expanded', String(open))
604
- }
605
-
606
- settingsBtn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
607
- settingsBtn.addEventListener('click', (e) => {
608
- e.stopPropagation()
609
- setOpen(!dropdown.classList.contains('oe-attaches__dropdown--open'))
610
- }, { signal })
611
-
612
- document.addEventListener('click', (e) => {
613
- const target = e.target
614
- if (!(target instanceof globalThis.Node)) return
615
- if (!dropdown.contains(/** @type {import('../../core/types').DOMNode} */ (target))) setOpen(false)
616
- }, { signal })
617
- document.addEventListener('keydown', (event) => {
618
- if (event.key !== 'Escape' || !dropdown.classList.contains('oe-attaches__dropdown--open')) return
619
- event.preventDefault()
620
- setOpen(false)
621
- settingsBtn.focus()
622
- }, { signal })
623
-
624
- dropdown.append(settingsBtn, panel)
625
- actions.appendChild(dropdown)
626
-
627
- actions.appendChild(this.#sep())
628
-
629
- // Add files
630
- const addBtn = document.createElement('button')
631
- addBtn.type = 'button'
632
- addBtn.className = 'oe-attaches__action-btn'
633
- addBtn.innerHTML = `${ICON_UPLOAD} ${this._t('addFiles', 'Add files')}`
634
- addBtn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
635
- addBtn.addEventListener('click', (e) => { e.stopPropagation(); this.#triggerFileInput(wrapper) }, { signal })
636
- actions.appendChild(addBtn)
637
-
638
- for (const action of this.#config.actions ?? []) {
639
- const sourceBtn = document.createElement('button')
640
- sourceBtn.type = 'button'
641
- sourceBtn.className = 'oe-attaches__action-btn'
642
- if (action.icon) sourceBtn.insertAdjacentHTML('afterbegin', action.icon)
643
- sourceBtn.append(document.createTextNode(` ${action.label}`))
644
- sourceBtn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
645
- sourceBtn.addEventListener('click', (e) => {
646
- e.stopPropagation()
647
- void this.#runCustomAction(wrapper, action.handler)
648
- }, { signal })
649
- actions.appendChild(sourceBtn)
650
- }
651
-
652
- actions.appendChild(this.#sep())
653
-
654
- // Delete all
655
- const delBtn = document.createElement('button')
656
- delBtn.type = 'button'
657
- delBtn.className = 'oe-attaches__action-btn oe-attaches__action-btn--danger'
658
- delBtn.innerHTML = ICON_TRASH
659
- delBtn.setAttribute('aria-label', this._t('deleteAll', 'Delete all'))
660
- delBtn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
661
- delBtn.addEventListener('click', (e) => {
662
- e.stopPropagation()
663
- const st = stateMap.get(wrapper)
664
- if (!st) return
665
- st.context.mutate(() => {
666
- this.#cancelTasks(wrapper)
667
- st.data = { files: [], variant: st.data.variant }
668
- this.#renderSelect(wrapper)
669
- })
670
- }, { signal })
671
- actions.appendChild(delBtn)
672
-
673
- wrapper.appendChild(actions)
674
- }
675
-
676
- /**
677
- * Build the variant selector dropdown panel.
678
- * @param {HTMLElement} wrapper
679
- * @param {AbortSignal} signal
680
- * @returns {HTMLDivElement}
681
- */
682
- #buildVariantPanel(wrapper, signal) {
683
- const s = stateMap.get(wrapper)
684
- const panel = document.createElement('div')
685
- panel.className = 'oe-attaches__dropdown-panel'
686
- panel.setAttribute('role', 'group')
687
- panel.setAttribute('aria-label', this._t('template', 'Template'))
688
- panel.addEventListener('click', (e) => e.stopPropagation(), { signal })
689
-
690
- const title = document.createElement('div')
691
- title.className = 'oe-attaches__tpl-title'
692
- title.textContent = this._t('template', 'Template')
693
- panel.appendChild(title)
694
-
695
- const grid = document.createElement('div')
696
- grid.className = 'oe-attaches__tpl-grid'
697
-
698
- for (const v of VARIANTS) {
699
- const meta = VARIANT_META[v]
700
- const btn = document.createElement('button')
701
- btn.type = 'button'
702
- btn.className = 'oe-attaches__tpl-btn' + (s?.data.variant === v ? ' oe-attaches__tpl-btn--active' : '')
703
- btn.innerHTML = meta.icon
704
- btn.title = this._t(meta.label, meta.label)
705
- btn.setAttribute('aria-label', btn.title)
706
- btn.setAttribute('aria-pressed', String(s?.data.variant === v))
707
- btn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
708
- btn.addEventListener('click', () => {
709
- const st = stateMap.get(wrapper)
710
- if (!st) return
711
- st.context.mutate(() => {
712
- this.#syncNames(wrapper)
713
- st.data.variant = v
714
- grid.querySelectorAll('.oe-attaches__tpl-btn').forEach(b => {
715
- b.classList.remove('oe-attaches__tpl-btn--active')
716
- b.setAttribute('aria-pressed', 'false')
717
- })
718
- btn.classList.add('oe-attaches__tpl-btn--active')
719
- btn.setAttribute('aria-pressed', 'true')
720
- // Re-render content only, keep actions intact
721
- this.#rerenderContent(wrapper)
722
- })
723
- }, { signal })
724
- grid.appendChild(btn)
725
- }
726
-
727
- panel.appendChild(grid)
728
- return panel
729
- }
730
-
731
- /**
732
- * Re-render file content without rebuilding action bar.
733
- * Preserves the actions element and its event listeners.
734
- * @param {HTMLElement} wrapper
735
- * @returns {void}
736
- */
737
- #rerenderContent(wrapper) {
738
- const s = stateMap.get(wrapper)
739
- if (!s) return
740
-
741
- // Remove only content elements, keep .oe-attaches__actions intact
742
- const actions = wrapper.querySelector('.oe-attaches__actions')
743
- for (const child of [...wrapper.children]) {
744
- if (child !== actions) child.remove()
745
- }
746
-
747
- const signal = s.viewController?.signal
748
- if (!signal || signal.aborted) return
749
- const files = s.data.files
750
- const v = s.data.variant || 'a'
751
-
752
- switch (v) {
753
- case 'b': this.#renderPills(wrapper, files, signal); break
754
- case 'f': this.#renderNotion(wrapper, files, signal); break
755
- case 'g': this.#renderMaterial(wrapper, files, signal); break
756
- default:
757
- if (files.length === 1) wrapper.appendChild(this.#buildCardA(wrapper, files[0], 0, signal))
758
- else wrapper.appendChild(this.#buildGroupA(wrapper, signal))
759
- break
760
- }
761
-
762
- // Move actions back to the end
763
- if (actions) wrapper.appendChild(actions)
764
- }
765
-
766
- /** @returns {HTMLDivElement} */
767
- #sep() {
768
- const s = document.createElement('div')
769
- s.className = 'oe-attaches__actions-sep'
770
- return s
771
- }
772
-
773
- // ── file handling ──────────────────────────────────────────────────────────
774
-
775
- /** @param {HTMLElement} wrapper @returns {void} */
776
- #triggerFileInput(wrapper) {
777
- const s = stateMap.get(wrapper)
778
- if (!s || s.context.readOnly) return
779
- triggerFileInput({
780
- multiple: true,
781
- signal: s.viewController?.signal,
782
- onFiles: (files) => { void this.#handleFiles(wrapper, files) },
783
- })
784
- }
785
-
786
- /**
787
- * Start an asynchronous additive operation owned by one block.
788
- * View re-renders do not cancel it; block destruction or explicit removal does.
789
- * @param {HTMLElement} wrapper
790
- * @returns {{ state: AttachesState, controller: AbortController } | null}
791
- */
792
- #startTask(wrapper) {
793
- const state = stateMap.get(wrapper)
794
- if (!state || state.context.readOnly) return null
795
- const controller = new AbortController()
796
- state.taskControllers.add(controller)
797
- wrapper.classList.add('oe-attaches--loading')
798
- return { state, controller }
799
- }
800
-
801
- /**
802
- * @param {HTMLElement} wrapper Block wrapper whose loading state is updated.
803
- * @param {AttachesState} state State that owns the asynchronous task.
804
- * @param {AbortController} controller Completed task controller.
805
- * @returns {void}
806
- */
807
- #finishTask(wrapper, state, controller) {
808
- state.taskControllers.delete(controller)
809
- if (state.taskControllers.size === 0) wrapper.classList.remove('oe-attaches--loading')
810
- }
811
-
812
- /** @param {HTMLElement} wrapper @returns {void} */
813
- #cancelTasks(wrapper) {
814
- const state = stateMap.get(wrapper)
815
- if (!state) return
816
- for (const controller of state.taskControllers) controller.abort()
817
- state.taskControllers.clear()
818
- wrapper.classList.remove('oe-attaches--loading')
819
- }
820
-
821
- /**
822
- * @param {HTMLElement} wrapper Block wrapper that receives uploaded files.
823
- * @param {FileList | File[]} fileList Browser files selected or dropped by the user.
824
- * @returns {Promise<void>}
825
- */
826
- async #handleFiles(wrapper, fileList) {
827
- const files = /** @type {File[]} */ (Array.from(fileList))
828
- if (files.length === 0) return
829
- const task = this.#startTask(wrapper)
830
- if (!task) return
831
- const { state: s, controller } = task
832
- const { signal } = controller
833
-
834
- try {
835
- const uploadFile = this.#config.uploadFile
836
- if (uploadFile) {
837
- const added = []
838
- for (const file of files) {
839
- if (signal.aborted) break
840
- try {
841
- const ext = getExtension(file.name)
842
- const result = await uploadFile(file, { signal })
843
- const url = sanitizeUrl(String(result?.url || ''), { policy: 'download', fallback: '' })
844
- const resultSize = Number(result?.size)
845
- const size = Number.isFinite(resultSize) && resultSize >= 0 ? resultSize : file.size
846
- if (url) added.push({ url, name: file.name, size, extension: ext })
847
- } catch (error) {
848
- if (signal.aborted) break
849
- console.warn(`[Attaches] Failed to upload "${file.name}":`, error)
850
- }
851
- }
852
- if (!signal.aborted && stateMap.get(wrapper) === s && added.length > 0) {
853
- s.context.mutate(() => {
854
- s.data.files.push(...added)
855
- this.#renderFilled(wrapper)
856
- })
857
- }
858
- } else {
859
- const added = []
860
- for (const file of files) {
861
- if (signal.aborted) break
862
- const ext = getExtension(file.name)
863
- try {
864
- const url = URL.createObjectURL(file)
865
- this.#objectUrls.add(url)
866
- added.push({ url, name: file.name, size: file.size, extension: ext })
867
- } catch (error) {
868
- console.warn(`[Attaches] Failed to open "${file.name}":`, error)
869
- }
870
- }
871
- if (!signal.aborted && stateMap.get(wrapper) === s && added.length > 0) {
872
- s.context.mutate(() => {
873
- s.data.files.push(...added)
874
- this.#renderFilled(wrapper)
875
- })
876
- }
877
- }
878
- } finally {
879
- this.#finishTask(wrapper, s, controller)
880
- }
881
- }
882
-
883
- /**
884
- * Add files selected by an application source such as a media library.
885
- * One completed selection becomes one editor history operation.
886
- * @param {HTMLElement} wrapper
887
- * @param {(context: { signal: AbortSignal }) => Promise<Array<{ url: string, name: string, size?: number, extension?: string }> | null>} handler
888
- * @returns {Promise<void>}
889
- */
890
- async #runCustomAction(wrapper, handler) {
891
- const task = this.#startTask(wrapper)
892
- if (!task) return
893
- const { state: s, controller } = task
894
- const { signal } = controller
895
- try {
896
- const result = await handler({ signal })
897
- if (!Array.isArray(result) || signal.aborted || stateMap.get(wrapper) !== s) return
898
- const added = result.flatMap(item => {
899
- const url = sanitizeUrl(String(item?.url || ''), { policy: 'download', fallback: '' })
900
- const name = String(item?.name || '').trim()
901
- if (!url || !name) return []
902
- const size = Number(item?.size)
903
- return [{
904
- url,
905
- name,
906
- size: Number.isFinite(size) && size >= 0 ? size : 0,
907
- extension: String(item?.extension || getExtension(name)),
908
- }]
909
- })
910
- if (!added.length) return
911
- s.context.mutate(() => {
912
- s.data.files.push(...added)
913
- this.#renderFilled(wrapper)
914
- })
915
- } catch (error) {
916
- if (!signal.aborted) console.warn('[Attaches] Application source failed:', error)
917
- } finally {
918
- this.#finishTask(wrapper, s, controller)
919
- }
920
- }
921
- }
1
+ import { triggerFileInput } from '../shared/fileInput.js'
2
+ import { BlockPluginAbstract } from '../BlockPluginAbstract.js'
3
+ import { getFileIcon, getExtension, formatSize, EXT_COLORS } from '../../shared/fileUtils.js'
4
+ import { validateAttachesData } from '../../shared/blockDataValidators.js'
5
+ import { sanitizeUrl } from '../../shared/sanitize/sanitizeUrl.js'
6
+ import { normalizeTextValue } from '../../shared/textFormat.js'
7
+ import { createPluginLayer } from '../shared/layer.js'
8
+ import { openSourceEditor, preloadSourceEditor } from '../shared/sourceEditor.js'
9
+
10
+ const editorStyles = new URL('./attaches.css', import.meta.url).href
11
+ const sourceEditorStyles = new URL('../shared/sourceEditor.css', import.meta.url).href
12
+
13
+ const ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"/><path d="M12 11v6"/><path d="M9.5 13.5l2.5-2.5l2.5 2.5"/></svg>'
14
+ const ICON_SELECT = '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"/><path d="M12 11v6"/><path d="M9.5 13.5l2.5-2.5l2.5 2.5"/></svg>'
15
+ const ICON_UPLOAD = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><path d="M7 9l5-5l5 5"/><path d="M12 4v12"/></svg>'
16
+ const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7h16"/><path stroke-linecap="round" stroke-linejoin="round" d="M10 11v6"/><path stroke-linecap="round" stroke-linejoin="round" d="M14 11v6"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12"/><path stroke-linecap="round" stroke-linejoin="round" d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/></svg>'
17
+ const ICON_CHEVRON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
18
+ const ICON_SETTINGS = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>'
19
+ const ICON_FILE_DEFAULT = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/></svg>'
20
+ const ICON_FILE_SM = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/></svg>'
21
+
22
+ /** @type {string[]} */
23
+ const VARIANTS = ['a', 'b', 'f', 'g']
24
+
25
+ /** @type {Record<string, { icon: string, label: string }>} */
26
+ const VARIANT_META = {
27
+ a: { icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"/></svg>', label: 'variantA' },
28
+ b: { icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>', label: 'variantB' },
29
+ f: { icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18"/><path d="M3 6h18"/><path d="M3 18h18"/></svg>', label: 'variantF' },
30
+ g: { icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="3"/></svg>', label: 'variantG' },
31
+ }
32
+
33
+ /**
34
+ * @typedef {{ url: string, name: string, size: number, extension: string }} FileEntry
35
+ * @typedef {Object} AttachesAction
36
+ * @property {string} label Plain-text label shown on the source button.
37
+ * @property {string} [icon] Trusted application-owned icon markup. Never pass user-authored HTML.
38
+ * @property {(context: { signal: AbortSignal }) => Promise<Array<{ url: string, name: string, size?: number, extension?: string }> | null>} handler Opens an application-owned source and returns selected files, or `null` when selection is cancelled.
39
+ * @typedef {Object} AttachesConfig
40
+ * @property {(file: File, context: { signal: AbortSignal }) => Promise<{ url: string, size?: number }>} [uploadFile] Uploads a browser file. Without this callback the plugin creates a temporary object URL valid until the editor is destroyed.
41
+ * @property {AttachesAction[]} [actions] Additional file sources such as a media library.
42
+ * @property {boolean} [injectStyles=true] Whether the editor should load the built-in plugin stylesheet.
43
+ * @property {string} [css] Additional or replacement stylesheet URL, depending on `injectStyles`.
44
+ * @typedef {{
45
+ * data: { files: FileEntry[], variant: string },
46
+ * viewController: AbortController | null,
47
+ * taskControllers: Set<AbortController>,
48
+ * expanded: boolean,
49
+ * context: import('../../core/types').BlockMutationContext,
50
+ * }} AttachesState
51
+ */
52
+
53
+ /** @type {WeakMap<HTMLElement, AttachesState>} */
54
+ const stateMap = new WeakMap()
55
+ let groupSequence = 0
56
+
57
+ /**
58
+ * Derive a readable attachment name from a URL without trusting it as markup.
59
+ * @param {string} url Sanitized absolute file URL.
60
+ * @returns {string} Decoded final path segment, or an empty string.
61
+ */
62
+ function getUrlFileName(url) {
63
+ try {
64
+ const segment = new URL(url).pathname.split('/').filter(Boolean).pop()
65
+ if (!segment) return ''
66
+ try {
67
+ return decodeURIComponent(segment)
68
+ } catch {
69
+ return segment
70
+ }
71
+ } catch {
72
+ return ''
73
+ }
74
+ }
75
+
76
+ /**
77
+ * File attachment block supporting direct uploads and consumer-defined file
78
+ * source actions such as a media library.
79
+ * @extends {BlockPluginAbstract<AttachesConfig>}
80
+ */
81
+ export class Attaches extends BlockPluginAbstract {
82
+ static isTextBlock = false
83
+ static styles = [editorStyles, sourceEditorStyles]
84
+ type = 'attaches'
85
+ icon = ICON
86
+ inlineTools = false
87
+
88
+
89
+ #objectUrls = new Set()
90
+ /**
91
+ * Create an Attaches instance with the supplied consumer configuration.
92
+ * @param {AttachesConfig} [config]
93
+ */
94
+ constructor(config) {
95
+ super(config)
96
+ }
97
+
98
+ /** @returns {AttachesConfig} Consumer configuration for this plugin instance. */
99
+ get #config() {
100
+ return /** @type {AttachesConfig} */ (this._config)
101
+ }
102
+
103
+ /**
104
+ * Return the localized toolbox label for this block.
105
+ * @returns {string} Localized toolbox title.
106
+ */
107
+ get title() { return this._t('title', 'File') }
108
+
109
+ /**
110
+ * Create the editable DOM owned by this block instance.
111
+ * @param {Record<string, unknown>} data
112
+ * @param {import('../../core/types').BlockMutationContext} context
113
+ * @returns {HTMLElement}
114
+ */
115
+ render(data, context) {
116
+ const normalizeSize = (value) => {
117
+ const size = Number(value)
118
+ return Number.isFinite(size) && size >= 0 ? size : 0
119
+ }
120
+ let files
121
+ if (data?.file && !data?.files) {
122
+ const f = /** @type {any} */ (data.file)
123
+ const url = sanitizeUrl(normalizeTextValue(f.url), { policy: 'download', fallback: '' })
124
+ const name = normalizeTextValue(f.name)
125
+ files = url ? [{
126
+ url,
127
+ name,
128
+ size: normalizeSize(f.size),
129
+ extension: normalizeTextValue(f.extension) || getExtension(name),
130
+ }] : []
131
+ } else {
132
+ const inputFiles = Array.isArray(data?.files) ? data.files : []
133
+ files = /** @type {any[]} */ (inputFiles).map(f => ({
134
+ url: sanitizeUrl(normalizeTextValue(f?.url), { policy: 'download', fallback: '' }),
135
+ name: normalizeTextValue(f?.name),
136
+ size: normalizeSize(f?.size),
137
+ extension: normalizeTextValue(f?.extension) || getExtension(normalizeTextValue(f?.name)),
138
+ })).filter(f => f.url)
139
+ }
140
+ const requestedVariant = normalizeTextValue(data?.variant) || 'f'
141
+ const variant = VARIANTS.includes(requestedVariant) ? requestedVariant : 'f'
142
+ const wrapper = document.createElement('div')
143
+ wrapper.classList.add('oe-attaches')
144
+ wrapper.contentEditable = 'false'
145
+ wrapper.tabIndex = -1
146
+ stateMap.set(wrapper, {
147
+ data: { files, variant },
148
+ viewController: null,
149
+ taskControllers: new Set(),
150
+ expanded: false,
151
+ context,
152
+ })
153
+ if (files.length > 0) this.#renderFilled(wrapper)
154
+ else this.#renderSelect(wrapper)
155
+ return wrapper
156
+ }
157
+
158
+ /**
159
+ * Serialize the current block DOM into document data.
160
+ * @param {HTMLElement} el
161
+ * @returns {{ files: FileEntry[], variant: string }}
162
+ */
163
+ save(el) {
164
+ const s = stateMap.get(el)
165
+ if (!s) return { files: [], variant: 'f' }
166
+ this.#syncNames(el)
167
+ return { files: s.data.files.map(f => ({ ...f })), variant: s.data.variant }
168
+ }
169
+
170
+ /**
171
+ * Check whether serialized data satisfies this block's schema.
172
+ * @param {Record<string, unknown>} data
173
+ * @returns {boolean}
174
+ */
175
+ validate(data) {
176
+ return validateAttachesData(data)
177
+ }
178
+
179
+ /**
180
+ * Check whether the block has no meaningful user content.
181
+ * @param {HTMLElement} el
182
+ * @returns {boolean}
183
+ */
184
+ isEmpty(el) {
185
+ const s = stateMap.get(el)
186
+ return !s || s.data.files.length === 0
187
+ }
188
+
189
+ /**
190
+ * Extract neutral text that can initialize another block type.
191
+ * @param {HTMLElement} el
192
+ * @returns {{ text: string }}
193
+ */
194
+ exportData(el) {
195
+ const s = stateMap.get(el)
196
+ return { text: s ? s.data.files.map(f => f.name).join(', ') : '' }
197
+ }
198
+
199
+ /**
200
+ * Abort work and listeners owned by one rendered block.
201
+ * @param {HTMLElement} el
202
+ * @returns {void}
203
+ */
204
+ destroy(el) {
205
+ const s = stateMap.get(el)
206
+ if (s) {
207
+ s.viewController?.abort()
208
+ for (const controller of s.taskControllers) controller.abort()
209
+ s.taskControllers.clear()
210
+ stateMap.delete(el)
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Release temporary local URLs after all block snapshots and undo history
216
+ * owned by this editor have become unreachable.
217
+ * @returns {void}
218
+ */
219
+ dispose() {
220
+ for (const url of this.#objectUrls) URL.revokeObjectURL(url)
221
+ this.#objectUrls.clear()
222
+ }
223
+
224
+ // ── sync editable names ────────────────────────────────────────────────────
225
+
226
+ /** @param {HTMLElement} wrapper @returns {void} */
227
+ #syncNames(wrapper) {
228
+ const s = stateMap.get(wrapper)
229
+ if (!s) return
230
+ wrapper.querySelectorAll('.oe-attaches__name:not(.oe-attaches__name--static)').forEach((el, i) => {
231
+ if (s.data.files[i]) {
232
+ const t = String(el.textContent ?? '').trim()
233
+ if (t) s.data.files[i].name = t
234
+ }
235
+ })
236
+ }
237
+
238
+ // ── dropzone ───────────────────────────────────────────────────────────────
239
+
240
+ /** @param {HTMLElement} wrapper @returns {void} */
241
+ #renderSelect(wrapper) {
242
+ const s = stateMap.get(wrapper)
243
+ if (!s) return
244
+ wrapper.innerHTML = ''
245
+ wrapper.classList.remove('oe-attaches--filled')
246
+ s.viewController?.abort()
247
+ s.viewController = new AbortController()
248
+ const { signal } = s.viewController
249
+
250
+ const select = document.createElement('div')
251
+ select.className = 'oe-attaches__select'
252
+
253
+ if (s.context.readOnly) {
254
+ select.textContent = this._t('emptyReadonly', 'No files')
255
+ wrapper.appendChild(select)
256
+ return
257
+ }
258
+
259
+ const icon = document.createElement('div')
260
+ icon.className = 'oe-attaches__select-icon'
261
+ icon.innerHTML = ICON_SELECT
262
+
263
+ const text = document.createElement('div')
264
+ text.className = 'oe-attaches__select-text'
265
+ const link = document.createElement('button')
266
+ link.type = 'button'
267
+ link.className = 'oe-attaches__select-link'
268
+ link.textContent = this._t('dropzoneUpload', 'Upload')
269
+ link.addEventListener('click', (e) => { e.stopPropagation(); this.#triggerFileInput(wrapper) }, { signal })
270
+ const urlLink = document.createElement('button')
271
+ urlLink.type = 'button'
272
+ urlLink.className = 'oe-attaches__select-link'
273
+ urlLink.textContent = this._t('dropzoneUrl', 'insert a URL')
274
+ urlLink.addEventListener('click', (event) => {
275
+ event.preventDefault()
276
+ event.stopPropagation()
277
+ this.#openUrlEditor(wrapper)
278
+ }, { signal })
279
+ text.append(
280
+ link,
281
+ document.createTextNode(' ' + this._t('dropzoneText', 'files from your device or drag and drop them here')),
282
+ document.createTextNode(` ${this._t('dropzoneUrlPrefix', 'or')} `),
283
+ urlLink,
284
+ )
285
+ select.append(icon, text)
286
+
287
+ const configuredActions = this.#config.actions ?? []
288
+ if (configuredActions.length) {
289
+ const sources = document.createElement('div')
290
+ sources.className = 'oe-attaches__select-actions'
291
+ for (const action of configuredActions) {
292
+ const button = document.createElement('button')
293
+ button.type = 'button'
294
+ button.className = 'oe-attaches__select-action'
295
+ if (action.icon) button.insertAdjacentHTML('afterbegin', action.icon)
296
+ button.append(document.createTextNode(action.label))
297
+ button.addEventListener('click', event => {
298
+ event.stopPropagation()
299
+ void this.#runCustomAction(wrapper, action.handler)
300
+ }, { signal })
301
+ sources.appendChild(button)
302
+ }
303
+ select.appendChild(sources)
304
+ }
305
+
306
+ wrapper.addEventListener('dragover', (e) => { e.preventDefault(); select.classList.add('oe-attaches__select--dragover') }, { signal })
307
+ wrapper.addEventListener('dragleave', () => select.classList.remove('oe-attaches__select--dragover'), { signal })
308
+ wrapper.addEventListener('drop', (e) => {
309
+ e.preventDefault()
310
+ select.classList.remove('oe-attaches__select--dragover')
311
+ if (e.dataTransfer?.files?.length) void this.#handleFiles(wrapper, e.dataTransfer.files)
312
+ }, { signal })
313
+
314
+ wrapper.appendChild(select)
315
+ preloadSourceEditor(wrapper, signal, ['url'])
316
+ }
317
+
318
+ // ── filled state (dispatches by variant) ──────────────────────────────────
319
+
320
+ /** @param {HTMLElement} wrapper @returns {void} */
321
+ #renderFilled(wrapper) {
322
+ const s = stateMap.get(wrapper)
323
+ if (!s) return
324
+ this.#syncNames(wrapper)
325
+ wrapper.innerHTML = ''
326
+ wrapper.classList.add('oe-attaches--filled')
327
+ s.viewController?.abort()
328
+ s.viewController = new AbortController()
329
+ const { signal } = s.viewController
330
+ const files = s.data.files
331
+ const v = s.data.variant || 'a'
332
+
333
+ switch (v) {
334
+ case 'b':
335
+ this.#renderPills(wrapper, files, signal)
336
+ break
337
+ case 'f':
338
+ this.#renderNotion(wrapper, files, signal)
339
+ break
340
+ case 'g':
341
+ this.#renderMaterial(wrapper, files, signal)
342
+ break
343
+ default: // 'a'
344
+ if (files.length === 1) wrapper.appendChild(this.#buildCardA(wrapper, files[0], 0, signal))
345
+ else wrapper.appendChild(this.#buildGroupA(wrapper, signal))
346
+ break
347
+ }
348
+
349
+ if (!s.context.readOnly) this.#renderActions(wrapper, signal)
350
+ }
351
+
352
+ // ── Variant A: Minimal ────────────────────────────────────────────────────
353
+
354
+ /**
355
+ * @param {HTMLElement} w Block wrapper that owns the file state.
356
+ * @param {FileEntry} file File represented by this card.
357
+ * @param {number} i File index in the block data.
358
+ * @param {AbortSignal} sig Current view lifecycle signal.
359
+ * @returns {HTMLDivElement}
360
+ */
361
+ #buildCardA(w, file, i, sig) {
362
+ const card = document.createElement('div')
363
+ card.className = 'oe-attaches__card'
364
+ card.appendChild(this.#buildIconA(file))
365
+ const info = document.createElement('div')
366
+ info.className = 'oe-attaches__info'
367
+ info.appendChild(this.#buildNameEl(w, i, file, sig))
368
+ if (file.size) { const m = document.createElement('div'); m.className = 'oe-attaches__meta'; m.textContent = formatSize(file.size); info.appendChild(m) }
369
+ card.append(info, this.#buildRemoveBtn(w, i, sig))
370
+ return card
371
+ }
372
+
373
+ /**
374
+ * @param {HTMLElement} w Block wrapper that owns the group.
375
+ * @param {AbortSignal} sig Current view lifecycle signal.
376
+ * @returns {HTMLDivElement}
377
+ */
378
+ #buildGroupA(w, sig) {
379
+ const s = stateMap.get(w)
380
+ if (!s) return document.createElement('div')
381
+ const files = s.data.files
382
+ const group = document.createElement('div')
383
+ group.className = 'oe-attaches__group'
384
+
385
+ const header = document.createElement('div')
386
+ header.className = 'oe-attaches__group-header'
387
+ const iconWrap = document.createElement('div')
388
+ iconWrap.className = 'oe-attaches__icon'
389
+ iconWrap.innerHTML = ICON_FILE_DEFAULT
390
+ const headerInfo = document.createElement('div')
391
+ headerInfo.className = 'oe-attaches__info'
392
+ const countText = document.createElement('div')
393
+ countText.className = 'oe-attaches__name oe-attaches__name--static'
394
+ countText.textContent = `${files.length} ${this._p('filesCount', files.length, 'files')}`
395
+ headerInfo.appendChild(countText)
396
+ const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0)
397
+ if (totalSize > 0) { const m = document.createElement('span'); m.className = 'oe-attaches__meta'; m.textContent = formatSize(totalSize); headerInfo.appendChild(m) }
398
+ const chevron = document.createElement('button')
399
+ chevron.type = 'button'
400
+ chevron.className = 'oe-attaches__chevron'
401
+ chevron.innerHTML = ICON_CHEVRON
402
+ chevron.setAttribute('aria-label', this._t('toggleGroup', 'Show or hide files'))
403
+ chevron.setAttribute('aria-expanded', String(s.expanded))
404
+ header.append(iconWrap, headerInfo, chevron)
405
+
406
+ const body = document.createElement('div')
407
+ body.className = 'oe-attaches__group-body'
408
+ body.id = `rector-editor-attaches-group-${++groupSequence}`
409
+ chevron.setAttribute('aria-controls', body.id)
410
+ if (s.expanded) { body.classList.add('oe-attaches__group-body--open'); chevron.classList.add('oe-attaches__chevron--open') }
411
+ for (let i = 0; i < files.length; i++) {
412
+ const row = document.createElement('div')
413
+ row.className = 'oe-attaches__row'
414
+ row.append(this.#buildNameEl(w, i, files[i], sig))
415
+ if (files[i].size) { const sz = document.createElement('span'); sz.className = 'oe-attaches__row-size'; sz.textContent = formatSize(files[i].size); row.appendChild(sz) }
416
+ row.appendChild(this.#buildRemoveBtn(w, i, sig))
417
+ body.appendChild(row)
418
+ }
419
+ const toggle = () => {
420
+ const st = stateMap.get(w)
421
+ if (!st) return
422
+ st.expanded = !st.expanded
423
+ body.classList.toggle('oe-attaches__group-body--open', st.expanded)
424
+ chevron.classList.toggle('oe-attaches__chevron--open', st.expanded)
425
+ chevron.setAttribute('aria-expanded', String(st.expanded))
426
+ }
427
+ header.addEventListener('click', (event) => {
428
+ const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null)
429
+ if (target?.closest('button, [contenteditable="true"]')) return
430
+ toggle()
431
+ }, { signal: sig })
432
+ chevron.addEventListener('click', (event) => {
433
+ event.stopPropagation()
434
+ toggle()
435
+ }, { signal: sig })
436
+ group.append(header, body)
437
+ return group
438
+ }
439
+
440
+ /** Variant A icon: always default file SVG + extension badge. @param {FileEntry} file @returns {HTMLDivElement} */
441
+ #buildIconA(file) {
442
+ const wrap = document.createElement('div')
443
+ wrap.className = 'oe-attaches__icon'
444
+ wrap.innerHTML = ICON_FILE_DEFAULT
445
+ if (file.extension) {
446
+ const badge = document.createElement('span')
447
+ badge.className = 'oe-attaches__ext'
448
+ badge.textContent = file.extension.toUpperCase()
449
+ const color = EXT_COLORS[file.extension.toLowerCase()]
450
+ if (color) badge.style.backgroundColor = color
451
+ wrap.appendChild(badge)
452
+ }
453
+ return wrap
454
+ }
455
+
456
+ // ── Variant B: Pill ────────────────────────────────────────────────────────
457
+
458
+ /**
459
+ * @param {HTMLElement} w Block wrapper that receives the pill list.
460
+ * @param {FileEntry[]} files Files to render.
461
+ * @param {AbortSignal} sig Current view lifecycle signal.
462
+ * @returns {void}
463
+ */
464
+ #renderPills(w, files, sig) {
465
+ const container = document.createElement('div')
466
+ container.className = 'oe-attaches__pills'
467
+ for (let i = 0; i < files.length; i++) {
468
+ const pill = document.createElement('div')
469
+ pill.className = 'oe-attaches__pill'
470
+ const ic = document.createElement('div')
471
+ ic.className = 'oe-attaches__pill-icon'
472
+ ic.innerHTML = ICON_FILE_SM
473
+ pill.appendChild(ic)
474
+ pill.appendChild(this.#buildNameEl(w, i, files[i], sig))
475
+ if (files[i].size) { const sz = document.createElement('span'); sz.className = 'oe-attaches__pill-size'; sz.textContent = formatSize(files[i].size); pill.appendChild(sz) }
476
+ pill.appendChild(this.#buildRemoveBtn(w, i, sig))
477
+ container.appendChild(pill)
478
+ }
479
+ w.appendChild(container)
480
+ }
481
+
482
+ // ── Variant F: Notion ──────────────────────────────────────────────────────
483
+
484
+ /**
485
+ * @param {HTMLElement} w Block wrapper that receives the table-like list.
486
+ * @param {FileEntry[]} files Files to render.
487
+ * @param {AbortSignal} sig Current view lifecycle signal.
488
+ * @returns {void}
489
+ */
490
+ #renderNotion(w, files, sig) {
491
+ const table = document.createElement('div')
492
+ table.className = 'oe-attaches__notion'
493
+ for (let i = 0; i < files.length; i++) {
494
+ const row = document.createElement('div')
495
+ row.className = 'oe-attaches__notion-row'
496
+ row.appendChild(this.#buildNameEl(w, i, files[i], sig))
497
+ const ext = (files[i].extension || '').toUpperCase()
498
+ if (ext) {
499
+ const tag = document.createElement('span')
500
+ tag.className = 'oe-attaches__notion-tag'
501
+ tag.textContent = ext
502
+ const color = EXT_COLORS[files[i].extension?.toLowerCase()] || null
503
+ if (color) { tag.style.backgroundColor = `${color}20`; tag.style.color = color }
504
+ row.appendChild(tag)
505
+ }
506
+ if (files[i].size) { const sz = document.createElement('span'); sz.className = 'oe-attaches__notion-size'; sz.textContent = formatSize(files[i].size); row.appendChild(sz) }
507
+ row.appendChild(this.#buildRemoveBtn(w, i, sig))
508
+ table.appendChild(row)
509
+ }
510
+ w.appendChild(table)
511
+ }
512
+
513
+ // ── Variant G: Material ────────────────────────────────────────────────────
514
+
515
+ /**
516
+ * @param {HTMLElement} w Block wrapper that receives the material cards.
517
+ * @param {FileEntry[]} files Files to render.
518
+ * @param {AbortSignal} sig Current view lifecycle signal.
519
+ * @returns {void}
520
+ */
521
+ #renderMaterial(w, files, sig) {
522
+ const stack = document.createElement('div')
523
+ stack.className = 'oe-attaches__material'
524
+ for (let i = 0; i < files.length; i++) {
525
+ const card = document.createElement('div')
526
+ card.className = 'oe-attaches__material-card'
527
+ card.appendChild(this.#buildIconG(files[i]))
528
+ const info = document.createElement('div')
529
+ info.className = 'oe-attaches__info'
530
+ info.appendChild(this.#buildNameEl(w, i, files[i], sig))
531
+ if (files[i].size) { const m = document.createElement('div'); m.className = 'oe-attaches__meta'; m.textContent = formatSize(files[i].size); info.appendChild(m) }
532
+ card.append(info, this.#buildRemoveBtn(w, i, sig))
533
+ stack.appendChild(card)
534
+ }
535
+ w.appendChild(stack)
536
+ }
537
+
538
+ /** Variant G icon: custom type-specific icon, no badge. @param {FileEntry} file @returns {HTMLDivElement} */
539
+ #buildIconG(file) {
540
+ const wrap = document.createElement('div')
541
+ wrap.className = 'oe-attaches__material-icon'
542
+ const { svg } = getFileIcon(file.extension)
543
+ wrap.innerHTML = svg
544
+ return wrap
545
+ }
546
+
547
+ // ── shared builders ────────────────────────────────────────────────────────
548
+
549
+ /**
550
+ * @param {HTMLElement} wrapper
551
+ * @param {number} index
552
+ * @param {FileEntry} file
553
+ * @param {AbortSignal} signal
554
+ * @returns {HTMLDivElement}
555
+ */
556
+ #buildNameEl(wrapper, index, file, signal) {
557
+ const state = stateMap.get(wrapper)
558
+ const editable = !state?.context.readOnly
559
+ const el = document.createElement('div')
560
+ el.className = `oe-attaches__name${editable ? '' : ' oe-attaches__name--static'}`
561
+ el.contentEditable = String(editable)
562
+ const fallbackName = this._t('untitled', 'File')
563
+ el.textContent = file.name || fallbackName
564
+ if (!editable) return el
565
+
566
+ el.addEventListener('keydown', (e) => {
567
+ if (e.key === 'Enter') { e.preventDefault(); el.blur() }
568
+ if (!e.ctrlKey && !e.metaKey) e.stopPropagation()
569
+ }, { signal })
570
+ el.addEventListener('blur', () => {
571
+ const s = stateMap.get(wrapper)
572
+ if (s?.data.files[index]) s.data.files[index].name = String(el.textContent ?? '').trim() || fallbackName
573
+ }, { signal })
574
+ return el
575
+ }
576
+
577
+ /**
578
+ * @param {HTMLElement} wrapper
579
+ * @param {number} index
580
+ * @param {AbortSignal} signal
581
+ * @returns {HTMLButtonElement}
582
+ */
583
+ #buildRemoveBtn(wrapper, index, signal) {
584
+ const btn = document.createElement('button')
585
+ btn.type = 'button'
586
+ btn.className = 'oe-attaches__remove'
587
+ btn.innerHTML = '&times;'
588
+ btn.setAttribute('aria-label', this._t('delete', 'Delete'))
589
+ const state = stateMap.get(wrapper)
590
+ if (state?.context.readOnly) {
591
+ btn.hidden = true
592
+ btn.disabled = true
593
+ return btn
594
+ }
595
+ btn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
596
+ btn.addEventListener('click', (e) => {
597
+ e.stopPropagation()
598
+ const st = stateMap.get(wrapper)
599
+ if (!st) return
600
+ st.context.mutate(() => {
601
+ st.data.files.splice(index, 1)
602
+ if (st.data.files.length > 0) {
603
+ this.#renderFilled(wrapper)
604
+ } else {
605
+ this.#cancelTasks(wrapper)
606
+ this.#renderSelect(wrapper)
607
+ }
608
+ })
609
+ }, { signal })
610
+ return btn
611
+ }
612
+
613
+ // ── actions ────────────────────────────────────────────────────────────────
614
+
615
+ /**
616
+ * @param {HTMLElement} wrapper Block wrapper that owns the action bar.
617
+ * @param {AbortSignal} signal Current view lifecycle signal.
618
+ * @returns {void}
619
+ */
620
+ #renderActions(wrapper, signal) {
621
+ const actions = document.createElement('div')
622
+ actions.className = 'oe-attaches__actions'
623
+
624
+ // Settings dropdown (variant selector)
625
+ const dropdown = document.createElement('div')
626
+ dropdown.className = 'oe-attaches__dropdown'
627
+
628
+ const settingsBtn = document.createElement('button')
629
+ settingsBtn.type = 'button'
630
+ settingsBtn.className = 'oe-attaches__action-btn'
631
+ settingsBtn.innerHTML = `${ICON_SETTINGS} ${this._t('settings', 'Settings')}`
632
+ settingsBtn.setAttribute('aria-haspopup', 'true')
633
+ settingsBtn.setAttribute('aria-expanded', 'false')
634
+
635
+ const panel = this.#buildVariantPanel(wrapper, signal)
636
+ const layer = createPluginLayer(wrapper, signal)
637
+ const setOpen = (open) => {
638
+ dropdown.classList.toggle('oe-attaches__dropdown--open', open)
639
+ settingsBtn.setAttribute('aria-expanded', String(open))
640
+ if (open) layer.open()
641
+ else layer.close()
642
+ }
643
+
644
+ settingsBtn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
645
+ settingsBtn.addEventListener('click', (e) => {
646
+ e.stopPropagation()
647
+ setOpen(!dropdown.classList.contains('oe-attaches__dropdown--open'))
648
+ }, { signal })
649
+
650
+ document.addEventListener('click', (e) => {
651
+ const target = e.target
652
+ if (!(target instanceof globalThis.Node)) return
653
+ if (!dropdown.contains(/** @type {import('../../core/types').DOMNode} */ (target))) setOpen(false)
654
+ }, { signal })
655
+ document.addEventListener('keydown', (event) => {
656
+ if (event.key !== 'Escape' || !dropdown.classList.contains('oe-attaches__dropdown--open')) return
657
+ event.preventDefault()
658
+ setOpen(false)
659
+ settingsBtn.focus()
660
+ }, { signal })
661
+
662
+ dropdown.append(settingsBtn, panel)
663
+ actions.appendChild(dropdown)
664
+
665
+ actions.appendChild(this.#sep())
666
+
667
+ // Add files
668
+ const addBtn = document.createElement('button')
669
+ addBtn.type = 'button'
670
+ addBtn.className = 'oe-attaches__action-btn'
671
+ addBtn.innerHTML = `${ICON_UPLOAD} ${this._t('addFiles', 'Add files')}`
672
+ addBtn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
673
+ addBtn.addEventListener('click', (e) => { e.stopPropagation(); this.#triggerFileInput(wrapper) }, { signal })
674
+ actions.appendChild(addBtn)
675
+
676
+ for (const action of this.#config.actions ?? []) {
677
+ const sourceBtn = document.createElement('button')
678
+ sourceBtn.type = 'button'
679
+ sourceBtn.className = 'oe-attaches__action-btn'
680
+ if (action.icon) sourceBtn.insertAdjacentHTML('afterbegin', action.icon)
681
+ sourceBtn.append(document.createTextNode(` ${action.label}`))
682
+ sourceBtn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
683
+ sourceBtn.addEventListener('click', (e) => {
684
+ e.stopPropagation()
685
+ void this.#runCustomAction(wrapper, action.handler)
686
+ }, { signal })
687
+ actions.appendChild(sourceBtn)
688
+ }
689
+
690
+ actions.appendChild(this.#sep())
691
+
692
+ // Delete all
693
+ const delBtn = document.createElement('button')
694
+ delBtn.type = 'button'
695
+ delBtn.className = 'oe-attaches__action-btn oe-attaches__action-btn--danger'
696
+ delBtn.innerHTML = ICON_TRASH
697
+ delBtn.setAttribute('aria-label', this._t('deleteAll', 'Delete all'))
698
+ delBtn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
699
+ delBtn.addEventListener('click', (e) => {
700
+ e.stopPropagation()
701
+ const st = stateMap.get(wrapper)
702
+ if (!st) return
703
+ st.context.mutate(() => {
704
+ this.#cancelTasks(wrapper)
705
+ st.data = { files: [], variant: st.data.variant }
706
+ this.#renderSelect(wrapper)
707
+ })
708
+ }, { signal })
709
+ actions.appendChild(delBtn)
710
+
711
+ wrapper.appendChild(actions)
712
+ }
713
+
714
+ /**
715
+ * Build the variant selector dropdown panel.
716
+ * @param {HTMLElement} wrapper
717
+ * @param {AbortSignal} signal
718
+ * @returns {HTMLDivElement}
719
+ */
720
+ #buildVariantPanel(wrapper, signal) {
721
+ const s = stateMap.get(wrapper)
722
+ const panel = document.createElement('div')
723
+ panel.className = 'oe-attaches__dropdown-panel'
724
+ panel.setAttribute('role', 'group')
725
+ panel.setAttribute('aria-label', this._t('template', 'Template'))
726
+ panel.addEventListener('click', (e) => e.stopPropagation(), { signal })
727
+
728
+ const title = document.createElement('div')
729
+ title.className = 'oe-attaches__tpl-title'
730
+ title.textContent = this._t('template', 'Template')
731
+ panel.appendChild(title)
732
+
733
+ const grid = document.createElement('div')
734
+ grid.className = 'oe-attaches__tpl-grid'
735
+
736
+ for (const v of VARIANTS) {
737
+ const meta = VARIANT_META[v]
738
+ const btn = document.createElement('button')
739
+ btn.type = 'button'
740
+ btn.className = 'oe-attaches__tpl-btn' + (s?.data.variant === v ? ' oe-attaches__tpl-btn--active' : '')
741
+ btn.innerHTML = meta.icon
742
+ btn.title = this._t(meta.label, meta.label)
743
+ btn.setAttribute('aria-label', btn.title)
744
+ btn.setAttribute('aria-pressed', String(s?.data.variant === v))
745
+ btn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
746
+ btn.addEventListener('click', () => {
747
+ const st = stateMap.get(wrapper)
748
+ if (!st) return
749
+ st.context.mutate(() => {
750
+ this.#syncNames(wrapper)
751
+ st.data.variant = v
752
+ grid.querySelectorAll('.oe-attaches__tpl-btn').forEach(b => {
753
+ b.classList.remove('oe-attaches__tpl-btn--active')
754
+ b.setAttribute('aria-pressed', 'false')
755
+ })
756
+ btn.classList.add('oe-attaches__tpl-btn--active')
757
+ btn.setAttribute('aria-pressed', 'true')
758
+ // Re-render content only, keep actions intact
759
+ this.#rerenderContent(wrapper)
760
+ })
761
+ }, { signal })
762
+ grid.appendChild(btn)
763
+ }
764
+
765
+ panel.appendChild(grid)
766
+ return panel
767
+ }
768
+
769
+ /**
770
+ * Re-render file content without rebuilding action bar.
771
+ * Preserves the actions element and its event listeners.
772
+ * @param {HTMLElement} wrapper
773
+ * @returns {void}
774
+ */
775
+ #rerenderContent(wrapper) {
776
+ const s = stateMap.get(wrapper)
777
+ if (!s) return
778
+
779
+ // Remove only content elements, keep .oe-attaches__actions intact
780
+ const actions = wrapper.querySelector('.oe-attaches__actions')
781
+ for (const child of [...wrapper.children]) {
782
+ if (child !== actions) child.remove()
783
+ }
784
+
785
+ const signal = s.viewController?.signal
786
+ if (!signal || signal.aborted) return
787
+ const files = s.data.files
788
+ const v = s.data.variant || 'a'
789
+
790
+ switch (v) {
791
+ case 'b': this.#renderPills(wrapper, files, signal); break
792
+ case 'f': this.#renderNotion(wrapper, files, signal); break
793
+ case 'g': this.#renderMaterial(wrapper, files, signal); break
794
+ default:
795
+ if (files.length === 1) wrapper.appendChild(this.#buildCardA(wrapper, files[0], 0, signal))
796
+ else wrapper.appendChild(this.#buildGroupA(wrapper, signal))
797
+ break
798
+ }
799
+
800
+ // Move actions back to the end
801
+ if (actions) wrapper.appendChild(actions)
802
+ }
803
+
804
+ /** @returns {HTMLDivElement} */
805
+ #sep() {
806
+ const s = document.createElement('div')
807
+ s.className = 'oe-attaches__actions-sep'
808
+ return s
809
+ }
810
+
811
+ // ── file handling ──────────────────────────────────────────────────────────
812
+
813
+ /**
814
+ * Open the shared in-editor URL form and append one remote file.
815
+ * @param {HTMLElement} wrapper Block wrapper that owns the form.
816
+ * @returns {void}
817
+ */
818
+ #openUrlEditor(wrapper) {
819
+ const state = stateMap.get(wrapper)
820
+ const signal = state?.viewController?.signal
821
+ if (!state || state.context.readOnly || !signal || signal.aborted) return
822
+
823
+ openSourceEditor({
824
+ wrapper,
825
+ signal,
826
+ kind: 'url',
827
+ title: this._t('urlEditorTitle', 'Add file by URL'),
828
+ label: this._t('urlEditorLabel', 'File URL'),
829
+ placeholder: this._t('urlEditorPlaceholder', 'https://example.com/file.pdf'),
830
+ submitText: this._t('sourceSubmit', 'Add'),
831
+ cancelText: this._t('sourceCancel', 'Cancel'),
832
+ invalidText: this._t('invalidUrl', 'Enter a valid file URL.'),
833
+ normalize: value => sanitizeUrl(value, { policy: 'download', fallback: '' }),
834
+ onSubmit: url => {
835
+ const current = stateMap.get(wrapper)
836
+ if (current !== state || current.context.readOnly) return
837
+ const name = getUrlFileName(url) || this._t('urlFileName', 'Linked file')
838
+ current.context.mutate(() => {
839
+ current.data.files.push({
840
+ url,
841
+ name,
842
+ size: 0,
843
+ extension: getExtension(name),
844
+ })
845
+ this.#renderFilled(wrapper)
846
+ })
847
+ },
848
+ })
849
+ }
850
+
851
+ /** @param {HTMLElement} wrapper @returns {void} */
852
+ #triggerFileInput(wrapper) {
853
+ const s = stateMap.get(wrapper)
854
+ if (!s || s.context.readOnly) return
855
+ triggerFileInput({
856
+ multiple: true,
857
+ signal: s.viewController?.signal,
858
+ onFiles: (files) => { void this.#handleFiles(wrapper, files) },
859
+ })
860
+ }
861
+
862
+ /**
863
+ * Start an asynchronous additive operation owned by one block.
864
+ * View re-renders do not cancel it; block destruction or explicit removal does.
865
+ * @param {HTMLElement} wrapper
866
+ * @returns {{ state: AttachesState, controller: AbortController } | null}
867
+ */
868
+ #startTask(wrapper) {
869
+ const state = stateMap.get(wrapper)
870
+ if (!state || state.context.readOnly) return null
871
+ const controller = new AbortController()
872
+ state.taskControllers.add(controller)
873
+ wrapper.classList.add('oe-attaches--loading')
874
+ return { state, controller }
875
+ }
876
+
877
+ /**
878
+ * @param {HTMLElement} wrapper Block wrapper whose loading state is updated.
879
+ * @param {AttachesState} state State that owns the asynchronous task.
880
+ * @param {AbortController} controller Completed task controller.
881
+ * @returns {void}
882
+ */
883
+ #finishTask(wrapper, state, controller) {
884
+ state.taskControllers.delete(controller)
885
+ if (state.taskControllers.size === 0) wrapper.classList.remove('oe-attaches--loading')
886
+ }
887
+
888
+ /** @param {HTMLElement} wrapper @returns {void} */
889
+ #cancelTasks(wrapper) {
890
+ const state = stateMap.get(wrapper)
891
+ if (!state) return
892
+ for (const controller of state.taskControllers) controller.abort()
893
+ state.taskControllers.clear()
894
+ wrapper.classList.remove('oe-attaches--loading')
895
+ }
896
+
897
+ /**
898
+ * @param {HTMLElement} wrapper Block wrapper that receives uploaded files.
899
+ * @param {FileList | File[]} fileList Browser files selected or dropped by the user.
900
+ * @returns {Promise<void>}
901
+ */
902
+ async #handleFiles(wrapper, fileList) {
903
+ const files = /** @type {File[]} */ (Array.from(fileList))
904
+ if (files.length === 0) return
905
+ const task = this.#startTask(wrapper)
906
+ if (!task) return
907
+ const { state: s, controller } = task
908
+ const { signal } = controller
909
+
910
+ try {
911
+ const uploadFile = this.#config.uploadFile
912
+ if (uploadFile) {
913
+ const added = []
914
+ for (const file of files) {
915
+ if (signal.aborted) break
916
+ try {
917
+ const ext = getExtension(file.name)
918
+ const result = await uploadFile(file, { signal })
919
+ const url = sanitizeUrl(String(result?.url || ''), { policy: 'download', fallback: '' })
920
+ const resultSize = Number(result?.size)
921
+ const size = Number.isFinite(resultSize) && resultSize >= 0 ? resultSize : file.size
922
+ if (url) added.push({ url, name: file.name, size, extension: ext })
923
+ } catch (error) {
924
+ if (signal.aborted) break
925
+ console.warn(`[Attaches] Failed to upload "${file.name}":`, error)
926
+ }
927
+ }
928
+ if (!signal.aborted && stateMap.get(wrapper) === s && added.length > 0) {
929
+ s.context.mutate(() => {
930
+ s.data.files.push(...added)
931
+ this.#renderFilled(wrapper)
932
+ })
933
+ }
934
+ } else {
935
+ const added = []
936
+ for (const file of files) {
937
+ if (signal.aborted) break
938
+ const ext = getExtension(file.name)
939
+ try {
940
+ const url = URL.createObjectURL(file)
941
+ this.#objectUrls.add(url)
942
+ added.push({ url, name: file.name, size: file.size, extension: ext })
943
+ } catch (error) {
944
+ console.warn(`[Attaches] Failed to open "${file.name}":`, error)
945
+ }
946
+ }
947
+ if (!signal.aborted && stateMap.get(wrapper) === s && added.length > 0) {
948
+ s.context.mutate(() => {
949
+ s.data.files.push(...added)
950
+ this.#renderFilled(wrapper)
951
+ })
952
+ }
953
+ }
954
+ } finally {
955
+ this.#finishTask(wrapper, s, controller)
956
+ }
957
+ }
958
+
959
+ /**
960
+ * Add files selected by an application source such as a media library.
961
+ * One completed selection becomes one editor history operation.
962
+ * @param {HTMLElement} wrapper
963
+ * @param {(context: { signal: AbortSignal }) => Promise<Array<{ url: string, name: string, size?: number, extension?: string }> | null>} handler
964
+ * @returns {Promise<void>}
965
+ */
966
+ async #runCustomAction(wrapper, handler) {
967
+ const task = this.#startTask(wrapper)
968
+ if (!task) return
969
+ const { state: s, controller } = task
970
+ const { signal } = controller
971
+ try {
972
+ const result = await handler({ signal })
973
+ if (!Array.isArray(result) || signal.aborted || stateMap.get(wrapper) !== s) return
974
+ const added = result.flatMap(item => {
975
+ const url = sanitizeUrl(String(item?.url || ''), { policy: 'download', fallback: '' })
976
+ const name = String(item?.name || '').trim()
977
+ if (!url || !name) return []
978
+ const size = Number(item?.size)
979
+ return [{
980
+ url,
981
+ name,
982
+ size: Number.isFinite(size) && size >= 0 ? size : 0,
983
+ extension: String(item?.extension || getExtension(name)),
984
+ }]
985
+ })
986
+ if (!added.length) return
987
+ s.context.mutate(() => {
988
+ s.data.files.push(...added)
989
+ this.#renderFilled(wrapper)
990
+ })
991
+ } catch (error) {
992
+ if (!signal.aborted) console.warn('[Attaches] Application source failed:', error)
993
+ } finally {
994
+ this.#finishTask(wrapper, s, controller)
995
+ }
996
+ }
997
+ }