@rtif-sdk/web 1.0.0 → 1.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.
- package/dist/block-drag-handler.d.ts +5 -0
- package/dist/block-drag-handler.d.ts.map +1 -1
- package/dist/block-drag-handler.js +28 -2
- package/dist/block-drag-handler.js.map +1 -1
- package/dist/block-renderer.d.ts +12 -6
- package/dist/block-renderer.d.ts.map +1 -1
- package/dist/block-renderer.js +98 -9
- package/dist/block-renderer.js.map +1 -1
- package/dist/block-type-dropdown.d.ts +78 -0
- package/dist/block-type-dropdown.d.ts.map +1 -0
- package/dist/block-type-dropdown.js +276 -0
- package/dist/block-type-dropdown.js.map +1 -0
- package/dist/color-picker.d.ts +91 -0
- package/dist/color-picker.d.ts.map +1 -0
- package/dist/color-picker.js +346 -0
- package/dist/color-picker.js.map +1 -0
- package/dist/content-handlers.d.ts +7 -8
- package/dist/content-handlers.d.ts.map +1 -1
- package/dist/content-handlers.js +122 -93
- package/dist/content-handlers.js.map +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +117 -14
- package/dist/editor.js.map +1 -1
- package/dist/embed-utils.d.ts +148 -0
- package/dist/embed-utils.d.ts.map +1 -0
- package/dist/embed-utils.js +197 -0
- package/dist/embed-utils.js.map +1 -0
- package/dist/font-family-picker.d.ts +105 -0
- package/dist/font-family-picker.d.ts.map +1 -0
- package/dist/font-family-picker.js +314 -0
- package/dist/font-family-picker.js.map +1 -0
- package/dist/font-size-picker.d.ts +82 -0
- package/dist/font-size-picker.d.ts.map +1 -0
- package/dist/font-size-picker.js +290 -0
- package/dist/font-size-picker.js.map +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +2 -1
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +1 -1
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/link-plugin.d.ts +4 -0
- package/dist/plugins/link-plugin.d.ts.map +1 -1
- package/dist/plugins/link-plugin.js +17 -0
- package/dist/plugins/link-plugin.js.map +1 -1
- package/dist/plugins/mark-utils.d.ts +31 -0
- package/dist/plugins/mark-utils.d.ts.map +1 -1
- package/dist/plugins/mark-utils.js +46 -0
- package/dist/plugins/mark-utils.js.map +1 -1
- package/dist/renderer.d.ts +2 -2
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +62 -16
- package/dist/renderer.js.map +1 -1
- package/dist/selection-sync.d.ts +2 -26
- package/dist/selection-sync.d.ts.map +1 -1
- package/dist/selection-sync.js +49 -13
- package/dist/selection-sync.js.map +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +17 -5
package/dist/content-handlers.js
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Provides handlers for:
|
|
5
5
|
* - Plain text (`text/plain`) — splits on newlines, creates blocks
|
|
6
6
|
* - RTIF JSON (`application/x-rtif+json`) — preserves marks and block attrs
|
|
7
|
-
* - HTML (`text/html`) —
|
|
7
|
+
* - HTML (`text/html`) — deserializes via @rtif-sdk/format-html
|
|
8
8
|
*
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
|
+
import { deserialize as deserializeHtml } from '@rtif-sdk/format-html';
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
// Plain text handler
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
@@ -68,6 +69,105 @@ export function createPlainTextHandler(generateBlockId) {
|
|
|
68
69
|
},
|
|
69
70
|
};
|
|
70
71
|
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Shared helper for block-based paste handlers
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
/**
|
|
76
|
+
* Convert an array of RTIF blocks into operations and dispatch them.
|
|
77
|
+
*
|
|
78
|
+
* Generates a 4-phase operation sequence:
|
|
79
|
+
* 1. Insert text + split blocks
|
|
80
|
+
* 2. Apply span marks
|
|
81
|
+
* 3. Apply block attrs on new blocks
|
|
82
|
+
* 4. Apply block type on new blocks
|
|
83
|
+
*
|
|
84
|
+
* Used by both the RTIF JSON and HTML paste handlers.
|
|
85
|
+
*
|
|
86
|
+
* @param blocks - Source blocks to insert
|
|
87
|
+
* @param context - The content pipeline context
|
|
88
|
+
*/
|
|
89
|
+
function insertBlocksAsOps(blocks, context) {
|
|
90
|
+
let insertOffset = context.deleteSelectionAndGetOffset();
|
|
91
|
+
const ops = [];
|
|
92
|
+
// Track the offsets where each new block starts, for set_span_marks later
|
|
93
|
+
const blockInsertions = [];
|
|
94
|
+
// Phase 1: Insert text and create blocks
|
|
95
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
96
|
+
const block = blocks[bi];
|
|
97
|
+
const isFirst = bi === 0;
|
|
98
|
+
let newBlockId = null;
|
|
99
|
+
if (!isFirst) {
|
|
100
|
+
newBlockId = context.generateBlockId();
|
|
101
|
+
ops.push({
|
|
102
|
+
type: 'split_block',
|
|
103
|
+
offset: insertOffset,
|
|
104
|
+
newBlockId,
|
|
105
|
+
});
|
|
106
|
+
insertOffset += 1;
|
|
107
|
+
}
|
|
108
|
+
const blockStartOffset = insertOffset;
|
|
109
|
+
// Insert the full text of all spans in this block
|
|
110
|
+
const fullText = block.spans.map((s) => s.text).join('');
|
|
111
|
+
if (fullText.length > 0) {
|
|
112
|
+
ops.push({
|
|
113
|
+
type: 'insert_text',
|
|
114
|
+
offset: insertOffset,
|
|
115
|
+
text: fullText,
|
|
116
|
+
});
|
|
117
|
+
insertOffset += fullText.length;
|
|
118
|
+
}
|
|
119
|
+
blockInsertions.push({
|
|
120
|
+
blockStartOffset,
|
|
121
|
+
block,
|
|
122
|
+
isFirstBlock: isFirst,
|
|
123
|
+
newBlockId,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// Phase 2: Apply span marks for each block
|
|
127
|
+
for (const { blockStartOffset, block } of blockInsertions) {
|
|
128
|
+
let spanOffset = blockStartOffset;
|
|
129
|
+
for (const span of block.spans) {
|
|
130
|
+
if (span.marks && Object.keys(span.marks).length > 0) {
|
|
131
|
+
ops.push({
|
|
132
|
+
type: 'set_span_marks',
|
|
133
|
+
offset: spanOffset,
|
|
134
|
+
count: span.text.length,
|
|
135
|
+
marks: span.marks,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
spanOffset += span.text.length;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Phase 3: Apply block attrs (only on NEW blocks created by split, not the original)
|
|
142
|
+
for (const { block, isFirstBlock, newBlockId, } of blockInsertions) {
|
|
143
|
+
if (isFirstBlock)
|
|
144
|
+
continue; // Don't set attrs on the block the cursor was in
|
|
145
|
+
if (!block.attrs || Object.keys(block.attrs).length === 0)
|
|
146
|
+
continue;
|
|
147
|
+
if (newBlockId === null)
|
|
148
|
+
continue;
|
|
149
|
+
ops.push({
|
|
150
|
+
type: 'set_block_attrs',
|
|
151
|
+
blockId: newBlockId,
|
|
152
|
+
attrs: block.attrs,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// Phase 4: Apply block type on NEW blocks to match source document
|
|
156
|
+
for (const { block, isFirstBlock, newBlockId, } of blockInsertions) {
|
|
157
|
+
if (isFirstBlock)
|
|
158
|
+
continue; // Don't change the cursor's original block type
|
|
159
|
+
if (newBlockId === null)
|
|
160
|
+
continue;
|
|
161
|
+
ops.push({
|
|
162
|
+
type: 'set_block_type',
|
|
163
|
+
blockId: newBlockId,
|
|
164
|
+
blockType: block.type,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (ops.length > 0) {
|
|
168
|
+
context.dispatch(ops);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
71
171
|
/**
|
|
72
172
|
* Create a content handler for RTIF JSON paste.
|
|
73
173
|
*
|
|
@@ -77,16 +177,15 @@ export function createPlainTextHandler(generateBlockId) {
|
|
|
77
177
|
*
|
|
78
178
|
* Priority: -80 (highest built-in, runs before HTML and plain text).
|
|
79
179
|
*
|
|
80
|
-
* @param generateBlockId - Factory for generating unique block IDs
|
|
81
180
|
* @returns A ContentHandler for RTIF JSON paste
|
|
82
181
|
*
|
|
83
182
|
* @example
|
|
84
183
|
* ```ts
|
|
85
|
-
* const handler = createRtifPasteHandler(
|
|
184
|
+
* const handler = createRtifPasteHandler();
|
|
86
185
|
* pipeline.register(handler);
|
|
87
186
|
* ```
|
|
88
187
|
*/
|
|
89
|
-
export function createRtifPasteHandler(
|
|
188
|
+
export function createRtifPasteHandler() {
|
|
90
189
|
return {
|
|
91
190
|
id: 'rtif:rtif-json',
|
|
92
191
|
accept: ['application/x-rtif+json'],
|
|
@@ -99,86 +198,7 @@ export function createRtifPasteHandler(generateBlockId) {
|
|
|
99
198
|
const payload = parseRtifPayload(jsonStr);
|
|
100
199
|
if (payload === null)
|
|
101
200
|
return false;
|
|
102
|
-
|
|
103
|
-
const ops = [];
|
|
104
|
-
// Track the offsets where each new block starts, for set_span_marks later
|
|
105
|
-
const blockInsertions = [];
|
|
106
|
-
// Phase 1: Insert text and create blocks
|
|
107
|
-
for (let bi = 0; bi < payload.blocks.length; bi++) {
|
|
108
|
-
const block = payload.blocks[bi];
|
|
109
|
-
const isFirst = bi === 0;
|
|
110
|
-
let newBlockId = null;
|
|
111
|
-
if (!isFirst) {
|
|
112
|
-
newBlockId = generateBlockId();
|
|
113
|
-
ops.push({
|
|
114
|
-
type: 'split_block',
|
|
115
|
-
offset: insertOffset,
|
|
116
|
-
newBlockId,
|
|
117
|
-
});
|
|
118
|
-
insertOffset += 1;
|
|
119
|
-
}
|
|
120
|
-
const blockStartOffset = insertOffset;
|
|
121
|
-
// Insert the full text of all spans in this block
|
|
122
|
-
const fullText = block.spans.map((s) => s.text).join('');
|
|
123
|
-
if (fullText.length > 0) {
|
|
124
|
-
ops.push({
|
|
125
|
-
type: 'insert_text',
|
|
126
|
-
offset: insertOffset,
|
|
127
|
-
text: fullText,
|
|
128
|
-
});
|
|
129
|
-
insertOffset += fullText.length;
|
|
130
|
-
}
|
|
131
|
-
blockInsertions.push({
|
|
132
|
-
blockStartOffset,
|
|
133
|
-
block,
|
|
134
|
-
isFirstBlock: isFirst,
|
|
135
|
-
newBlockId,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
// Phase 2: Apply span marks for each block
|
|
139
|
-
for (const { blockStartOffset, block } of blockInsertions) {
|
|
140
|
-
let spanOffset = blockStartOffset;
|
|
141
|
-
for (const span of block.spans) {
|
|
142
|
-
if (span.marks && Object.keys(span.marks).length > 0) {
|
|
143
|
-
ops.push({
|
|
144
|
-
type: 'set_span_marks',
|
|
145
|
-
offset: spanOffset,
|
|
146
|
-
count: span.text.length,
|
|
147
|
-
marks: span.marks,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
spanOffset += span.text.length;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
// Phase 3: Apply block attrs (only on NEW blocks created by split, not the original)
|
|
154
|
-
for (const { block, isFirstBlock, newBlockId, } of blockInsertions) {
|
|
155
|
-
if (isFirstBlock)
|
|
156
|
-
continue; // Don't set attrs on the block the cursor was in
|
|
157
|
-
if (!block.attrs || Object.keys(block.attrs).length === 0)
|
|
158
|
-
continue;
|
|
159
|
-
if (newBlockId === null)
|
|
160
|
-
continue;
|
|
161
|
-
ops.push({
|
|
162
|
-
type: 'set_block_attrs',
|
|
163
|
-
blockId: newBlockId,
|
|
164
|
-
attrs: block.attrs,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
// Phase 4: Apply block type on NEW blocks to match source document
|
|
168
|
-
for (const { block, isFirstBlock, newBlockId, } of blockInsertions) {
|
|
169
|
-
if (isFirstBlock)
|
|
170
|
-
continue; // Don't change the cursor's original block type
|
|
171
|
-
if (newBlockId === null)
|
|
172
|
-
continue;
|
|
173
|
-
ops.push({
|
|
174
|
-
type: 'set_block_type',
|
|
175
|
-
blockId: newBlockId,
|
|
176
|
-
blockType: block.type,
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
if (ops.length > 0) {
|
|
180
|
-
context.dispatch(ops);
|
|
181
|
-
}
|
|
201
|
+
insertBlocksAsOps(payload.blocks, context);
|
|
182
202
|
return true;
|
|
183
203
|
},
|
|
184
204
|
};
|
|
@@ -230,17 +250,17 @@ function parseRtifPayload(jsonStr) {
|
|
|
230
250
|
}
|
|
231
251
|
}
|
|
232
252
|
// ---------------------------------------------------------------------------
|
|
233
|
-
// HTML paste handler
|
|
253
|
+
// HTML paste handler
|
|
234
254
|
// ---------------------------------------------------------------------------
|
|
235
255
|
/**
|
|
236
|
-
* Create a
|
|
256
|
+
* Create a content handler for HTML paste.
|
|
237
257
|
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
258
|
+
* Accepts `text/html` content and deserializes it via `@rtif-sdk/format-html`,
|
|
259
|
+
* preserving formatting (bold, italic, links, headings, lists, code blocks, etc.).
|
|
240
260
|
*
|
|
241
261
|
* Priority: -90 (between RTIF JSON and plain text).
|
|
242
262
|
*
|
|
243
|
-
* @returns A ContentHandler
|
|
263
|
+
* @returns A ContentHandler for HTML paste
|
|
244
264
|
*
|
|
245
265
|
* @example
|
|
246
266
|
* ```ts
|
|
@@ -253,10 +273,19 @@ export function createHtmlPasteHandler() {
|
|
|
253
273
|
id: 'rtif:html',
|
|
254
274
|
accept: ['text/html'],
|
|
255
275
|
priority: -90,
|
|
256
|
-
async handle(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
276
|
+
async handle(item, context) {
|
|
277
|
+
const html = await item.getString();
|
|
278
|
+
if (html === null || html === '')
|
|
279
|
+
return false;
|
|
280
|
+
const doc = deserializeHtml(html);
|
|
281
|
+
// Check if deserialization produced only a single empty block
|
|
282
|
+
if (doc.blocks.length === 1 &&
|
|
283
|
+
doc.blocks[0].spans.length === 1 &&
|
|
284
|
+
doc.blocks[0].spans[0].text === '') {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
insertBlocksAsOps(doc.blocks, context);
|
|
288
|
+
return true;
|
|
260
289
|
},
|
|
261
290
|
};
|
|
262
291
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content-handlers.js","sourceRoot":"","sources":["../src/content-handlers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;
|
|
1
|
+
{"version":3,"file":"content-handlers.js","sourceRoot":"","sources":["../src/content-handlers.ts"],"names":[],"mappings":"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"}
|
package/dist/editor.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"editor.d.ts","sourceRoot":"","sources":["../src/editor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"editor.d.ts","sourceRoot":"","sources":["../src/editor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAkD7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS,CAguBlE"}
|
package/dist/editor.js
CHANGED
|
@@ -35,6 +35,9 @@ import { createCursorRectAPI } from './cursor-rect.js';
|
|
|
35
35
|
import { createTriggerManager } from './trigger-manager.js';
|
|
36
36
|
import { findContiguousMarkRange, adjustOffsetAroundAtomicMarks, adjustOffsetAroundAtomicBlocks } from './plugins/mark-utils.js';
|
|
37
37
|
import { installWebPlugins } from './plugin-kit.js';
|
|
38
|
+
import { createLinkPopover } from './link-popover.js';
|
|
39
|
+
import { LinkCommands } from './plugins/link-plugin.js';
|
|
40
|
+
import { createPerfInstrumentation } from './perf.js';
|
|
38
41
|
// ---------------------------------------------------------------------------
|
|
39
42
|
// Factory
|
|
40
43
|
// ---------------------------------------------------------------------------
|
|
@@ -72,6 +75,14 @@ export function createWebEditor(config) {
|
|
|
72
75
|
const autoFocus = config.autoFocus ?? false;
|
|
73
76
|
let destroyed = false;
|
|
74
77
|
// -------------------------------------------------------------------
|
|
78
|
+
// Performance instrumentation
|
|
79
|
+
// -------------------------------------------------------------------
|
|
80
|
+
const perfEnabled = config.perf != null && config.perf !== false;
|
|
81
|
+
const perfInstrumentation = perfEnabled ? createPerfInstrumentation() : null;
|
|
82
|
+
if (perfInstrumentation && typeof config.perf === 'object') {
|
|
83
|
+
perfInstrumentation.setObserver(config.perf);
|
|
84
|
+
}
|
|
85
|
+
// -------------------------------------------------------------------
|
|
75
86
|
// Set up the contenteditable root
|
|
76
87
|
// -------------------------------------------------------------------
|
|
77
88
|
root.setAttribute('contenteditable', readOnly ? 'false' : 'true');
|
|
@@ -110,7 +121,9 @@ export function createWebEditor(config) {
|
|
|
110
121
|
// -------------------------------------------------------------------
|
|
111
122
|
// Initial render
|
|
112
123
|
// -------------------------------------------------------------------
|
|
113
|
-
|
|
124
|
+
// Persistent blockId → DOM element Map for O(1) lookups in reconcile + selection sync
|
|
125
|
+
const blockElementMap = new Map();
|
|
126
|
+
renderInitial(root, engine.state.doc, markRenderers, blockRenderers, blockElementMap);
|
|
114
127
|
// -------------------------------------------------------------------
|
|
115
128
|
// Command bus
|
|
116
129
|
// -------------------------------------------------------------------
|
|
@@ -188,7 +201,7 @@ export function createWebEditor(config) {
|
|
|
188
201
|
const contentPipeline = createContentPipeline();
|
|
189
202
|
// Register built-in handlers (priority: RTIF JSON -80, image -85, HTML -90,
|
|
190
203
|
// URL -95, plain text -100, file drop -105)
|
|
191
|
-
contentPipeline.register(createRtifPasteHandler(
|
|
204
|
+
contentPipeline.register(createRtifPasteHandler());
|
|
192
205
|
contentPipeline.register(createImageContentHandler());
|
|
193
206
|
contentPipeline.register(createHtmlPasteHandler());
|
|
194
207
|
contentPipeline.register(createUrlContentHandler());
|
|
@@ -218,6 +231,7 @@ export function createWebEditor(config) {
|
|
|
218
231
|
getDoc: () => engine.state.doc,
|
|
219
232
|
dispatch: (ops) => engine.dispatch(ops),
|
|
220
233
|
isReadOnly: () => readOnly,
|
|
234
|
+
getBlockElementMap: () => blockElementMap,
|
|
221
235
|
});
|
|
222
236
|
// -------------------------------------------------------------------
|
|
223
237
|
// Paste handler (via content pipeline)
|
|
@@ -258,7 +272,7 @@ export function createWebEditor(config) {
|
|
|
258
272
|
}
|
|
259
273
|
else {
|
|
260
274
|
// Find nearest block for between-blocks indicator
|
|
261
|
-
const blockEl = findNearestBlock(root, e.clientY);
|
|
275
|
+
const blockEl = findNearestBlock(root, e.clientY, engine.state.doc, blockElementMap);
|
|
262
276
|
if (blockEl) {
|
|
263
277
|
const blockRect = blockEl.getBoundingClientRect();
|
|
264
278
|
const midY = blockRect.top + blockRect.height / 2;
|
|
@@ -310,7 +324,7 @@ export function createWebEditor(config) {
|
|
|
310
324
|
// Programmatic selection change (Cmd+A, Cmd+Home/End):
|
|
311
325
|
// update engine state AND sync to DOM.
|
|
312
326
|
engine.setSelection(sel);
|
|
313
|
-
setDomSelection(root, engine.state.doc, sel);
|
|
327
|
+
setDomSelection(root, engine.state.doc, sel, blockElementMap);
|
|
314
328
|
},
|
|
315
329
|
isMac: () => isMac(),
|
|
316
330
|
});
|
|
@@ -349,6 +363,11 @@ export function createWebEditor(config) {
|
|
|
349
363
|
return;
|
|
350
364
|
if (compositionHandler.isComposing())
|
|
351
365
|
return;
|
|
366
|
+
// Early containment check: skip if selection is outside this editor
|
|
367
|
+
const domSel = document.getSelection();
|
|
368
|
+
if (!domSel || !domSel.focusNode || !root.contains(domSel.focusNode)) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
352
371
|
const cache = buildBlockOffsetCache(engine.state.doc);
|
|
353
372
|
const sel = readDomSelection(root, cache);
|
|
354
373
|
if (sel === null)
|
|
@@ -358,7 +377,7 @@ export function createWebEditor(config) {
|
|
|
358
377
|
if (adjustedSel !== sel) {
|
|
359
378
|
// Selection was adjusted — update engine AND re-sync DOM
|
|
360
379
|
engine.setSelection(adjustedSel);
|
|
361
|
-
setDomSelection(root, engine.state.doc, adjustedSel);
|
|
380
|
+
setDomSelection(root, engine.state.doc, adjustedSel, blockElementMap);
|
|
362
381
|
return;
|
|
363
382
|
}
|
|
364
383
|
// Adjust offsets that land inside atomic blocks (HR, image, embed)
|
|
@@ -378,7 +397,7 @@ export function createWebEditor(config) {
|
|
|
378
397
|
focus: { offset: adjFocus },
|
|
379
398
|
};
|
|
380
399
|
engine.setSelection(blockAdjustedSel);
|
|
381
|
-
setDomSelection(root, engine.state.doc, blockAdjustedSel);
|
|
400
|
+
setDomSelection(root, engine.state.doc, blockAdjustedSel, blockElementMap);
|
|
382
401
|
return;
|
|
383
402
|
}
|
|
384
403
|
// Update engine selection without triggering a dispatch
|
|
@@ -423,19 +442,52 @@ export function createWebEditor(config) {
|
|
|
423
442
|
// -------------------------------------------------------------------
|
|
424
443
|
// Engine onChange — reconcile DOM and sync selection
|
|
425
444
|
// -------------------------------------------------------------------
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
445
|
+
// Batched reconciliation: when multiple onChange events fire in the same
|
|
446
|
+
// synchronous turn (e.g., from plugin afterApply → engine.dispatch), only
|
|
447
|
+
// the final state is reconciled. The first onChange in a synchronous turn
|
|
448
|
+
// is applied immediately; subsequent ones replace `pendingState` and a
|
|
449
|
+
// microtask flushes the final state.
|
|
450
|
+
let pendingState = null;
|
|
451
|
+
let isReconciling = false;
|
|
452
|
+
const doReconcile = (state) => {
|
|
453
|
+
perfInstrumentation?.markReconcileStart();
|
|
429
454
|
const composingBlockId = compositionHandler.getComposingBlockId();
|
|
430
|
-
reconcile(root, prevDoc, state.doc, composingBlockId, markRenderers, blockRenderers);
|
|
455
|
+
reconcile(root, prevDoc, state.doc, composingBlockId, markRenderers, blockRenderers, blockElementMap);
|
|
431
456
|
prevDoc = state.doc;
|
|
457
|
+
perfInstrumentation?.markReconcileEnd();
|
|
432
458
|
// Sync selection to DOM (unless composing)
|
|
433
459
|
if (!compositionHandler.isComposing()) {
|
|
434
|
-
|
|
460
|
+
perfInstrumentation?.markSelectionSyncStart();
|
|
461
|
+
setDomSelection(root, state.doc, state.selection, blockElementMap);
|
|
435
462
|
scrollToCursor(root);
|
|
463
|
+
perfInstrumentation?.markSelectionSyncEnd();
|
|
436
464
|
}
|
|
437
465
|
// Update placeholder visibility
|
|
438
466
|
updatePlaceholder(root, state.doc);
|
|
467
|
+
perfInstrumentation?.collect();
|
|
468
|
+
};
|
|
469
|
+
const unsubscribeOnChange = engine.onChange((state) => {
|
|
470
|
+
if (destroyed)
|
|
471
|
+
return;
|
|
472
|
+
if (isReconciling) {
|
|
473
|
+
// Nested dispatch during reconciliation — defer to microtask
|
|
474
|
+
pendingState = state;
|
|
475
|
+
queueMicrotask(() => {
|
|
476
|
+
if (destroyed || !pendingState)
|
|
477
|
+
return;
|
|
478
|
+
const deferred = pendingState;
|
|
479
|
+
pendingState = null;
|
|
480
|
+
doReconcile(deferred);
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
isReconciling = true;
|
|
485
|
+
try {
|
|
486
|
+
doReconcile(state);
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
isReconciling = false;
|
|
490
|
+
}
|
|
439
491
|
});
|
|
440
492
|
// -------------------------------------------------------------------
|
|
441
493
|
// Exclusive mark boundary helper
|
|
@@ -537,9 +589,13 @@ export function createWebEditor(config) {
|
|
|
537
589
|
root.focus();
|
|
538
590
|
}
|
|
539
591
|
// -------------------------------------------------------------------
|
|
592
|
+
// Auto-wire link popover
|
|
593
|
+
// -------------------------------------------------------------------
|
|
594
|
+
let linkPopoverHandle = null;
|
|
595
|
+
// -------------------------------------------------------------------
|
|
540
596
|
// Return WebEditor handle
|
|
541
597
|
// -------------------------------------------------------------------
|
|
542
|
-
|
|
598
|
+
const webEditor = {
|
|
543
599
|
engine,
|
|
544
600
|
commandBus,
|
|
545
601
|
content: contentPipeline,
|
|
@@ -547,6 +603,8 @@ export function createWebEditor(config) {
|
|
|
547
603
|
blockRenderers,
|
|
548
604
|
triggers: triggerManager,
|
|
549
605
|
cursorRect,
|
|
606
|
+
get linkPopover() { return linkPopoverHandle; },
|
|
607
|
+
perf: perfInstrumentation,
|
|
550
608
|
focus() {
|
|
551
609
|
if (destroyed)
|
|
552
610
|
return;
|
|
@@ -555,7 +613,7 @@ export function createWebEditor(config) {
|
|
|
555
613
|
// reset the contenteditable's selection when focus leaves and returns
|
|
556
614
|
// (e.g., after interacting with a popover). The engine is the source
|
|
557
615
|
// of truth, so always sync after focusing.
|
|
558
|
-
setDomSelection(root, engine.state.doc, engine.state.selection);
|
|
616
|
+
setDomSelection(root, engine.state.doc, engine.state.selection, blockElementMap);
|
|
559
617
|
},
|
|
560
618
|
blur() {
|
|
561
619
|
if (destroyed)
|
|
@@ -571,6 +629,15 @@ export function createWebEditor(config) {
|
|
|
571
629
|
if (destroyed)
|
|
572
630
|
return;
|
|
573
631
|
destroyed = true;
|
|
632
|
+
// Clean up auto-wired link popover
|
|
633
|
+
if (linkPopoverHandle) {
|
|
634
|
+
linkPopoverHandle.dispose();
|
|
635
|
+
try {
|
|
636
|
+
engine.exec(LinkCommands._UNWIRE_UI);
|
|
637
|
+
}
|
|
638
|
+
catch { /* engine may be destroyed */ }
|
|
639
|
+
linkPopoverHandle = null;
|
|
640
|
+
}
|
|
574
641
|
// Detach event handlers
|
|
575
642
|
shortcutHandler.detach();
|
|
576
643
|
inputBridge.detach();
|
|
@@ -601,6 +668,10 @@ export function createWebEditor(config) {
|
|
|
601
668
|
// Reset composition handler
|
|
602
669
|
compositionHandler.reset();
|
|
603
670
|
resetSuppression();
|
|
671
|
+
// Clear block element map
|
|
672
|
+
blockElementMap.clear();
|
|
673
|
+
// Destroy performance instrumentation
|
|
674
|
+
perfInstrumentation?.destroy();
|
|
604
675
|
// Remove contenteditable attributes
|
|
605
676
|
root.removeAttribute('contenteditable');
|
|
606
677
|
root.removeAttribute('role');
|
|
@@ -613,6 +684,12 @@ export function createWebEditor(config) {
|
|
|
613
684
|
root.style.whiteSpace = '';
|
|
614
685
|
},
|
|
615
686
|
};
|
|
687
|
+
// Auto-wire link popover when link plugin is registered and not disabled/readOnly
|
|
688
|
+
if (config.linkPopover !== false && !readOnly && engine.canExecute(LinkCommands._WIRE_UI)) {
|
|
689
|
+
linkPopoverHandle = createLinkPopover(webEditor, root);
|
|
690
|
+
engine.exec(LinkCommands._WIRE_UI, linkPopoverHandle.show);
|
|
691
|
+
}
|
|
692
|
+
return webEditor;
|
|
616
693
|
}
|
|
617
694
|
// ---------------------------------------------------------------------------
|
|
618
695
|
// Helpers
|
|
@@ -689,7 +766,30 @@ function getOffsetFromDropPoint(root, doc, clientX, clientY) {
|
|
|
689
766
|
* @param clientY - The Y coordinate to find the nearest block to
|
|
690
767
|
* @returns The nearest block element, or null if no blocks found
|
|
691
768
|
*/
|
|
692
|
-
function findNearestBlock(root, clientY) {
|
|
769
|
+
function findNearestBlock(root, clientY, doc, blockElementMap) {
|
|
770
|
+
// Use persistent Map for O(1) element lookup (avoids querySelectorAll + n getBoundingClientRect)
|
|
771
|
+
if (doc && blockElementMap && blockElementMap.size > 0) {
|
|
772
|
+
let nearest = null;
|
|
773
|
+
let nearestDistance = Infinity;
|
|
774
|
+
for (const block of doc.blocks) {
|
|
775
|
+
const el = blockElementMap.get(block.id);
|
|
776
|
+
if (!el)
|
|
777
|
+
continue;
|
|
778
|
+
const rect = el.getBoundingClientRect();
|
|
779
|
+
const midY = rect.top + rect.height / 2;
|
|
780
|
+
const distance = Math.abs(clientY - midY);
|
|
781
|
+
if (distance < nearestDistance) {
|
|
782
|
+
nearestDistance = distance;
|
|
783
|
+
nearest = el;
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
// Blocks are in document order; once distance starts increasing, we've found the nearest
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return nearest;
|
|
791
|
+
}
|
|
792
|
+
// Fallback: scan DOM children
|
|
693
793
|
const blocks = root.querySelectorAll('[data-rtif-block]');
|
|
694
794
|
if (blocks.length === 0)
|
|
695
795
|
return null;
|
|
@@ -704,6 +804,9 @@ function findNearestBlock(root, clientY) {
|
|
|
704
804
|
nearestDistance = distance;
|
|
705
805
|
nearest = block;
|
|
706
806
|
}
|
|
807
|
+
else {
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
707
810
|
}
|
|
708
811
|
return nearest;
|
|
709
812
|
}
|