@getdom/studio 0.1.0

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 (213) hide show
  1. package/LICENSE.md +109 -0
  2. package/README.md +44 -0
  3. package/SKILL.md +83 -0
  4. package/package.json +73 -0
  5. package/public/llms.txt +57 -0
  6. package/public/skills.sh +11 -0
  7. package/src/pages/_layout/componentCatalog.generated.js +4514 -0
  8. package/src/pages/_layout/componentInspector.js +60 -0
  9. package/src/pages/_layout/componentManager.js +192 -0
  10. package/src/pages/_layout/componentRuntimeRecords.js +49 -0
  11. package/src/pages/_layout/inspector/componentRegistry.js +639 -0
  12. package/src/pages/_layout/inspector/serialize.js +134 -0
  13. package/src/pages/_layout/inspector/useInspector.js +293 -0
  14. package/src/pages/components/accordion/DomAccordion.vue +39 -0
  15. package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
  16. package/src/pages/components/button/DomButton.vue +132 -0
  17. package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
  18. package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
  19. package/src/pages/components/dialog/DomDialog.vue +271 -0
  20. package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
  21. package/src/pages/components/dialog/DomDialogStack.vue +289 -0
  22. package/src/pages/components/dialog/useDialogs.js +253 -0
  23. package/src/pages/components/drawer/DomDrawer.vue +159 -0
  24. package/src/pages/components/dropdown/DomDropdown.vue +261 -0
  25. package/src/pages/components/icon-button/DomIconButton.vue +155 -0
  26. package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
  27. package/src/pages/components/menu/DomMenu.vue +359 -0
  28. package/src/pages/components/menu/DomMenuItem.vue +233 -0
  29. package/src/pages/components/menu/DomMenuLabel.vue +19 -0
  30. package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
  31. package/src/pages/components/menu/menuContext.js +11 -0
  32. package/src/pages/components/menu/menuPlacement.js +26 -0
  33. package/src/pages/components/menu/submenu.js +58 -0
  34. package/src/pages/components/pagination/DomPagination.vue +265 -0
  35. package/src/pages/components/playground/DomRenderer.vue +48 -0
  36. package/src/pages/components/popover/DomPopover.vue +244 -0
  37. package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
  38. package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
  39. package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
  40. package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
  41. package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
  42. package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
  43. package/src/pages/components/stepper/DomStepper.vue +386 -0
  44. package/src/pages/components/tabs/DomTabs.vue +149 -0
  45. package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
  46. package/src/pages/components/toast/DomToastItem.vue +52 -0
  47. package/src/pages/components/toast/DomToastStack.vue +768 -0
  48. package/src/pages/components/toast/useToasts.js +237 -0
  49. package/src/pages/components/toolbar/DomToolbar.vue +158 -0
  50. package/src/pages/components/tooltip/DomTooltip.vue +69 -0
  51. package/src/pages/components/tooltip/vDomTooltip.js +106 -0
  52. package/src/pages/components/tree-view/DomTreeView.vue +961 -0
  53. package/src/pages/components/tree-view/treeSchemas.js +129 -0
  54. package/src/pages/components/tree-view/treeUtils.js +228 -0
  55. package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
  56. package/src/pages/data/_shared/resourceRecord.js +29 -0
  57. package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
  58. package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
  59. package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
  60. package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
  61. package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
  62. package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
  63. package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
  64. package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
  65. package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
  66. package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
  67. package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
  68. package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
  69. package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
  70. package/src/pages/data/kanban/DomKanban.vue +1703 -0
  71. package/src/pages/data/kanban/kanbanApi.js +80 -0
  72. package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
  73. package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
  74. package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
  75. package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
  76. package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
  77. package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
  78. package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
  79. package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
  80. package/src/pages/forms/_shared/options.js +61 -0
  81. package/src/pages/forms/_shared/useOptionMenu.js +59 -0
  82. package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
  83. package/src/pages/forms/calendar/DomCalendar.vue +442 -0
  84. package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
  85. package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
  86. package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
  87. package/src/pages/forms/color-input/DomColorInput.vue +118 -0
  88. package/src/pages/forms/combobox/DomCombobox.vue +237 -0
  89. package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
  90. package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
  91. package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
  92. package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
  93. package/src/pages/forms/field/DomField.vue +267 -0
  94. package/src/pages/forms/field/fieldLayout.js +1 -0
  95. package/src/pages/forms/field/fieldProps.js +114 -0
  96. package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
  97. package/src/pages/forms/field/useField.js +343 -0
  98. package/src/pages/forms/field/validators.js +364 -0
  99. package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
  100. package/src/pages/forms/form/DomForm.vue +985 -0
  101. package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
  102. package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
  103. package/src/pages/forms/form/adapters/shared.js +53 -0
  104. package/src/pages/forms/form/adapters/zod.js +235 -0
  105. package/src/pages/forms/form/formApi.js +38 -0
  106. package/src/pages/forms/form/formDefinition.js +392 -0
  107. package/src/pages/forms/form/schemaAdapter.js +54 -0
  108. package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
  109. package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
  110. package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
  111. package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
  112. package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
  113. package/src/pages/forms/listbox/DomListbox.vue +105 -0
  114. package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
  115. package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
  116. package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
  117. package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
  118. package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
  119. package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
  120. package/src/pages/forms/password-strength/passwordStrength.js +92 -0
  121. package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
  122. package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
  123. package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
  124. package/src/pages/forms/select/DomSelect.vue +479 -0
  125. package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
  126. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
  127. package/src/pages/forms/text-input/DomTextInput.vue +48 -0
  128. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
  129. package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
  130. package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
  131. package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
  132. package/src/pages/forms/time-selector/timeUtils.js +194 -0
  133. package/src/pages/forms/toggle/DomToggle.vue +158 -0
  134. package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
  135. package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
  136. package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
  137. package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
  138. package/src/pages/lib/css/color.js +245 -0
  139. package/src/pages/lib/headless/accordion.d.ts +7 -0
  140. package/src/pages/lib/headless/accordion.js +246 -0
  141. package/src/pages/lib/headless/autocomplete.d.ts +4 -0
  142. package/src/pages/lib/headless/autocomplete.js +33 -0
  143. package/src/pages/lib/headless/base.d.ts +25 -0
  144. package/src/pages/lib/headless/base.js +127 -0
  145. package/src/pages/lib/headless/checkbox.d.ts +19 -0
  146. package/src/pages/lib/headless/checkbox.js +99 -0
  147. package/src/pages/lib/headless/combobox.d.ts +7 -0
  148. package/src/pages/lib/headless/combobox.js +488 -0
  149. package/src/pages/lib/headless/dialog.d.ts +23 -0
  150. package/src/pages/lib/headless/dialog.js +384 -0
  151. package/src/pages/lib/headless/drawer.d.ts +21 -0
  152. package/src/pages/lib/headless/drawer.js +682 -0
  153. package/src/pages/lib/headless/dropdown.d.ts +19 -0
  154. package/src/pages/lib/headless/dropdown.js +412 -0
  155. package/src/pages/lib/headless/floating-effect.js +118 -0
  156. package/src/pages/lib/headless/floating.d.ts +34 -0
  157. package/src/pages/lib/headless/floating.js +382 -0
  158. package/src/pages/lib/headless/index.d.ts +19 -0
  159. package/src/pages/lib/headless/index.js +19 -0
  160. package/src/pages/lib/headless/listbox.d.ts +19 -0
  161. package/src/pages/lib/headless/listbox.js +126 -0
  162. package/src/pages/lib/headless/menu.d.ts +18 -0
  163. package/src/pages/lib/headless/menu.js +117 -0
  164. package/src/pages/lib/headless/popover-panel.d.ts +18 -0
  165. package/src/pages/lib/headless/popover-panel.js +309 -0
  166. package/src/pages/lib/headless/popover.d.ts +19 -0
  167. package/src/pages/lib/headless/popover.js +391 -0
  168. package/src/pages/lib/headless/radio-group.d.ts +19 -0
  169. package/src/pages/lib/headless/radio-group.js +100 -0
  170. package/src/pages/lib/headless/tabs.d.ts +18 -0
  171. package/src/pages/lib/headless/tabs.js +119 -0
  172. package/src/pages/lib/headless/toast.d.ts +13 -0
  173. package/src/pages/lib/headless/toast.js +65 -0
  174. package/src/pages/lib/headless/toggle.d.ts +19 -0
  175. package/src/pages/lib/headless/toggle.js +162 -0
  176. package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
  177. package/src/pages/lib/headless/tooltip-controller.js +549 -0
  178. package/src/pages/lib/headless/tooltip.d.ts +19 -0
  179. package/src/pages/lib/headless/tooltip.js +153 -0
  180. package/src/pages/lib/headless/transitions.d.ts +5 -0
  181. package/src/pages/lib/headless/transitions.js +89 -0
  182. package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
  183. package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
  184. package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
  185. package/src/pages/lib/vue/codeHighlighter.js +132 -0
  186. package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
  187. package/src/pages/lib/vue/index.d.ts +6806 -0
  188. package/src/pages/lib/vue/index.js +218 -0
  189. package/src/pages/lib/vue/useTheme.js +116 -0
  190. package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
  191. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
  192. package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
  193. package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
  194. package/src/pages/mobile/shell/DomAppShell.vue +51 -0
  195. package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
  196. package/src/pages/mobile/stack/DomAppStack.vue +711 -0
  197. package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
  198. package/src/pages/visual/alert/DomAlert.vue +363 -0
  199. package/src/pages/visual/avatar/DomAvatar.vue +110 -0
  200. package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
  201. package/src/pages/visual/badge/DomBadge.vue +105 -0
  202. package/src/pages/visual/card/DomCard.vue +65 -0
  203. package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
  204. package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
  205. package/src/pages/visual/progress/DomProgress.vue +212 -0
  206. package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
  207. package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
  208. package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
  209. package/src/pages/visual/spinner/DomSpinner.vue +122 -0
  210. package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
  211. package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
  212. package/src/pages/visual/text-diff/textDiff.js +300 -0
  213. package/src/style.css +446 -0
