@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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/block-drag-handler.d.ts +189 -0
- package/dist/block-drag-handler.d.ts.map +1 -0
- package/dist/block-drag-handler.js +745 -0
- package/dist/block-drag-handler.js.map +1 -0
- package/dist/block-renderer.d.ts +402 -0
- package/dist/block-renderer.d.ts.map +1 -0
- package/dist/block-renderer.js +424 -0
- package/dist/block-renderer.js.map +1 -0
- package/dist/clipboard.d.ts +178 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +432 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/command-bus.d.ts +113 -0
- package/dist/command-bus.d.ts.map +1 -0
- package/dist/command-bus.js +70 -0
- package/dist/command-bus.js.map +1 -0
- package/dist/composition.d.ts +220 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +271 -0
- package/dist/composition.js.map +1 -0
- package/dist/content-extraction.d.ts +69 -0
- package/dist/content-extraction.d.ts.map +1 -0
- package/dist/content-extraction.js +228 -0
- package/dist/content-extraction.js.map +1 -0
- package/dist/content-handler-file.d.ts +40 -0
- package/dist/content-handler-file.d.ts.map +1 -0
- package/dist/content-handler-file.js +91 -0
- package/dist/content-handler-file.js.map +1 -0
- package/dist/content-handler-image.d.ts +82 -0
- package/dist/content-handler-image.d.ts.map +1 -0
- package/dist/content-handler-image.js +120 -0
- package/dist/content-handler-image.js.map +1 -0
- package/dist/content-handler-url.d.ts +129 -0
- package/dist/content-handler-url.d.ts.map +1 -0
- package/dist/content-handler-url.js +244 -0
- package/dist/content-handler-url.js.map +1 -0
- package/dist/content-handlers.d.ts +67 -0
- package/dist/content-handlers.d.ts.map +1 -0
- package/dist/content-handlers.js +263 -0
- package/dist/content-handlers.js.map +1 -0
- package/dist/content-pipeline.d.ts +383 -0
- package/dist/content-pipeline.d.ts.map +1 -0
- package/dist/content-pipeline.js +232 -0
- package/dist/content-pipeline.js.map +1 -0
- package/dist/cursor-nav.d.ts +149 -0
- package/dist/cursor-nav.d.ts.map +1 -0
- package/dist/cursor-nav.js +230 -0
- package/dist/cursor-nav.js.map +1 -0
- package/dist/cursor-rect.d.ts +65 -0
- package/dist/cursor-rect.d.ts.map +1 -0
- package/dist/cursor-rect.js +98 -0
- package/dist/cursor-rect.js.map +1 -0
- package/dist/drop-indicator.d.ts +108 -0
- package/dist/drop-indicator.d.ts.map +1 -0
- package/dist/drop-indicator.js +236 -0
- package/dist/drop-indicator.js.map +1 -0
- package/dist/editor.d.ts +41 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +710 -0
- package/dist/editor.js.map +1 -0
- package/dist/floating-toolbar.d.ts +93 -0
- package/dist/floating-toolbar.d.ts.map +1 -0
- package/dist/floating-toolbar.js +159 -0
- package/dist/floating-toolbar.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +119 -0
- package/dist/index.js.map +1 -0
- package/dist/input-bridge.d.ts +273 -0
- package/dist/input-bridge.d.ts.map +1 -0
- package/dist/input-bridge.js +884 -0
- package/dist/input-bridge.js.map +1 -0
- package/dist/link-popover.d.ts +38 -0
- package/dist/link-popover.d.ts.map +1 -0
- package/dist/link-popover.js +278 -0
- package/dist/link-popover.js.map +1 -0
- package/dist/mark-renderer.d.ts +275 -0
- package/dist/mark-renderer.d.ts.map +1 -0
- package/dist/mark-renderer.js +210 -0
- package/dist/mark-renderer.js.map +1 -0
- package/dist/perf.d.ts +145 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +260 -0
- package/dist/perf.js.map +1 -0
- package/dist/plugin-kit.d.ts +265 -0
- package/dist/plugin-kit.d.ts.map +1 -0
- package/dist/plugin-kit.js +234 -0
- package/dist/plugin-kit.js.map +1 -0
- package/dist/plugins/alignment-plugin.d.ts +68 -0
- package/dist/plugins/alignment-plugin.d.ts.map +1 -0
- package/dist/plugins/alignment-plugin.js +98 -0
- package/dist/plugins/alignment-plugin.js.map +1 -0
- package/dist/plugins/block-utils.d.ts +113 -0
- package/dist/plugins/block-utils.d.ts.map +1 -0
- package/dist/plugins/block-utils.js +191 -0
- package/dist/plugins/block-utils.js.map +1 -0
- package/dist/plugins/blockquote-plugin.d.ts +39 -0
- package/dist/plugins/blockquote-plugin.d.ts.map +1 -0
- package/dist/plugins/blockquote-plugin.js +88 -0
- package/dist/plugins/blockquote-plugin.js.map +1 -0
- package/dist/plugins/bold-plugin.d.ts +37 -0
- package/dist/plugins/bold-plugin.d.ts.map +1 -0
- package/dist/plugins/bold-plugin.js +48 -0
- package/dist/plugins/bold-plugin.js.map +1 -0
- package/dist/plugins/callout-plugin.d.ts +100 -0
- package/dist/plugins/callout-plugin.d.ts.map +1 -0
- package/dist/plugins/callout-plugin.js +200 -0
- package/dist/plugins/callout-plugin.js.map +1 -0
- package/dist/plugins/code-block-plugin.d.ts +62 -0
- package/dist/plugins/code-block-plugin.d.ts.map +1 -0
- package/dist/plugins/code-block-plugin.js +176 -0
- package/dist/plugins/code-block-plugin.js.map +1 -0
- package/dist/plugins/code-plugin.d.ts +37 -0
- package/dist/plugins/code-plugin.d.ts.map +1 -0
- package/dist/plugins/code-plugin.js +48 -0
- package/dist/plugins/code-plugin.js.map +1 -0
- package/dist/plugins/embed-plugin.d.ts +90 -0
- package/dist/plugins/embed-plugin.d.ts.map +1 -0
- package/dist/plugins/embed-plugin.js +147 -0
- package/dist/plugins/embed-plugin.js.map +1 -0
- package/dist/plugins/font-family-plugin.d.ts +58 -0
- package/dist/plugins/font-family-plugin.d.ts.map +1 -0
- package/dist/plugins/font-family-plugin.js +57 -0
- package/dist/plugins/font-family-plugin.js.map +1 -0
- package/dist/plugins/font-size-plugin.d.ts +57 -0
- package/dist/plugins/font-size-plugin.d.ts.map +1 -0
- package/dist/plugins/font-size-plugin.js +56 -0
- package/dist/plugins/font-size-plugin.js.map +1 -0
- package/dist/plugins/heading-plugin.d.ts +52 -0
- package/dist/plugins/heading-plugin.d.ts.map +1 -0
- package/dist/plugins/heading-plugin.js +114 -0
- package/dist/plugins/heading-plugin.js.map +1 -0
- package/dist/plugins/hr-plugin.d.ts +33 -0
- package/dist/plugins/hr-plugin.d.ts.map +1 -0
- package/dist/plugins/hr-plugin.js +75 -0
- package/dist/plugins/hr-plugin.js.map +1 -0
- package/dist/plugins/image-plugin.d.ts +115 -0
- package/dist/plugins/image-plugin.d.ts.map +1 -0
- package/dist/plugins/image-plugin.js +199 -0
- package/dist/plugins/image-plugin.js.map +1 -0
- package/dist/plugins/indent-plugin.d.ts +62 -0
- package/dist/plugins/indent-plugin.d.ts.map +1 -0
- package/dist/plugins/indent-plugin.js +128 -0
- package/dist/plugins/indent-plugin.js.map +1 -0
- package/dist/plugins/index.d.ts +45 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +42 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/italic-plugin.d.ts +37 -0
- package/dist/plugins/italic-plugin.d.ts.map +1 -0
- package/dist/plugins/italic-plugin.js +48 -0
- package/dist/plugins/italic-plugin.js.map +1 -0
- package/dist/plugins/link-plugin.d.ts +129 -0
- package/dist/plugins/link-plugin.d.ts.map +1 -0
- package/dist/plugins/link-plugin.js +212 -0
- package/dist/plugins/link-plugin.js.map +1 -0
- package/dist/plugins/list-plugin.d.ts +53 -0
- package/dist/plugins/list-plugin.d.ts.map +1 -0
- package/dist/plugins/list-plugin.js +309 -0
- package/dist/plugins/list-plugin.js.map +1 -0
- package/dist/plugins/mark-utils.d.ts +173 -0
- package/dist/plugins/mark-utils.d.ts.map +1 -0
- package/dist/plugins/mark-utils.js +425 -0
- package/dist/plugins/mark-utils.js.map +1 -0
- package/dist/plugins/mention-plugin.d.ts +191 -0
- package/dist/plugins/mention-plugin.d.ts.map +1 -0
- package/dist/plugins/mention-plugin.js +295 -0
- package/dist/plugins/mention-plugin.js.map +1 -0
- package/dist/plugins/strikethrough-plugin.d.ts +37 -0
- package/dist/plugins/strikethrough-plugin.d.ts.map +1 -0
- package/dist/plugins/strikethrough-plugin.js +48 -0
- package/dist/plugins/strikethrough-plugin.js.map +1 -0
- package/dist/plugins/text-color-plugin.d.ts +57 -0
- package/dist/plugins/text-color-plugin.d.ts.map +1 -0
- package/dist/plugins/text-color-plugin.js +56 -0
- package/dist/plugins/text-color-plugin.js.map +1 -0
- package/dist/plugins/underline-plugin.d.ts +37 -0
- package/dist/plugins/underline-plugin.d.ts.map +1 -0
- package/dist/plugins/underline-plugin.js +48 -0
- package/dist/plugins/underline-plugin.js.map +1 -0
- package/dist/presets.d.ts +95 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +159 -0
- package/dist/presets.js.map +1 -0
- package/dist/renderer.d.ts +125 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +415 -0
- package/dist/renderer.js.map +1 -0
- package/dist/scroll-to-cursor.d.ts +25 -0
- package/dist/scroll-to-cursor.d.ts.map +1 -0
- package/dist/scroll-to-cursor.js +59 -0
- package/dist/scroll-to-cursor.js.map +1 -0
- package/dist/selection-sync.d.ts +159 -0
- package/dist/selection-sync.d.ts.map +1 -0
- package/dist/selection-sync.js +527 -0
- package/dist/selection-sync.js.map +1 -0
- package/dist/shortcut-handler.d.ts +98 -0
- package/dist/shortcut-handler.d.ts.map +1 -0
- package/dist/shortcut-handler.js +155 -0
- package/dist/shortcut-handler.js.map +1 -0
- package/dist/toolbar.d.ts +103 -0
- package/dist/toolbar.d.ts.map +1 -0
- package/dist/toolbar.js +134 -0
- package/dist/toolbar.js.map +1 -0
- package/dist/trigger-manager.d.ts +205 -0
- package/dist/trigger-manager.d.ts.map +1 -0
- package/dist/trigger-manager.js +466 -0
- package/dist/trigger-manager.js.map +1 -0
- package/dist/types.d.ts +216 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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
|