@shelamkoff/rector 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +25 -24
  2. package/dist/core/clipboard/Clipboard.d.ts +2 -0
  3. package/dist/core/clipboard/Clipboard.js +52 -24
  4. package/dist/core/index.js +195 -194
  5. package/dist/core/locale/en.d.ts +1 -0
  6. package/dist/core/locale/en.js +1 -0
  7. package/dist/core/locale/ru.d.ts +1 -0
  8. package/dist/core/locale/ru.js +1 -0
  9. package/dist/core/themes/light.css +1 -1
  10. package/dist/core/themes/variables.css +72 -10
  11. package/dist/locale/en.d.ts +41 -0
  12. package/dist/locale/ru.d.ts +41 -0
  13. package/dist/plugins/attaches/attaches.css +16 -14
  14. package/dist/plugins/attaches/index.js +997 -920
  15. package/dist/plugins/attaches/locale/en.d.ts +9 -0
  16. package/dist/plugins/attaches/locale/en.js +11 -2
  17. package/dist/plugins/attaches/locale/ru.d.ts +9 -0
  18. package/dist/plugins/attaches/locale/ru.js +11 -2
  19. package/dist/plugins/carousel/carousel.css +213 -49
  20. package/dist/plugins/carousel/index.js +918 -858
  21. package/dist/plugins/carousel/locale/en.d.ts +15 -0
  22. package/dist/plugins/carousel/locale/en.js +18 -3
  23. package/dist/plugins/carousel/locale/ru.d.ts +15 -0
  24. package/dist/plugins/carousel/locale/ru.js +18 -3
  25. package/dist/plugins/gallery/gallery.css +6 -1
  26. package/dist/plugins/gallery/index.js +375 -354
  27. package/dist/plugins/gallery/locale/en.d.ts +8 -0
  28. package/dist/plugins/gallery/locale/en.js +11 -3
  29. package/dist/plugins/gallery/locale/ru.d.ts +8 -0
  30. package/dist/plugins/gallery/locale/ru.js +11 -3
  31. package/dist/plugins/gallery/view-empty.d.ts +2 -0
  32. package/dist/plugins/gallery/view-empty.js +21 -15
  33. package/dist/plugins/gallery/view-filled.d.ts +2 -2
  34. package/dist/plugins/gallery/view-filled.js +61 -56
  35. package/dist/plugins/image/image.css +7 -2
  36. package/dist/plugins/image/index.js +365 -345
  37. package/dist/plugins/image/locale/en.d.ts +8 -0
  38. package/dist/plugins/image/locale/en.js +11 -3
  39. package/dist/plugins/image/locale/ru.d.ts +8 -0
  40. package/dist/plugins/image/locale/ru.js +11 -3
  41. package/dist/plugins/image/view-empty.d.ts +2 -0
  42. package/dist/plugins/image/view-empty.js +21 -15
  43. package/dist/plugins/image/view-filled.d.ts +2 -2
  44. package/dist/plugins/image/view-filled.js +77 -72
  45. package/dist/plugins/shared/dropzone.d.ts +6 -0
  46. package/dist/plugins/shared/dropzone.js +47 -36
  47. package/dist/plugins/shared/layer.d.ts +13 -0
  48. package/dist/plugins/shared/layer.js +45 -0
  49. package/dist/plugins/shared/sourceEditor.css +165 -0
  50. package/dist/plugins/shared/sourceEditor.d.ts +97 -0
  51. package/dist/plugins/shared/sourceEditor.js +260 -0
  52. package/dist/renderer/renderers/carousel/index.js +24 -23
  53. package/dist/renderer/renderers/carousel/styles.css +8 -2
  54. package/package.json +32 -32