@@ -0,0 +1,3030 @@
1
+ <script>
2
+ import { fieldProps } from '../../forms/field/fieldProps.js';
3
+
4
+ /**
5
+ * Returns the default Tiptap JSON document used when no model value is supplied.
6
+ *
7
+ * @returns {Record<string, unknown>} Empty document content.
8
+ */
9
+ export function createDefaultDocumentContent() {
10
+ return {
11
+ type: 'doc',
12
+ content: [
13
+ {
14
+ type: 'heading',
15
+ attrs: { level: 1 },
16
+ content: [{ type: 'text', text: 'Customer launch note' }],
17
+ },
18
+ {
19
+ type: 'paragraph',
20
+ content: [{ type: 'text', text: 'The July release gives teams one place to draft product copy, review AI suggestions, and keep the final document structured for publishing.' }],
21
+ },
22
+ {
23
+ type: 'paragraph',
24
+ content: [{ type: 'text', text: 'The current draft needs a warmer introduction, a shorter benefits section, and one image from the product media library before it is ready to share.' }],
25
+ },
26
+ ],
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Returns the built-in editor tool ids.
32
+ *
33
+ * @returns {Array<string>} Built-in tool ids.
34
+ */
35
+ function defaultDocumentEditorTools() {
36
+ return [
37
+ 'undo',
38
+ 'redo',
39
+ 'paragraph',
40
+ 'heading1',
41
+ 'heading2',
42
+ 'heading3',
43
+ 'bold',
44
+ 'italic',
45
+ 'underline',
46
+ 'bulletList',
47
+ 'orderedList',
48
+ 'blockquote',
49
+ 'codeBlock',
50
+ 'link',
51
+ 'table',
52
+ 'image',
53
+ 'video',
54
+ 'rewrite',
55
+ ];
56
+ }
57
+
58
+ /**
59
+ * Returns an empty custom tool list.
60
+ *
61
+ * @returns {Array<Record<string, unknown>>} Custom tools.
62
+ */
63
+ function defaultCustomTools() {
64
+ return [];
65
+ }
66
+
67
+ /**
68
+ * Returns an empty suggestion list.
69
+ *
70
+ * @returns {Array<Record<string, unknown>>} Suggestions.
71
+ */
72
+ function defaultSuggestions() {
73
+ return [];
74
+ }
75
+
76
+ /**
77
+ * Returns an empty collaborator list.
78
+ *
79
+ * @returns {Array<Record<string, unknown>>} Collaborators.
80
+ */
81
+ function defaultCollaborators() {
82
+ return [];
83
+ }
84
+
85
+ /**
86
+ * Returns an empty custom Tiptap extension list.
87
+ *
88
+ * @returns {Array<unknown>} Custom Tiptap extensions.
89
+ */
90
+ function defaultExtensions() {
91
+ return [];
92
+ }
93
+
94
+ /**
95
+ * Returns no collaboration adapter by default.
96
+ *
97
+ * @returns {Record<string, unknown>|null} Collaboration config.
98
+ */
99
+ function defaultCollaboration() {
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Returns the default AI author used for local suggestions.
105
+ *
106
+ * @returns {{ id: string, name: string, color: string, type: string }} AI author metadata.
107
+ */
108
+ function defaultAiUser() {
109
+ return {
110
+ id: 'ai-editor',
111
+ name: 'AI editor',
112
+ color: '#2563eb',
113
+ type: 'ai',
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Checks whether an output mode is supported.
119
+ *
120
+ * @param {string} value Output mode.
121
+ * @returns {boolean} True when supported.
122
+ */
123
+ function isDocumentEditorOutput(value) {
124
+ return ['json', 'html'].includes(value);
125
+ }
126
+
127
+ /**
128
+ * Checks whether a chrome mode is supported.
129
+ *
130
+ * @param {string|boolean} value Chrome mode.
131
+ * @returns {boolean} True when supported.
132
+ */
133
+ function isDocumentEditorChrome(value) {
134
+ return ['document', 'field', 'none'].includes(value) || value === false;
135
+ }
136
+
137
+ export const documentEditorProps = {
138
+ ...fieldProps,
139
+ modelValue: {
140
+ type: [Object, String],
141
+ default: createDefaultDocumentContent,
142
+ _edit: { component: 'DomJsonInput', description: 'Controlled editor content. JSON is preferred; HTML strings are accepted for simple imports.' },
143
+ },
144
+ output: {
145
+ type: String,
146
+ default: 'json',
147
+ validator: isDocumentEditorOutput,
148
+ _edit: { options: ['json', 'html'], description: 'Value emitted through update:modelValue.' },
149
+ },
150
+ editable: {
151
+ type: Boolean,
152
+ default: true,
153
+ _edit: { description: 'Allow editing and toolbar commands.' },
154
+ },
155
+ chrome: {
156
+ type: [String, Boolean],
157
+ default: 'document',
158
+ validator: isDocumentEditorChrome,
159
+ _edit: { options: ['document', 'field', 'none', false], description: 'Render the full document workspace, form-field chrome, or no outer field chrome.' },
160
+ },
161
+ placeholder: {
162
+ type: String,
163
+ default: 'Write something useful...',
164
+ _edit: { description: 'Placeholder shown inside empty blocks.' },
165
+ },
166
+ title: {
167
+ type: String,
168
+ default: 'Document editor',
169
+ _edit: { description: 'Accessible editor title.' },
170
+ },
171
+ status: {
172
+ type: String,
173
+ default: '',
174
+ _edit: { description: 'Optional save or collaboration status label.' },
175
+ },
176
+ tools: {
177
+ type: Array,
178
+ default: defaultDocumentEditorTools,
179
+ _edit: { component: 'DomJsonListInput', description: 'Built-in toolbar tool ids to show.' },
180
+ },
181
+ customTools: {
182
+ type: Array,
183
+ default: defaultCustomTools,
184
+ _edit: { component: 'DomJsonListInput', description: 'Additional toolbar actions emitted through tool-action.' },
185
+ },
186
+ suggestions: {
187
+ type: Array,
188
+ default: defaultSuggestions,
189
+ _edit: { component: 'DomJsonListInput', description: 'Externally supplied rewrite suggestions.' },
190
+ },
191
+ collaborators: {
192
+ type: Array,
193
+ default: defaultCollaborators,
194
+ _edit: { component: 'DomJsonListInput', description: 'Human, AI, or system collaborators shown in the editor header.' },
195
+ },
196
+ extensions: {
197
+ type: Array,
198
+ default: defaultExtensions,
199
+ _edit: { component: 'DomJsonListInput', description: 'Additional Tiptap extensions appended to the built-in editor extension list.' },
200
+ },
201
+ configureExtensions: {
202
+ type: Function,
203
+ default: null,
204
+ _edit: { description: 'Optional hook that receives the default extension list and Tiptap runtime modules, then returns the final extension list.' },
205
+ },
206
+ collaboration: {
207
+ type: Object,
208
+ default: defaultCollaboration,
209
+ _edit: { component: 'DomJsonInput', description: 'Optional Yjs collaboration adapter with document/fragment, provider, field, and user.' },
210
+ },
211
+ rewriteProvider: {
212
+ type: Function,
213
+ default: null,
214
+ _edit: { description: 'Optional async function that returns a rewrite suggestion for the current selection.' },
215
+ },
216
+ aiUser: {
217
+ type: Object,
218
+ default: defaultAiUser,
219
+ _edit: { component: 'DomJsonInput', description: 'AI author metadata used for locally created rewrite suggestions.' },
220
+ },
221
+ showSuggestionRail: {
222
+ type: Boolean,
223
+ default: true,
224
+ _edit: { description: 'Show the review rail for pending AI suggestions.' },
225
+ },
226
+ showToolbar: {
227
+ type: Boolean,
228
+ default: true,
229
+ _edit: { description: 'Show the WYSIWYG toolbar. Slash commands remain available when enabled.' },
230
+ },
231
+ showSlashMenu: {
232
+ type: Boolean,
233
+ default: true,
234
+ _edit: { description: 'Open the block command menu when the user types / at the start of a block.' },
235
+ },
236
+ autofocus: {
237
+ type: Boolean,
238
+ default: false,
239
+ _edit: { description: 'Focus the editor when the client runtime mounts.' },
240
+ },
241
+ minHeight: {
242
+ type: String,
243
+ default: '34rem',
244
+ _edit: { description: 'Minimum height for the editable document canvas.' },
245
+ },
246
+ };
247
+ </script>
248
+
249
+ <script setup>
250
+ import { computed, markRaw, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, watch } from 'vue';
251
+ import DomButton from '../../components/button/DomButton.vue';
252
+ import DomDialog from '../../components/dialog/DomDialog.vue';
253
+ import DomIconButton from '../../components/icon-button/DomIconButton.vue';
254
+ import DomToolbar from '../../components/toolbar/DomToolbar.vue';
255
+ import DomField from '../../forms/field/DomField.vue';
256
+ import { useField } from '../../forms/field/useField.js';
257
+ import DomNativeSelect from '../../forms/native-select/DomNativeSelect.vue';
258
+ import DomTextInput from '../../forms/text-input/DomTextInput.vue';
259
+ import DomBadge from '../../visual/badge/DomBadge.vue';
260
+ import DomTextDiff from '../../visual/text-diff/DomTextDiff.vue';
261
+ import { codeLanguageOptionValue } from '../../lib/vue/codeHighlighter.js';
262
+ import {
263
+ createDocumentEditorCodeBlockExtension,
264
+ createDocumentEditorCodeHighlightExtension,
265
+ documentEditorCodeLanguages,
266
+ } from './documentEditorCodeBlock.js';
267
+
268
+ defineOptions({
269
+ __doc: {
270
+ name: 'Document editor',
271
+ tag: '<DomDocumentEditor>',
272
+ description: 'A client-only WYSIWYG document editor shell for structured writing, tables, language-aware highlighted code blocks, AI rewrite suggestions, media insertion, video embeds, and optional Yjs collaboration adapters.',
273
+ icon: 'M5 4h14v16H5V4Zm4 5h6M9 13h6M9 17h3',
274
+ events: [
275
+ { name: 'update:modelValue', payload: 'Record<string, unknown> | string', description: 'Fired with JSON or HTML content depending on the output prop.' },
276
+ { name: 'change', payload: 'DocumentEditorChangePayload', description: 'Fired with JSON, HTML, and plain text whenever the editor changes.' },
277
+ { name: 'selection-change', payload: 'DocumentEditorSelectionPayload', description: 'Fired when the editor selection changes.' },
278
+ { name: 'rewrite-request', payload: 'DocumentEditorRewriteRequest', description: 'Fired when the user asks AI to rewrite the selected range.' },
279
+ { name: 'suggestion-create', payload: 'DocumentEditorSuggestion', description: 'Fired after a rewrite suggestion is added to the review rail.' },
280
+ { name: 'suggestion-accept', payload: 'DocumentEditorSuggestion', description: 'Fired after a suggestion is accepted.' },
281
+ { name: 'suggestion-reject', payload: 'DocumentEditorSuggestion', description: 'Fired after a suggestion is rejected.' },
282
+ { name: 'media-request', payload: 'DocumentEditorMediaRequestPayload', description: 'Fired when the image tool or an existing image requests an app-owned media picker.' },
283
+ { name: 'media-insert', payload: 'DocumentEditorMediaPayload', description: 'Fired after insertImage inserts a media-backed image node.' },
284
+ { name: 'media-update', payload: 'DocumentEditorMediaPayload', description: 'Fired after an existing media-backed image node is replaced.' },
285
+ { name: 'video-insert', payload: 'DocumentEditorVideoPayload', description: 'Fired after a video embed node is inserted.' },
286
+ { name: 'tool-action', payload: '({ tool, editor })', description: 'Fired when a custom toolbar tool is activated.' },
287
+ { name: 'ready', payload: '({ editor })', description: 'Fired after the browser-only editor runtime mounts.' },
288
+ { name: 'runtime-error', payload: 'Error', description: 'Fired when the editor runtime cannot mount.' },
289
+ { name: 'focus', payload: 'FocusEvent', description: 'Fired when the editable document receives focus.' },
290
+ { name: 'blur', payload: 'FocusEvent', description: 'Fired when the editable document loses focus.' },
291
+ ],
292
+ slots: [
293
+ { name: 'toolbar-start', payload: '{ editor, active }', description: 'Add controls before the built-in toolbar groups.' },
294
+ { name: 'toolbar-end', payload: '{ editor, active }', description: 'Add controls after the built-in toolbar groups.' },
295
+ { name: 'suggestion', payload: '{ suggestion, accept, reject }', description: 'Replace a suggestion review card.' },
296
+ { name: 'empty-suggestions', description: 'Replace the empty suggestion rail state.' },
297
+ ],
298
+ },
299
+ });
300
+
301
+ const props = defineProps(documentEditorProps);
302
+ const emit = defineEmits([
303
+ 'update:modelValue',
304
+ 'change',
305
+ 'selection-change',
306
+ 'rewrite-request',
307
+ 'suggestion-create',
308
+ 'suggestion-accept',
309
+ 'suggestion-reject',
310
+ 'media-request',
311
+ 'media-insert',
312
+ 'media-update',
313
+ 'video-insert',
314
+ 'tool-action',
315
+ 'ready',
316
+ 'runtime-error',
317
+ 'focus',
318
+ 'blur',
319
+ ]);
320
+
321
+ const editor = shallowRef(null);
322
+ const editorShell = ref(null);
323
+ const editorContentComponent = shallowRef(null);
324
+ const editorReady = ref(false);
325
+ const editorLoading = ref(false);
326
+ const runtimeError = ref('');
327
+ const editorTick = ref(0);
328
+ const internalSuggestions = ref([]);
329
+ const rewriteInstruction = ref('');
330
+ const rewriteBusy = ref(false);
331
+ const videoDialogOpen = ref(false);
332
+ const videoUrl = ref('');
333
+ const videoTitle = ref('');
334
+ const linkDialogOpen = ref(false);
335
+ const linkUrl = ref('');
336
+ const slashMenuOpen = ref(false);
337
+ const slashMenuPanel = ref(null);
338
+ const slashMenuQuery = ref('');
339
+ const slashMenuIndex = ref(0);
340
+ const slashMenuRange = ref(null);
341
+ const slashMenuStyle = ref({});
342
+ const lastContentSignature = ref('');
343
+ const lastChangePayload = ref(null);
344
+ const codeLanguageInputId = `dom-document-editor-code-language-${useId()}`;
345
+ let suggestionCounter = 0;
346
+ let applyingExternalContent = false;
347
+ let slashMenuFrame = 0;
348
+
349
+ const toolIcons = {
350
+ undo: 'M9 14 4 9l5-5M4 9h9a6 6 0 0 1 0 12h-1',
351
+ redo: 'm15 14 5-5-5-5M20 9h-9a6 6 0 0 0 0 12h1',
352
+ paragraph: 'M13 4v16M17 4v16M19 4H9a4 4 0 0 0 0 8h4',
353
+ heading1: 'M4 6v12M12 6v12M4 12h8M17 9l2-2v10M17 17h4',
354
+ heading2: 'M4 6v12M12 6v12M4 12h8M16 8h4l-4 5h4',
355
+ heading3: 'M4 6v12M12 6v12M4 12h8M16 7h4l-3 4h1a3 3 0 0 1 0 6h-2',
356
+ bold: 'M7 5h6a4 4 0 0 1 0 8H7V5Zm0 8h7a4 4 0 0 1 0 8H7v-8Z',
357
+ italic: 'M10 5h8M6 19h8M14 5l-4 14',
358
+ underline: 'M7 5v6a5 5 0 0 0 10 0V5M6 21h12',
359
+ bulletList: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
360
+ orderedList: 'M10 6h11M10 12h11M10 18h11M4 6h1v4M3 10h3M3 14h3l-3 4h3',
361
+ blockquote: 'M8 8H5v5h3v3H4V8h4Zm9 0h-3v5h3v3h-4V8h4Z',
362
+ codeBlock: 'm8 9-3 3 3 3M16 9l3 3-3 3M14 5l-4 14',
363
+ link: 'M10 13a5 5 0 0 0 7.54.54l1.92-1.92a5 5 0 0 0-7.07-7.07l-1.1 1.1M14 11a5 5 0 0 0-7.54-.54l-1.92 1.92a5 5 0 0 0 7.07 7.07l1.1-1.1',
364
+ table: 'M4 5h16v14H4V5Zm0 5h16M9 5v14M15 5v14',
365
+ image: 'M4 5h16v14H4V5Zm3 10 3-3 3 3 2-2 4 4M8 9h.01',
366
+ video: 'M4 6h12v12H4V6Zm12 4 5-3v10l-5-3',
367
+ rewrite: 'M12 3l1.4 4.2L18 9l-4.6 1.8L12 15l-1.4-4.2L6 9l4.6-1.8L12 3ZM5 16l.8 2.2L8 19l-2.2.8L5 22l-.8-2.2L2 19l2.2-.8L5 16ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z',
368
+ };
369
+
370
+ const slashBlockOptions = [
371
+ { id: 'paragraph', label: 'Paragraph', description: 'Plain text block', tool: 'paragraph', icon: toolIcons.paragraph },
372
+ { id: 'heading1', label: 'Heading 1', description: 'Page heading', tool: 'heading1', icon: toolIcons.heading1 },
373
+ { id: 'heading2', label: 'Heading 2', description: 'Section heading', tool: 'heading2', icon: toolIcons.heading2 },
374
+ { id: 'heading3', label: 'Heading 3', description: 'Sub-section heading', tool: 'heading3', icon: toolIcons.heading3 },
375
+ { id: 'bulletList', label: 'Bullet list', description: 'Unordered list', tool: 'bulletList', icon: toolIcons.bulletList },
376
+ { id: 'orderedList', label: 'Numbered list', description: 'Ordered list', tool: 'orderedList', icon: toolIcons.orderedList },
377
+ { id: 'blockquote', label: 'Quote', description: 'Quoted callout block', tool: 'blockquote', icon: toolIcons.blockquote },
378
+ { id: 'codeBlock', label: 'Code block', description: 'Preformatted code', tool: 'codeBlock', icon: toolIcons.codeBlock },
379
+ { id: 'table', label: 'Table', description: 'Insert a 3 by 3 table', tool: 'table', icon: toolIcons.table },
380
+ { id: 'image', label: 'Image', description: 'Insert an image', tool: 'image', icon: toolIcons.image },
381
+ { id: 'video', label: 'Video', description: 'Embed a video', tool: 'video', icon: toolIcons.video },
382
+ ];
383
+
384
+ const field = useField(props, emit, { idPrefix: 'dom-document-editor' });
385
+ const enabledToolSet = computed(() => new Set(props.tools.length ? props.tools : defaultDocumentEditorTools()));
386
+ const suggestions = computed(() => [
387
+ ...props.suggestions.map(normalizeExternalSuggestion),
388
+ ...internalSuggestions.value,
389
+ ]);
390
+ const suggestionCards = computed(() => suggestions.value);
391
+ const hasSuggestions = computed(() => suggestionCards.value.length > 0);
392
+ const hasCollaboration = computed(() => Boolean(collaborationDocument(props.collaboration) || props.collaboration?.fragment));
393
+ const visibleCollaborators = computed(() => normalizeCollaborators(props.collaborators, props.collaboration));
394
+ const statusLabel = computed(() => {
395
+ if (runtimeError.value) return 'Editor unavailable';
396
+ if (editorLoading.value) return 'Loading editor';
397
+ if (props.status) return props.status;
398
+ if (hasCollaboration.value) return 'Collaborative';
399
+ return editorReady.value ? 'Ready' : 'Client only';
400
+ });
401
+ const toolbarState = computed(() => {
402
+ editorTick.value;
403
+ const instance = editor.value;
404
+ return {
405
+ paragraph: Boolean(instance?.isActive?.('paragraph')),
406
+ heading1: Boolean(instance?.isActive?.('heading', { level: 1 })),
407
+ heading2: Boolean(instance?.isActive?.('heading', { level: 2 })),
408
+ heading3: Boolean(instance?.isActive?.('heading', { level: 3 })),
409
+ bold: Boolean(instance?.isActive?.('bold')),
410
+ italic: Boolean(instance?.isActive?.('italic')),
411
+ underline: Boolean(instance?.isActive?.('underline')),
412
+ bulletList: Boolean(instance?.isActive?.('bulletList')),
413
+ orderedList: Boolean(instance?.isActive?.('orderedList')),
414
+ blockquote: Boolean(instance?.isActive?.('blockquote')),
415
+ codeBlock: Boolean(instance?.isActive?.('codeBlock')),
416
+ codeLanguage: codeLanguageOptionValue(instance?.getAttributes?.('codeBlock')?.language || 'txt'),
417
+ link: Boolean(instance?.isActive?.('link')),
418
+ table: Boolean(instance?.isActive?.('table')),
419
+ tableHeader: Boolean(instance?.isActive?.('tableHeader')),
420
+ };
421
+ });
422
+ const fieldChrome = computed(() => (props.chrome === 'field' ? 'field' : false));
423
+ const hasDocumentChrome = computed(() => props.chrome === 'document');
424
+ const hasFieldChrome = computed(() => props.chrome === 'field');
425
+ const hasBareChrome = computed(() => props.chrome === false || props.chrome === 'none');
426
+ const isEditorEditable = computed(() => Boolean(props.editable && !field.disabled.value && !field.readOnly.value));
427
+ const canUseEditor = computed(() => editorReady.value && isEditorEditable.value && editor.value);
428
+ const codeBlockControlsVisible = computed(() => Boolean(props.showToolbar && canUseEditor.value && showTool('codeBlock') && toolbarState.value.codeBlock));
429
+ const tableControlsVisible = computed(() => Boolean(props.showToolbar && canUseEditor.value && showTool('table') && toolbarState.value.table));
430
+ const customToolbarTools = computed(() => props.customTools.filter((tool) => tool?.id && tool?.label));
431
+ const editorPaperStyle = computed(resolveEditorPaperStyle);
432
+ const editorTitle = computed(() => props.title || field.fieldAttrs.value.label || 'Document editor');
433
+ const hiddenInputValue = computed(resolveHiddenInputValue);
434
+ const slashMenuOptions = computed(resolveSlashMenuOptions);
435
+ const editorClasses = computed(() => [
436
+ hasFieldChrome.value
437
+ ? 'dom-document-editor dom-document-editor--field skin-input relative block w-full min-w-0 overflow-visible !p-0 text-canvas-fg'
438
+ : hasBareChrome.value
439
+ ? 'dom-document-editor dom-document-editor--bare relative w-full min-w-0 overflow-visible bg-transparent text-canvas-fg'
440
+ : 'dom-document-editor dom-document-editor--document relative w-full min-w-0 overflow-visible rounded-lg border border-border bg-canvas text-canvas-fg shadow-sm',
441
+ !isEditorEditable.value && 'opacity-90',
442
+ field.invalid.value && 'dom-document-editor--invalid',
443
+ ]);
444
+ const toolbarClasses = computed(() => [
445
+ 'dom-document-editor__toolbar',
446
+ hasDocumentChrome.value && 'border-b border-border',
447
+ ]);
448
+ const paperClasses = computed(() => [
449
+ 'dom-document-editor__paper',
450
+ hasFieldChrome.value && 'dom-document-editor__paper--field',
451
+ !isEditorEditable.value && 'dom-document-editor__paper--readonly',
452
+ ]);
453
+
454
+ watch(() => [
455
+ isEditorEditable.value,
456
+ field.id.value,
457
+ field.describedBy.value,
458
+ field.invalid.value,
459
+ editorTitle.value,
460
+ ], updateEditorEditable);
461
+ watch(() => field.value.value, applyExternalModelValue, { deep: true });
462
+ watch(() => props.collaboration?.user, updateCollaborationUser, { deep: true });
463
+ watch(slashMenuOptions, syncSlashMenuIndex);
464
+
465
+ onMounted(mountEditor);
466
+ onBeforeUnmount(destroyEditor);
467
+
468
+ defineExpose({
469
+ editor,
470
+ focusEditor,
471
+ insertImage,
472
+ insertVideo,
473
+ insertTable,
474
+ requestRewrite,
475
+ createSuggestion,
476
+ acceptSuggestion,
477
+ rejectSuggestion,
478
+ savePayload,
479
+ });
480
+
481
+ /**
482
+ * Mounts the browser-only editor runtime.
483
+ *
484
+ * @returns {Promise<void>} Resolves when the editor has mounted or failed.
485
+ */
486
+ async function mountEditor() {
487
+ if (typeof window === 'undefined' || editor.value) return;
488
+ editorLoading.value = true;
489
+ runtimeError.value = '';
490
+ try {
491
+ const modules = await loadEditorRuntime();
492
+ editorContentComponent.value = markRaw(modules.EditorContent);
493
+ const extensions = createEditorExtensions(modules);
494
+ const content = hasCollaboration.value ? undefined : normalizeEditorContent(field.value.value);
495
+ editor.value = markRaw(new modules.Editor({
496
+ extensions,
497
+ content,
498
+ editable: isEditorEditable.value,
499
+ autofocus: props.autofocus ? 'end' : false,
500
+ editorProps: {
501
+ attributes: editorDomAttributes(),
502
+ handleKeyDown: handleEditorKeyDown,
503
+ },
504
+ onUpdate: handleEditorUpdate,
505
+ onSelectionUpdate: handleEditorSelectionUpdate,
506
+ onFocus: handleEditorFocus,
507
+ onBlur: handleEditorBlur,
508
+ }));
509
+ editorReady.value = true;
510
+ lastContentSignature.value = contentSignature(field.value.value);
511
+ await nextTick();
512
+ emit('ready', { editor: editor.value });
513
+ emitEditorChange();
514
+ } catch (error) {
515
+ runtimeError.value = error?.message || String(error || 'Editor runtime failed.');
516
+ emit('runtime-error', error);
517
+ } finally {
518
+ editorLoading.value = false;
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Dynamically imports editor packages so the SSR build never evaluates the editing engine.
524
+ *
525
+ * @returns {Promise<Record<string, unknown>>} Editor runtime modules.
526
+ */
527
+ async function loadEditorRuntime() {
528
+ const [
529
+ tiptap,
530
+ starterKitModule,
531
+ placeholderModule,
532
+ linkModule,
533
+ imageModule,
534
+ underlineModule,
535
+ codeBlockModule,
536
+ tableModule,
537
+ collaborationModule,
538
+ collaborationCaretModule,
539
+ prosemirrorStateModule,
540
+ prosemirrorViewModule,
541
+ ] = await Promise.all([
542
+ import('@tiptap/vue-3'),
543
+ import('@tiptap/starter-kit'),
544
+ import('@tiptap/extension-placeholder'),
545
+ import('@tiptap/extension-link'),
546
+ import('@tiptap/extension-image'),
547
+ import('@tiptap/extension-underline'),
548
+ import('@tiptap/extension-code-block'),
549
+ import('@tiptap/extension-table'),
550
+ import('@tiptap/extension-collaboration'),
551
+ import('@tiptap/extension-collaboration-caret'),
552
+ import('@tiptap/pm/state'),
553
+ import('@tiptap/pm/view'),
554
+ ]);
555
+ return {
556
+ Editor: tiptap.Editor,
557
+ EditorContent: tiptap.EditorContent,
558
+ Mark: tiptap.Mark,
559
+ Node: tiptap.Node,
560
+ Extension: tiptap.Extension,
561
+ mergeAttributes: tiptap.mergeAttributes,
562
+ StarterKit: starterKitModule.default || starterKitModule.StarterKit,
563
+ Placeholder: placeholderModule.default || placeholderModule.Placeholder,
564
+ Link: linkModule.default || linkModule.Link,
565
+ Image: imageModule.default || imageModule.Image,
566
+ Underline: underlineModule.default || underlineModule.Underline,
567
+ CodeBlock: codeBlockModule.default || codeBlockModule.CodeBlock,
568
+ TableKit: tableModule.TableKit,
569
+ Collaboration: collaborationModule.default || collaborationModule.Collaboration,
570
+ CollaborationCaret: collaborationCaretModule.default || collaborationCaretModule.CollaborationCaret,
571
+ createCollaborationMappablePosition: collaborationModule.createMappablePosition,
572
+ getCollaborationUpdatedPosition: collaborationModule.getUpdatedPosition,
573
+ isCollaborationChange: collaborationModule.isChangeOrigin,
574
+ Plugin: prosemirrorStateModule.Plugin,
575
+ PluginKey: prosemirrorStateModule.PluginKey,
576
+ TextSelection: prosemirrorStateModule.TextSelection,
577
+ Decoration: prosemirrorViewModule.Decoration,
578
+ DecorationSet: prosemirrorViewModule.DecorationSet,
579
+ };
580
+ }
581
+
582
+ /**
583
+ * Creates the Tiptap extension list for the current editor props.
584
+ *
585
+ * @param {Record<string, unknown>} modules Runtime modules.
586
+ * @returns {Array<unknown>} Tiptap extensions.
587
+ */
588
+ function createEditorExtensions(modules) {
589
+ const extensions = [
590
+ modules.StarterKit.configure({
591
+ heading: { levels: [1, 2, 3, 4] },
592
+ codeBlock: false,
593
+ link: false,
594
+ underline: false,
595
+ undoRedo: hasCollaboration.value ? false : undefined,
596
+ }),
597
+ createDocumentEditorCodeBlockExtension(modules.CodeBlock, modules.mergeAttributes),
598
+ createDocumentEditorCodeHighlightExtension(modules),
599
+ modules.TableKit.configure({
600
+ table: {
601
+ resizable: true,
602
+ lastColumnResizable: false,
603
+ },
604
+ }),
605
+ modules.Placeholder.configure({ placeholder: props.placeholder }),
606
+ modules.Link.configure({
607
+ openOnClick: false,
608
+ autolink: true,
609
+ defaultProtocol: 'https',
610
+ HTMLAttributes: {
611
+ class: 'dom-document-editor__link',
612
+ },
613
+ }),
614
+ modules.Underline,
615
+ createImageExtension(modules.Image),
616
+ createVideoEmbedExtension(modules.Node, modules.mergeAttributes),
617
+ createSuggestionMarkExtension(modules.Mark, modules.mergeAttributes),
618
+ ];
619
+ const collaborationConfig = props.collaboration || {};
620
+ const yDocument = collaborationDocument(collaborationConfig);
621
+ if (hasCollaboration.value) {
622
+ extensions.push(modules.Collaboration.configure({
623
+ document: yDocument,
624
+ fragment: collaborationConfig.fragment || null,
625
+ field: collaborationConfig.field || 'default',
626
+ provider: collaborationConfig.provider || null,
627
+ }));
628
+ extensions.push(createCollaborationSelectionGuardExtension(modules));
629
+ }
630
+ if (hasCollaboration.value && collaborationConfig.provider) {
631
+ extensions.push(modules.CollaborationCaret.configure({
632
+ provider: collaborationConfig.provider,
633
+ user: normalizeCollaborator(collaborationConfig.user || props.aiUser, props.aiUser),
634
+ render: renderCollaborationCaret,
635
+ selectionRender: renderCollaborationSelection,
636
+ }));
637
+ }
638
+ extensions.push(...props.extensions.filter(Boolean));
639
+ if (typeof props.configureExtensions === 'function') {
640
+ const configured = props.configureExtensions(createExtensionContext(modules, extensions));
641
+ if (Array.isArray(configured)) return configured.filter(Boolean);
642
+ }
643
+ return extensions;
644
+ }
645
+
646
+ /**
647
+ * Preserves a focused text cursor when a burst of remote Yjs updates is
648
+ * incorrectly reconstructed at the end of the document.
649
+ *
650
+ * Yjs normally restores a ProseMirror selection from relative positions. Some
651
+ * mobile browsers can expose a stale DOM selection during rapid remote updates,
652
+ * so this guard keeps a second relative bookmark and only intervenes for the
653
+ * narrow mid-document-to-document-end failure case.
654
+ *
655
+ * @param {Record<string, unknown>} modules Runtime modules loaded for Tiptap.
656
+ * @returns {unknown} Tiptap extension containing the selection guard plugin.
657
+ */
658
+ function createCollaborationSelectionGuardExtension(modules) {
659
+ const pluginKey = new modules.PluginKey('domDocumentEditorCollaborationSelectionGuard');
660
+ let cursorBookmark = null;
661
+
662
+ /**
663
+ * Captures the current collapsed text selection as a Yjs-relative position.
664
+ *
665
+ * @param {Record<string, unknown>} state Current ProseMirror state.
666
+ * @returns {void}
667
+ */
668
+ function captureCursorBookmark(state) {
669
+ const selection = state?.selection;
670
+ if (!(selection instanceof modules.TextSelection) || !selection.empty) {
671
+ cursorBookmark = null;
672
+ return;
673
+ }
674
+ try {
675
+ cursorBookmark = modules.createCollaborationMappablePosition(selection.head, state);
676
+ } catch {
677
+ cursorBookmark = null;
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Resolves the saved relative cursor against the document after a remote update.
683
+ *
684
+ * @param {Record<string, unknown>} transaction Remote collaboration transaction.
685
+ * @param {Record<string, unknown>} state Updated ProseMirror state.
686
+ * @returns {number|null} Resolved cursor position or null when unavailable.
687
+ */
688
+ function resolveCursorBookmark(transaction, state) {
689
+ if (!cursorBookmark) return null;
690
+ try {
691
+ const result = modules.getCollaborationUpdatedPosition(cursorBookmark, transaction, state);
692
+ return Number.isFinite(result?.position?.position) ? result.position.position : null;
693
+ } catch {
694
+ return null;
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Checks whether a transaction was created by this selection guard.
700
+ *
701
+ * @param {Record<string, unknown>} transaction ProseMirror transaction.
702
+ * @returns {boolean} Whether the transaction already restored the cursor.
703
+ */
704
+ function isSelectionRestoreTransaction(transaction) {
705
+ return Boolean(transaction.getMeta(pluginKey)?.restored);
706
+ }
707
+
708
+ return modules.Extension.create({
709
+ name: 'domDocumentEditorCollaborationSelectionGuard',
710
+
711
+ /**
712
+ * Adds the ProseMirror transaction and selection tracking plugin.
713
+ *
714
+ * @returns {Array<unknown>} ProseMirror plugins.
715
+ */
716
+ addProseMirrorPlugins() {
717
+ const editorInstance = this.editor;
718
+ return [new modules.Plugin({
719
+ key: pluginKey,
720
+ state: {
721
+ /**
722
+ * Initializes selection-capture state.
723
+ *
724
+ * @returns {{ skipCapture: boolean }} Initial plugin state.
725
+ */
726
+ init() {
727
+ return { skipCapture: false };
728
+ },
729
+
730
+ /**
731
+ * Keeps the pre-update bookmark until a local or restored selection arrives.
732
+ *
733
+ * @param {Record<string, unknown>} transaction Applied transaction.
734
+ * @param {{ skipCapture: boolean }} pluginState Previous plugin state.
735
+ * @returns {{ skipCapture: boolean }} Next plugin state.
736
+ */
737
+ apply(transaction, pluginState) {
738
+ if (modules.isCollaborationChange(transaction)) return { skipCapture: true };
739
+ if (transaction.getMeta(pluginKey)?.restored) return { skipCapture: false };
740
+ if (pluginState.skipCapture && !transaction.docChanged && !transaction.selectionSet) return pluginState;
741
+ return { skipCapture: false };
742
+ },
743
+ },
744
+
745
+ /**
746
+ * Restores a mid-document cursor only when a remote update moved it to the end.
747
+ *
748
+ * @param {Array<Record<string, unknown>>} transactions Applied transactions.
749
+ * @param {Record<string, unknown>} oldState State before the transactions.
750
+ * @param {Record<string, unknown>} newState State after the transactions.
751
+ * @returns {Record<string, unknown>|null} Corrective transaction or null.
752
+ */
753
+ appendTransaction(transactions, oldState, newState) {
754
+ if (transactions.some(isSelectionRestoreTransaction)) return null;
755
+ const remoteTransaction = transactions.find(modules.isCollaborationChange);
756
+ if (!remoteTransaction || !cursorBookmark || !editorInstance.view.hasFocus()) return null;
757
+ if (!(oldState.selection instanceof modules.TextSelection) || !oldState.selection.empty) return null;
758
+ if (!(newState.selection instanceof modules.TextSelection) || !newState.selection.empty) return null;
759
+
760
+ const oldDocumentEnd = modules.TextSelection.atEnd(oldState.doc).head;
761
+ const newDocumentEnd = modules.TextSelection.atEnd(newState.doc).head;
762
+ const wasAtDocumentEnd = oldState.selection.head >= oldDocumentEnd;
763
+ const landedAtDocumentEnd = newState.selection.head >= newDocumentEnd;
764
+ if (wasAtDocumentEnd || !landedAtDocumentEnd) return null;
765
+
766
+ let restoredPosition = resolveCursorBookmark(remoteTransaction, newState);
767
+ if ((restoredPosition === null || restoredPosition >= newDocumentEnd)
768
+ && newState.doc.content.size >= oldState.doc.content.size) {
769
+ restoredPosition = Math.min(oldState.selection.head, newDocumentEnd - 1);
770
+ }
771
+ if (!Number.isFinite(restoredPosition) || restoredPosition <= 0 || restoredPosition >= newDocumentEnd) return null;
772
+
773
+ const restoredSelection = modules.TextSelection.near(newState.doc.resolve(restoredPosition));
774
+ if (restoredSelection.head >= newDocumentEnd || restoredSelection.eq(newState.selection)) return null;
775
+ return newState.tr
776
+ .setSelection(restoredSelection)
777
+ .setMeta(pluginKey, { restored: true })
778
+ .setMeta('addToHistory', false);
779
+ },
780
+
781
+ /**
782
+ * Captures stable local selections after ProseMirror has updated its view.
783
+ *
784
+ * @param {Record<string, unknown>} view ProseMirror editor view.
785
+ * @returns {{ update: (nextView: Record<string, unknown>) => void }} Plugin view hooks.
786
+ */
787
+ view(view) {
788
+ captureCursorBookmark(view.state);
789
+ return {
790
+ /**
791
+ * Refreshes the bookmark after local or corrective transactions.
792
+ *
793
+ * @param {Record<string, unknown>} nextView Updated editor view.
794
+ * @returns {void}
795
+ */
796
+ update(nextView) {
797
+ if (pluginKey.getState(nextView.state)?.skipCapture) return;
798
+ captureCursorBookmark(nextView.state);
799
+ },
800
+ };
801
+ },
802
+ })];
803
+ },
804
+ });
805
+ }
806
+
807
+ /**
808
+ * Creates the public context passed into extension configuration hooks.
809
+ *
810
+ * @param {Record<string, unknown>} modules Runtime modules loaded for Tiptap.
811
+ * @param {Array<unknown>} extensions Default extension list.
812
+ * @returns {Record<string, unknown>} Extension configuration context.
813
+ */
814
+ function createExtensionContext(modules, extensions) {
815
+ return {
816
+ modules,
817
+ extensions,
818
+ props,
819
+ helpers: createToolContext(),
820
+ };
821
+ }
822
+
823
+ /**
824
+ * Creates an image extension that stores media ids beside image URLs.
825
+ *
826
+ * @param {unknown} Image Tiptap image extension.
827
+ * @returns {unknown} Extended image node.
828
+ */
829
+ function createImageExtension(Image) {
830
+ return Image.extend({
831
+ /**
832
+ * Adds media-aware image attributes.
833
+ *
834
+ * @returns {Record<string, unknown>} Attribute config.
835
+ */
836
+ addAttributes() {
837
+ return {
838
+ ...this.parent?.(),
839
+ mediaId: {
840
+ default: null,
841
+ parseHTML: parseMediaIdAttribute,
842
+ renderHTML: renderMediaIdAttribute,
843
+ },
844
+ caption: {
845
+ default: null,
846
+ parseHTML: parseCaptionAttribute,
847
+ renderHTML: renderCaptionAttribute,
848
+ },
849
+ };
850
+ },
851
+
852
+ /**
853
+ * Renders an image with an accessible replacement action.
854
+ *
855
+ * The node view only owns editor interaction. Applications still choose
856
+ * and persist media through the transport-neutral media request event.
857
+ *
858
+ * @returns {Function} Tiptap image node-view factory.
859
+ */
860
+ addNodeView() {
861
+ return imageNodeView;
862
+ },
863
+ });
864
+ }
865
+
866
+ /**
867
+ * Creates an editable image node view with a hover and keyboard-focus action.
868
+ *
869
+ * @param {Record<string, unknown>} context Tiptap node-view context.
870
+ * @returns {Record<string, unknown>} ProseMirror node view.
871
+ */
872
+ function imageNodeView(context) {
873
+ let currentNode = context.node;
874
+ const dom = document.createElement('div');
875
+ const image = document.createElement('img');
876
+ const action = document.createElement('button');
877
+
878
+ dom.className = 'dom-document-editor__image-node';
879
+ dom.setAttribute('data-document-editor-image', '');
880
+ dom.contentEditable = 'false';
881
+ dom.draggable = true;
882
+ image.draggable = false;
883
+ action.type = 'button';
884
+ action.className = 'dom-document-editor__image-action';
885
+ action.textContent = 'Change';
886
+ action.setAttribute('aria-label', 'Change image');
887
+ action.addEventListener('mousedown', preventImageActionBlur);
888
+ action.addEventListener('click', requestImageNodeReplacement);
889
+ dom.append(image, action);
890
+ syncImageNodeElement(image, currentNode.attrs);
891
+
892
+ /**
893
+ * Opens the consumer-owned media picker for this image node.
894
+ *
895
+ * @param {MouseEvent} event Image action click event.
896
+ * @returns {void}
897
+ */
898
+ function requestImageNodeReplacement(event) {
899
+ event.preventDefault();
900
+ event.stopPropagation();
901
+ requestMedia({
902
+ image: { ...currentNode.attrs },
903
+ replaceImage: replaceImageNode,
904
+ });
905
+ }
906
+
907
+ /**
908
+ * Replaces this image node while preserving its document position.
909
+ *
910
+ * @param {Record<string, unknown>} payload Media payload selected by the consumer.
911
+ * @returns {Record<string, unknown>|null} Updated image attributes, or null when stale.
912
+ */
913
+ function replaceImageNode(payload = {}) {
914
+ if (!editor.value || typeof context.getPos !== 'function') return null;
915
+ const position = context.getPos();
916
+ const node = editor.value.state.doc.nodeAt(position);
917
+
918
+ if (!node || node.type.name !== 'image') return null;
919
+ const replacement = normalizeImagePayload(payload);
920
+
921
+ if (!replacement.src) return null;
922
+ const attributes = {
923
+ ...node.attrs,
924
+ ...replacement,
925
+ };
926
+ const updated = editor.value.chain().focus().command(({ tr }) => {
927
+ tr.setNodeMarkup(position, undefined, attributes);
928
+ return true;
929
+ }).run();
930
+
931
+ if (!updated) return null;
932
+ emit('media-update', attributes);
933
+ emitEditorChange();
934
+ return attributes;
935
+ }
936
+
937
+ return {
938
+ dom,
939
+ /**
940
+ * Refreshes the image DOM when Tiptap updates the current node.
941
+ *
942
+ * @param {Record<string, unknown>} node Updated ProseMirror node.
943
+ * @returns {boolean} True when the node view accepted the update.
944
+ */
945
+ update(node) {
946
+ if (node.type.name !== currentNode.type.name) return false;
947
+ currentNode = node;
948
+ syncImageNodeElement(image, node.attrs);
949
+ return true;
950
+ },
951
+ /**
952
+ * Keeps button events out of ProseMirror's selection handling.
953
+ *
954
+ * @param {Event} event DOM event raised inside the node view.
955
+ * @returns {boolean} True when the image action owns the event.
956
+ */
957
+ stopEvent(event) {
958
+ return event.target === action || action.contains(event.target);
959
+ },
960
+ /**
961
+ * Ignores node-view DOM mutations because Tiptap owns its attributes.
962
+ *
963
+ * @returns {boolean} Always true.
964
+ */
965
+ ignoreMutation() {
966
+ return true;
967
+ },
968
+ /**
969
+ * Releases DOM event handlers when ProseMirror removes the node view.
970
+ *
971
+ * @returns {void}
972
+ */
973
+ destroy() {
974
+ action.removeEventListener('mousedown', preventImageActionBlur);
975
+ action.removeEventListener('click', requestImageNodeReplacement);
976
+ },
977
+ };
978
+ }
979
+
980
+ /**
981
+ * Prevents the editor selection from moving before the media picker opens.
982
+ *
983
+ * @param {MouseEvent} event Image action pointer event.
984
+ * @returns {void}
985
+ */
986
+ function preventImageActionBlur(event) {
987
+ event.preventDefault();
988
+ }
989
+
990
+ /**
991
+ * Synchronizes serializable image attributes onto a node-view image element.
992
+ *
993
+ * @param {HTMLImageElement} image Image element rendered by the node view.
994
+ * @param {Record<string, unknown>} attributes Current Tiptap image attributes.
995
+ * @returns {void}
996
+ */
997
+ function syncImageNodeElement(image, attributes = {}) {
998
+ setOptionalElementAttribute(image, 'src', attributes.src);
999
+ setOptionalElementAttribute(image, 'alt', attributes.alt, true);
1000
+ setOptionalElementAttribute(image, 'title', attributes.title);
1001
+ setOptionalElementAttribute(image, 'data-media-id', attributes.mediaId);
1002
+ setOptionalElementAttribute(image, 'data-caption', attributes.caption);
1003
+ }
1004
+
1005
+ /**
1006
+ * Sets or removes one node-view DOM attribute.
1007
+ *
1008
+ * @param {HTMLElement} element Element receiving the attribute.
1009
+ * @param {string} name Attribute name.
1010
+ * @param {unknown} value Attribute value.
1011
+ * @param {boolean} preserveEmpty Whether an empty string remains meaningful.
1012
+ * @returns {void}
1013
+ */
1014
+ function setOptionalElementAttribute(element, name, value, preserveEmpty = false) {
1015
+ const normalized = value === null || value === undefined ? '' : String(value);
1016
+
1017
+ if (normalized || preserveEmpty) {
1018
+ element.setAttribute(name, normalized);
1019
+ return;
1020
+ }
1021
+
1022
+ element.removeAttribute(name);
1023
+ }
1024
+
1025
+ /**
1026
+ * Creates the custom video embed node used by the insert-video tool.
1027
+ *
1028
+ * @param {unknown} Node Tiptap node factory.
1029
+ * @param {Function} mergeAttributes Attribute merge helper.
1030
+ * @returns {unknown} Video embed extension.
1031
+ */
1032
+ function createVideoEmbedExtension(Node, mergeAttributes) {
1033
+ return Node.create({
1034
+ name: 'videoEmbed',
1035
+ group: 'block',
1036
+ atom: true,
1037
+ draggable: true,
1038
+
1039
+ /**
1040
+ * Adds serializable video embed attributes.
1041
+ *
1042
+ * @returns {Record<string, unknown>} Attribute config.
1043
+ */
1044
+ addAttributes() {
1045
+ return {
1046
+ src: { default: '' },
1047
+ title: { default: 'Embedded video' },
1048
+ provider: { default: 'video' },
1049
+ };
1050
+ },
1051
+
1052
+ /**
1053
+ * Parses persisted video embed nodes from HTML.
1054
+ *
1055
+ * @returns {Array<Record<string, unknown>>} Parse rules.
1056
+ */
1057
+ parseHTML() {
1058
+ return [{ tag: 'div[data-video-embed]' }];
1059
+ },
1060
+
1061
+ /**
1062
+ * Renders a video embed node.
1063
+ *
1064
+ * @param {Record<string, unknown>} context Render context.
1065
+ * @returns {Array<unknown>} Tiptap render tuple.
1066
+ */
1067
+ renderHTML(context) {
1068
+ return renderVideoEmbedNode(context.HTMLAttributes, mergeAttributes);
1069
+ },
1070
+
1071
+ /**
1072
+ * Adds the setVideoEmbed command.
1073
+ *
1074
+ * @returns {Record<string, Function>} Command map.
1075
+ */
1076
+ addCommands() {
1077
+ return {
1078
+ setVideoEmbed: insertVideoCommand(this.name),
1079
+ };
1080
+ },
1081
+ });
1082
+ }
1083
+
1084
+ /**
1085
+ * Creates the inline AI suggestion mark.
1086
+ *
1087
+ * @param {unknown} Mark Tiptap mark factory.
1088
+ * @param {Function} mergeAttributes Attribute merge helper.
1089
+ * @returns {unknown} Suggestion mark extension.
1090
+ */
1091
+ function createSuggestionMarkExtension(Mark, mergeAttributes) {
1092
+ return Mark.create({
1093
+ name: 'aiSuggestion',
1094
+ inclusive: false,
1095
+
1096
+ /**
1097
+ * Adds a suggestion id attribute.
1098
+ *
1099
+ * @returns {Record<string, unknown>} Attribute config.
1100
+ */
1101
+ addAttributes() {
1102
+ return {
1103
+ id: {
1104
+ default: null,
1105
+ parseHTML: parseSuggestionIdAttribute,
1106
+ renderHTML: renderSuggestionIdAttribute,
1107
+ },
1108
+ };
1109
+ },
1110
+
1111
+ /**
1112
+ * Parses suggestion marks from persisted HTML.
1113
+ *
1114
+ * @returns {Array<Record<string, unknown>>} Parse rules.
1115
+ */
1116
+ parseHTML() {
1117
+ return [{ tag: 'span[data-suggestion-id]' }];
1118
+ },
1119
+
1120
+ /**
1121
+ * Renders a suggestion mark around the original text.
1122
+ *
1123
+ * @param {Record<string, unknown>} context Render context.
1124
+ * @returns {Array<unknown>} Tiptap render tuple.
1125
+ */
1126
+ renderHTML(context) {
1127
+ return ['span', mergeAttributes(context.HTMLAttributes, { class: 'dom-document-editor__suggestion-mark' }), 0];
1128
+ },
1129
+ });
1130
+ }
1131
+
1132
+ /**
1133
+ * Destroys the mounted editor instance.
1134
+ *
1135
+ * @returns {void}
1136
+ */
1137
+ function destroyEditor() {
1138
+ editor.value?.destroy?.();
1139
+ editor.value = null;
1140
+ editorReady.value = false;
1141
+ }
1142
+
1143
+ /**
1144
+ * Applies an external model value when the owner changes it.
1145
+ *
1146
+ * @param {Record<string, unknown>|string} value External content value.
1147
+ * @returns {void}
1148
+ */
1149
+ function applyExternalModelValue(value) {
1150
+ if (!editor.value || applyingExternalContent || hasCollaboration.value) return;
1151
+ const signature = contentSignature(value);
1152
+ if (signature === lastContentSignature.value) return;
1153
+ applyingExternalContent = true;
1154
+ editor.value.commands.setContent(normalizeEditorContent(value), false);
1155
+ lastContentSignature.value = signature;
1156
+ applyingExternalContent = false;
1157
+ bumpEditorTick();
1158
+ }
1159
+
1160
+ /**
1161
+ * Updates the Tiptap editable state.
1162
+ *
1163
+ * @returns {void}
1164
+ */
1165
+ function updateEditorEditable() {
1166
+ editor.value?.setEditable?.(isEditorEditable.value);
1167
+ editor.value?.setOptions?.({
1168
+ editorProps: {
1169
+ attributes: editorDomAttributes(),
1170
+ handleKeyDown: handleEditorKeyDown,
1171
+ },
1172
+ });
1173
+ }
1174
+
1175
+ /**
1176
+ * Builds DOM attributes for the editable ProseMirror element.
1177
+ *
1178
+ * @returns {Record<string, string>} Editor DOM attributes.
1179
+ */
1180
+ function editorDomAttributes() {
1181
+ return {
1182
+ id: field.id.value,
1183
+ class: 'dom-document-editor__prosemirror',
1184
+ 'aria-label': editorTitle.value,
1185
+ 'aria-multiline': 'true',
1186
+ 'aria-describedby': field.describedBy.value || undefined,
1187
+ 'aria-invalid': field.invalid.value ? 'true' : undefined,
1188
+ 'data-editable': isEditorEditable.value ? 'true' : 'false',
1189
+ 'data-invalid': field.invalid.value ? '' : undefined,
1190
+ role: 'textbox',
1191
+ };
1192
+ }
1193
+
1194
+ /**
1195
+ * Resolves CSS variables that size the editable paper surface.
1196
+ *
1197
+ * @returns {Record<string, string>} Inline style variables.
1198
+ */
1199
+ function resolveEditorPaperStyle() {
1200
+ return {
1201
+ '--dom-document-editor-min-height': props.minHeight || '34rem',
1202
+ };
1203
+ }
1204
+
1205
+ /**
1206
+ * Serializes the current model value for a native hidden form field.
1207
+ *
1208
+ * @returns {string} Native input value.
1209
+ */
1210
+ function resolveHiddenInputValue() {
1211
+ const value = field.value.value;
1212
+ if (typeof value === 'string') return value;
1213
+ try {
1214
+ return JSON.stringify(value || null);
1215
+ } catch {
1216
+ return '';
1217
+ }
1218
+ }
1219
+
1220
+ /**
1221
+ * Moves keyboard focus into the document editing canvas.
1222
+ *
1223
+ * @returns {void}
1224
+ */
1225
+ function focusEditor() {
1226
+ if (!isEditorEditable.value || !editor.value) return;
1227
+ editor.value.chain().focus().run();
1228
+ }
1229
+
1230
+ /**
1231
+ * Updates the collaboration caret user when the owner changes it.
1232
+ *
1233
+ * @returns {void}
1234
+ */
1235
+ function updateCollaborationUser() {
1236
+ if (!editor.value?.commands?.updateUser || !props.collaboration?.user) return;
1237
+ editor.value.commands.updateUser(normalizeCollaborator(props.collaboration.user, props.aiUser));
1238
+ }
1239
+
1240
+ /**
1241
+ * Handles a Tiptap content update.
1242
+ *
1243
+ * @returns {void}
1244
+ */
1245
+ function handleEditorUpdate() {
1246
+ if (applyingExternalContent) return;
1247
+ bumpEditorTick();
1248
+ updateSlashMenu();
1249
+ emitEditorChange();
1250
+ }
1251
+
1252
+ /**
1253
+ * Handles a Tiptap selection update.
1254
+ *
1255
+ * @returns {void}
1256
+ */
1257
+ function handleEditorSelectionUpdate() {
1258
+ bumpEditorTick();
1259
+ updateSlashMenu();
1260
+ emit('selection-change', currentSelectionPayload());
1261
+ }
1262
+
1263
+ /**
1264
+ * Handles editor focus and mirrors it into the form field state.
1265
+ *
1266
+ * @param {Record<string, unknown>} payload Tiptap focus payload.
1267
+ * @returns {void}
1268
+ */
1269
+ function handleEditorFocus(payload) {
1270
+ field.onFocus(payload?.event || payload);
1271
+ bumpEditorTick();
1272
+ }
1273
+
1274
+ /**
1275
+ * Handles editor blur and mirrors it into the form field state.
1276
+ *
1277
+ * @param {Record<string, unknown>} payload Tiptap blur payload.
1278
+ * @returns {void}
1279
+ */
1280
+ function handleEditorBlur(payload) {
1281
+ field.onBlur(payload?.event || payload);
1282
+ bumpEditorTick();
1283
+ if (typeof window !== 'undefined') {
1284
+ window.setTimeout(closeSlashMenu, 120);
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * Increments the reactive editor tick used by toolbar state.
1290
+ *
1291
+ * @returns {void}
1292
+ */
1293
+ function bumpEditorTick() {
1294
+ editorTick.value += 1;
1295
+ }
1296
+
1297
+ /**
1298
+ * Emits the current document content payload.
1299
+ *
1300
+ * @returns {void}
1301
+ */
1302
+ function emitEditorChange() {
1303
+ if (!editor.value) return;
1304
+ const payload = savePayload();
1305
+ const nextModelValue = props.output === 'html' ? payload.html : payload.json;
1306
+ lastChangePayload.value = payload;
1307
+ lastContentSignature.value = contentSignature(nextModelValue);
1308
+ field.setValue(nextModelValue);
1309
+ emit('change', payload);
1310
+ }
1311
+
1312
+ /**
1313
+ * Returns the latest serializable editor payload.
1314
+ *
1315
+ * @returns {{ json: Record<string, unknown>|null, html: string, text: string }} Save payload.
1316
+ */
1317
+ function savePayload() {
1318
+ if (!editor.value) {
1319
+ return {
1320
+ json: normalizeEditorContent(field.value.value),
1321
+ html: '',
1322
+ text: '',
1323
+ };
1324
+ }
1325
+ return {
1326
+ json: editor.value.getJSON(),
1327
+ html: editor.value.getHTML(),
1328
+ text: editor.value.getText(),
1329
+ };
1330
+ }
1331
+
1332
+ /**
1333
+ * Requests an AI rewrite for the current selection or block.
1334
+ *
1335
+ * @param {string} instruction Optional rewrite instruction.
1336
+ * @returns {Promise<Record<string, unknown>|null>} Created suggestion or null.
1337
+ */
1338
+ async function requestRewrite(instruction = rewriteInstruction.value) {
1339
+ if (!editor.value || !isEditorEditable.value || rewriteBusy.value) return null;
1340
+ const payload = currentSelectionPayload();
1341
+ rewriteBusy.value = true;
1342
+ const request = {
1343
+ ...payload,
1344
+ instruction,
1345
+ author: props.aiUser,
1346
+ editor: editor.value,
1347
+ respond: createRewriteResponder(payload, instruction),
1348
+ };
1349
+ emit('rewrite-request', request);
1350
+ try {
1351
+ if (typeof props.rewriteProvider === 'function') {
1352
+ const response = await props.rewriteProvider(request);
1353
+ if (response) return createSuggestion(normalizeProviderSuggestion(response, payload, instruction));
1354
+ }
1355
+ return null;
1356
+ } finally {
1357
+ rewriteBusy.value = false;
1358
+ }
1359
+ }
1360
+
1361
+ /**
1362
+ * Creates a callback that external rewrite handlers can use to add a suggestion.
1363
+ *
1364
+ * @param {Record<string, unknown>} selectionPayload Selection payload.
1365
+ * @param {string} instruction Rewrite instruction.
1366
+ * @returns {Function} Suggestion callback.
1367
+ */
1368
+ function createRewriteResponder(selectionPayload, instruction) {
1369
+ /**
1370
+ * Adds a rewrite suggestion from an external AI handler.
1371
+ *
1372
+ * @param {string|Record<string, unknown>} response Proposed text or suggestion record.
1373
+ * @returns {Record<string, unknown>} Created suggestion.
1374
+ */
1375
+ return function respond(response) {
1376
+ return createSuggestion(normalizeProviderSuggestion(response, selectionPayload, instruction));
1377
+ };
1378
+ }
1379
+
1380
+ /**
1381
+ * Creates and marks a pending suggestion.
1382
+ *
1383
+ * @param {Record<string, unknown>} payload Suggestion payload.
1384
+ * @returns {Record<string, unknown>|null} Created suggestion.
1385
+ */
1386
+ function createSuggestion(payload = {}) {
1387
+ if (!editor.value) return null;
1388
+ const normalized = normalizeSuggestion(payload, currentSelectionPayload());
1389
+ if (!normalized.proposed) return null;
1390
+ internalSuggestions.value = [
1391
+ ...internalSuggestions.value,
1392
+ normalized,
1393
+ ];
1394
+ markSuggestionRange(normalized);
1395
+ emit('suggestion-create', normalized);
1396
+ return normalized;
1397
+ }
1398
+
1399
+ /**
1400
+ * Accepts a pending suggestion and replaces the marked range with the proposal.
1401
+ *
1402
+ * @param {Record<string, unknown>} suggestion Suggestion record.
1403
+ * @returns {void}
1404
+ */
1405
+ function acceptSuggestion(suggestion) {
1406
+ if (!editor.value || !suggestion) return;
1407
+ const range = findSuggestionRange(suggestion.id) || suggestionRange(suggestion);
1408
+ if (range) {
1409
+ editor.value.chain().focus().insertContentAt(range, suggestion.proposed || '').run();
1410
+ }
1411
+ removeInternalSuggestion(suggestion.id);
1412
+ emit('suggestion-accept', suggestion);
1413
+ emitEditorChange();
1414
+ }
1415
+
1416
+ /**
1417
+ * Rejects a pending suggestion and clears its inline mark.
1418
+ *
1419
+ * @param {Record<string, unknown>} suggestion Suggestion record.
1420
+ * @returns {void}
1421
+ */
1422
+ function rejectSuggestion(suggestion) {
1423
+ if (!editor.value || !suggestion) return;
1424
+ const range = findSuggestionRange(suggestion.id) || suggestionRange(suggestion);
1425
+ if (range) {
1426
+ editor.value.chain().focus().setTextSelection(range).unsetMark('aiSuggestion').run();
1427
+ }
1428
+ removeInternalSuggestion(suggestion.id);
1429
+ emit('suggestion-reject', suggestion);
1430
+ }
1431
+
1432
+ /**
1433
+ * Inserts an image node from a media browser or custom picker payload.
1434
+ *
1435
+ * @param {Record<string, unknown>} payload Media payload.
1436
+ * @returns {Record<string, unknown>|null} Inserted image attributes.
1437
+ */
1438
+ function insertImage(payload = {}) {
1439
+ if (!editor.value) return null;
1440
+ const image = normalizeImagePayload(payload);
1441
+ if (!image.src) return null;
1442
+ editor.value.chain().focus().setImage(image).run();
1443
+ emit('media-insert', image);
1444
+ emitEditorChange();
1445
+ return image;
1446
+ }
1447
+
1448
+ /**
1449
+ * Opens the app-owned media picker request.
1450
+ *
1451
+ * @returns {void}
1452
+ */
1453
+ function requestMedia(payload = {}) {
1454
+ emit('media-request', {
1455
+ editor: editor.value,
1456
+ insertImage,
1457
+ ...payload,
1458
+ });
1459
+ }
1460
+
1461
+ /**
1462
+ * Opens the video URL dialog.
1463
+ *
1464
+ * @returns {void}
1465
+ */
1466
+ function openVideoDialog() {
1467
+ videoUrl.value = '';
1468
+ videoTitle.value = '';
1469
+ videoDialogOpen.value = true;
1470
+ }
1471
+
1472
+ /**
1473
+ * Inserts a video embed from the video dialog.
1474
+ *
1475
+ * @returns {void}
1476
+ */
1477
+ function commitVideoDialog() {
1478
+ const inserted = insertVideo({
1479
+ src: videoUrl.value,
1480
+ title: videoTitle.value,
1481
+ });
1482
+ if (!inserted) return;
1483
+ videoDialogOpen.value = false;
1484
+ }
1485
+
1486
+ /**
1487
+ * Inserts a video embed.
1488
+ *
1489
+ * @param {Record<string, unknown>} payload Video payload.
1490
+ * @returns {Record<string, unknown>|null} Inserted video attrs.
1491
+ */
1492
+ function insertVideo(payload = {}) {
1493
+ if (!editor.value) return null;
1494
+ const video = normalizeVideoPayload(payload);
1495
+ if (!video.src) return null;
1496
+ editor.value.chain().focus().setVideoEmbed(video).run();
1497
+ emit('video-insert', video);
1498
+ emitEditorChange();
1499
+ return video;
1500
+ }
1501
+
1502
+ /**
1503
+ * Inserts a default table and places the cursor in its first header cell.
1504
+ *
1505
+ * @returns {boolean} True when the table was inserted.
1506
+ */
1507
+ function insertTable() {
1508
+ if (!editor.value || !isEditorEditable.value) return false;
1509
+ const inserted = editor.value
1510
+ .chain()
1511
+ .focus()
1512
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
1513
+ .run();
1514
+ bumpEditorTick();
1515
+ return inserted;
1516
+ }
1517
+
1518
+ /**
1519
+ * Opens the link editor dialog.
1520
+ *
1521
+ * @returns {void}
1522
+ */
1523
+ function openLinkDialog() {
1524
+ if (!editor.value) return;
1525
+ linkUrl.value = editor.value.getAttributes('link')?.href || '';
1526
+ linkDialogOpen.value = true;
1527
+ }
1528
+
1529
+ /**
1530
+ * Applies or clears the current link mark.
1531
+ *
1532
+ * @returns {void}
1533
+ */
1534
+ function commitLinkDialog() {
1535
+ if (!editor.value) return;
1536
+ const href = String(linkUrl.value || '').trim();
1537
+ if (!href) {
1538
+ editor.value.chain().focus().extendMarkRange('link').unsetLink().run();
1539
+ linkDialogOpen.value = false;
1540
+ return;
1541
+ }
1542
+ editor.value.chain().focus().extendMarkRange('link').setLink({ href }).run();
1543
+ linkDialogOpen.value = false;
1544
+ emitEditorChange();
1545
+ }
1546
+
1547
+ /**
1548
+ * Runs a built-in editor command.
1549
+ *
1550
+ * @param {string} tool Tool id.
1551
+ * @returns {void}
1552
+ */
1553
+ function runTool(tool) {
1554
+ if (!editor.value || !isEditorEditable.value) return;
1555
+ const chain = editor.value.chain().focus();
1556
+ if (tool === 'undo') chain.undo().run();
1557
+ else if (tool === 'redo') chain.redo().run();
1558
+ else if (tool === 'paragraph') chain.setParagraph().run();
1559
+ else if (tool === 'heading1') chain.toggleHeading({ level: 1 }).run();
1560
+ else if (tool === 'heading2') chain.toggleHeading({ level: 2 }).run();
1561
+ else if (tool === 'heading3') chain.toggleHeading({ level: 3 }).run();
1562
+ else if (tool === 'bold') chain.toggleBold().run();
1563
+ else if (tool === 'italic') chain.toggleItalic().run();
1564
+ else if (tool === 'underline') chain.toggleUnderline().run();
1565
+ else if (tool === 'bulletList') chain.toggleBulletList().run();
1566
+ else if (tool === 'orderedList') chain.toggleOrderedList().run();
1567
+ else if (tool === 'blockquote') chain.toggleBlockquote().run();
1568
+ else if (tool === 'codeBlock') chain.toggleCodeBlock().run();
1569
+ else if (tool === 'link') openLinkDialog();
1570
+ else if (tool === 'table') insertTable();
1571
+ else if (tool === 'image') requestMedia();
1572
+ else if (tool === 'video') openVideoDialog();
1573
+ else if (tool === 'rewrite') requestRewrite();
1574
+ bumpEditorTick();
1575
+ }
1576
+
1577
+ /**
1578
+ * Updates the active code block's persisted syntax language.
1579
+ *
1580
+ * @param {string} language Selected DOM Studio language value.
1581
+ * @returns {void}
1582
+ */
1583
+ function setCodeLanguage(language) {
1584
+ if (!editor.value || !isEditorEditable.value || !toolbarState.value.codeBlock) return;
1585
+ editor.value
1586
+ .chain()
1587
+ .focus()
1588
+ .updateAttributes('codeBlock', { language: codeLanguageOptionValue(language) })
1589
+ .run();
1590
+ bumpEditorTick();
1591
+ }
1592
+
1593
+ /**
1594
+ * Runs a table command against the active table selection.
1595
+ *
1596
+ * @param {string} command Tiptap table command name.
1597
+ * @returns {void}
1598
+ */
1599
+ function runTableCommand(command) {
1600
+ if (!editor.value || !isEditorEditable.value || !toolbarState.value.table) return;
1601
+ const chain = editor.value.chain().focus();
1602
+ if (command === 'addRowBefore') chain.addRowBefore().run();
1603
+ else if (command === 'addRowAfter') chain.addRowAfter().run();
1604
+ else if (command === 'deleteRow') chain.deleteRow().run();
1605
+ else if (command === 'addColumnBefore') chain.addColumnBefore().run();
1606
+ else if (command === 'addColumnAfter') chain.addColumnAfter().run();
1607
+ else if (command === 'deleteColumn') chain.deleteColumn().run();
1608
+ else if (command === 'toggleHeaderRow') chain.toggleHeaderRow().run();
1609
+ else if (command === 'deleteTable') chain.deleteTable().run();
1610
+ bumpEditorTick();
1611
+ }
1612
+
1613
+ /**
1614
+ * Checks whether a table command can run for the current cell selection.
1615
+ *
1616
+ * @param {string} command Tiptap table command name.
1617
+ * @returns {boolean} True when the command is currently available.
1618
+ */
1619
+ function canRunTableCommand(command) {
1620
+ if (!editor.value || !isEditorEditable.value || !toolbarState.value.table) return false;
1621
+ const commands = editor.value.can?.();
1622
+ const commandHandler = commands?.[command];
1623
+ return typeof commandHandler === 'function' && Boolean(commandHandler.call(commands));
1624
+ }
1625
+
1626
+ /**
1627
+ * Invokes a custom toolbar tool.
1628
+ *
1629
+ * @param {Record<string, unknown>} tool Custom tool config.
1630
+ * @returns {void}
1631
+ */
1632
+ function runCustomTool(tool) {
1633
+ if (!tool) return;
1634
+ if (typeof tool.command === 'function') {
1635
+ tool.command(createToolContext(tool));
1636
+ }
1637
+ emit('tool-action', { tool, editor: editor.value });
1638
+ }
1639
+
1640
+ /**
1641
+ * Creates the helper context supplied to custom toolbar tools and extension hooks.
1642
+ *
1643
+ * @param {Record<string, unknown>|null} tool Custom tool config.
1644
+ * @returns {Record<string, unknown>} Tool helper context.
1645
+ */
1646
+ function createToolContext(tool = null) {
1647
+ return {
1648
+ tool,
1649
+ editor: editor.value,
1650
+ selection: currentSelectionPayload(),
1651
+ insertImage,
1652
+ insertVideo,
1653
+ insertTable,
1654
+ requestRewrite,
1655
+ createSuggestion,
1656
+ savePayload,
1657
+ runTool,
1658
+ };
1659
+ }
1660
+
1661
+ /**
1662
+ * Returns whether a built-in tool should be visible.
1663
+ *
1664
+ * @param {string} tool Tool id.
1665
+ * @returns {boolean} True when visible.
1666
+ */
1667
+ function showTool(tool) {
1668
+ return enabledToolSet.value.has(tool);
1669
+ }
1670
+
1671
+ /**
1672
+ * Returns whether a toolbar tool is active.
1673
+ *
1674
+ * @param {string} tool Tool id.
1675
+ * @returns {boolean} True when active.
1676
+ */
1677
+ function toolActive(tool) {
1678
+ return Boolean(toolbarState.value[tool]);
1679
+ }
1680
+
1681
+ /**
1682
+ * Returns whether the current key event should open the slash command menu.
1683
+ *
1684
+ * @param {KeyboardEvent} event Keyboard event from ProseMirror.
1685
+ * @returns {boolean} True when slash commands should take over the key.
1686
+ */
1687
+ function shouldOpenSlashMenuFromKey(event) {
1688
+ if (!props.showSlashMenu || !isEditorEditable.value || !editor.value) return false;
1689
+ if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return false;
1690
+ const selection = editor.value.state?.selection;
1691
+ if (!selection?.empty) return false;
1692
+ const cursor = selection.$from;
1693
+ if (!cursor.parent?.isTextblock) return false;
1694
+ const blockText = cursor.parent.textBetween(0, cursor.parentOffset, '\n', '\ufffc');
1695
+ return !blockText.trim();
1696
+ }
1697
+
1698
+ /**
1699
+ * Inserts the slash trigger and opens the block command menu from its range.
1700
+ *
1701
+ * @returns {boolean} True when the slash menu opened.
1702
+ */
1703
+ function openSlashMenuFromKey() {
1704
+ if (!editor.value) return false;
1705
+ const from = editor.value.state.selection.from;
1706
+ editor.value.chain().focus().insertContent('/').run();
1707
+ slashMenuRange.value = { from, to: from + 1 };
1708
+ slashMenuQuery.value = '';
1709
+ slashMenuIndex.value = 0;
1710
+ slashMenuOpen.value = slashMenuOptions.value.length > 0;
1711
+ positionSlashMenu(from + 1);
1712
+ return true;
1713
+ }
1714
+
1715
+ /**
1716
+ * Handles keyboard control while the slash command menu is open.
1717
+ *
1718
+ * @param {unknown} view ProseMirror editor view.
1719
+ * @param {KeyboardEvent} event Keyboard event from ProseMirror.
1720
+ * @returns {boolean} True when the editor should stop handling the key.
1721
+ */
1722
+ function handleEditorKeyDown(view, event) {
1723
+ if (shouldOpenSlashMenuFromKey(event) && slashMenuOptions.value.length) {
1724
+ event.preventDefault();
1725
+ return openSlashMenuFromKey();
1726
+ }
1727
+ if (!slashMenuOpen.value) return false;
1728
+ if (event.key === 'ArrowDown') {
1729
+ event.preventDefault();
1730
+ moveSlashMenuSelection(1);
1731
+ return true;
1732
+ }
1733
+ if (event.key === 'ArrowUp') {
1734
+ event.preventDefault();
1735
+ moveSlashMenuSelection(-1);
1736
+ return true;
1737
+ }
1738
+ if (event.key === 'Enter' || event.key === 'Tab') {
1739
+ event.preventDefault();
1740
+ applySlashMenuOption(slashMenuOptions.value[slashMenuIndex.value]);
1741
+ return true;
1742
+ }
1743
+ if (event.key === 'Escape') {
1744
+ event.preventDefault();
1745
+ closeSlashMenu();
1746
+ return true;
1747
+ }
1748
+ return false;
1749
+ }
1750
+
1751
+ /**
1752
+ * Resolves slash menu block options from the enabled tool list and query.
1753
+ *
1754
+ * @returns {Array<Record<string, unknown>>} Matching slash menu options.
1755
+ */
1756
+ function resolveSlashMenuOptions() {
1757
+ if (!props.showSlashMenu) return [];
1758
+ const query = slashMenuQuery.value.trim().toLowerCase();
1759
+ return slashBlockOptions
1760
+ .filter((option) => !option.tool || showTool(option.tool))
1761
+ .filter((option) => {
1762
+ if (!query) return true;
1763
+ return `${option.label} ${option.description}`.toLowerCase().includes(query);
1764
+ });
1765
+ }
1766
+
1767
+ /**
1768
+ * Keeps the selected slash menu index inside the available option range.
1769
+ *
1770
+ * @returns {void}
1771
+ */
1772
+ function syncSlashMenuIndex() {
1773
+ if (!slashMenuOptions.value.length) {
1774
+ slashMenuIndex.value = 0;
1775
+ return;
1776
+ }
1777
+ slashMenuIndex.value = Math.min(slashMenuIndex.value, slashMenuOptions.value.length - 1);
1778
+ scrollActiveSlashMenuOptionIntoView();
1779
+ }
1780
+
1781
+ /**
1782
+ * Moves the highlighted slash command option.
1783
+ *
1784
+ * @param {number} delta Index movement direction.
1785
+ * @returns {void}
1786
+ */
1787
+ function moveSlashMenuSelection(delta) {
1788
+ const count = slashMenuOptions.value.length;
1789
+ if (!count) return;
1790
+ slashMenuIndex.value = (slashMenuIndex.value + delta + count) % count;
1791
+ scrollActiveSlashMenuOptionIntoView();
1792
+ }
1793
+
1794
+ /**
1795
+ * Keeps the highlighted slash command visible inside the scrollable menu panel.
1796
+ *
1797
+ * @returns {void}
1798
+ */
1799
+ function scrollActiveSlashMenuOptionIntoView() {
1800
+ nextTick(() => {
1801
+ const menu = slashMenuPanel.value;
1802
+ const option = menu?.querySelector?.('[aria-selected="true"]');
1803
+ if (!menu || !option) return;
1804
+ const menuRect = menu.getBoundingClientRect();
1805
+ const optionRect = option.getBoundingClientRect();
1806
+ const edgeInset = 4;
1807
+ if (optionRect.top < menuRect.top + edgeInset) {
1808
+ menu.scrollTop += optionRect.top - menuRect.top - edgeInset;
1809
+ } else if (optionRect.bottom > menuRect.bottom - edgeInset) {
1810
+ menu.scrollTop += optionRect.bottom - menuRect.bottom + edgeInset;
1811
+ }
1812
+ });
1813
+ }
1814
+
1815
+ /**
1816
+ * Updates slash menu visibility from the current selection.
1817
+ *
1818
+ * @returns {void}
1819
+ */
1820
+ function updateSlashMenu() {
1821
+ if (!props.showSlashMenu || !isEditorEditable.value || !editor.value) {
1822
+ closeSlashMenu();
1823
+ return;
1824
+ }
1825
+ const match = currentSlashMatch();
1826
+ if (!match) {
1827
+ closeSlashMenu();
1828
+ return;
1829
+ }
1830
+ slashMenuRange.value = { from: match.from, to: match.to };
1831
+ slashMenuQuery.value = match.query;
1832
+ slashMenuOpen.value = slashMenuOptions.value.length > 0;
1833
+ positionSlashMenu(match.to);
1834
+ }
1835
+
1836
+ /**
1837
+ * Returns the active slash-command query when the cursor is after a block-start slash.
1838
+ *
1839
+ * @returns {{ from: number, to: number, query: string }|null} Slash match.
1840
+ */
1841
+ function currentSlashMatch() {
1842
+ const instance = editor.value;
1843
+ if (!instance?.state?.selection?.empty) return null;
1844
+ const selection = instance.state.selection;
1845
+ const cursor = selection.$from;
1846
+ if (!cursor.parent?.isTextblock) return null;
1847
+ const blockText = cursor.parent.textBetween(0, cursor.parentOffset, '\n', '\ufffc');
1848
+ const slashIndex = blockText.lastIndexOf('/');
1849
+ if (slashIndex < 0) return null;
1850
+ const beforeSlash = blockText.slice(0, slashIndex);
1851
+ if (beforeSlash.trim()) return null;
1852
+ const query = blockText.slice(slashIndex + 1);
1853
+ if (query.includes('/') || query.length > 32) return null;
1854
+ const from = cursor.start() + slashIndex;
1855
+ return {
1856
+ from,
1857
+ to: selection.from,
1858
+ query,
1859
+ };
1860
+ }
1861
+
1862
+ /**
1863
+ * Positions the slash menu near the current cursor coordinates.
1864
+ *
1865
+ * @param {number} pos Editor document position.
1866
+ * @returns {void}
1867
+ */
1868
+ function positionSlashMenu(pos) {
1869
+ if (typeof window === 'undefined') return;
1870
+ if (slashMenuFrame) window.cancelAnimationFrame(slashMenuFrame);
1871
+ slashMenuFrame = window.requestAnimationFrame(() => {
1872
+ const shellRect = editorShell.value?.getBoundingClientRect?.();
1873
+ const coords = editor.value?.view?.coordsAtPos?.(pos);
1874
+ if (!shellRect || !coords) return;
1875
+ const menuWidth = 260;
1876
+ const left = Math.max(8, Math.min(coords.left - shellRect.left, shellRect.width - menuWidth - 8));
1877
+ const top = Math.max(8, coords.bottom - shellRect.top + 6);
1878
+ slashMenuStyle.value = {
1879
+ left: `${left}px`,
1880
+ top: `${top}px`,
1881
+ };
1882
+ });
1883
+ }
1884
+
1885
+ /**
1886
+ * Applies a selected slash menu block option.
1887
+ *
1888
+ * @param {Record<string, unknown>} option Slash command option.
1889
+ * @returns {void}
1890
+ */
1891
+ function applySlashMenuOption(option) {
1892
+ if (!option || !editor.value || !isEditorEditable.value) return;
1893
+ const range = slashMenuRange.value;
1894
+ if (option.id === 'image' || option.id === 'video' || option.id === 'table') {
1895
+ deleteSlashRange();
1896
+ closeSlashMenu();
1897
+ if (option.id === 'image') requestMedia();
1898
+ else if (option.id === 'video') openVideoDialog();
1899
+ else insertTable();
1900
+ return;
1901
+ }
1902
+ const chain = editor.value.chain().focus();
1903
+ if (range) chain.deleteRange(range);
1904
+ if (option.id === 'paragraph') chain.setParagraph().run();
1905
+ else if (option.id === 'heading1') chain.setNode('heading', { level: 1 }).run();
1906
+ else if (option.id === 'heading2') chain.setNode('heading', { level: 2 }).run();
1907
+ else if (option.id === 'heading3') chain.setNode('heading', { level: 3 }).run();
1908
+ else if (option.id === 'bulletList') chain.toggleBulletList().run();
1909
+ else if (option.id === 'orderedList') chain.toggleOrderedList().run();
1910
+ else if (option.id === 'blockquote') chain.toggleBlockquote().run();
1911
+ else if (option.id === 'codeBlock') chain.setCodeBlock({ language: 'txt' }).run();
1912
+ closeSlashMenu();
1913
+ bumpEditorTick();
1914
+ }
1915
+
1916
+ /**
1917
+ * Deletes the active slash trigger range.
1918
+ *
1919
+ * @returns {void}
1920
+ */
1921
+ function deleteSlashRange() {
1922
+ if (!editor.value || !slashMenuRange.value) return;
1923
+ editor.value.chain().focus().deleteRange(slashMenuRange.value).run();
1924
+ }
1925
+
1926
+ /**
1927
+ * Closes the slash command menu and clears its query state.
1928
+ *
1929
+ * @returns {void}
1930
+ */
1931
+ function closeSlashMenu() {
1932
+ slashMenuOpen.value = false;
1933
+ slashMenuQuery.value = '';
1934
+ slashMenuRange.value = null;
1935
+ }
1936
+
1937
+ /**
1938
+ * Returns the payload for the current selection, falling back to the active block.
1939
+ *
1940
+ * @returns {Record<string, unknown>} Selection payload.
1941
+ */
1942
+ function currentSelectionPayload() {
1943
+ if (!editor.value) {
1944
+ return {
1945
+ from: 0,
1946
+ to: 0,
1947
+ empty: true,
1948
+ selectedText: '',
1949
+ selectedJson: null,
1950
+ };
1951
+ }
1952
+ const range = selectedRangeOrCurrentBlock(editor.value);
1953
+ const selectedText = editor.value.state.doc.textBetween(range.from, range.to, '\n\n');
1954
+ return {
1955
+ from: range.from,
1956
+ to: range.to,
1957
+ empty: range.empty,
1958
+ selectedText,
1959
+ selectedJson: selectedJsonForRange(editor.value, range),
1960
+ };
1961
+ }
1962
+
1963
+ /**
1964
+ * Returns the selected range or the current text block range.
1965
+ *
1966
+ * @param {Record<string, unknown>} instance Tiptap editor instance.
1967
+ * @returns {{ from: number, to: number, empty: boolean }} Editor range.
1968
+ */
1969
+ function selectedRangeOrCurrentBlock(instance) {
1970
+ const selection = instance.state.selection;
1971
+ if (!selection.empty) {
1972
+ return {
1973
+ from: selection.from,
1974
+ to: selection.to,
1975
+ empty: false,
1976
+ };
1977
+ }
1978
+ const depth = selection.$from.depth;
1979
+ if (depth > 0) {
1980
+ return {
1981
+ from: selection.$from.start(depth),
1982
+ to: selection.$from.end(depth),
1983
+ empty: true,
1984
+ };
1985
+ }
1986
+ return {
1987
+ from: selection.from,
1988
+ to: selection.to,
1989
+ empty: true,
1990
+ };
1991
+ }
1992
+
1993
+ /**
1994
+ * Serializes the selected slice for AI request context.
1995
+ *
1996
+ * @param {Record<string, unknown>} instance Tiptap editor instance.
1997
+ * @param {{ from: number, to: number }} range Editor range.
1998
+ * @returns {Record<string, unknown>|null} JSON slice.
1999
+ */
2000
+ function selectedJsonForRange(instance, range) {
2001
+ try {
2002
+ return {
2003
+ type: 'slice',
2004
+ content: instance.state.doc.slice(range.from, range.to).content.toJSON(),
2005
+ };
2006
+ } catch {
2007
+ return null;
2008
+ }
2009
+ }
2010
+
2011
+ /**
2012
+ * Adds the suggestion mark to the original editor range.
2013
+ *
2014
+ * @param {Record<string, unknown>} suggestion Suggestion record.
2015
+ * @returns {void}
2016
+ */
2017
+ function markSuggestionRange(suggestion) {
2018
+ const range = suggestionRange(suggestion);
2019
+ if (!editor.value || !range || range.from === range.to) return;
2020
+ editor.value.chain().focus().setTextSelection(range).setMark('aiSuggestion', { id: suggestion.id }).run();
2021
+ }
2022
+
2023
+ /**
2024
+ * Finds the current editor range for a suggestion mark.
2025
+ *
2026
+ * @param {string} suggestionId Suggestion id.
2027
+ * @returns {{ from: number, to: number }|null} Current marked range.
2028
+ */
2029
+ function findSuggestionRange(suggestionId) {
2030
+ if (!editor.value || !suggestionId) return null;
2031
+ let from = null;
2032
+ let to = null;
2033
+ editor.value.state.doc.descendants((node, pos) => {
2034
+ if (!node.isText) return true;
2035
+ const hasMark = node.marks.some((mark) => mark.type.name === 'aiSuggestion' && mark.attrs.id === suggestionId);
2036
+ if (!hasMark) return true;
2037
+ if (from === null) from = pos;
2038
+ to = pos + node.nodeSize;
2039
+ return true;
2040
+ });
2041
+ return from === null ? null : { from, to };
2042
+ }
2043
+
2044
+ /**
2045
+ * Returns a suggestion's stored range.
2046
+ *
2047
+ * @param {Record<string, unknown>} suggestion Suggestion record.
2048
+ * @returns {{ from: number, to: number }|null} Stored range.
2049
+ */
2050
+ function suggestionRange(suggestion) {
2051
+ const from = Number(suggestion?.from);
2052
+ const to = Number(suggestion?.to);
2053
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from < 0 || to < from) return null;
2054
+ return { from, to };
2055
+ }
2056
+
2057
+ /**
2058
+ * Removes an internal suggestion by id.
2059
+ *
2060
+ * @param {string} suggestionId Suggestion id.
2061
+ * @returns {void}
2062
+ */
2063
+ function removeInternalSuggestion(suggestionId) {
2064
+ internalSuggestions.value = internalSuggestions.value.filter((suggestion) => suggestion.id !== suggestionId);
2065
+ }
2066
+
2067
+ /**
2068
+ * Normalizes editor content for Tiptap.
2069
+ *
2070
+ * @param {Record<string, unknown>|string} value Editor content value.
2071
+ * @returns {Record<string, unknown>|string} Tiptap content.
2072
+ */
2073
+ function normalizeEditorContent(value) {
2074
+ if (!value) return createDefaultDocumentContent();
2075
+ if (typeof value === 'string') return value;
2076
+ if (value.type === 'doc') return value;
2077
+ return createDefaultDocumentContent();
2078
+ }
2079
+
2080
+ /**
2081
+ * Normalizes a provider response into a suggestion payload.
2082
+ *
2083
+ * @param {string|Record<string, unknown>} response Rewrite provider response.
2084
+ * @param {Record<string, unknown>} selectionPayload Selection payload.
2085
+ * @param {string} instruction Rewrite instruction.
2086
+ * @returns {Record<string, unknown>} Suggestion payload.
2087
+ */
2088
+ function normalizeProviderSuggestion(response, selectionPayload, instruction = '') {
2089
+ if (typeof response === 'string') {
2090
+ return {
2091
+ ...selectionPayload,
2092
+ instruction,
2093
+ proposed: response,
2094
+ };
2095
+ }
2096
+ return {
2097
+ ...selectionPayload,
2098
+ ...(response || {}),
2099
+ instruction: response?.instruction || instruction,
2100
+ proposed: response?.proposed || response?.text || '',
2101
+ };
2102
+ }
2103
+
2104
+ /**
2105
+ * Normalizes a suggestion record.
2106
+ *
2107
+ * @param {Record<string, unknown>} payload Suggestion payload.
2108
+ * @param {Record<string, unknown>} fallback Selection fallback.
2109
+ * @returns {Record<string, unknown>} Normalized suggestion.
2110
+ */
2111
+ function normalizeSuggestion(payload = {}, fallback = {}) {
2112
+ suggestionCounter += 1;
2113
+ return {
2114
+ id: String(payload.id || `sug_local_${Date.now()}_${suggestionCounter}`),
2115
+ type: payload.type || 'replace',
2116
+ from: Number.isFinite(Number(payload.from)) ? Number(payload.from) : Number(fallback.from || 0),
2117
+ to: Number.isFinite(Number(payload.to)) ? Number(payload.to) : Number(fallback.to || 0),
2118
+ original: String(payload.original || payload.selectedText || fallback.selectedText || ''),
2119
+ proposed: String(payload.proposed || payload.text || ''),
2120
+ instruction: String(payload.instruction || ''),
2121
+ author: normalizeCollaborator(payload.author || props.aiUser, props.aiUser),
2122
+ createdAt: payload.createdAt || new Date().toISOString(),
2123
+ status: payload.status || 'pending',
2124
+ };
2125
+ }
2126
+
2127
+ /**
2128
+ * Normalizes an externally supplied suggestion without mutating it.
2129
+ *
2130
+ * @param {Record<string, unknown>} suggestion Suggestion record.
2131
+ * @returns {Record<string, unknown>} Normalized suggestion.
2132
+ */
2133
+ function normalizeExternalSuggestion(suggestion = {}) {
2134
+ return {
2135
+ ...suggestion,
2136
+ id: String(suggestion.id || `external_${suggestionCounter}`),
2137
+ original: String(suggestion.original || ''),
2138
+ proposed: String(suggestion.proposed || ''),
2139
+ author: normalizeCollaborator(suggestion.author || props.aiUser, props.aiUser),
2140
+ };
2141
+ }
2142
+
2143
+ /**
2144
+ * Normalizes a media picker payload into image node attrs.
2145
+ *
2146
+ * @param {Record<string, unknown>} payload Media payload.
2147
+ * @returns {Record<string, unknown>} Image attrs.
2148
+ */
2149
+ function normalizeImagePayload(payload = {}) {
2150
+ const file = payload.file || payload.item?.file || payload.media?.file || {};
2151
+ const item = payload.item || payload.media || payload;
2152
+ const src = payload.src || payload.url || file.publicUrl || file.previewUrl || file.thumbnailUrl || item.publicUrl || item.thumbnailUrl || '';
2153
+ const alt = payload.alt || file.meta?.alt || item.alt || item.name || file.name || '';
2154
+ return {
2155
+ src,
2156
+ alt,
2157
+ title: payload.title || item.name || file.name || '',
2158
+ mediaId: payload.mediaId || item.ulid || item.id || '',
2159
+ caption: payload.caption || '',
2160
+ };
2161
+ }
2162
+
2163
+ /**
2164
+ * Normalizes a video URL payload for the video embed node.
2165
+ *
2166
+ * @param {Record<string, unknown>} payload Video payload.
2167
+ * @returns {Record<string, unknown>} Video attrs.
2168
+ */
2169
+ function normalizeVideoPayload(payload = {}) {
2170
+ const src = normalizeVideoUrl(payload.src || payload.url || '');
2171
+ return {
2172
+ src,
2173
+ title: payload.title || 'Embedded video',
2174
+ provider: payload.provider || videoProviderFromUrl(src),
2175
+ };
2176
+ }
2177
+
2178
+ /**
2179
+ * Converts common video page URLs to embeddable URLs.
2180
+ *
2181
+ * @param {string} value Video URL.
2182
+ * @returns {string} Embed URL.
2183
+ */
2184
+ function normalizeVideoUrl(value) {
2185
+ const url = String(value || '').trim();
2186
+ const youtubeMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([A-Za-z0-9_-]+)/);
2187
+ if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
2188
+ const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
2189
+ if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
2190
+ return url;
2191
+ }
2192
+
2193
+ /**
2194
+ * Infers a video provider key from a URL.
2195
+ *
2196
+ * @param {string} url Video URL.
2197
+ * @returns {string} Provider key.
2198
+ */
2199
+ function videoProviderFromUrl(url) {
2200
+ if (/youtube\.com|youtu\.be/i.test(url)) return 'youtube';
2201
+ if (/vimeo\.com/i.test(url)) return 'vimeo';
2202
+ return 'video';
2203
+ }
2204
+
2205
+ /**
2206
+ * Builds a stable signature for editor content comparisons.
2207
+ *
2208
+ * @param {unknown} value Content value.
2209
+ * @returns {string} Serialized signature.
2210
+ */
2211
+ function contentSignature(value) {
2212
+ try {
2213
+ return JSON.stringify(value || null);
2214
+ } catch {
2215
+ return String(value || '');
2216
+ }
2217
+ }
2218
+
2219
+ /**
2220
+ * Returns a Yjs document from a collaboration config.
2221
+ *
2222
+ * @param {Record<string, unknown>|null} config Collaboration config.
2223
+ * @returns {unknown|null} Yjs document or null.
2224
+ */
2225
+ function collaborationDocument(config) {
2226
+ return config?.document || config?.doc || null;
2227
+ }
2228
+
2229
+ /**
2230
+ * Normalizes collaborators for header display.
2231
+ *
2232
+ * @param {Array<Record<string, unknown>>} collaborators Explicit collaborators.
2233
+ * @param {Record<string, unknown>|null} collaborationConfig Collaboration config.
2234
+ * @returns {Array<Record<string, unknown>>} Display collaborators.
2235
+ */
2236
+ function normalizeCollaborators(collaborators, collaborationConfig) {
2237
+ const records = [...(Array.isArray(collaborators) ? collaborators : [])];
2238
+ if (collaborationConfig?.user) records.unshift(collaborationConfig.user);
2239
+ return records.map((collaborator) => normalizeCollaborator(collaborator, props.aiUser));
2240
+ }
2241
+
2242
+ /**
2243
+ * Normalizes one collaborator record.
2244
+ *
2245
+ * @param {Record<string, unknown>} collaborator Collaborator metadata.
2246
+ * @param {Record<string, unknown>} fallback Fallback collaborator.
2247
+ * @returns {Record<string, unknown>} Normalized collaborator.
2248
+ */
2249
+ function normalizeCollaborator(collaborator = {}, fallback = {}) {
2250
+ return {
2251
+ id: String(collaborator.id || fallback.id || 'user'),
2252
+ name: String(collaborator.name || fallback.name || 'User'),
2253
+ color: String(collaborator.color || fallback.color || '#2563eb'),
2254
+ type: String(collaborator.type || fallback.type || 'human'),
2255
+ };
2256
+ }
2257
+
2258
+ /**
2259
+ * Renders a collaboration caret element for Tiptap.
2260
+ *
2261
+ * @param {Record<string, unknown>} user User metadata.
2262
+ * @returns {HTMLElement} Caret element.
2263
+ */
2264
+ function renderCollaborationCaret(user) {
2265
+ const cursor = document.createElement('span');
2266
+ cursor.className = 'dom-document-editor__collaboration-caret';
2267
+ cursor.style.borderColor = user.color || '#2563eb';
2268
+ const label = document.createElement('span');
2269
+ label.className = 'dom-document-editor__collaboration-label';
2270
+ label.style.backgroundColor = user.color || '#2563eb';
2271
+ label.textContent = user.name || 'User';
2272
+ cursor.appendChild(label);
2273
+ return cursor;
2274
+ }
2275
+
2276
+ /**
2277
+ * Renders collaboration selection decoration attrs for Tiptap.
2278
+ *
2279
+ * @param {Record<string, unknown>} user User metadata.
2280
+ * @returns {Record<string, string>} Selection decoration attrs.
2281
+ */
2282
+ function renderCollaborationSelection(user) {
2283
+ return {
2284
+ nodeName: 'span',
2285
+ class: 'dom-document-editor__collaboration-selection',
2286
+ style: `background-color: ${user.color || '#2563eb'}33`,
2287
+ 'data-user': user.name || 'User',
2288
+ };
2289
+ }
2290
+
2291
+ /**
2292
+ * Parses a media id from an image element.
2293
+ *
2294
+ * @param {HTMLElement} element HTML element.
2295
+ * @returns {string|null} Media id.
2296
+ */
2297
+ function parseMediaIdAttribute(element) {
2298
+ return element.getAttribute('data-media-id');
2299
+ }
2300
+
2301
+ /**
2302
+ * Renders a media id attribute.
2303
+ *
2304
+ * @param {Record<string, unknown>} attrs Image attributes.
2305
+ * @returns {Record<string, string>} HTML attrs.
2306
+ */
2307
+ function renderMediaIdAttribute(attrs) {
2308
+ return attrs.mediaId ? { 'data-media-id': attrs.mediaId } : {};
2309
+ }
2310
+
2311
+ /**
2312
+ * Parses an image caption from an element.
2313
+ *
2314
+ * @param {HTMLElement} element HTML element.
2315
+ * @returns {string|null} Caption.
2316
+ */
2317
+ function parseCaptionAttribute(element) {
2318
+ return element.getAttribute('data-caption');
2319
+ }
2320
+
2321
+ /**
2322
+ * Renders an image caption attribute.
2323
+ *
2324
+ * @param {Record<string, unknown>} attrs Image attributes.
2325
+ * @returns {Record<string, string>} HTML attrs.
2326
+ */
2327
+ function renderCaptionAttribute(attrs) {
2328
+ return attrs.caption ? { 'data-caption': attrs.caption } : {};
2329
+ }
2330
+
2331
+ /**
2332
+ * Parses a suggestion id from a mark element.
2333
+ *
2334
+ * @param {HTMLElement} element HTML element.
2335
+ * @returns {string|null} Suggestion id.
2336
+ */
2337
+ function parseSuggestionIdAttribute(element) {
2338
+ return element.getAttribute('data-suggestion-id');
2339
+ }
2340
+
2341
+ /**
2342
+ * Renders a suggestion id attribute.
2343
+ *
2344
+ * @param {Record<string, unknown>} attrs Suggestion attrs.
2345
+ * @returns {Record<string, string>} HTML attrs.
2346
+ */
2347
+ function renderSuggestionIdAttribute(attrs) {
2348
+ return attrs.id ? { 'data-suggestion-id': attrs.id } : {};
2349
+ }
2350
+
2351
+ /**
2352
+ * Renders the video embed node tuple.
2353
+ *
2354
+ * @param {Record<string, unknown>} attrs Video attrs.
2355
+ * @param {Function} mergeAttributes Attribute merge helper.
2356
+ * @returns {Array<unknown>} Render tuple.
2357
+ */
2358
+ function renderVideoEmbedNode(attrs, mergeAttributes) {
2359
+ const src = String(attrs.src || '');
2360
+ const title = String(attrs.title || 'Embedded video');
2361
+ return [
2362
+ 'div',
2363
+ mergeAttributes(attrs, {
2364
+ 'data-video-embed': '',
2365
+ class: 'dom-document-editor__video-node',
2366
+ }),
2367
+ [
2368
+ 'iframe',
2369
+ {
2370
+ src,
2371
+ title,
2372
+ allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
2373
+ allowfullscreen: 'true',
2374
+ },
2375
+ ],
2376
+ ];
2377
+ }
2378
+
2379
+ /**
2380
+ * Creates the Tiptap command factory for video insertion.
2381
+ *
2382
+ * @param {string} nodeName Video node name.
2383
+ * @returns {Function} Command factory.
2384
+ */
2385
+ function insertVideoCommand(nodeName) {
2386
+ /**
2387
+ * Builds a command that inserts a video node.
2388
+ *
2389
+ * @param {Record<string, unknown>} attrs Video attrs.
2390
+ * @returns {Function} Tiptap command.
2391
+ */
2392
+ return function setVideoEmbed(attrs) {
2393
+ /**
2394
+ * Inserts the configured video node through Tiptap commands.
2395
+ *
2396
+ * @param {Record<string, unknown>} context Command context.
2397
+ * @returns {boolean} Whether insertion succeeded.
2398
+ */
2399
+ return function executeVideoCommand(context) {
2400
+ return context.commands.insertContent({
2401
+ type: nodeName,
2402
+ attrs,
2403
+ });
2404
+ };
2405
+ };
2406
+ }
2407
+ </script>
2408
+
2409
+ <template>
2410
+ <DomField v-bind="field.fieldAttrs.value" :chrome="fieldChrome">
2411
+ <section
2412
+ ref="editorShell"
2413
+ :class="editorClasses"
2414
+ :aria-label="editorTitle"
2415
+ :data-invalid="field.invalid.value ? '' : undefined"
2416
+ >
2417
+ <input
2418
+ v-if="props.name && field.htmlName.value"
2419
+ type="hidden"
2420
+ :name="field.htmlName.value"
2421
+ :value="hiddenInputValue"
2422
+ />
2423
+
2424
+ <header v-if="hasDocumentChrome" class="flex flex-wrap items-center gap-3 border-b border-border bg-secondary/35 px-3 py-2">
2425
+ <div class="min-w-0 flex-1">
2426
+ <p class="truncate text-sm font-semibold text-canvas-fg">{{ editorTitle }}</p>
2427
+ <p class="truncate text-xs text-muted-fg">{{ statusLabel }}</p>
2428
+ </div>
2429
+ <div v-if="visibleCollaborators.length" class="flex items-center -space-x-2" aria-label="Collaborators">
2430
+ <span
2431
+ v-for="collaborator in visibleCollaborators"
2432
+ :key="collaborator.id"
2433
+ class="grid size-8 place-items-center rounded-full border-2 border-canvas text-[11px] font-semibold text-white shadow-sm"
2434
+ :style="{ backgroundColor: collaborator.color }"
2435
+ :title="collaborator.name"
2436
+ >
2437
+ {{ collaborator.name.slice(0, 2).toUpperCase() }}
2438
+ </span>
2439
+ </div>
2440
+ <DomBadge :label="hasCollaboration ? 'Yjs ready' : 'Client only'" tone="info" variant="soft" size="sm" />
2441
+ </header>
2442
+
2443
+ <div v-if="showToolbar" data-document-editor-toolbar :class="toolbarClasses">
2444
+ <DomToolbar label="Document editor toolbar" skin="flat" control-size="sm" control-radius="md" class="w-full">
2445
+ <slot name="toolbar-start" :editor="editor" :active="toolbarState" />
2446
+ <div v-if="showTool('undo') || showTool('redo')" class="flex items-center gap-1 border-r border-border pr-1">
2447
+ <DomIconButton v-if="showTool('undo')" label="Undo" size="sm" :icon="toolIcons.undo" :disabled="!canUseEditor" @click="runTool('undo')" />
2448
+ <DomIconButton v-if="showTool('redo')" label="Redo" size="sm" :icon="toolIcons.redo" :disabled="!canUseEditor" @click="runTool('redo')" />
2449
+ </div>
2450
+ <div class="flex items-center gap-1 border-r border-border pr-1">
2451
+ <DomIconButton v-if="showTool('paragraph')" label="Paragraph" size="sm" :icon="toolIcons.paragraph" :active="toolActive('paragraph')" :disabled="!canUseEditor" @click="runTool('paragraph')" />
2452
+ <DomIconButton v-if="showTool('heading1')" label="Heading 1" size="sm" :icon="toolIcons.heading1" :active="toolActive('heading1')" :disabled="!canUseEditor" @click="runTool('heading1')" />
2453
+ <DomIconButton v-if="showTool('heading2')" label="Heading 2" size="sm" :icon="toolIcons.heading2" :active="toolActive('heading2')" :disabled="!canUseEditor" @click="runTool('heading2')" />
2454
+ <DomIconButton v-if="showTool('heading3')" label="Heading 3" size="sm" :icon="toolIcons.heading3" :active="toolActive('heading3')" :disabled="!canUseEditor" @click="runTool('heading3')" />
2455
+ </div>
2456
+ <div class="flex items-center gap-1 border-r border-border pr-1">
2457
+ <DomIconButton v-if="showTool('bold')" label="Bold" size="sm" :icon="toolIcons.bold" :active="toolActive('bold')" :disabled="!canUseEditor" @click="runTool('bold')" />
2458
+ <DomIconButton v-if="showTool('italic')" label="Italic" size="sm" :icon="toolIcons.italic" :active="toolActive('italic')" :disabled="!canUseEditor" @click="runTool('italic')" />
2459
+ <DomIconButton v-if="showTool('underline')" label="Underline" size="sm" :icon="toolIcons.underline" :active="toolActive('underline')" :disabled="!canUseEditor" @click="runTool('underline')" />
2460
+ </div>
2461
+ <div class="flex items-center gap-1 border-r border-border pr-1">
2462
+ <DomIconButton v-if="showTool('bulletList')" label="Bullet list" size="sm" :icon="toolIcons.bulletList" :active="toolActive('bulletList')" :disabled="!canUseEditor" @click="runTool('bulletList')" />
2463
+ <DomIconButton v-if="showTool('orderedList')" label="Ordered list" size="sm" :icon="toolIcons.orderedList" :active="toolActive('orderedList')" :disabled="!canUseEditor" @click="runTool('orderedList')" />
2464
+ <DomIconButton v-if="showTool('blockquote')" label="Quote" size="sm" :icon="toolIcons.blockquote" :active="toolActive('blockquote')" :disabled="!canUseEditor" @click="runTool('blockquote')" />
2465
+ <DomIconButton v-if="showTool('codeBlock')" label="Code block" size="sm" :icon="toolIcons.codeBlock" :active="toolActive('codeBlock')" :disabled="!canUseEditor" @click="runTool('codeBlock')" />
2466
+ </div>
2467
+ <div class="flex items-center gap-1 border-r border-border pr-1">
2468
+ <DomIconButton v-if="showTool('link')" label="Link" size="sm" :icon="toolIcons.link" :active="toolActive('link')" :disabled="!canUseEditor" @click="runTool('link')" />
2469
+ <DomIconButton v-if="showTool('table')" label="Insert table" size="sm" :icon="toolIcons.table" :disabled="!canUseEditor" @click="runTool('table')" />
2470
+ <DomIconButton v-if="showTool('image')" label="Insert image" size="sm" :icon="toolIcons.image" :disabled="!canUseEditor" @click="runTool('image')" />
2471
+ <DomIconButton v-if="showTool('video')" label="Embed video" size="sm" :icon="toolIcons.video" :disabled="!canUseEditor" @click="runTool('video')" />
2472
+ </div>
2473
+ <DomIconButton v-if="showTool('rewrite')" label="Rewrite selection" size="sm" variant="primary" :icon="toolIcons.rewrite" :loading="rewriteBusy" :disabled="!canUseEditor" @click="runTool('rewrite')" />
2474
+ <DomIconButton
2475
+ v-for="tool in customToolbarTools"
2476
+ :key="tool.id"
2477
+ :label="tool.label"
2478
+ size="sm"
2479
+ :variant="tool.variant || 'ghost'"
2480
+ :icon="tool.icon || 'M12 5v14M5 12h14'"
2481
+ :disabled="!canUseEditor || tool.disabled"
2482
+ @click="runCustomTool(tool)"
2483
+ />
2484
+ <slot name="toolbar-end" :editor="editor" :active="toolbarState" />
2485
+ </DomToolbar>
2486
+ </div>
2487
+
2488
+ <div v-if="tableControlsVisible" class="border-b border-border bg-secondary/25 px-2 py-1.5">
2489
+ <DomToolbar label="Selected table controls" skin="flat" control-size="sm" control-radius="md" class="w-full">
2490
+ <span class="px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-fg">Table</span>
2491
+ <div class="flex flex-wrap items-center gap-1 border-l border-border pl-1">
2492
+ <DomButton type="button" size="sm" variant="ghost" :disabled="!canRunTableCommand('addRowBefore')" @mousedown.prevent @click="runTableCommand('addRowBefore')">Row before</DomButton>
2493
+ <DomButton type="button" size="sm" variant="ghost" :disabled="!canRunTableCommand('addRowAfter')" @mousedown.prevent @click="runTableCommand('addRowAfter')">Row after</DomButton>
2494
+ <DomButton type="button" size="sm" variant="ghost" :disabled="!canRunTableCommand('deleteRow')" @mousedown.prevent @click="runTableCommand('deleteRow')">Delete row</DomButton>
2495
+ </div>
2496
+ <div class="flex flex-wrap items-center gap-1 border-l border-border pl-1">
2497
+ <DomButton type="button" size="sm" variant="ghost" :disabled="!canRunTableCommand('addColumnBefore')" @mousedown.prevent @click="runTableCommand('addColumnBefore')">Column before</DomButton>
2498
+ <DomButton type="button" size="sm" variant="ghost" :disabled="!canRunTableCommand('addColumnAfter')" @mousedown.prevent @click="runTableCommand('addColumnAfter')">Column after</DomButton>
2499
+ <DomButton type="button" size="sm" variant="ghost" :disabled="!canRunTableCommand('deleteColumn')" @mousedown.prevent @click="runTableCommand('deleteColumn')">Delete column</DomButton>
2500
+ </div>
2501
+ <DomButton type="button" size="sm" variant="secondary" :disabled="!canRunTableCommand('toggleHeaderRow')" :aria-pressed="toolbarState.tableHeader ? 'true' : 'false'" @mousedown.prevent @click="runTableCommand('toggleHeaderRow')">
2502
+ {{ toolbarState.tableHeader ? 'Remove header row' : 'Make header row' }}
2503
+ </DomButton>
2504
+ <DomButton type="button" size="sm" variant="danger" :disabled="!canRunTableCommand('deleteTable')" @mousedown.prevent @click="runTableCommand('deleteTable')">Delete table</DomButton>
2505
+ </DomToolbar>
2506
+ </div>
2507
+
2508
+ <div v-if="codeBlockControlsVisible" class="border-b border-border bg-secondary/25 px-2 py-1.5">
2509
+ <DomToolbar label="Selected code block controls" skin="flat" control-size="sm" control-radius="md" class="w-full">
2510
+ <label :for="codeLanguageInputId" class="px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-fg">Language</label>
2511
+ <div class="w-44 [&_select]:!h-8 [&_select]:!py-1 [&_select]:text-xs">
2512
+ <DomNativeSelect
2513
+ :id="codeLanguageInputId"
2514
+ :model-value="toolbarState.codeLanguage"
2515
+ :options="documentEditorCodeLanguages"
2516
+ :chrome="false"
2517
+ placeholder=""
2518
+ @update:model-value="setCodeLanguage"
2519
+ />
2520
+ </div>
2521
+ </DomToolbar>
2522
+ </div>
2523
+
2524
+ <div
2525
+ class="dom-document-editor__body"
2526
+ :class="{ 'dom-document-editor__body--rail': showSuggestionRail }"
2527
+ :style="editorPaperStyle"
2528
+ >
2529
+ <div class="min-w-0" :class="hasFieldChrome ? 'bg-transparent' : 'bg-canvas'">
2530
+ <div v-if="runtimeError" class="m-4 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
2531
+ {{ runtimeError }}
2532
+ </div>
2533
+ <div v-else-if="!editorReady" class="m-4 rounded-lg border border-border bg-secondary/35 p-6 text-sm text-muted-fg">
2534
+ {{ editorLoading ? 'Loading the client editor runtime...' : 'The editor mounts in the browser.' }}
2535
+ </div>
2536
+ <div
2537
+ v-else
2538
+ :class="paperClasses"
2539
+ :style="editorPaperStyle"
2540
+ @click="focusEditor"
2541
+ >
2542
+ <component
2543
+ :is="editorContentComponent"
2544
+ :editor="editor"
2545
+ class="dom-document-editor__content"
2546
+ />
2547
+ </div>
2548
+ </div>
2549
+
2550
+ <aside v-if="showSuggestionRail" class="border-t border-border bg-secondary/30 p-3 lg:border-l lg:border-t-0">
2551
+ <div class="space-y-3">
2552
+ <div>
2553
+ <p class="text-xs font-semibold uppercase text-muted-fg">AI rewrite</p>
2554
+ <textarea
2555
+ v-model="rewriteInstruction"
2556
+ rows="3"
2557
+ class="skin-input mt-2 min-h-20 w-full resize-y text-sm"
2558
+ placeholder="Make it shorter, clearer, more formal..."
2559
+ ></textarea>
2560
+ <DomButton class="mt-2 w-full justify-center" size="sm" variant="primary" :loading="rewriteBusy" :disabled="!canUseEditor" @click="requestRewrite()">
2561
+ Rewrite selection
2562
+ </DomButton>
2563
+ </div>
2564
+
2565
+ <div class="border-t border-border pt-3">
2566
+ <div class="flex items-center justify-between gap-3">
2567
+ <p class="text-xs font-semibold uppercase text-muted-fg">Suggestions</p>
2568
+ <DomBadge :label="String(suggestionCards.length)" size="sm" />
2569
+ </div>
2570
+ <div v-if="hasSuggestions" class="mt-3 space-y-3">
2571
+ <article
2572
+ v-for="suggestion in suggestionCards"
2573
+ :key="suggestion.id"
2574
+ class="rounded-lg border border-border bg-canvas p-3 shadow-sm"
2575
+ >
2576
+ <slot name="suggestion" :suggestion="suggestion" :accept="acceptSuggestion" :reject="rejectSuggestion">
2577
+ <div class="flex items-start justify-between gap-2">
2578
+ <div class="min-w-0">
2579
+ <p class="truncate text-sm font-semibold text-canvas-fg">{{ suggestion.author.name }}</p>
2580
+ <p class="mt-0.5 text-xs text-muted-fg">{{ suggestion.instruction || 'Rewrite suggestion' }}</p>
2581
+ </div>
2582
+ <DomBadge label="Diff" tone="primary" size="sm" />
2583
+ </div>
2584
+ <DomTextDiff
2585
+ class="mt-3"
2586
+ :original="suggestion.original"
2587
+ :proposed="suggestion.proposed"
2588
+ original-label="Current"
2589
+ proposed-label="Replacement"
2590
+ />
2591
+ <div class="mt-3 flex items-center justify-end gap-2">
2592
+ <DomButton size="sm" variant="secondary" @click="rejectSuggestion(suggestion)">Reject</DomButton>
2593
+ <DomButton size="sm" variant="primary" @click="acceptSuggestion(suggestion)">Accept</DomButton>
2594
+ </div>
2595
+ </slot>
2596
+ </article>
2597
+ </div>
2598
+ <div v-else class="mt-3 rounded-lg border border-dashed border-border bg-canvas/70 p-4 text-sm leading-6 text-muted-fg">
2599
+ <slot name="empty-suggestions">Highlight copy or place the cursor in a block, then ask for a rewrite.</slot>
2600
+ </div>
2601
+ </div>
2602
+ </div>
2603
+ </aside>
2604
+ </div>
2605
+
2606
+ <div
2607
+ v-if="slashMenuOpen && slashMenuOptions.length"
2608
+ ref="slashMenuPanel"
2609
+ class="dom-document-editor__slash-menu skin-popover"
2610
+ :style="slashMenuStyle"
2611
+ role="listbox"
2612
+ aria-label="Block menu"
2613
+ >
2614
+ <button
2615
+ v-for="(option, index) in slashMenuOptions"
2616
+ :key="option.id"
2617
+ type="button"
2618
+ class="dom-document-editor__slash-item"
2619
+ :class="{ 'dom-document-editor__slash-item--active': index === slashMenuIndex }"
2620
+ role="option"
2621
+ :aria-selected="index === slashMenuIndex ? 'true' : 'false'"
2622
+ @mousedown.prevent
2623
+ @click="applySlashMenuOption(option)"
2624
+ >
2625
+ <span class="dom-document-editor__slash-icon">
2626
+ <svg viewBox="0 0 24 24" class="size-4" fill="none" aria-hidden="true">
2627
+ <path :d="option.icon" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
2628
+ </svg>
2629
+ </span>
2630
+ <span class="min-w-0 text-left">
2631
+ <span class="block truncate text-sm font-medium text-canvas-fg">{{ option.label }}</span>
2632
+ <span class="block truncate text-xs text-muted-fg">{{ option.description }}</span>
2633
+ </span>
2634
+ </button>
2635
+ </div>
2636
+
2637
+ <DomDialog v-model="linkDialogOpen" title="Edit link" description="Add a destination URL for the selected text." width="min(92vw, 30rem)">
2638
+ <DomTextInput v-model="linkUrl" label="URL" placeholder="https://example.com" />
2639
+ <template #footer>
2640
+ <DomButton type="button" size="sm" variant="secondary" data-close>Cancel</DomButton>
2641
+ <DomButton type="button" size="sm" variant="primary" @click="commitLinkDialog">Apply link</DomButton>
2642
+ </template>
2643
+ </DomDialog>
2644
+
2645
+ <DomDialog v-model="videoDialogOpen" title="Embed video" description="Paste a YouTube, Vimeo, or iframe-safe video URL." width="min(92vw, 32rem)">
2646
+ <div class="space-y-3">
2647
+ <DomTextInput v-model="videoUrl" label="Video URL" placeholder="https://www.youtube.com/watch?v=..." />
2648
+ <DomTextInput v-model="videoTitle" label="Title" placeholder="Product walkthrough" />
2649
+ </div>
2650
+ <template #footer>
2651
+ <DomButton type="button" size="sm" variant="secondary" data-close>Cancel</DomButton>
2652
+ <DomButton type="button" size="sm" variant="primary" @click="commitVideoDialog">Insert video</DomButton>
2653
+ </template>
2654
+ </DomDialog>
2655
+ </section>
2656
+ </DomField>
2657
+ </template>
2658
+
2659
+ <style scoped>
2660
+ .dom-document-editor__body {
2661
+ display: grid;
2662
+ min-height: var(--dom-document-editor-min-height, 34rem);
2663
+ }
2664
+
2665
+ .dom-document-editor--field {
2666
+ cursor: text;
2667
+ }
2668
+
2669
+ .dom-document-editor--field:focus-within {
2670
+ border-color: var(--ring);
2671
+ box-shadow:
2672
+ var(--skin-input-shadow, inset 0 1px 2px rgb(15 23 42 / 0.1)),
2673
+ 0 0 0 2px color-mix(in oklch, var(--ring) 30%, transparent);
2674
+ }
2675
+
2676
+ .dom-document-editor--invalid {
2677
+ border-color: var(--destructive);
2678
+ }
2679
+
2680
+ .dom-document-editor__toolbar {
2681
+ display: block;
2682
+ }
2683
+
2684
+ .dom-document-editor__content {
2685
+ min-height: inherit;
2686
+ }
2687
+
2688
+ .dom-document-editor__paper {
2689
+ background: color-mix(in oklch, var(--canvas) 96%, white);
2690
+ border: 1px solid color-mix(in oklch, var(--border) 82%, var(--canvas-fg));
2691
+ border-radius: 0.75rem;
2692
+ box-shadow: 0 18px 45px color-mix(in oklch, var(--canvas-fg) 8%, transparent);
2693
+ cursor: text;
2694
+ margin: 1rem;
2695
+ min-height: var(--dom-document-editor-min-height, 34rem);
2696
+ overflow: hidden;
2697
+ transition: border-color 160ms ease, box-shadow 160ms ease;
2698
+ }
2699
+
2700
+ .dom-document-editor__paper:focus-within {
2701
+ border-color: var(--primary);
2702
+ box-shadow:
2703
+ 0 0 0 3px color-mix(in oklch, var(--primary) 18%, transparent),
2704
+ 0 18px 45px color-mix(in oklch, var(--canvas-fg) 8%, transparent);
2705
+ }
2706
+
2707
+ .dom-document-editor__paper--readonly {
2708
+ cursor: default;
2709
+ }
2710
+
2711
+ .dom-document-editor__paper--field {
2712
+ background: transparent;
2713
+ border: 0;
2714
+ border-radius: 0;
2715
+ box-shadow: none;
2716
+ margin: 0;
2717
+ }
2718
+
2719
+ .dom-document-editor__paper--field:focus-within {
2720
+ border-color: transparent;
2721
+ box-shadow: none;
2722
+ }
2723
+
2724
+ .dom-document-editor__slash-menu {
2725
+ border-radius: 0.75rem;
2726
+ box-shadow: var(--shadow-lg);
2727
+ display: grid;
2728
+ gap: 0.25rem;
2729
+ max-height: 18rem;
2730
+ overflow: auto;
2731
+ padding: 0.35rem;
2732
+ position: absolute;
2733
+ width: min(16.25rem, calc(100% - 1rem));
2734
+ z-index: 30;
2735
+ }
2736
+
2737
+ .dom-document-editor__slash-item {
2738
+ align-items: center;
2739
+ border-radius: 0.55rem;
2740
+ display: grid;
2741
+ gap: 0.65rem;
2742
+ grid-template-columns: auto minmax(0, 1fr);
2743
+ padding: 0.55rem 0.65rem;
2744
+ text-align: left;
2745
+ transition: background-color 140ms ease;
2746
+ width: 100%;
2747
+ }
2748
+
2749
+ .dom-document-editor__slash-item:hover,
2750
+ .dom-document-editor__slash-item--active {
2751
+ background: var(--secondary);
2752
+ }
2753
+
2754
+ .dom-document-editor__slash-icon {
2755
+ align-items: center;
2756
+ background: var(--canvas);
2757
+ border: 1px solid var(--border);
2758
+ border-radius: 0.5rem;
2759
+ color: var(--muted-fg);
2760
+ display: inline-flex;
2761
+ height: 1.85rem;
2762
+ justify-content: center;
2763
+ width: 1.85rem;
2764
+ }
2765
+
2766
+ :deep(.dom-document-editor__prosemirror) {
2767
+ cursor: text;
2768
+ min-height: inherit;
2769
+ outline: none;
2770
+ padding: clamp(1rem, 4vw, 2.25rem);
2771
+ }
2772
+
2773
+ .dom-document-editor--field :deep(.dom-document-editor__prosemirror) {
2774
+ padding: 0.85rem 1rem 1rem;
2775
+ }
2776
+
2777
+ :deep(.dom-document-editor__prosemirror > * + *) {
2778
+ margin-top: 0.85em;
2779
+ }
2780
+
2781
+ :deep(.dom-document-editor__prosemirror h1) {
2782
+ font-size: 2rem;
2783
+ font-weight: 750;
2784
+ line-height: 1.15;
2785
+ }
2786
+
2787
+ :deep(.dom-document-editor__prosemirror h2) {
2788
+ font-size: 1.55rem;
2789
+ font-weight: 720;
2790
+ line-height: 1.2;
2791
+ }
2792
+
2793
+ :deep(.dom-document-editor__prosemirror h3) {
2794
+ font-size: 1.25rem;
2795
+ font-weight: 700;
2796
+ line-height: 1.25;
2797
+ }
2798
+
2799
+ :deep(.dom-document-editor__prosemirror p) {
2800
+ color: var(--canvas-fg);
2801
+ line-height: 1.75;
2802
+ }
2803
+
2804
+ :deep(.dom-document-editor__prosemirror ul),
2805
+ :deep(.dom-document-editor__prosemirror ol) {
2806
+ padding-left: 1.35rem;
2807
+ }
2808
+
2809
+ :deep(.dom-document-editor__prosemirror ul) {
2810
+ list-style: disc;
2811
+ }
2812
+
2813
+ :deep(.dom-document-editor__prosemirror ol) {
2814
+ list-style: decimal;
2815
+ }
2816
+
2817
+ :deep(.dom-document-editor__prosemirror blockquote) {
2818
+ border-left: 3px solid var(--primary);
2819
+ color: var(--muted-fg);
2820
+ padding-left: 1rem;
2821
+ }
2822
+
2823
+ :deep(.dom-document-editor__prosemirror pre) {
2824
+ background: var(--secondary);
2825
+ border: 1px solid var(--border);
2826
+ border-radius: 0.65rem;
2827
+ color: var(--canvas-fg);
2828
+ overflow-x: auto;
2829
+ padding: 0.9rem 1rem;
2830
+ position: relative;
2831
+ }
2832
+
2833
+ :deep(.dom-document-editor__prosemirror pre.dom-code-block::before) {
2834
+ color: var(--muted-fg);
2835
+ content: attr(data-language);
2836
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2837
+ font-size: 9px;
2838
+ font-weight: 600;
2839
+ letter-spacing: 0.08em;
2840
+ line-height: 1;
2841
+ pointer-events: none;
2842
+ position: absolute;
2843
+ right: 0.75rem;
2844
+ text-transform: uppercase;
2845
+ top: 0.35rem;
2846
+ }
2847
+
2848
+ :deep(.dom-document-editor__prosemirror pre code) {
2849
+ background: transparent;
2850
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
2851
+ font-size: 0.875em;
2852
+ line-height: 1.65;
2853
+ padding: 0;
2854
+ }
2855
+
2856
+ :deep(.dom-document-editor__prosemirror .tableWrapper) {
2857
+ overflow-x: auto;
2858
+ }
2859
+
2860
+ :deep(.dom-document-editor__prosemirror table) {
2861
+ border-collapse: collapse;
2862
+ table-layout: fixed;
2863
+ width: 100%;
2864
+ }
2865
+
2866
+ :deep(.dom-document-editor__prosemirror th),
2867
+ :deep(.dom-document-editor__prosemirror td) {
2868
+ border: 1px solid var(--border);
2869
+ box-sizing: border-box;
2870
+ min-width: 5rem;
2871
+ padding: 0.55rem 0.65rem;
2872
+ position: relative;
2873
+ vertical-align: top;
2874
+ }
2875
+
2876
+ :deep(.dom-document-editor__prosemirror th) {
2877
+ background: var(--secondary);
2878
+ font-weight: 700;
2879
+ text-align: left;
2880
+ }
2881
+
2882
+ :deep(.dom-document-editor__prosemirror .selectedCell::after) {
2883
+ background: color-mix(in oklch, var(--primary) 14%, transparent);
2884
+ content: '';
2885
+ inset: 0;
2886
+ pointer-events: none;
2887
+ position: absolute;
2888
+ z-index: 2;
2889
+ }
2890
+
2891
+ :deep(.dom-document-editor__prosemirror .column-resize-handle) {
2892
+ background: var(--primary);
2893
+ bottom: -1px;
2894
+ pointer-events: none;
2895
+ position: absolute;
2896
+ right: -2px;
2897
+ top: 0;
2898
+ width: 4px;
2899
+ z-index: 3;
2900
+ }
2901
+
2902
+ :deep(.dom-document-editor__prosemirror.resize-cursor) {
2903
+ cursor: col-resize;
2904
+ }
2905
+
2906
+ :deep(.dom-document-editor__prosemirror img) {
2907
+ border: 1px solid var(--border);
2908
+ border-radius: 0.75rem;
2909
+ display: block;
2910
+ max-width: 100%;
2911
+ }
2912
+
2913
+ :deep(.dom-document-editor__image-node) {
2914
+ display: block;
2915
+ position: relative;
2916
+ width: fit-content;
2917
+ }
2918
+
2919
+ :deep(.dom-document-editor__image-action) {
2920
+ align-items: center;
2921
+ background: color-mix(in oklch, var(--canvas) 92%, transparent);
2922
+ border: 1px solid color-mix(in oklch, var(--border) 82%, transparent);
2923
+ border-radius: 0.45rem;
2924
+ box-shadow: 0 0.25rem 0.75rem color-mix(in oklch, var(--fg) 15%, transparent);
2925
+ color: var(--canvas-fg);
2926
+ cursor: pointer;
2927
+ display: inline-flex;
2928
+ font-size: 0.75rem;
2929
+ font-weight: 650;
2930
+ line-height: 1;
2931
+ opacity: 0;
2932
+ padding: 0.5rem 0.65rem;
2933
+ position: absolute;
2934
+ right: 0.75rem;
2935
+ top: 0.75rem;
2936
+ transform: translateY(-0.15rem);
2937
+ transition: opacity 120ms ease, transform 120ms ease, background-color 120ms ease;
2938
+ z-index: 2;
2939
+ }
2940
+
2941
+ :deep(.dom-document-editor__image-node:hover .dom-document-editor__image-action),
2942
+ :deep(.dom-document-editor__image-node:focus-within .dom-document-editor__image-action) {
2943
+ opacity: 1;
2944
+ transform: translateY(0);
2945
+ }
2946
+
2947
+ :deep(.dom-document-editor__image-action:hover) {
2948
+ background: var(--canvas);
2949
+ }
2950
+
2951
+ :deep(.dom-document-editor__image-action:focus-visible) {
2952
+ outline: 2px solid color-mix(in oklch, var(--ring) 65%, transparent);
2953
+ outline-offset: 2px;
2954
+ }
2955
+
2956
+ :deep(.dom-document-editor__link) {
2957
+ color: var(--primary);
2958
+ text-decoration: underline;
2959
+ text-underline-offset: 0.18em;
2960
+ }
2961
+
2962
+ :deep(.dom-document-editor__suggestion-mark) {
2963
+ background: color-mix(in oklch, var(--primary) 14%, transparent);
2964
+ border-bottom: 2px solid var(--primary);
2965
+ border-radius: 0.2rem;
2966
+ padding: 0.05rem 0.1rem;
2967
+ }
2968
+
2969
+ :deep(.dom-document-editor__video-node) {
2970
+ aspect-ratio: 16 / 9;
2971
+ background: var(--secondary);
2972
+ border: 1px solid var(--border);
2973
+ border-radius: 0.75rem;
2974
+ overflow: hidden;
2975
+ }
2976
+
2977
+ :deep(.dom-document-editor__video-node iframe) {
2978
+ border: 0;
2979
+ height: 100%;
2980
+ width: 100%;
2981
+ }
2982
+
2983
+ :deep(.dom-document-editor__prosemirror .is-empty::before) {
2984
+ color: var(--muted-fg);
2985
+ content: attr(data-placeholder);
2986
+ float: left;
2987
+ height: 0;
2988
+ pointer-events: none;
2989
+ }
2990
+
2991
+ :deep(.dom-document-editor__collaboration-caret) {
2992
+ border-left: 2px solid var(--primary);
2993
+ border-right: 2px solid var(--primary);
2994
+ margin-left: -1px;
2995
+ margin-right: -1px;
2996
+ pointer-events: none;
2997
+ position: relative;
2998
+ word-break: normal;
2999
+ }
3000
+
3001
+ :deep(.dom-document-editor__collaboration-label) {
3002
+ border-radius: 999px;
3003
+ color: white;
3004
+ font-size: 11px;
3005
+ font-weight: 650;
3006
+ left: -2px;
3007
+ line-height: 1;
3008
+ padding: 0.2rem 0.45rem;
3009
+ position: absolute;
3010
+ top: -1.45rem;
3011
+ white-space: nowrap;
3012
+ }
3013
+
3014
+ @media (max-width: 640px) {
3015
+ .dom-document-editor__paper:not(.dom-document-editor__paper--field) {
3016
+ margin: 0.75rem;
3017
+ min-height: min(var(--dom-document-editor-min-height, 34rem), 28rem);
3018
+ }
3019
+
3020
+ .dom-document-editor__paper--field {
3021
+ margin: 0;
3022
+ }
3023
+ }
3024
+
3025
+ @media (min-width: 1024px) {
3026
+ .dom-document-editor__body--rail {
3027
+ grid-template-columns: minmax(0, 1fr) 20rem;
3028
+ }
3029
+ }
3030
+ </style>