@shelamkoff/rector 1.0.1 → 1.0.3

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