@@ -6,6 +6,14 @@ declare const _default: {
6
6
  'plugin.image.dropzone': string;
7
7
  'plugin.image.dropzoneUpload': string;
8
8
  'plugin.image.dropzoneText': string;
9
+ 'plugin.image.dropzoneUrlPrefix': string;
10
+ 'plugin.image.dropzoneUrl': string;
11
+ 'plugin.image.urlEditorTitle': string;
12
+ 'plugin.image.urlEditorLabel': string;
13
+ 'plugin.image.urlEditorPlaceholder': string;
14
+ 'plugin.image.sourceSubmit': string;
15
+ 'plugin.image.sourceCancel': string;
16
+ 'plugin.image.invalidUrl': string;
9
17
  'plugin.image.replace': string;
10
18
  'plugin.image.delete': string;
11
19
  'plugin.image.caption': string;
@@ -6,6 +6,14 @@ export default {
6
6
  'plugin.image.dropzone': 'or drag and drop a file here',
7
7
  'plugin.image.dropzoneUpload': 'Upload',
8
8
  'plugin.image.dropzoneText': 'an image from your device or drag and drop it here',
9
+ 'plugin.image.dropzoneUrlPrefix': 'or',
10
+ 'plugin.image.dropzoneUrl': 'insert by URL',
11
+ 'plugin.image.urlEditorTitle': 'Insert image by URL',
12
+ 'plugin.image.urlEditorLabel': 'Image URL',
13
+ 'plugin.image.urlEditorPlaceholder': 'https://example.com/image.jpg',
14
+ 'plugin.image.sourceSubmit': 'Insert',
15
+ 'plugin.image.sourceCancel': 'Cancel',
16
+ 'plugin.image.invalidUrl': 'Enter a valid image URL.',
9
17
  'plugin.image.replace': 'Replace',
10
18
  'plugin.image.delete': 'Delete',
11
19
  'plugin.image.caption': 'Caption',
@@ -33,6 +41,6 @@ export default {
33
41
  'plugin.image.value.fill': 'Fill',
34
42
  'plugin.image.value.scale-down': 'Scale down',
35
43
  'plugin.image.value.solid': 'Solid',
36
- 'plugin.image.value.dashed': 'Dashed',
37
- 'plugin.image.emptyReadonly': 'No image',
38
- }
44
+ 'plugin.image.value.dashed': 'Dashed',
45
+ 'plugin.image.emptyReadonly': 'No image',
46
+ }
@@ -6,6 +6,14 @@ declare const _default: {
6
6
  'plugin.image.dropzone': string;
7
7
  'plugin.image.dropzoneUpload': string;
8
8
  'plugin.image.dropzoneText': string;
9
+ 'plugin.image.dropzoneUrlPrefix': string;
10
+ 'plugin.image.dropzoneUrl': string;
11
+ 'plugin.image.urlEditorTitle': string;
12
+ 'plugin.image.urlEditorLabel': string;
13
+ 'plugin.image.urlEditorPlaceholder': string;
14
+ 'plugin.image.sourceSubmit': string;
15
+ 'plugin.image.sourceCancel': string;
16
+ 'plugin.image.invalidUrl': string;
9
17
  'plugin.image.replace': string;
10
18
  'plugin.image.delete': string;
11
19
  'plugin.image.caption': string;
@@ -6,6 +6,14 @@ export default {
6
6
  'plugin.image.dropzone': 'или перетащите файл сюда',
7
7
  'plugin.image.dropzoneUpload': 'Загрузите',
8
8
  'plugin.image.dropzoneText': 'изображение с устройства или перетащите его сюда',
9
+ 'plugin.image.dropzoneUrlPrefix': 'или',
10
+ 'plugin.image.dropzoneUrl': 'вставьте по URL',
11
+ 'plugin.image.urlEditorTitle': 'Вставка изображения по URL',
12
+ 'plugin.image.urlEditorLabel': 'URL изображения',
13
+ 'plugin.image.urlEditorPlaceholder': 'https://example.com/image.jpg',
14
+ 'plugin.image.sourceSubmit': 'Вставить',
15
+ 'plugin.image.sourceCancel': 'Отмена',
16
+ 'plugin.image.invalidUrl': 'Введите корректный URL изображения.',
9
17
  'plugin.image.replace': 'Заменить',
10
18
  'plugin.image.delete': 'Удалить',
11
19
  'plugin.image.caption': 'Подпись',
@@ -33,6 +41,6 @@ export default {
33
41
  'plugin.image.value.fill': 'Растянуть',
34
42
  'plugin.image.value.scale-down': 'Только уменьшать',
35
43
  'plugin.image.value.solid': 'Сплошная',
36
- 'plugin.image.value.dashed': 'Штриховая',
37
- 'plugin.image.emptyReadonly': 'Нет изображения',
38
- }
44
+ 'plugin.image.value.dashed': 'Штриховая',
45
+ 'plugin.image.emptyReadonly': 'Нет изображения',
46
+ }
@@ -4,6 +4,7 @@
4
4
  * @property {boolean} readOnly
5
5
  * @property {(file: File) => void} onFileDropped
6
6
  * @property {() => void} onUploadClick
7
+ * @property {() => void} onOpenUrlEditor
7
8
  * @property {Array<{ icon?: string, label: string, handler: (context: { signal: AbortSignal }) => Promise<{url: string, alt?: string} | null> }>} customActions
8
9
  * @property {(handler: (context: { signal: AbortSignal }) => Promise<{url: string, alt?: string} | null>) => Promise<void>} runCustomAction
9
10
  */
@@ -25,6 +26,7 @@ export type EmptyViewDeps = {
25
26
  readOnly: boolean;
26
27
  onFileDropped: (file: File) => void;
27
28
  onUploadClick: () => void;
29
+ onOpenUrlEditor: () => void;
28
30
  customActions: Array<{
29
31
  icon?: string;
30
32
  label: string;
@@ -1,14 +1,15 @@
1
- import { renderDropzone } from '../shared/dropzone.js'
2
- import { isSupportedImageFile } from '../shared/fileInput.js'
1
+ import { renderDropzone } from '../shared/dropzone.js'
2
+ import { isSupportedImageFile } from '../shared/fileInput.js'
3
3
  import { CSS } from './css.js'
4
4
  import { ICON_SELECT } from './icons.js'
5
5
 
6
6
  /**
7
- * @typedef {Object} EmptyViewDeps
8
- * @property {(key: string, fallback: string) => string} t
9
- * @property {boolean} readOnly
7
+ * @typedef {Object} EmptyViewDeps
8
+ * @property {(key: string, fallback: string) => string} t
9
+ * @property {boolean} readOnly
10
10
  * @property {(file: File) => void} onFileDropped
11
11
  * @property {() => void} onUploadClick
12
+ * @property {() => void} onOpenUrlEditor
12
13
  * @property {Array<{ icon?: string, label: string, handler: (context: { signal: AbortSignal }) => Promise<{url: string, alt?: string} | null> }>} customActions
13
14
  * @property {(handler: (context: { signal: AbortSignal }) => Promise<{url: string, alt?: string} | null>) => Promise<void>} runCustomAction
14
15
  */
@@ -20,11 +21,11 @@ import { ICON_SELECT } from './icons.js'
20
21
  * Listeners are attached with the state's AbortSignal so they're
21
22
  * automatically removed on the next render or on disposal.
22
23
  *
23
- * @param {HTMLElement} wrapper
24
- * @param {import('./state.js').ImageState} state
25
- * @param {EmptyViewDeps} deps
26
- * @returns {void}
27
- */
24
+ * @param {HTMLElement} wrapper
25
+ * @param {import('./state.js').ImageState} state
26
+ * @param {EmptyViewDeps} deps
27
+ * @returns {void}
28
+ */
28
29
  export function renderEmptyView(wrapper, state, deps) {
29
30
  state.resetTransient()
30
31
 
@@ -41,10 +42,15 @@ export function renderEmptyView(wrapper, state, deps) {
41
42
  filled: CSS.filled,
42
43
  }, {
43
44
  iconHtml: ICON_SELECT,
44
- uploadText: deps.t('dropzoneUpload', 'Upload'),
45
- afterText: deps.t('dropzoneText', 'an image from your device or drag and drop it here'),
46
- readOnly: deps.readOnly,
47
- emptyText: deps.t('emptyReadonly', 'No image'),
45
+ uploadText: deps.t('dropzoneUpload', 'Upload'),
46
+ afterText: deps.t('dropzoneText', 'an image from your device or drag and drop it here'),
47
+ inlineActions: [{
48
+ prefix: deps.t('dropzoneUrlPrefix', 'or'),
49
+ label: deps.t('dropzoneUrl', 'insert by URL'),
50
+ onSelect: deps.onOpenUrlEditor,
51
+ }],
52
+ readOnly: deps.readOnly,
53
+ emptyText: deps.t('emptyReadonly', 'No image'),
48
54
  onUploadClick: deps.onUploadClick,
49
55
  actions: deps.customActions.map(action => ({
50
56
  icon: action.icon,
@@ -53,7 +59,7 @@ export function renderEmptyView(wrapper, state, deps) {
53
59
  })),
54
60
  onDrop: (dt) => {
55
61
  const file = dt.files[0]
56
- if (file && isSupportedImageFile(file)) {
62
+ if (file && isSupportedImageFile(file)) {
57
63
  deps.onFileDropped(file)
58
64
  }
59
65
  },
@@ -3,7 +3,7 @@
3
3
  * @property {(key: string, fallback: string) => string} t
4
4
  * @property {boolean} readOnly
5
5
  * @property {() => void} onTriggerFileInput
6
- * @property {() => void} onPromptUrl
6
+ * @property {() => void} onOpenUrlEditor
7
7
  * @property {() => void} onDelete
8
8
  * @property {(operation: () => void) => void} mutate
9
9
  * @property {Array<{ icon?: string, label: string, handler: (context: { signal: AbortSignal }) => Promise<{url: string, alt?: string} | null> }>} customActions
@@ -27,7 +27,7 @@ export type FilledViewDeps = {
27
27
  t: (key: string, fallback: string) => string;
28
28
  readOnly: boolean;
29
29
  onTriggerFileInput: () => void;
30
- onPromptUrl: () => void;
30
+ onOpenUrlEditor: () => void;
31
31
  onDelete: () => void;
32
32
  mutate: (operation: () => void) => void;
33
33
  customActions: Array<{
@@ -8,13 +8,14 @@ import {
8
8
  } from './icons.js'
9
9
  import { applyInlineStyles } from './styles.js'
10
10
  import { buildSettingsPanel } from './settings.js'
11
+ import { createPluginLayer } from '../shared/layer.js'
11
12
 
12
13
  /**
13
- * @typedef {Object} FilledViewDeps
14
- * @property {(key: string, fallback: string) => string} t
15
- * @property {boolean} readOnly
14
+ * @typedef {Object} FilledViewDeps
15
+ * @property {(key: string, fallback: string) => string} t
16
+ * @property {boolean} readOnly
16
17
  * @property {() => void} onTriggerFileInput
17
- * @property {() => void} onPromptUrl
18
+ * @property {() => void} onOpenUrlEditor
18
19
  * @property {() => void} onDelete
19
20
  * @property {(operation: () => void) => void} mutate
20
21
  * @property {Array<{ icon?: string, label: string, handler: (context: { signal: AbortSignal }) => Promise<{url: string, alt?: string} | null> }>} customActions
@@ -29,11 +30,11 @@ import { buildSettingsPanel } from './settings.js'
29
30
  * Listeners use the state's AbortSignal — automatically removed on the next
30
31
  * render or on disposal.
31
32
  *
32
- * @param {HTMLElement} wrapper
33
- * @param {import('./state.js').ImageState} state
34
- * @param {FilledViewDeps} deps
35
- * @returns {void}
36
- */
33
+ * @param {HTMLElement} wrapper
34
+ * @param {import('./state.js').ImageState} state
35
+ * @param {FilledViewDeps} deps
36
+ * @returns {void}
37
+ */
37
38
  export function renderFilledView(wrapper, state, deps) {
38
39
  state.resetTransient()
39
40
  wrapper.innerHTML = ''
@@ -45,24 +46,24 @@ export function renderFilledView(wrapper, state, deps) {
45
46
  container.className = CSS.imageContainer
46
47
 
47
48
  const img = document.createElement('img')
48
- img.className = CSS.image
49
- setSafeUrlAttribute(img, 'src', state.data.file.url, 'media')
50
- applyInlineStyles(state, img, container)
49
+ img.className = CSS.image
50
+ setSafeUrlAttribute(img, 'src', state.data.file.url, 'media')
51
+ applyInlineStyles(state, img, container)
51
52
  container.appendChild(img)
52
53
 
53
- // Caption
54
- const caption = renderCaption(state, signal, deps)
55
- img.alt = caption.textContent?.trim() || ''
56
- if (!deps.readOnly) {
57
- caption.addEventListener('input', () => {
58
- img.alt = caption.textContent?.trim() || ''
59
- }, { signal })
60
- }
54
+ // Caption
55
+ const caption = renderCaption(state, signal, deps)
56
+ img.alt = caption.textContent?.trim() || ''
57
+ if (!deps.readOnly) {
58
+ caption.addEventListener('input', () => {
59
+ img.alt = caption.textContent?.trim() || ''
60
+ }, { signal })
61
+ }
61
62
  container.appendChild(caption)
62
63
  wrapper.appendChild(container)
63
64
 
64
65
  // Action bar
65
- if (!deps.readOnly) wrapper.appendChild(renderActions(wrapper, state, deps, signal))
66
+ if (!deps.readOnly) wrapper.appendChild(renderActions(wrapper, state, deps, signal))
66
67
  }
67
68
 
68
69
  /**
@@ -74,7 +75,7 @@ export function renderFilledView(wrapper, state, deps) {
74
75
  function renderCaption(state, signal, deps) {
75
76
  const caption = document.createElement('div')
76
77
  caption.className = CSS.caption
77
- caption.contentEditable = deps.readOnly ? 'false' : 'true'
78
+ caption.contentEditable = deps.readOnly ? 'false' : 'true'
78
79
  caption.dataset.placeholder = deps.t('caption', 'Caption')
79
80
 
80
81
  if (state.data.caption) {
@@ -96,23 +97,23 @@ function renderCaption(state, signal, deps) {
96
97
  }
97
98
  }
98
99
 
99
- if (!deps.readOnly) {
100
- caption.addEventListener('input', syncEmpty, { signal })
101
- caption.addEventListener('focus', () => caption.removeAttribute('data-empty'), { signal })
102
- caption.addEventListener('blur', () => {
103
- if (!caption.textContent?.trim()) {
104
- caption.innerHTML = ''
105
- caption.setAttribute('data-empty', 'true')
106
- state.data.caption = ''
107
- }
108
- }, { signal })
109
- caption.addEventListener('keydown', (e) => {
110
- if (e.key === 'Backspace' && !caption.textContent?.trim()) {
111
- e.preventDefault()
112
- e.stopPropagation()
113
- }
114
- }, { signal })
115
- }
100
+ if (!deps.readOnly) {
101
+ caption.addEventListener('input', syncEmpty, { signal })
102
+ caption.addEventListener('focus', () => caption.removeAttribute('data-empty'), { signal })
103
+ caption.addEventListener('blur', () => {
104
+ if (!caption.textContent?.trim()) {
105
+ caption.innerHTML = ''
106
+ caption.setAttribute('data-empty', 'true')
107
+ state.data.caption = ''
108
+ }
109
+ }, { signal })
110
+ caption.addEventListener('keydown', (e) => {
111
+ if (e.key === 'Backspace' && !caption.textContent?.trim()) {
112
+ e.preventDefault()
113
+ e.stopPropagation()
114
+ }
115
+ }, { signal })
116
+ }
116
117
 
117
118
  return caption
118
119
  }
@@ -135,27 +136,33 @@ function renderActions(wrapper, state, deps, signal) {
135
136
  dropdown.className = CSS.dropdown
136
137
 
137
138
  const settingsBtn = document.createElement('button')
138
- settingsBtn.type = 'button'
139
- settingsBtn.className = CSS.actionBtn
140
- settingsBtn.innerHTML = `${ICON_SETTINGS} ${deps.t('settings', 'Settings')}`
141
- settingsBtn.setAttribute('aria-haspopup', 'true')
142
- settingsBtn.setAttribute('aria-expanded', 'false')
143
-
144
- const panel = buildSettingsPanel(wrapper, state, settingsDeps)
145
- panel.setAttribute('role', 'group')
146
- panel.setAttribute('aria-label', deps.t('settings', 'Settings'))
139
+ settingsBtn.type = 'button'
140
+ settingsBtn.className = CSS.actionBtn
141
+ settingsBtn.innerHTML = `${ICON_SETTINGS} ${deps.t('settings', 'Settings')}`
142
+ settingsBtn.setAttribute('aria-haspopup', 'true')
143
+ settingsBtn.setAttribute('aria-expanded', 'false')
144
+
145
+ const panel = buildSettingsPanel(wrapper, state, settingsDeps)
146
+ panel.setAttribute('role', 'group')
147
+ panel.setAttribute('aria-label', deps.t('settings', 'Settings'))
148
+ const settingsLayer = createPluginLayer(wrapper, signal)
149
+ const closeSettings = () => {
150
+ dropdown.classList.remove(CSS.dropdownOpen)
151
+ settingsBtn.setAttribute('aria-expanded', 'false')
152
+ settingsLayer.close()
153
+ }
147
154
 
148
155
  settingsBtn.addEventListener('click', (e) => {
149
156
  e.stopPropagation()
150
157
  const isOpen = dropdown.classList.contains(CSS.dropdownOpen)
151
- if (isOpen) {
152
- dropdown.classList.remove(CSS.dropdownOpen)
153
- settingsBtn.setAttribute('aria-expanded', 'false')
154
- } else {
158
+ if (isOpen) {
159
+ closeSettings()
160
+ } else {
161
+ settingsLayer.open()
155
162
  panel.style.top = ''
156
163
  panel.style.bottom = ''
157
- dropdown.classList.add(CSS.dropdownOpen)
158
- settingsBtn.setAttribute('aria-expanded', 'true')
164
+ dropdown.classList.add(CSS.dropdownOpen)
165
+ settingsBtn.setAttribute('aria-expanded', 'true')
159
166
 
160
167
  const btnRect = settingsBtn.getBoundingClientRect()
161
168
  const panelRect = panel.getBoundingClientRect()
@@ -173,19 +180,17 @@ function renderActions(wrapper, state, deps, signal) {
173
180
  dropdown.append(settingsBtn, panel)
174
181
 
175
182
  document.addEventListener('click', (e) => {
176
- if (!dropdown.contains(/** @type {Node} */ (e.target))) {
177
- dropdown.classList.remove(CSS.dropdownOpen)
178
- settingsBtn.setAttribute('aria-expanded', 'false')
179
- }
180
- }, { signal })
181
-
182
- dropdown.addEventListener('keydown', (event) => {
183
- if (event.key !== 'Escape' || !dropdown.classList.contains(CSS.dropdownOpen)) return
184
- event.preventDefault()
185
- dropdown.classList.remove(CSS.dropdownOpen)
186
- settingsBtn.setAttribute('aria-expanded', 'false')
187
- settingsBtn.focus()
188
- }, { signal })
183
+ if (!dropdown.contains(/** @type {Node} */ (e.target))) {
184
+ closeSettings()
185
+ }
186
+ }, { signal })
187
+
188
+ dropdown.addEventListener('keydown', (event) => {
189
+ if (event.key !== 'Escape' || !dropdown.classList.contains(CSS.dropdownOpen)) return
190
+ event.preventDefault()
191
+ closeSettings()
192
+ settingsBtn.focus()
193
+ }, { signal })
189
194
 
190
195
  const sep1 = makeSep()
191
196
 
@@ -209,10 +214,10 @@ function renderActions(wrapper, state, deps, signal) {
209
214
 
210
215
  // Delete (icon only)
211
216
  const deleteBtn = document.createElement('button')
212
- deleteBtn.type = 'button'
213
- deleteBtn.className = `${CSS.actionBtn} ${CSS.actionBtnDanger}`
214
- deleteBtn.innerHTML = ICON_TRASH
215
- deleteBtn.setAttribute('aria-label', deps.t('delete', 'Delete'))
217
+ deleteBtn.type = 'button'
218
+ deleteBtn.className = `${CSS.actionBtn} ${CSS.actionBtnDanger}`
219
+ deleteBtn.innerHTML = ICON_TRASH
220
+ deleteBtn.setAttribute('aria-label', deps.t('delete', 'Delete'))
216
221
  deleteBtn.addEventListener('mousedown', (e) => e.preventDefault(), { signal })
217
222
  deleteBtn.addEventListener('click', (e) => {
218
223
  e.stopPropagation()
@@ -264,7 +269,7 @@ function showReplaceView(actions, mainView, deps, signal) {
264
269
 
265
270
  view.appendChild(makeActionBtn(
266
271
  `${ICON_URL} URL`,
267
- () => { deps.onPromptUrl(); restore() },
272
+ () => { deps.onOpenUrlEditor(); restore() },
268
273
  signal,
269
274
  ))
270
275
 
@@ -20,6 +20,7 @@
20
20
  * @property {string} afterText Localized text after the upload link.
21
21
  * @property {() => void} onUploadClick Opens the file input.
22
22
  * @property {(dataTransfer: DataTransfer) => void} onDrop Handles dropped files.
23
+ * @property {Array<{ label: string, prefix?: string, onSelect: () => void }>} [inlineActions]
23
24
  * @property {Array<{ icon?: string, label: string, onSelect: () => void }>} [actions]
24
25
  * @property {boolean} [readOnly] Render a non-interactive empty state.
25
26
  * @property {string} [emptyText] Text shown for an empty read-only block.
@@ -90,6 +91,11 @@ export type DropzoneConfig = {
90
91
  * Handles dropped files.
91
92
  */
92
93
  onDrop: (dataTransfer: DataTransfer) => void;
94
+ inlineActions?: Array<{
95
+ label: string;
96
+ prefix?: string;
97
+ onSelect: () => void;
98
+ }>;
93
99
  actions?: Array<{
94
100
  icon?: string;
95
101
  label: string;
@@ -5,38 +5,39 @@
5
5
 
6
6
  /**
7
7
  * @typedef {Object} DropzoneCssMap
8
- * @property {string} select Wrapper class.
9
- * @property {string} selectIcon Icon container class.
10
- * @property {string} selectText Text container class.
11
- * @property {string} selectLink Upload link class.
12
- * @property {string} dropzoneActive Class added during dragover.
13
- * @property {string} filled Class removed from the wrapper during rendering.
14
- * @property {string} [selectActions] Optional application-source list class.
15
- * @property {string} [selectAction] Optional application-source button class.
8
+ * @property {string} select Wrapper class.
9
+ * @property {string} selectIcon Icon container class.
10
+ * @property {string} selectText Text container class.
11
+ * @property {string} selectLink Upload link class.
12
+ * @property {string} dropzoneActive Class added during dragover.
13
+ * @property {string} filled Class removed from the wrapper during rendering.
14
+ * @property {string} [selectActions] Optional application-source list class.
15
+ * @property {string} [selectAction] Optional application-source button class.
16
16
  */
17
17
 
18
18
  /**
19
19
  * @typedef {Object} DropzoneConfig
20
- * @property {string} iconHtml SVG icon markup.
21
- * @property {string} uploadText Localized upload-link text.
22
- * @property {string} afterText Localized text after the upload link.
23
- * @property {() => void} onUploadClick Opens the file input.
24
- * @property {(dataTransfer: DataTransfer) => void} onDrop Handles dropped files.
25
- * @property {Array<{ icon?: string, label: string, onSelect: () => void }>} [actions]
26
- * @property {boolean} [readOnly] Render a non-interactive empty state.
27
- * @property {string} [emptyText] Text shown for an empty read-only block.
20
+ * @property {string} iconHtml SVG icon markup.
21
+ * @property {string} uploadText Localized upload-link text.
22
+ * @property {string} afterText Localized text after the upload link.
23
+ * @property {() => void} onUploadClick Opens the file input.
24
+ * @property {(dataTransfer: DataTransfer) => void} onDrop Handles dropped files.
25
+ * @property {Array<{ label: string, prefix?: string, onSelect: () => void }>} [inlineActions]
26
+ * @property {Array<{ icon?: string, label: string, onSelect: () => void }>} [actions]
27
+ * @property {boolean} [readOnly] Render a non-interactive empty state.
28
+ * @property {string} [emptyText] Text shown for an empty read-only block.
28
29
  */
29
30
 
30
31
  /**
31
32
  * Render a dropzone (empty-state) view into a wrapper element.
32
33
  * Clears the wrapper, removes the `filled` class, and wires drag-and-drop.
33
34
  *
34
- * @param {HTMLElement} wrapper Block wrapper element.
35
- * @param {AbortSignal} signal Enables automatic listener cleanup.
36
- * @param {DropzoneCssMap} css — plugin-specific CSS class names
37
- * @param {DropzoneConfig} config — icon, text, and callbacks
38
- * @returns {void}
39
- */
35
+ * @param {HTMLElement} wrapper Block wrapper element.
36
+ * @param {AbortSignal} signal Enables automatic listener cleanup.
37
+ * @param {DropzoneCssMap} css — plugin-specific CSS class names
38
+ * @param {DropzoneConfig} config — icon, text, and callbacks
39
+ * @returns {void}
40
+ */
40
41
  export function renderDropzone(wrapper, signal, css, config) {
41
42
  wrapper.innerHTML = ''
42
43
  wrapper.classList.remove(css.filled)
@@ -48,17 +49,17 @@ export function renderDropzone(wrapper, signal, css, config) {
48
49
  icon.className = css.selectIcon
49
50
  icon.innerHTML = config.iconHtml
50
51
 
51
- const text = document.createElement('div')
52
- text.className = css.selectText
53
-
54
- if (config.readOnly) {
55
- text.textContent = config.emptyText || config.afterText
56
- select.append(icon, text)
57
- wrapper.appendChild(select)
58
- return
59
- }
60
-
61
- const uploadLink = document.createElement('button')
52
+ const text = document.createElement('div')
53
+ text.className = css.selectText
54
+
55
+ if (config.readOnly) {
56
+ text.textContent = config.emptyText || config.afterText
57
+ select.append(icon, text)
58
+ wrapper.appendChild(select)
59
+ return
60
+ }
61
+
62
+ const uploadLink = document.createElement('button')
62
63
  uploadLink.type = 'button'
63
64
  uploadLink.className = css.selectLink
64
65
  uploadLink.textContent = config.uploadText
@@ -67,9 +68,19 @@ export function renderDropzone(wrapper, signal, css, config) {
67
68
  config.onUploadClick()
68
69
  }, { signal })
69
70
 
70
- const afterText = document.createTextNode(' ' + config.afterText)
71
-
72
- text.append(uploadLink, afterText)
71
+ text.append(uploadLink, document.createTextNode(' ' + config.afterText))
72
+ for (const action of config.inlineActions || []) {
73
+ if (action.prefix) text.append(document.createTextNode(' ' + action.prefix + ' '))
74
+ const button = document.createElement('button')
75
+ button.type = 'button'
76
+ button.className = css.selectLink
77
+ button.textContent = action.label
78
+ button.addEventListener('click', (event) => {
79
+ event.stopPropagation()
80
+ action.onSelect()
81
+ }, { signal })
82
+ text.appendChild(button)
83
+ }
73
84
  select.append(icon, text)
74
85
 
75
86
  if (css.selectActions && css.selectAction && config.actions?.length) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Keep a plugin surface above neighbouring editor blocks while it is open.
3
+ * Calls are reference-counted so a source editor can coexist with a settings
4
+ * panel owned by the same block.
5
+ *
6
+ * @param {HTMLElement} owner Element inside the plugin block.
7
+ * @param {AbortSignal} signal Plugin render lifecycle signal.
8
+ * @returns {{ open: () => void, close: () => void }}
9
+ */
10
+ export function createPluginLayer(owner: HTMLElement, signal: AbortSignal): {
11
+ open: () => void;
12
+ close: () => void;
13
+ };
@@ -0,0 +1,45 @@
1
+ const openLayers = new WeakMap()
2
+
3
+ /**
4
+ * Keep a plugin surface above neighbouring editor blocks while it is open.
5
+ * Calls are reference-counted so a source editor can coexist with a settings
6
+ * panel owned by the same block.
7
+ *
8
+ * @param {HTMLElement} owner Element inside the plugin block.
9
+ * @param {AbortSignal} signal Plugin render lifecycle signal.
10
+ * @returns {{ open: () => void, close: () => void }}
11
+ */
12
+ export function createPluginLayer(owner, signal) {
13
+ /** @type {HTMLElement | null} */
14
+ let block = null
15
+ let opened = false
16
+
17
+ const close = () => {
18
+ if (!opened || !block) return
19
+ const currentBlock = block
20
+ opened = false
21
+ block = null
22
+ const next = Math.max(0, (openLayers.get(currentBlock) || 1) - 1)
23
+ if (next === 0) {
24
+ openLayers.delete(currentBlock)
25
+ currentBlock.removeAttribute('data-oe-layer-open')
26
+ return
27
+ }
28
+ openLayers.set(currentBlock, next)
29
+ }
30
+
31
+ signal.addEventListener('abort', close, { once: true })
32
+
33
+ return {
34
+ open() {
35
+ if (opened || signal.aborted) return
36
+ const candidate = owner.closest('.oe-block')
37
+ if (!(candidate instanceof HTMLElement)) return
38
+ block = candidate
39
+ opened = true
40
+ openLayers.set(block, (openLayers.get(block) || 0) + 1)
41
+ block.setAttribute('data-oe-layer-open', 'true')
42
+ },
43
+ close,
44
+ }
45
+ }