@rtif-sdk/web 1.0.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 (215) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/block-drag-handler.d.ts +189 -0
  4. package/dist/block-drag-handler.d.ts.map +1 -0
  5. package/dist/block-drag-handler.js +745 -0
  6. package/dist/block-drag-handler.js.map +1 -0
  7. package/dist/block-renderer.d.ts +402 -0
  8. package/dist/block-renderer.d.ts.map +1 -0
  9. package/dist/block-renderer.js +424 -0
  10. package/dist/block-renderer.js.map +1 -0
  11. package/dist/clipboard.d.ts +178 -0
  12. package/dist/clipboard.d.ts.map +1 -0
  13. package/dist/clipboard.js +432 -0
  14. package/dist/clipboard.js.map +1 -0
  15. package/dist/command-bus.d.ts +113 -0
  16. package/dist/command-bus.d.ts.map +1 -0
  17. package/dist/command-bus.js +70 -0
  18. package/dist/command-bus.js.map +1 -0
  19. package/dist/composition.d.ts +220 -0
  20. package/dist/composition.d.ts.map +1 -0
  21. package/dist/composition.js +271 -0
  22. package/dist/composition.js.map +1 -0
  23. package/dist/content-extraction.d.ts +69 -0
  24. package/dist/content-extraction.d.ts.map +1 -0
  25. package/dist/content-extraction.js +228 -0
  26. package/dist/content-extraction.js.map +1 -0
  27. package/dist/content-handler-file.d.ts +40 -0
  28. package/dist/content-handler-file.d.ts.map +1 -0
  29. package/dist/content-handler-file.js +91 -0
  30. package/dist/content-handler-file.js.map +1 -0
  31. package/dist/content-handler-image.d.ts +82 -0
  32. package/dist/content-handler-image.d.ts.map +1 -0
  33. package/dist/content-handler-image.js +120 -0
  34. package/dist/content-handler-image.js.map +1 -0
  35. package/dist/content-handler-url.d.ts +129 -0
  36. package/dist/content-handler-url.d.ts.map +1 -0
  37. package/dist/content-handler-url.js +244 -0
  38. package/dist/content-handler-url.js.map +1 -0
  39. package/dist/content-handlers.d.ts +67 -0
  40. package/dist/content-handlers.d.ts.map +1 -0
  41. package/dist/content-handlers.js +263 -0
  42. package/dist/content-handlers.js.map +1 -0
  43. package/dist/content-pipeline.d.ts +383 -0
  44. package/dist/content-pipeline.d.ts.map +1 -0
  45. package/dist/content-pipeline.js +232 -0
  46. package/dist/content-pipeline.js.map +1 -0
  47. package/dist/cursor-nav.d.ts +149 -0
  48. package/dist/cursor-nav.d.ts.map +1 -0
  49. package/dist/cursor-nav.js +230 -0
  50. package/dist/cursor-nav.js.map +1 -0
  51. package/dist/cursor-rect.d.ts +65 -0
  52. package/dist/cursor-rect.d.ts.map +1 -0
  53. package/dist/cursor-rect.js +98 -0
  54. package/dist/cursor-rect.js.map +1 -0
  55. package/dist/drop-indicator.d.ts +108 -0
  56. package/dist/drop-indicator.d.ts.map +1 -0
  57. package/dist/drop-indicator.js +236 -0
  58. package/dist/drop-indicator.js.map +1 -0
  59. package/dist/editor.d.ts +41 -0
  60. package/dist/editor.d.ts.map +1 -0
  61. package/dist/editor.js +710 -0
  62. package/dist/editor.js.map +1 -0
  63. package/dist/floating-toolbar.d.ts +93 -0
  64. package/dist/floating-toolbar.d.ts.map +1 -0
  65. package/dist/floating-toolbar.js +159 -0
  66. package/dist/floating-toolbar.js.map +1 -0
  67. package/dist/index.d.ts +62 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +119 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/input-bridge.d.ts +273 -0
  72. package/dist/input-bridge.d.ts.map +1 -0
  73. package/dist/input-bridge.js +884 -0
  74. package/dist/input-bridge.js.map +1 -0
  75. package/dist/link-popover.d.ts +38 -0
  76. package/dist/link-popover.d.ts.map +1 -0
  77. package/dist/link-popover.js +278 -0
  78. package/dist/link-popover.js.map +1 -0
  79. package/dist/mark-renderer.d.ts +275 -0
  80. package/dist/mark-renderer.d.ts.map +1 -0
  81. package/dist/mark-renderer.js +210 -0
  82. package/dist/mark-renderer.js.map +1 -0
  83. package/dist/perf.d.ts +145 -0
  84. package/dist/perf.d.ts.map +1 -0
  85. package/dist/perf.js +260 -0
  86. package/dist/perf.js.map +1 -0
  87. package/dist/plugin-kit.d.ts +265 -0
  88. package/dist/plugin-kit.d.ts.map +1 -0
  89. package/dist/plugin-kit.js +234 -0
  90. package/dist/plugin-kit.js.map +1 -0
  91. package/dist/plugins/alignment-plugin.d.ts +68 -0
  92. package/dist/plugins/alignment-plugin.d.ts.map +1 -0
  93. package/dist/plugins/alignment-plugin.js +98 -0
  94. package/dist/plugins/alignment-plugin.js.map +1 -0
  95. package/dist/plugins/block-utils.d.ts +113 -0
  96. package/dist/plugins/block-utils.d.ts.map +1 -0
  97. package/dist/plugins/block-utils.js +191 -0
  98. package/dist/plugins/block-utils.js.map +1 -0
  99. package/dist/plugins/blockquote-plugin.d.ts +39 -0
  100. package/dist/plugins/blockquote-plugin.d.ts.map +1 -0
  101. package/dist/plugins/blockquote-plugin.js +88 -0
  102. package/dist/plugins/blockquote-plugin.js.map +1 -0
  103. package/dist/plugins/bold-plugin.d.ts +37 -0
  104. package/dist/plugins/bold-plugin.d.ts.map +1 -0
  105. package/dist/plugins/bold-plugin.js +48 -0
  106. package/dist/plugins/bold-plugin.js.map +1 -0
  107. package/dist/plugins/callout-plugin.d.ts +100 -0
  108. package/dist/plugins/callout-plugin.d.ts.map +1 -0
  109. package/dist/plugins/callout-plugin.js +200 -0
  110. package/dist/plugins/callout-plugin.js.map +1 -0
  111. package/dist/plugins/code-block-plugin.d.ts +62 -0
  112. package/dist/plugins/code-block-plugin.d.ts.map +1 -0
  113. package/dist/plugins/code-block-plugin.js +176 -0
  114. package/dist/plugins/code-block-plugin.js.map +1 -0
  115. package/dist/plugins/code-plugin.d.ts +37 -0
  116. package/dist/plugins/code-plugin.d.ts.map +1 -0
  117. package/dist/plugins/code-plugin.js +48 -0
  118. package/dist/plugins/code-plugin.js.map +1 -0
  119. package/dist/plugins/embed-plugin.d.ts +90 -0
  120. package/dist/plugins/embed-plugin.d.ts.map +1 -0
  121. package/dist/plugins/embed-plugin.js +147 -0
  122. package/dist/plugins/embed-plugin.js.map +1 -0
  123. package/dist/plugins/font-family-plugin.d.ts +58 -0
  124. package/dist/plugins/font-family-plugin.d.ts.map +1 -0
  125. package/dist/plugins/font-family-plugin.js +57 -0
  126. package/dist/plugins/font-family-plugin.js.map +1 -0
  127. package/dist/plugins/font-size-plugin.d.ts +57 -0
  128. package/dist/plugins/font-size-plugin.d.ts.map +1 -0
  129. package/dist/plugins/font-size-plugin.js +56 -0
  130. package/dist/plugins/font-size-plugin.js.map +1 -0
  131. package/dist/plugins/heading-plugin.d.ts +52 -0
  132. package/dist/plugins/heading-plugin.d.ts.map +1 -0
  133. package/dist/plugins/heading-plugin.js +114 -0
  134. package/dist/plugins/heading-plugin.js.map +1 -0
  135. package/dist/plugins/hr-plugin.d.ts +33 -0
  136. package/dist/plugins/hr-plugin.d.ts.map +1 -0
  137. package/dist/plugins/hr-plugin.js +75 -0
  138. package/dist/plugins/hr-plugin.js.map +1 -0
  139. package/dist/plugins/image-plugin.d.ts +115 -0
  140. package/dist/plugins/image-plugin.d.ts.map +1 -0
  141. package/dist/plugins/image-plugin.js +199 -0
  142. package/dist/plugins/image-plugin.js.map +1 -0
  143. package/dist/plugins/indent-plugin.d.ts +62 -0
  144. package/dist/plugins/indent-plugin.d.ts.map +1 -0
  145. package/dist/plugins/indent-plugin.js +128 -0
  146. package/dist/plugins/indent-plugin.js.map +1 -0
  147. package/dist/plugins/index.d.ts +45 -0
  148. package/dist/plugins/index.d.ts.map +1 -0
  149. package/dist/plugins/index.js +42 -0
  150. package/dist/plugins/index.js.map +1 -0
  151. package/dist/plugins/italic-plugin.d.ts +37 -0
  152. package/dist/plugins/italic-plugin.d.ts.map +1 -0
  153. package/dist/plugins/italic-plugin.js +48 -0
  154. package/dist/plugins/italic-plugin.js.map +1 -0
  155. package/dist/plugins/link-plugin.d.ts +129 -0
  156. package/dist/plugins/link-plugin.d.ts.map +1 -0
  157. package/dist/plugins/link-plugin.js +212 -0
  158. package/dist/plugins/link-plugin.js.map +1 -0
  159. package/dist/plugins/list-plugin.d.ts +53 -0
  160. package/dist/plugins/list-plugin.d.ts.map +1 -0
  161. package/dist/plugins/list-plugin.js +309 -0
  162. package/dist/plugins/list-plugin.js.map +1 -0
  163. package/dist/plugins/mark-utils.d.ts +173 -0
  164. package/dist/plugins/mark-utils.d.ts.map +1 -0
  165. package/dist/plugins/mark-utils.js +425 -0
  166. package/dist/plugins/mark-utils.js.map +1 -0
  167. package/dist/plugins/mention-plugin.d.ts +191 -0
  168. package/dist/plugins/mention-plugin.d.ts.map +1 -0
  169. package/dist/plugins/mention-plugin.js +295 -0
  170. package/dist/plugins/mention-plugin.js.map +1 -0
  171. package/dist/plugins/strikethrough-plugin.d.ts +37 -0
  172. package/dist/plugins/strikethrough-plugin.d.ts.map +1 -0
  173. package/dist/plugins/strikethrough-plugin.js +48 -0
  174. package/dist/plugins/strikethrough-plugin.js.map +1 -0
  175. package/dist/plugins/text-color-plugin.d.ts +57 -0
  176. package/dist/plugins/text-color-plugin.d.ts.map +1 -0
  177. package/dist/plugins/text-color-plugin.js +56 -0
  178. package/dist/plugins/text-color-plugin.js.map +1 -0
  179. package/dist/plugins/underline-plugin.d.ts +37 -0
  180. package/dist/plugins/underline-plugin.d.ts.map +1 -0
  181. package/dist/plugins/underline-plugin.js +48 -0
  182. package/dist/plugins/underline-plugin.js.map +1 -0
  183. package/dist/presets.d.ts +95 -0
  184. package/dist/presets.d.ts.map +1 -0
  185. package/dist/presets.js +159 -0
  186. package/dist/presets.js.map +1 -0
  187. package/dist/renderer.d.ts +125 -0
  188. package/dist/renderer.d.ts.map +1 -0
  189. package/dist/renderer.js +415 -0
  190. package/dist/renderer.js.map +1 -0
  191. package/dist/scroll-to-cursor.d.ts +25 -0
  192. package/dist/scroll-to-cursor.d.ts.map +1 -0
  193. package/dist/scroll-to-cursor.js +59 -0
  194. package/dist/scroll-to-cursor.js.map +1 -0
  195. package/dist/selection-sync.d.ts +159 -0
  196. package/dist/selection-sync.d.ts.map +1 -0
  197. package/dist/selection-sync.js +527 -0
  198. package/dist/selection-sync.js.map +1 -0
  199. package/dist/shortcut-handler.d.ts +98 -0
  200. package/dist/shortcut-handler.d.ts.map +1 -0
  201. package/dist/shortcut-handler.js +155 -0
  202. package/dist/shortcut-handler.js.map +1 -0
  203. package/dist/toolbar.d.ts +103 -0
  204. package/dist/toolbar.d.ts.map +1 -0
  205. package/dist/toolbar.js +134 -0
  206. package/dist/toolbar.js.map +1 -0
  207. package/dist/trigger-manager.d.ts +205 -0
  208. package/dist/trigger-manager.d.ts.map +1 -0
  209. package/dist/trigger-manager.js +466 -0
  210. package/dist/trigger-manager.js.map +1 -0
  211. package/dist/types.d.ts +216 -0
  212. package/dist/types.d.ts.map +1 -0
  213. package/dist/types.js +2 -0
  214. package/dist/types.js.map +1 -0
  215. package/package.json +30 -0
package/dist/editor.js ADDED
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Web editor entry point — composes all modules into a single `WebEditor`.
3
+ *
4
+ * The `createWebEditor()` factory sets up a contenteditable element, wires
5
+ * the input bridge, renderer, selection sync, composition handler, clipboard,
6
+ * cursor navigation, command bus, shortcut handler, content pipeline, and
7
+ * drag-and-drop handling. It returns a {@link WebEditor} handle for imperative
8
+ * control (focus, blur, destroy).
9
+ *
10
+ * @module
11
+ */
12
+ import { resolve } from '@rtif-sdk/core';
13
+ import { renderInitial, reconcile } from './renderer.js';
14
+ import { buildBlockOffsetCache, domPointToRtifOffset, readDomSelection, setDomSelection, isSuppressed, resetSuppression, } from './selection-sync.js';
15
+ import { InputBridge } from './input-bridge.js';
16
+ import { CompositionHandler } from './composition.js';
17
+ import { ClipboardHandler } from './clipboard.js';
18
+ import { CursorNavHandler, isMac } from './cursor-nav.js';
19
+ import { createCommandBus } from './command-bus.js';
20
+ import { ShortcutHandler } from './shortcut-handler.js';
21
+ import { scrollToCursor } from './scroll-to-cursor.js';
22
+ import { createContentPipeline } from './content-pipeline.js';
23
+ import { extractFromPaste, extractFromDrop } from './content-extraction.js';
24
+ import { createPlainTextHandler } from './content-handlers.js';
25
+ import { createRtifPasteHandler } from './content-handlers.js';
26
+ import { createHtmlPasteHandler } from './content-handlers.js';
27
+ import { createImageContentHandler } from './content-handler-image.js';
28
+ import { createUrlContentHandler } from './content-handler-url.js';
29
+ import { createFileDropHandler } from './content-handler-file.js';
30
+ import { DropIndicator } from './drop-indicator.js';
31
+ import { createMarkRendererRegistry, registerBuiltinRenderers, } from './mark-renderer.js';
32
+ import { createBlockRendererRegistry, registerBuiltinBlockRenderers, } from './block-renderer.js';
33
+ import { BlockDragHandler } from './block-drag-handler.js';
34
+ import { createCursorRectAPI } from './cursor-rect.js';
35
+ import { createTriggerManager } from './trigger-manager.js';
36
+ import { findContiguousMarkRange, adjustOffsetAroundAtomicMarks, adjustOffsetAroundAtomicBlocks } from './plugins/mark-utils.js';
37
+ import { installWebPlugins } from './plugin-kit.js';
38
+ // ---------------------------------------------------------------------------
39
+ // Factory
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Create a web editor instance backed by the RTIF engine.
43
+ *
44
+ * Sets up `contenteditable`, attaches all event handlers, performs the initial
45
+ * render, and optionally auto-focuses. Returns a {@link WebEditor} handle.
46
+ *
47
+ * @param config - Editor configuration (root element, engine, options)
48
+ * @returns A WebEditor instance with imperative control methods
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * import { createEngine } from '@rtif-sdk/engine';
53
+ * import { createWebEditor } from '@rtif-sdk/web';
54
+ *
55
+ * const engine = createEngine({
56
+ * initialDoc: { version: 1, blocks: [{ id: 'b1', type: 'text', spans: [{ text: '' }] }] },
57
+ * });
58
+ *
59
+ * const editor = createWebEditor({
60
+ * root: document.getElementById('editor')!,
61
+ * engine,
62
+ * accessibleLabel: 'Document editor',
63
+ * });
64
+ *
65
+ * editor.focus();
66
+ * ```
67
+ */
68
+ export function createWebEditor(config) {
69
+ const { root, engine } = config;
70
+ const readOnly = config.readOnly ?? false;
71
+ const spellcheck = config.spellcheck ?? true;
72
+ const autoFocus = config.autoFocus ?? false;
73
+ let destroyed = false;
74
+ // -------------------------------------------------------------------
75
+ // Set up the contenteditable root
76
+ // -------------------------------------------------------------------
77
+ root.setAttribute('contenteditable', readOnly ? 'false' : 'true');
78
+ root.setAttribute('role', 'textbox');
79
+ root.setAttribute('aria-multiline', 'true');
80
+ root.setAttribute('aria-label', config.accessibleLabel);
81
+ root.setAttribute('data-rtif-root', '');
82
+ root.spellcheck = spellcheck;
83
+ // Accessibility: announce read-only state to screen readers
84
+ if (readOnly) {
85
+ root.setAttribute('aria-readonly', 'true');
86
+ }
87
+ // CSS containment for layout isolation (Phase 8)
88
+ root.style.contain = 'content';
89
+ // Preserve whitespace so trailing spaces, consecutive spaces, and tabs render
90
+ root.style.whiteSpace = 'pre-wrap';
91
+ if (config.placeholder) {
92
+ root.setAttribute('data-placeholder', config.placeholder);
93
+ }
94
+ // -------------------------------------------------------------------
95
+ // Mark renderer registry
96
+ // -------------------------------------------------------------------
97
+ const markRenderers = createMarkRendererRegistry();
98
+ registerBuiltinRenderers(markRenderers);
99
+ // -------------------------------------------------------------------
100
+ // Block renderer registry
101
+ // -------------------------------------------------------------------
102
+ const blockRenderers = createBlockRendererRegistry();
103
+ registerBuiltinBlockRenderers(blockRenderers);
104
+ // -------------------------------------------------------------------
105
+ // Web plugins (custom marks, blocks, attrs, replacers)
106
+ // -------------------------------------------------------------------
107
+ if (config.webPlugins) {
108
+ installWebPlugins(config.webPlugins, engine, markRenderers, blockRenderers);
109
+ }
110
+ // -------------------------------------------------------------------
111
+ // Initial render
112
+ // -------------------------------------------------------------------
113
+ renderInitial(root, engine.state.doc, markRenderers, blockRenderers);
114
+ // -------------------------------------------------------------------
115
+ // Command bus
116
+ // -------------------------------------------------------------------
117
+ const commandBus = createCommandBus(engine);
118
+ // -------------------------------------------------------------------
119
+ // Composition handler
120
+ // -------------------------------------------------------------------
121
+ const compositionHandler = new CompositionHandler({
122
+ getSelection: () => engine.state.selection,
123
+ getBlockIdAtOffset: (offset) => {
124
+ try {
125
+ const { blockIndex } = resolve(engine.state.doc, offset);
126
+ return engine.state.doc.blocks[blockIndex]?.id ?? null;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ },
132
+ });
133
+ // -------------------------------------------------------------------
134
+ // Cursor rect API (for positioning floating UI)
135
+ // -------------------------------------------------------------------
136
+ const cursorRect = createCursorRectAPI(root, () => engine.state.doc);
137
+ // -------------------------------------------------------------------
138
+ // Trigger manager (for @mentions, #hashtags, /slash-commands)
139
+ // Must be created BEFORE shortcutHandler so trigger keys are
140
+ // intercepted first (both use capture-phase keydown).
141
+ // -------------------------------------------------------------------
142
+ const triggerManager = createTriggerManager({
143
+ root,
144
+ engine,
145
+ cursorRect,
146
+ isComposing: () => compositionHandler.isComposing(),
147
+ });
148
+ // -------------------------------------------------------------------
149
+ // Shortcut handler
150
+ // -------------------------------------------------------------------
151
+ const shortcutHandler = new ShortcutHandler(root, {
152
+ getShortcuts: () => engine.getShortcuts(),
153
+ commandBus,
154
+ isComposing: () => compositionHandler.isComposing(),
155
+ });
156
+ // -------------------------------------------------------------------
157
+ // Input bridge
158
+ // -------------------------------------------------------------------
159
+ // Shared closure for finding atomic mark ranges — used by InputBridge and
160
+ // selection change handler
161
+ const findAtomicMarkRangeFn = (offset) => {
162
+ const doc = engine.state.doc;
163
+ const marks = engine.getMarksAtOffset(offset);
164
+ for (const key of Object.keys(marks)) {
165
+ if (markRenderers.isAtomic(key)) {
166
+ return findContiguousMarkRange(doc, offset, key);
167
+ }
168
+ }
169
+ return null;
170
+ };
171
+ const inputBridge = new InputBridge(root, {
172
+ getSelection: () => engine.state.selection,
173
+ getDoc: () => engine.state.doc,
174
+ dispatch: (ops) => engine.dispatch(ops),
175
+ undo: () => engine.undo(),
176
+ redo: () => engine.redo(),
177
+ isComposing: () => compositionHandler.isComposing(),
178
+ isReadOnly: () => readOnly,
179
+ generateBlockId: () => generateId(),
180
+ getPendingMarks: () => engine.getPendingMarks(),
181
+ clearPendingMarks: () => engine.clearPendingMarks(),
182
+ findAtomicMarkRange: findAtomicMarkRangeFn,
183
+ isAtomicBlock: (type) => blockRenderers.isAtomic(type),
184
+ });
185
+ // -------------------------------------------------------------------
186
+ // Content pipeline
187
+ // -------------------------------------------------------------------
188
+ const contentPipeline = createContentPipeline();
189
+ // Register built-in handlers (priority: RTIF JSON -80, image -85, HTML -90,
190
+ // URL -95, plain text -100, file drop -105)
191
+ contentPipeline.register(createRtifPasteHandler(() => generateId()));
192
+ contentPipeline.register(createImageContentHandler());
193
+ contentPipeline.register(createHtmlPasteHandler());
194
+ contentPipeline.register(createUrlContentHandler());
195
+ contentPipeline.register(createPlainTextHandler(() => generateId()));
196
+ contentPipeline.register(createFileDropHandler());
197
+ // -------------------------------------------------------------------
198
+ // Clipboard handler (paste delegated to content pipeline)
199
+ // -------------------------------------------------------------------
200
+ const clipboardHandler = new ClipboardHandler(root, {
201
+ getSelection: () => engine.state.selection,
202
+ getDoc: () => engine.state.doc,
203
+ dispatch: (ops) => engine.dispatch(ops),
204
+ isReadOnly: () => readOnly,
205
+ isComposing: () => compositionHandler.isComposing(),
206
+ generateBlockId: () => generateId(),
207
+ skipPaste: true,
208
+ });
209
+ // -------------------------------------------------------------------
210
+ // Drop indicator
211
+ // -------------------------------------------------------------------
212
+ const dropIndicator = new DropIndicator(root);
213
+ // -------------------------------------------------------------------
214
+ // Block drag handler (reorder blocks via drag handles)
215
+ // -------------------------------------------------------------------
216
+ const blockDragHandler = new BlockDragHandler({
217
+ root,
218
+ getDoc: () => engine.state.doc,
219
+ dispatch: (ops) => engine.dispatch(ops),
220
+ isReadOnly: () => readOnly,
221
+ });
222
+ // -------------------------------------------------------------------
223
+ // Paste handler (via content pipeline)
224
+ // -------------------------------------------------------------------
225
+ const onPaste = (e) => {
226
+ e.preventDefault();
227
+ if (readOnly)
228
+ return;
229
+ if (compositionHandler.isComposing())
230
+ return;
231
+ const items = extractFromPaste(e);
232
+ if (items.length === 0)
233
+ return;
234
+ // Process async — fire and forget. Errors are swallowed by the pipeline.
235
+ void contentPipeline.process(items, {
236
+ engine,
237
+ commands: commandBus,
238
+ source: 'paste',
239
+ generateBlockId: () => generateId(),
240
+ });
241
+ };
242
+ // -------------------------------------------------------------------
243
+ // Drag and drop handlers
244
+ // -------------------------------------------------------------------
245
+ const onDragOver = (e) => {
246
+ if (readOnly)
247
+ return;
248
+ const dt = e.dataTransfer;
249
+ if (!dt)
250
+ return;
251
+ const hasFiles = dt.types.includes('Files');
252
+ const hasHandleable = dt.types.some((t) => contentPipeline.hasHandlerForType(t));
253
+ if (hasFiles || hasHandleable) {
254
+ e.preventDefault();
255
+ dt.dropEffect = 'copy';
256
+ if (hasFiles) {
257
+ dropIndicator.showForFile();
258
+ }
259
+ else {
260
+ // Find nearest block for between-blocks indicator
261
+ const blockEl = findNearestBlock(root, e.clientY);
262
+ if (blockEl) {
263
+ const blockRect = blockEl.getBoundingClientRect();
264
+ const midY = blockRect.top + blockRect.height / 2;
265
+ const position = e.clientY < midY ? 'before' : 'after';
266
+ dropIndicator.showBetweenBlocks(blockEl, position);
267
+ }
268
+ }
269
+ }
270
+ };
271
+ const onDragLeave = (e) => {
272
+ // Only hide if leaving the editor root (not entering a child)
273
+ if (e.relatedTarget &&
274
+ root.contains(e.relatedTarget)) {
275
+ return;
276
+ }
277
+ dropIndicator.hide();
278
+ };
279
+ const onDrop = (e) => {
280
+ e.preventDefault();
281
+ dropIndicator.hide();
282
+ if (readOnly)
283
+ return;
284
+ const items = extractFromDrop(e);
285
+ if (items.length === 0)
286
+ return;
287
+ // Compute drop offset from coordinates
288
+ const dropOffset = getOffsetFromDropPoint(root, engine.state.doc, e.clientX, e.clientY);
289
+ // Process async
290
+ void contentPipeline.process(items, {
291
+ engine,
292
+ commands: commandBus,
293
+ source: 'drop',
294
+ dropOffset: dropOffset ?? undefined,
295
+ generateBlockId: () => generateId(),
296
+ });
297
+ };
298
+ // -------------------------------------------------------------------
299
+ // Cursor navigation handler
300
+ // -------------------------------------------------------------------
301
+ const cursorNavHandler = new CursorNavHandler(root, {
302
+ getSelection: () => engine.state.selection,
303
+ getDoc: () => engine.state.doc,
304
+ dispatch: (ops) => engine.dispatch(ops),
305
+ undo: () => engine.undo(),
306
+ redo: () => engine.redo(),
307
+ isComposing: () => compositionHandler.isComposing(),
308
+ isReadOnly: () => readOnly,
309
+ setSelection: (sel) => {
310
+ // Programmatic selection change (Cmd+A, Cmd+Home/End):
311
+ // update engine state AND sync to DOM.
312
+ engine.setSelection(sel);
313
+ setDomSelection(root, engine.state.doc, sel);
314
+ },
315
+ isMac: () => isMac(),
316
+ });
317
+ // -------------------------------------------------------------------
318
+ // Selection change handler
319
+ // -------------------------------------------------------------------
320
+ let prevDoc = engine.state.doc;
321
+ /**
322
+ * Adjust a selection's anchor/focus if either falls inside an atomic mark.
323
+ * Infers direction from comparing new vs previous selection offsets.
324
+ * Returns the original selection reference if no adjustment needed.
325
+ */
326
+ function adjustSelectionAroundAtomicMarks(sel, prevSel, findAtomic) {
327
+ // Determine direction for anchor
328
+ const anchorDir = sel.anchor.offset > prevSel.anchor.offset ? 'forward'
329
+ : sel.anchor.offset < prevSel.anchor.offset ? 'backward'
330
+ : 'nearest';
331
+ // Determine direction for focus
332
+ const focusDir = sel.focus.offset > prevSel.focus.offset ? 'forward'
333
+ : sel.focus.offset < prevSel.focus.offset ? 'backward'
334
+ : 'nearest';
335
+ const adjustedAnchor = adjustOffsetAroundAtomicMarks(sel.anchor.offset, anchorDir, findAtomic);
336
+ const adjustedFocus = adjustOffsetAroundAtomicMarks(sel.focus.offset, focusDir, findAtomic);
337
+ if (adjustedAnchor === sel.anchor.offset && adjustedFocus === sel.focus.offset) {
338
+ return sel; // No adjustment needed — return same reference
339
+ }
340
+ return {
341
+ anchor: { offset: adjustedAnchor },
342
+ focus: { offset: adjustedFocus },
343
+ };
344
+ }
345
+ const selectionChangeHandler = () => {
346
+ if (destroyed)
347
+ return;
348
+ if (isSuppressed())
349
+ return;
350
+ if (compositionHandler.isComposing())
351
+ return;
352
+ const cache = buildBlockOffsetCache(engine.state.doc);
353
+ const sel = readDomSelection(root, cache);
354
+ if (sel === null)
355
+ return;
356
+ // Adjust offsets that land inside atomic marks
357
+ const adjustedSel = adjustSelectionAroundAtomicMarks(sel, engine.state.selection, findAtomicMarkRangeFn);
358
+ if (adjustedSel !== sel) {
359
+ // Selection was adjusted — update engine AND re-sync DOM
360
+ engine.setSelection(adjustedSel);
361
+ setDomSelection(root, engine.state.doc, adjustedSel);
362
+ return;
363
+ }
364
+ // Adjust offsets that land inside atomic blocks (HR, image, embed)
365
+ const isAtomicBlockFn = (type) => blockRenderers.isAtomic(type);
366
+ const prevSel = engine.state.selection;
367
+ const anchorDir = sel.anchor.offset > prevSel.anchor.offset ? 'forward'
368
+ : sel.anchor.offset < prevSel.anchor.offset ? 'backward'
369
+ : 'nearest';
370
+ const focusDir = sel.focus.offset > prevSel.focus.offset ? 'forward'
371
+ : sel.focus.offset < prevSel.focus.offset ? 'backward'
372
+ : 'nearest';
373
+ const adjAnchor = adjustOffsetAroundAtomicBlocks(engine.state.doc, sel.anchor.offset, anchorDir, isAtomicBlockFn);
374
+ const adjFocus = adjustOffsetAroundAtomicBlocks(engine.state.doc, sel.focus.offset, focusDir, isAtomicBlockFn);
375
+ if (adjAnchor !== sel.anchor.offset || adjFocus !== sel.focus.offset) {
376
+ const blockAdjustedSel = {
377
+ anchor: { offset: adjAnchor },
378
+ focus: { offset: adjFocus },
379
+ };
380
+ engine.setSelection(blockAdjustedSel);
381
+ setDomSelection(root, engine.state.doc, blockAdjustedSel);
382
+ return;
383
+ }
384
+ // Update engine selection without triggering a dispatch
385
+ // (engine.setSelection fires onSelectionChange which runs clearExclusiveMarksAtBoundary)
386
+ setEngineSelection(sel);
387
+ };
388
+ // -------------------------------------------------------------------
389
+ // Composition event handlers
390
+ // -------------------------------------------------------------------
391
+ const onCompositionStart = (e) => {
392
+ compositionHandler.onCompositionStart(e);
393
+ };
394
+ const onCompositionUpdate = (e) => {
395
+ compositionHandler.onCompositionUpdate(e);
396
+ };
397
+ const onCompositionEnd = (e) => {
398
+ const commit = compositionHandler.onCompositionEnd(e);
399
+ if (commit) {
400
+ if (commit.deleteRange) {
401
+ engine.dispatch([
402
+ {
403
+ type: 'delete_text',
404
+ offset: commit.deleteRange.offset,
405
+ count: commit.deleteRange.count,
406
+ },
407
+ {
408
+ type: 'insert_text',
409
+ offset: commit.deleteRange.offset,
410
+ text: commit.text,
411
+ },
412
+ ]);
413
+ }
414
+ else {
415
+ engine.dispatch({
416
+ type: 'insert_text',
417
+ offset: commit.offset,
418
+ text: commit.text,
419
+ });
420
+ }
421
+ }
422
+ };
423
+ // -------------------------------------------------------------------
424
+ // Engine onChange — reconcile DOM and sync selection
425
+ // -------------------------------------------------------------------
426
+ const unsubscribeOnChange = engine.onChange((state) => {
427
+ if (destroyed)
428
+ return;
429
+ const composingBlockId = compositionHandler.getComposingBlockId();
430
+ reconcile(root, prevDoc, state.doc, composingBlockId, markRenderers, blockRenderers);
431
+ prevDoc = state.doc;
432
+ // Sync selection to DOM (unless composing)
433
+ if (!compositionHandler.isComposing()) {
434
+ setDomSelection(root, state.doc, state.selection);
435
+ scrollToCursor(root);
436
+ }
437
+ // Update placeholder visibility
438
+ updatePlaceholder(root, state.doc);
439
+ });
440
+ // -------------------------------------------------------------------
441
+ // Exclusive mark boundary helper
442
+ // -------------------------------------------------------------------
443
+ /**
444
+ * Clear pending marks for exclusive mark types when the cursor is at
445
+ * the end boundary of an exclusive mark span. This prevents typing
446
+ * from extending the mark (e.g., typing after a link).
447
+ */
448
+ function clearExclusiveMarksAtBoundary(sel) {
449
+ if (sel.anchor.offset !== sel.focus.offset)
450
+ return; // range selection, skip
451
+ const offset = sel.focus.offset;
452
+ const marks = engine.getMarksAtOffset(offset);
453
+ for (const key of Object.keys(marks)) {
454
+ if (markRenderers.isExclusive(key)) {
455
+ const range = findContiguousMarkRange(engine.state.doc, offset, key);
456
+ if (range && offset === range.offset + range.count) {
457
+ engine.setPendingMark(key, null);
458
+ }
459
+ }
460
+ }
461
+ }
462
+ // -------------------------------------------------------------------
463
+ // Engine onSelectionChange — clear exclusive marks at boundaries
464
+ // -------------------------------------------------------------------
465
+ const unsubscribeOnSelectionChange = engine.onSelectionChange((state) => {
466
+ if (destroyed)
467
+ return;
468
+ clearExclusiveMarksAtBoundary(state.selection);
469
+ });
470
+ // -------------------------------------------------------------------
471
+ // Focus/blur handling
472
+ // -------------------------------------------------------------------
473
+ const onBlur = () => {
474
+ compositionHandler.reset();
475
+ resetSuppression();
476
+ };
477
+ // -------------------------------------------------------------------
478
+ // Set engine selection helper
479
+ // -------------------------------------------------------------------
480
+ /**
481
+ * Update the engine's selection state from a DOM-originated change.
482
+ *
483
+ * Called by the selectionchange handler when the user moves the cursor
484
+ * via click, arrow keys, or touch. Stores the selection in the engine
485
+ * so that subsequent operations use the correct cursor position.
486
+ *
487
+ * Does NOT re-set the DOM selection (it was already set by the user action).
488
+ */
489
+ function setEngineSelection(sel) {
490
+ engine.setSelection(sel);
491
+ }
492
+ // -------------------------------------------------------------------
493
+ // Escape key handler — clear pending marks to exit mark spans
494
+ // -------------------------------------------------------------------
495
+ const onEscapeKeyDown = (e) => {
496
+ if (e.key !== 'Escape' || e.defaultPrevented)
497
+ return;
498
+ const { selection } = engine.state;
499
+ if (selection.anchor.offset !== selection.focus.offset)
500
+ return; // range selection, skip
501
+ const offset = selection.focus.offset;
502
+ const marks = engine.getMarksAtOffset(offset);
503
+ const markKeys = Object.keys(marks);
504
+ if (markKeys.length === 0)
505
+ return; // no marks to exit
506
+ // Clear all current marks via pending marks
507
+ for (const key of markKeys) {
508
+ engine.setPendingMark(key, null);
509
+ }
510
+ e.preventDefault();
511
+ };
512
+ // -------------------------------------------------------------------
513
+ // Attach all event listeners
514
+ // -------------------------------------------------------------------
515
+ shortcutHandler.attach();
516
+ inputBridge.attach();
517
+ clipboardHandler.attach();
518
+ cursorNavHandler.attach();
519
+ blockDragHandler.attach();
520
+ root.addEventListener('compositionstart', onCompositionStart);
521
+ root.addEventListener('compositionupdate', onCompositionUpdate);
522
+ root.addEventListener('compositionend', onCompositionEnd);
523
+ root.addEventListener('blur', onBlur);
524
+ root.addEventListener('paste', onPaste);
525
+ root.addEventListener('dragover', onDragOver);
526
+ root.addEventListener('dragleave', onDragLeave);
527
+ root.addEventListener('drop', onDrop);
528
+ document.addEventListener('selectionchange', selectionChangeHandler);
529
+ // Escape handler registered AFTER TriggerManager and ShortcutHandler
530
+ // (all capture phase). TriggerManager/LinkPopover call e.preventDefault()
531
+ // when they handle Escape, so defaultPrevented check prevents conflicts.
532
+ root.addEventListener('keydown', onEscapeKeyDown, true);
533
+ // Initial placeholder update
534
+ updatePlaceholder(root, engine.state.doc);
535
+ // Auto-focus
536
+ if (autoFocus) {
537
+ root.focus();
538
+ }
539
+ // -------------------------------------------------------------------
540
+ // Return WebEditor handle
541
+ // -------------------------------------------------------------------
542
+ return {
543
+ engine,
544
+ commandBus,
545
+ content: contentPipeline,
546
+ markRenderers,
547
+ blockRenderers,
548
+ triggers: triggerManager,
549
+ cursorRect,
550
+ focus() {
551
+ if (destroyed)
552
+ return;
553
+ root.focus();
554
+ // Restore the engine's selection to the DOM. Browsers may clear or
555
+ // reset the contenteditable's selection when focus leaves and returns
556
+ // (e.g., after interacting with a popover). The engine is the source
557
+ // of truth, so always sync after focusing.
558
+ setDomSelection(root, engine.state.doc, engine.state.selection);
559
+ },
560
+ blur() {
561
+ if (destroyed)
562
+ return;
563
+ root.blur();
564
+ },
565
+ isFocused() {
566
+ if (destroyed)
567
+ return false;
568
+ return document.activeElement === root;
569
+ },
570
+ destroy() {
571
+ if (destroyed)
572
+ return;
573
+ destroyed = true;
574
+ // Detach event handlers
575
+ shortcutHandler.detach();
576
+ inputBridge.detach();
577
+ clipboardHandler.detach();
578
+ cursorNavHandler.detach();
579
+ blockDragHandler.detach();
580
+ root.removeEventListener('compositionstart', onCompositionStart);
581
+ root.removeEventListener('compositionupdate', onCompositionUpdate);
582
+ root.removeEventListener('compositionend', onCompositionEnd);
583
+ root.removeEventListener('blur', onBlur);
584
+ root.removeEventListener('paste', onPaste);
585
+ root.removeEventListener('dragover', onDragOver);
586
+ root.removeEventListener('dragleave', onDragLeave);
587
+ root.removeEventListener('drop', onDrop);
588
+ root.removeEventListener('keydown', onEscapeKeyDown, true);
589
+ document.removeEventListener('selectionchange', selectionChangeHandler);
590
+ // Unsubscribe from engine changes
591
+ unsubscribeOnChange();
592
+ unsubscribeOnSelectionChange();
593
+ // Destroy command bus
594
+ commandBus.destroy();
595
+ // Destroy trigger manager
596
+ triggerManager.destroy();
597
+ // Destroy content pipeline
598
+ contentPipeline.destroy();
599
+ // Destroy drop indicator
600
+ dropIndicator.destroy();
601
+ // Reset composition handler
602
+ compositionHandler.reset();
603
+ resetSuppression();
604
+ // Remove contenteditable attributes
605
+ root.removeAttribute('contenteditable');
606
+ root.removeAttribute('role');
607
+ root.removeAttribute('aria-multiline');
608
+ root.removeAttribute('aria-label');
609
+ root.removeAttribute('aria-readonly');
610
+ root.removeAttribute('data-rtif-root');
611
+ root.removeAttribute('data-placeholder');
612
+ root.style.contain = '';
613
+ root.style.whiteSpace = '';
614
+ },
615
+ };
616
+ }
617
+ // ---------------------------------------------------------------------------
618
+ // Helpers
619
+ // ---------------------------------------------------------------------------
620
+ /** Simple counter-based ID generator. Sufficient for single-session editing. */
621
+ let idCounter = 0;
622
+ /**
623
+ * Generate a unique block ID.
624
+ *
625
+ * Uses a simple counter for deterministic, unique IDs within a session.
626
+ * Production applications may want to use `crypto.randomUUID()` instead.
627
+ */
628
+ function generateId() {
629
+ return `b_${++idCounter}`;
630
+ }
631
+ /**
632
+ * Update the placeholder visibility based on document content.
633
+ *
634
+ * Shows the placeholder when the document has a single block with empty text.
635
+ * Uses a `data-empty` attribute that CSS can target.
636
+ */
637
+ function updatePlaceholder(root, doc) {
638
+ const isEmpty = doc.blocks.length === 1 &&
639
+ doc.blocks[0].spans.length === 1 &&
640
+ doc.blocks[0].spans[0].text === '';
641
+ if (isEmpty) {
642
+ root.setAttribute('data-empty', 'true');
643
+ }
644
+ else {
645
+ root.removeAttribute('data-empty');
646
+ }
647
+ }
648
+ /**
649
+ * Get an RTIF offset from drop coordinates using the browser's caret APIs.
650
+ *
651
+ * Uses `document.caretPositionFromPoint()` (standard) or
652
+ * `document.caretRangeFromPoint()` (WebKit/Blink fallback) to find the
653
+ * DOM position at the given screen coordinates, then converts to an
654
+ * absolute RTIF offset.
655
+ *
656
+ * @param root - The editor root element
657
+ * @param doc - The current RTIF document
658
+ * @param clientX - X coordinate (from DragEvent)
659
+ * @param clientY - Y coordinate (from DragEvent)
660
+ * @returns The absolute RTIF offset, or null if position cannot be determined
661
+ */
662
+ function getOffsetFromDropPoint(root, doc, clientX, clientY) {
663
+ const cache = buildBlockOffsetCache(doc);
664
+ // Standard API (Firefox 126+, Chrome 128+)
665
+ if (typeof document.caretPositionFromPoint === 'function') {
666
+ const pos = document.caretPositionFromPoint(clientX, clientY);
667
+ if (pos) {
668
+ return domPointToRtifOffset(root, pos.offsetNode, pos.offset, cache);
669
+ }
670
+ }
671
+ // WebKit/Blink fallback
672
+ // caretRangeFromPoint is non-standard but widely supported
673
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- browser-specific API not in lib.dom.d.ts
674
+ const caretRangeFromPoint = document.caretRangeFromPoint;
675
+ if (typeof caretRangeFromPoint === 'function') {
676
+ const range = caretRangeFromPoint.call(document, clientX, clientY);
677
+ if (range) {
678
+ return domPointToRtifOffset(root, range.startContainer, range.startOffset, cache);
679
+ }
680
+ }
681
+ return null;
682
+ }
683
+ /**
684
+ * Find the nearest block element to a given Y coordinate.
685
+ *
686
+ * Used for positioning the drop indicator between blocks during drag-over.
687
+ *
688
+ * @param root - The editor root element
689
+ * @param clientY - The Y coordinate to find the nearest block to
690
+ * @returns The nearest block element, or null if no blocks found
691
+ */
692
+ function findNearestBlock(root, clientY) {
693
+ const blocks = root.querySelectorAll('[data-rtif-block]');
694
+ if (blocks.length === 0)
695
+ return null;
696
+ let nearest = null;
697
+ let nearestDistance = Infinity;
698
+ for (let i = 0; i < blocks.length; i++) {
699
+ const block = blocks[i];
700
+ const rect = block.getBoundingClientRect();
701
+ const midY = rect.top + rect.height / 2;
702
+ const distance = Math.abs(clientY - midY);
703
+ if (distance < nearestDistance) {
704
+ nearestDistance = distance;
705
+ nearest = block;
706
+ }
707
+ }
708
+ return nearest;
709
+ }
710
+ //# sourceMappingURL=editor.js.map