@barocss/editor-view-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +89 -0
- package/dist/editor-view-react/src/EditorView.d.ts +14 -0
- package/dist/editor-view-react/src/EditorView.d.ts.map +1 -0
- package/dist/editor-view-react/src/EditorViewContentLayer.d.ts +9 -0
- package/dist/editor-view-react/src/EditorViewContentLayer.d.ts.map +1 -0
- package/dist/editor-view-react/src/EditorViewContext.d.ts +43 -0
- package/dist/editor-view-react/src/EditorViewContext.d.ts.map +1 -0
- package/dist/editor-view-react/src/EditorViewLayer.d.ts +8 -0
- package/dist/editor-view-react/src/EditorViewLayer.d.ts.map +1 -0
- package/dist/editor-view-react/src/EditorViewOverlayLayerContent.d.ts +14 -0
- package/dist/editor-view-react/src/EditorViewOverlayLayerContent.d.ts.map +1 -0
- package/dist/editor-view-react/src/dom-sync/classify-c1.d.ts +45 -0
- package/dist/editor-view-react/src/dom-sync/classify-c1.d.ts.map +1 -0
- package/dist/editor-view-react/src/dom-sync/edit-position.d.ts +6 -0
- package/dist/editor-view-react/src/dom-sync/edit-position.d.ts.map +1 -0
- package/dist/editor-view-react/src/index.d.ts +12 -0
- package/dist/editor-view-react/src/index.d.ts.map +1 -0
- package/dist/editor-view-react/src/input-handler.d.ts +51 -0
- package/dist/editor-view-react/src/input-handler.d.ts.map +1 -0
- package/dist/editor-view-react/src/mutation-observer-manager.d.ts +13 -0
- package/dist/editor-view-react/src/mutation-observer-manager.d.ts.map +1 -0
- package/dist/editor-view-react/src/selection-handler.d.ts +56 -0
- package/dist/editor-view-react/src/selection-handler.d.ts.map +1 -0
- package/dist/editor-view-react/src/types.d.ts +103 -0
- package/dist/editor-view-react/src/types.d.ts.map +1 -0
- package/dist/index.cjs +4 -0
- package/dist/index.js +11882 -0
- package/docs/SPEC_VERIFICATION.md +109 -0
- package/docs/editor-view-react-spec.md +359 -0
- package/docs/improvement-opportunities.md +66 -0
- package/docs/layers-spec.md +97 -0
- package/package.json +53 -0
- package/src/EditorView.tsx +312 -0
- package/src/EditorViewContentLayer.tsx +90 -0
- package/src/EditorViewContext.tsx +228 -0
- package/src/EditorViewLayer.tsx +35 -0
- package/src/EditorViewOverlayLayerContent.tsx +42 -0
- package/src/dom-sync/classify-c1.ts +91 -0
- package/src/dom-sync/edit-position.ts +27 -0
- package/src/index.ts +33 -0
- package/src/input-handler.ts +716 -0
- package/src/mutation-observer-manager.ts +65 -0
- package/src/selection-handler.ts +450 -0
- package/src/types.ts +123 -0
- package/test/EditorView-decorator.test.tsx +198 -0
- package/test/EditorView-layers.test.tsx +352 -0
- package/test/EditorView.test.tsx +218 -0
- package/test/dom-sync.test.ts +49 -0
- package/test/mutation-observer-manager.test.ts +48 -0
- package/test/selection-handler.test.ts +86 -0
- package/tsconfig.json +12 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MutationObserver manager for editor-view-react.
|
|
3
|
+
* Observes contentEditableElement and calls onMutations (e.g. inputHandler.handleDomMutations).
|
|
4
|
+
* Same role as editor-view-dom's MutationObserverManagerImpl; does not depend on editor-view-dom.
|
|
5
|
+
*/
|
|
6
|
+
export interface ReactMutationObserverManager {
|
|
7
|
+
setup(contentEditableElement: HTMLElement): void;
|
|
8
|
+
disconnect(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type OnMutations = (mutations: MutationRecord[]) => void;
|
|
12
|
+
|
|
13
|
+
export function createMutationObserverManager(onMutations: OnMutations): ReactMutationObserverManager {
|
|
14
|
+
let observer: MutationObserver | null = null;
|
|
15
|
+
let pendingMutations: MutationRecord[] = [];
|
|
16
|
+
let mutationTimer: number | null = null;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
setup(contentEditableElement: HTMLElement) {
|
|
20
|
+
if (observer) {
|
|
21
|
+
observer.disconnect();
|
|
22
|
+
observer = null;
|
|
23
|
+
}
|
|
24
|
+
if (mutationTimer != null) {
|
|
25
|
+
clearTimeout(mutationTimer);
|
|
26
|
+
mutationTimer = null;
|
|
27
|
+
}
|
|
28
|
+
pendingMutations = [];
|
|
29
|
+
|
|
30
|
+
observer = new MutationObserver((mutations: MutationRecord[]) => {
|
|
31
|
+
pendingMutations.push(...mutations);
|
|
32
|
+
if (mutationTimer != null) clearTimeout(mutationTimer);
|
|
33
|
+
mutationTimer = window.setTimeout(() => {
|
|
34
|
+
if (pendingMutations.length > 0) {
|
|
35
|
+
onMutations([...pendingMutations]);
|
|
36
|
+
pendingMutations = [];
|
|
37
|
+
}
|
|
38
|
+
mutationTimer = null;
|
|
39
|
+
}, 0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
observer.observe(contentEditableElement, {
|
|
43
|
+
childList: true,
|
|
44
|
+
subtree: true,
|
|
45
|
+
characterData: true,
|
|
46
|
+
attributes: true,
|
|
47
|
+
attributeFilter: ['data-bc-edit', 'data-bc-value', 'data-bc-sid', 'data-bc-stype'],
|
|
48
|
+
characterDataOldValue: true,
|
|
49
|
+
attributeOldValue: true,
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
disconnect() {
|
|
54
|
+
if (observer) {
|
|
55
|
+
observer.disconnect();
|
|
56
|
+
observer = null;
|
|
57
|
+
}
|
|
58
|
+
if (mutationTimer != null) {
|
|
59
|
+
clearTimeout(mutationTimer);
|
|
60
|
+
mutationTimer = null;
|
|
61
|
+
}
|
|
62
|
+
pendingMutations = [];
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import type { Editor } from '@barocss/editor-core';
|
|
2
|
+
import { fromDOMSelection } from '@barocss/editor-core';
|
|
3
|
+
import {
|
|
4
|
+
buildTextRunIndex,
|
|
5
|
+
binarySearchRun,
|
|
6
|
+
type ContainerRuns,
|
|
7
|
+
} from '@barocss/shared';
|
|
8
|
+
|
|
9
|
+
export type ModelSelection =
|
|
10
|
+
| { type: 'none' }
|
|
11
|
+
| { type: 'range'; startNodeId: string; startOffset: number; endNodeId: string; endOffset: number; direction?: 'forward' | 'backward' | 'none' }
|
|
12
|
+
| { type: 'node'; nodeId: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Selection handler for React editor view: converts DOM Selection to/from model selection
|
|
16
|
+
* using renderer-dom text run index. Does not depend on editor-view-dom.
|
|
17
|
+
*/
|
|
18
|
+
export class ReactSelectionHandler {
|
|
19
|
+
private editor: Editor;
|
|
20
|
+
private getContentEditableElement: () => HTMLElement | null;
|
|
21
|
+
private _isProgrammaticChange = false;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
editor: Editor,
|
|
25
|
+
getContentEditableElement: () => HTMLElement | null
|
|
26
|
+
) {
|
|
27
|
+
this.editor = editor;
|
|
28
|
+
this.getContentEditableElement = getContentEditableElement;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setProgrammaticChange(value: boolean): void {
|
|
32
|
+
this._isProgrammaticChange = value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns true if the given (or current) selection is entirely inside inline-text nodes.
|
|
37
|
+
* Used to restrict character input to editable text only (same as editor-view-dom).
|
|
38
|
+
*/
|
|
39
|
+
isSelectionInsideEditableText(domSelection?: Selection | null): boolean {
|
|
40
|
+
const sel = domSelection ?? window.getSelection();
|
|
41
|
+
if (!sel || sel.rangeCount === 0) return false;
|
|
42
|
+
|
|
43
|
+
const contentEditable = this.getContentEditableElement();
|
|
44
|
+
if (!contentEditable) return false;
|
|
45
|
+
if (!sel.anchorNode || !contentEditable.contains(sel.anchorNode)) return false;
|
|
46
|
+
if (sel.focusNode && !contentEditable.contains(sel.focusNode)) return false;
|
|
47
|
+
|
|
48
|
+
const dataStore = this.editor.dataStore;
|
|
49
|
+
if (!dataStore?.getNode) return false;
|
|
50
|
+
|
|
51
|
+
const checkNode = (node: Node | null): boolean => {
|
|
52
|
+
if (!node) return false;
|
|
53
|
+
const el = node.nodeType === Node.TEXT_NODE ? (node.parentElement as Element | null) : (node as Element);
|
|
54
|
+
if (!el) return false;
|
|
55
|
+
const found = el.closest('[data-bc-sid]');
|
|
56
|
+
if (!found) return false;
|
|
57
|
+
const sid = found.getAttribute('data-bc-sid');
|
|
58
|
+
if (!sid) return false;
|
|
59
|
+
const modelNode = dataStore.getNode(sid);
|
|
60
|
+
if (!modelNode) return false;
|
|
61
|
+
const stype = (modelNode as { stype?: string }).stype ?? (modelNode as { type?: string }).type;
|
|
62
|
+
return stype === 'inline-text';
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return checkNode(sel.anchorNode) && checkNode(sel.focusNode ?? sel.anchorNode);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
handleSelectionChange(): void {
|
|
69
|
+
if (this._isProgrammaticChange) return;
|
|
70
|
+
|
|
71
|
+
const selection = window.getSelection();
|
|
72
|
+
if (!selection) return;
|
|
73
|
+
|
|
74
|
+
const contentEditable = this.getContentEditableElement();
|
|
75
|
+
if (!contentEditable) return;
|
|
76
|
+
|
|
77
|
+
const anchorNode = selection.anchorNode;
|
|
78
|
+
const focusNode = selection.focusNode;
|
|
79
|
+
if (!anchorNode) return;
|
|
80
|
+
|
|
81
|
+
const isAnchorInside = contentEditable.contains(anchorNode);
|
|
82
|
+
const isFocusInside = !focusNode || contentEditable.contains(focusNode);
|
|
83
|
+
if (!isAnchorInside || !isFocusInside) return;
|
|
84
|
+
|
|
85
|
+
let node: Node | null = anchorNode;
|
|
86
|
+
while (node) {
|
|
87
|
+
if (node instanceof Element && node.hasAttribute('data-devtool')) return;
|
|
88
|
+
node = node.parentNode;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const modelSelection = this.convertDOMSelectionToModel(selection);
|
|
92
|
+
this.editor.updateSelection?.(modelSelection);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
convertDOMSelectionToModel(selection: Selection): ModelSelection {
|
|
96
|
+
if (selection.rangeCount === 0) return { type: 'none' };
|
|
97
|
+
|
|
98
|
+
const range = selection.getRangeAt(0);
|
|
99
|
+
const boundaries = this.convertRangeBoundariesToModel(
|
|
100
|
+
range.startContainer,
|
|
101
|
+
range.startOffset,
|
|
102
|
+
range.endContainer,
|
|
103
|
+
range.endOffset
|
|
104
|
+
);
|
|
105
|
+
if (!boundaries) return { type: 'none' };
|
|
106
|
+
|
|
107
|
+
const { startNodeId, startModelOffset, endNodeId, endModelOffset } = boundaries;
|
|
108
|
+
const startNode = this.findBestContainer(range.startContainer);
|
|
109
|
+
const endNode = this.findBestContainer(range.endContainer);
|
|
110
|
+
const direction =
|
|
111
|
+
startNode && endNode
|
|
112
|
+
? this.determineSelectionDirection(
|
|
113
|
+
selection,
|
|
114
|
+
startNode,
|
|
115
|
+
endNode,
|
|
116
|
+
startModelOffset,
|
|
117
|
+
endModelOffset
|
|
118
|
+
)
|
|
119
|
+
: 'forward';
|
|
120
|
+
|
|
121
|
+
const modelSelection = fromDOMSelection(
|
|
122
|
+
startNodeId,
|
|
123
|
+
startModelOffset,
|
|
124
|
+
endNodeId,
|
|
125
|
+
endModelOffset,
|
|
126
|
+
'range'
|
|
127
|
+
);
|
|
128
|
+
return { ...modelSelection, direction } as ModelSelection;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
convertStaticRangeToModel(
|
|
132
|
+
staticRange: StaticRange
|
|
133
|
+
): { type: 'range'; startNodeId: string; startOffset: number; endNodeId: string; endOffset: number; direction?: 'forward' } | null {
|
|
134
|
+
const boundaries = this.convertRangeBoundariesToModel(
|
|
135
|
+
staticRange.startContainer,
|
|
136
|
+
staticRange.startOffset,
|
|
137
|
+
staticRange.endContainer,
|
|
138
|
+
staticRange.endOffset
|
|
139
|
+
);
|
|
140
|
+
if (!boundaries) return null;
|
|
141
|
+
|
|
142
|
+
const { startNodeId, startModelOffset, endNodeId, endModelOffset } = boundaries;
|
|
143
|
+
return {
|
|
144
|
+
type: 'range',
|
|
145
|
+
startNodeId,
|
|
146
|
+
startOffset: startModelOffset,
|
|
147
|
+
endNodeId,
|
|
148
|
+
endOffset: endModelOffset,
|
|
149
|
+
direction: 'forward',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private convertRangeBoundariesToModel(
|
|
154
|
+
startContainer: Node,
|
|
155
|
+
startOffset: number,
|
|
156
|
+
endContainer: Node,
|
|
157
|
+
endOffset: number
|
|
158
|
+
): { startNodeId: string; startModelOffset: number; endNodeId: string; endModelOffset: number } | null {
|
|
159
|
+
const startNode = this.findBestContainer(startContainer);
|
|
160
|
+
const endNode = this.findBestContainer(endContainer);
|
|
161
|
+
|
|
162
|
+
if (!startNode || !endNode) return null;
|
|
163
|
+
|
|
164
|
+
const startNodeId = startNode.getAttribute('data-bc-sid');
|
|
165
|
+
const endNodeId = endNode.getAttribute('data-bc-sid');
|
|
166
|
+
|
|
167
|
+
if (!startNodeId || !endNodeId) return null;
|
|
168
|
+
if (!this.nodeExistsInModel(startNodeId) || !this.nodeExistsInModel(endNodeId)) return null;
|
|
169
|
+
|
|
170
|
+
const startRuns = this.ensureRuns(startNode, startNodeId);
|
|
171
|
+
const endRuns = startNode === endNode ? startRuns : this.ensureRuns(endNode, endNodeId);
|
|
172
|
+
|
|
173
|
+
const startModelOffset = this.convertOffsetWithRuns(
|
|
174
|
+
startNode,
|
|
175
|
+
startContainer,
|
|
176
|
+
startOffset,
|
|
177
|
+
startRuns,
|
|
178
|
+
false
|
|
179
|
+
);
|
|
180
|
+
const endModelOffset = this.convertOffsetWithRuns(
|
|
181
|
+
endNode,
|
|
182
|
+
endContainer,
|
|
183
|
+
endOffset,
|
|
184
|
+
endRuns,
|
|
185
|
+
true
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return { startNodeId, startModelOffset, endNodeId, endModelOffset };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private isTextContainer(element: Element): boolean {
|
|
192
|
+
return element.getAttribute('data-text-container') === 'true';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private nodeExistsInModel(nodeId: string): boolean {
|
|
196
|
+
try {
|
|
197
|
+
const ds = this.editor.dataStore;
|
|
198
|
+
if (ds) {
|
|
199
|
+
const node = ds.getNode(nodeId);
|
|
200
|
+
return node != null;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private findClosestDataNode(node: Node): Element | null {
|
|
209
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
210
|
+
const el = node as Element;
|
|
211
|
+
if (el.hasAttribute('data-bc-sid')) return el;
|
|
212
|
+
}
|
|
213
|
+
let current: Element | null = node.parentElement;
|
|
214
|
+
while (current) {
|
|
215
|
+
if (current.hasAttribute('data-bc-sid')) return current;
|
|
216
|
+
current = current.parentElement;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private findBestContainer(node: Node): Element | null {
|
|
222
|
+
let el = this.findClosestDataNode(node);
|
|
223
|
+
if (!el) return null;
|
|
224
|
+
|
|
225
|
+
if (this.isTextContainer(el)) return el;
|
|
226
|
+
|
|
227
|
+
let cur: Element | null = el;
|
|
228
|
+
while (cur) {
|
|
229
|
+
if (this.isTextContainer(cur)) return cur;
|
|
230
|
+
cur = cur.parentElement?.closest?.('[data-bc-sid]') ?? null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const sid = el.getAttribute('data-bc-sid');
|
|
234
|
+
if (sid) {
|
|
235
|
+
const model = this.editor.dataStore?.getNode?.(sid);
|
|
236
|
+
if ((model as { stype?: string })?.stype === 'document') return null;
|
|
237
|
+
}
|
|
238
|
+
return el;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private ensureRuns(containerEl: Element, containerId: string): ContainerRuns {
|
|
242
|
+
return buildTextRunIndex(containerEl, containerId, {
|
|
243
|
+
buildReverseMap: true,
|
|
244
|
+
excludePredicate: (el) => el.hasAttribute('data-bc-decorator'),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private convertOffsetWithRuns(
|
|
249
|
+
containerEl: Element,
|
|
250
|
+
container: Node,
|
|
251
|
+
offset: number,
|
|
252
|
+
runs: ContainerRuns,
|
|
253
|
+
isEnd: boolean
|
|
254
|
+
): number {
|
|
255
|
+
if (runs.total === 0) return 0;
|
|
256
|
+
if (container.nodeType === Node.TEXT_NODE) {
|
|
257
|
+
const textNode = container as Text;
|
|
258
|
+
const entry = runs.byNode?.get(textNode);
|
|
259
|
+
if (entry) {
|
|
260
|
+
const localLen = entry.end - entry.start;
|
|
261
|
+
const clamped = Math.max(0, Math.min(offset, localLen));
|
|
262
|
+
return entry.start + clamped;
|
|
263
|
+
}
|
|
264
|
+
const idx = binarySearchRun(runs.runs, Math.max(0, Math.min(offset, runs.total - 1)));
|
|
265
|
+
if (idx >= 0) return isEnd ? runs.runs[idx].end : runs.runs[idx].start;
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
const el = container as Element;
|
|
269
|
+
const boundaryText = this.findTextAtElementBoundary(containerEl, el, offset, isEnd);
|
|
270
|
+
if (boundaryText) {
|
|
271
|
+
const entry = runs.byNode?.get(boundaryText);
|
|
272
|
+
if (entry) return isEnd ? entry.end : entry.start;
|
|
273
|
+
}
|
|
274
|
+
return isEnd ? runs.total : 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private findTextAtElementBoundary(
|
|
278
|
+
containerEl: Element,
|
|
279
|
+
el: Element,
|
|
280
|
+
offset: number,
|
|
281
|
+
isEnd: boolean
|
|
282
|
+
): Text | null {
|
|
283
|
+
const walker = document.createTreeWalker(containerEl, NodeFilter.SHOW_TEXT);
|
|
284
|
+
const child = el.childNodes.item(offset) ?? null;
|
|
285
|
+
let lastBefore: Text | null = null;
|
|
286
|
+
let firstAtOrAfter: Text | null = null;
|
|
287
|
+
let t = walker.nextNode() as Text | null;
|
|
288
|
+
while (t) {
|
|
289
|
+
if (child) {
|
|
290
|
+
const pos = (t as Node).compareDocumentPosition(child);
|
|
291
|
+
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
292
|
+
firstAtOrAfter = t;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
lastBefore = t;
|
|
296
|
+
} else {
|
|
297
|
+
lastBefore = t;
|
|
298
|
+
}
|
|
299
|
+
t = walker.nextNode() as Text | null;
|
|
300
|
+
}
|
|
301
|
+
return isEnd ? (lastBefore ?? firstAtOrAfter) : (firstAtOrAfter ?? lastBefore);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private determineSelectionDirection(
|
|
305
|
+
selection: Selection,
|
|
306
|
+
startNode: Element,
|
|
307
|
+
endNode: Element,
|
|
308
|
+
startOffset: number,
|
|
309
|
+
endOffset: number
|
|
310
|
+
): 'forward' | 'backward' {
|
|
311
|
+
if (startNode === endNode) return startOffset <= endOffset ? 'forward' : 'backward';
|
|
312
|
+
|
|
313
|
+
const anchorNode = selection.anchorNode;
|
|
314
|
+
const focusNode = selection.focusNode;
|
|
315
|
+
if (!anchorNode || !focusNode) {
|
|
316
|
+
const position = startNode.compareDocumentPosition(endNode);
|
|
317
|
+
return position & Node.DOCUMENT_POSITION_FOLLOWING ? 'forward' : 'backward';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const anchorContainer = this.findBestContainer(anchorNode);
|
|
321
|
+
const focusContainer = this.findBestContainer(focusNode);
|
|
322
|
+
if (anchorContainer && focusContainer) {
|
|
323
|
+
const startNodeId = startNode.getAttribute('data-bc-sid');
|
|
324
|
+
const endNodeId = endNode.getAttribute('data-bc-sid');
|
|
325
|
+
const anchorId = anchorContainer.getAttribute('data-bc-sid');
|
|
326
|
+
const focusId = focusContainer.getAttribute('data-bc-sid');
|
|
327
|
+
if (anchorId === startNodeId && focusId === endNodeId) return 'forward';
|
|
328
|
+
if (anchorId === endNodeId && focusId === startNodeId) return 'backward';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const position = startNode.compareDocumentPosition(endNode);
|
|
332
|
+
return position & Node.DOCUMENT_POSITION_FOLLOWING ? 'forward' : 'backward';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
convertModelSelectionToDOM(modelSelection: ModelSelection | null | undefined): void {
|
|
336
|
+
this._isProgrammaticChange = true;
|
|
337
|
+
try {
|
|
338
|
+
if (!modelSelection || modelSelection.type === 'none') {
|
|
339
|
+
window.getSelection()?.removeAllRanges();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (modelSelection.type === 'range') {
|
|
343
|
+
this.convertRangeSelectionToDOM(modelSelection);
|
|
344
|
+
} else if (modelSelection.type === 'node') {
|
|
345
|
+
this.convertNodeSelectionToDOM(modelSelection);
|
|
346
|
+
}
|
|
347
|
+
} finally {
|
|
348
|
+
setTimeout(() => {
|
|
349
|
+
this._isProgrammaticChange = false;
|
|
350
|
+
}, 0);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private convertRangeSelectionToDOM(rangeSelection: {
|
|
355
|
+
startNodeId: string;
|
|
356
|
+
startOffset: number;
|
|
357
|
+
endNodeId: string;
|
|
358
|
+
endOffset: number;
|
|
359
|
+
}): void {
|
|
360
|
+
const { startNodeId, startOffset, endNodeId, endOffset } = rangeSelection;
|
|
361
|
+
|
|
362
|
+
const startElementRaw = document.querySelector(`[data-bc-sid="${startNodeId}"]`);
|
|
363
|
+
const endElementRaw = document.querySelector(`[data-bc-sid="${endNodeId}"]`);
|
|
364
|
+
if (!startElementRaw || !endElementRaw) return;
|
|
365
|
+
|
|
366
|
+
const startElement = this.findBestContainer(startElementRaw);
|
|
367
|
+
const endElement = this.findBestContainer(endElementRaw);
|
|
368
|
+
if (!startElement || !endElement) return;
|
|
369
|
+
|
|
370
|
+
const startRuns = this.getTextRunsForContainer(startElement);
|
|
371
|
+
const endRuns = this.getTextRunsForContainer(endElement);
|
|
372
|
+
|
|
373
|
+
let startRange = startRuns?.runs?.length
|
|
374
|
+
? this.findDOMRangeFromModelOffset(startRuns, startOffset)
|
|
375
|
+
: null;
|
|
376
|
+
let endRange = endRuns?.runs?.length
|
|
377
|
+
? this.findDOMRangeFromModelOffset(endRuns, endOffset)
|
|
378
|
+
: null;
|
|
379
|
+
|
|
380
|
+
if (!startRange) {
|
|
381
|
+
startRange = { node: startElementRaw, offset: Math.min(startOffset, startElementRaw.childNodes.length) };
|
|
382
|
+
}
|
|
383
|
+
if (!endRange) {
|
|
384
|
+
endRange = { node: endElementRaw, offset: Math.min(endOffset, endElementRaw.childNodes.length) };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const selection = window.getSelection();
|
|
388
|
+
if (!selection) return;
|
|
389
|
+
|
|
390
|
+
selection.removeAllRanges();
|
|
391
|
+
const range = document.createRange();
|
|
392
|
+
range.setStart(startRange.node, startRange.offset);
|
|
393
|
+
range.setEnd(endRange.node, endRange.offset);
|
|
394
|
+
selection.addRange(range);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private convertNodeSelectionToDOM(nodeSelection: { nodeId: string }): void {
|
|
398
|
+
const element = document.querySelector(`[data-bc-sid="${nodeSelection.nodeId}"]`);
|
|
399
|
+
if (!element) return;
|
|
400
|
+
|
|
401
|
+
const selection = window.getSelection();
|
|
402
|
+
if (!selection) return;
|
|
403
|
+
|
|
404
|
+
selection.removeAllRanges();
|
|
405
|
+
const range = document.createRange();
|
|
406
|
+
range.selectNodeContents(element);
|
|
407
|
+
selection.addRange(range);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private getTextRunsForContainer(container: Element): ContainerRuns | null {
|
|
411
|
+
try {
|
|
412
|
+
const containerId = container.getAttribute('data-bc-sid');
|
|
413
|
+
return buildTextRunIndex(container, containerId ?? undefined, {
|
|
414
|
+
buildReverseMap: true,
|
|
415
|
+
excludePredicate: (el) =>
|
|
416
|
+
el.hasAttribute('data-decorator-sid') ||
|
|
417
|
+
el.hasAttribute('data-bc-decorator') ||
|
|
418
|
+
el.hasAttribute('data-decorator-category'),
|
|
419
|
+
normalizeWhitespace: false,
|
|
420
|
+
});
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private findDOMRangeFromModelOffset(
|
|
427
|
+
runs: ContainerRuns,
|
|
428
|
+
modelOffset: number
|
|
429
|
+
): { node: Node; offset: number } | null {
|
|
430
|
+
if (modelOffset < 0 || modelOffset > runs.total) return null;
|
|
431
|
+
|
|
432
|
+
if (modelOffset === runs.total) {
|
|
433
|
+
const lastRun = runs.runs[runs.runs.length - 1];
|
|
434
|
+
return {
|
|
435
|
+
node: lastRun.domTextNode,
|
|
436
|
+
offset: lastRun.domTextNode.textContent?.length ?? 0,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const runIndex = binarySearchRun(runs.runs, modelOffset);
|
|
441
|
+
if (runIndex === -1) return null;
|
|
442
|
+
|
|
443
|
+
const run = runs.runs[runIndex];
|
|
444
|
+
const localOffset = modelOffset - run.start;
|
|
445
|
+
return {
|
|
446
|
+
node: run.domTextNode,
|
|
447
|
+
offset: Math.min(localOffset, run.domTextNode.textContent?.length ?? 0),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Editor } from '@barocss/editor-core';
|
|
2
|
+
import type { RendererRegistry } from '@barocss/dsl';
|
|
3
|
+
import type {
|
|
4
|
+
Decorator,
|
|
5
|
+
DecoratorGenerator,
|
|
6
|
+
DecoratorManager,
|
|
7
|
+
RemoteDecoratorManager,
|
|
8
|
+
PatternDecoratorConfigManager,
|
|
9
|
+
DecoratorGeneratorManager,
|
|
10
|
+
DecoratorExportData,
|
|
11
|
+
LoadDecoratorsPatternFunctions,
|
|
12
|
+
DecoratorQueryOptions,
|
|
13
|
+
DecoratorTypeSchema,
|
|
14
|
+
} from '@barocss/shared';
|
|
15
|
+
|
|
16
|
+
export type { DecoratorExportData, LoadDecoratorsPatternFunctions, DecoratorQueryOptions, DecoratorTypeSchema };
|
|
17
|
+
|
|
18
|
+
/** Model selection type for convert* APIs. */
|
|
19
|
+
export type ModelSelection =
|
|
20
|
+
| { type: 'none' }
|
|
21
|
+
| {
|
|
22
|
+
type: 'range';
|
|
23
|
+
startNodeId: string;
|
|
24
|
+
startOffset: number;
|
|
25
|
+
endNodeId: string;
|
|
26
|
+
endOffset: number;
|
|
27
|
+
direction?: 'forward' | 'backward' | 'none';
|
|
28
|
+
}
|
|
29
|
+
| { type: 'node'; nodeId: string };
|
|
30
|
+
|
|
31
|
+
/** Imperative handle for EditorView: decorator management and selection/convenience APIs. */
|
|
32
|
+
export interface EditorViewHandle {
|
|
33
|
+
addDecorator(decorator: Decorator | DecoratorGenerator): void;
|
|
34
|
+
removeDecorator(id: string): void;
|
|
35
|
+
updateDecorator(id: string, updates: Partial<Decorator>): void;
|
|
36
|
+
getDecorators(options?: DecoratorQueryOptions): Decorator[];
|
|
37
|
+
getDecorator(id: string): Decorator | undefined;
|
|
38
|
+
exportDecorators(): DecoratorExportData;
|
|
39
|
+
loadDecorators(data: DecoratorExportData, patternFunctions?: LoadDecoratorsPatternFunctions): void;
|
|
40
|
+
/** Content-editable root element (null until mounted). */
|
|
41
|
+
contentEditableElement: HTMLElement | null;
|
|
42
|
+
convertModelSelectionToDOM(sel: ModelSelection | null | undefined): void;
|
|
43
|
+
convertDOMSelectionToModel(selection: Selection): ModelSelection;
|
|
44
|
+
/** Converts a StaticRange (e.g. from getRangeAt) to model selection, or null if not resolvable. */
|
|
45
|
+
convertStaticRangeToModel(staticRange: StaticRange): ModelSelection | null;
|
|
46
|
+
defineDecoratorType(type: string, category: 'layer' | 'inline' | 'block', schema: DecoratorTypeSchema): void;
|
|
47
|
+
decoratorManager: DecoratorManager | null;
|
|
48
|
+
remoteDecoratorManager: RemoteDecoratorManager | null;
|
|
49
|
+
patternDecoratorConfigManager: PatternDecoratorConfigManager | null;
|
|
50
|
+
decoratorGeneratorManager: DecoratorGeneratorManager | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type EditorViewLayerType = 'decorator' | 'selection' | 'context' | 'custom';
|
|
54
|
+
|
|
55
|
+
/** Options for the content layer (document rendering). Decorators are managed internally; use ref.addDecorator / ref.getDecorators. */
|
|
56
|
+
export interface EditorViewContentLayerOptions {
|
|
57
|
+
/** Renderer registry. If omitted, uses getGlobalRegistry(). */
|
|
58
|
+
registry?: RendererRegistry;
|
|
59
|
+
/** Class name for the contenteditable wrapper. */
|
|
60
|
+
className?: string;
|
|
61
|
+
/** Whether the content is editable. Default true. */
|
|
62
|
+
editable?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Options for overlay layers (decorator, selection, context, custom). */
|
|
66
|
+
export interface EditorViewLayerOptions {
|
|
67
|
+
/** Class name for the layer wrapper. */
|
|
68
|
+
className?: string;
|
|
69
|
+
/** Inline styles. */
|
|
70
|
+
style?: React.CSSProperties;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Layer configuration for EditorView (optional per-layer classNames/styles). */
|
|
74
|
+
export interface EditorViewLayersConfig {
|
|
75
|
+
content?: EditorViewContentLayerOptions;
|
|
76
|
+
decorator?: EditorViewLayerOptions;
|
|
77
|
+
selection?: EditorViewLayerOptions;
|
|
78
|
+
context?: EditorViewLayerOptions;
|
|
79
|
+
custom?: EditorViewLayerOptions;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface EditorViewOptions {
|
|
83
|
+
/** Renderer registry (used by content layer if layers.content not set). */
|
|
84
|
+
registry?: RendererRegistry;
|
|
85
|
+
/** Class name for the root container. */
|
|
86
|
+
className?: string;
|
|
87
|
+
/** Per-layer configuration. */
|
|
88
|
+
layers?: EditorViewLayersConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface EditorViewProps {
|
|
92
|
+
/** Editor instance. */
|
|
93
|
+
editor: Editor;
|
|
94
|
+
/** Optional options (registry, className, layers). */
|
|
95
|
+
options?: EditorViewOptions;
|
|
96
|
+
/** Optional children (e.g. custom layer content). Rendered inside the custom layer slot when present. */
|
|
97
|
+
children?: React.ReactNode;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** EditorView ref type (when using forwardRef). Exposes decorator API like editor-view-dom. */
|
|
101
|
+
export type EditorViewRef = EditorViewHandle;
|
|
102
|
+
|
|
103
|
+
export interface EditorViewContentLayerProps {
|
|
104
|
+
/** Options (registry, className, editable). Editor is taken from EditorViewContext only. */
|
|
105
|
+
options?: EditorViewContentLayerOptions;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface EditorViewLayerProps {
|
|
109
|
+
/** Layer type (data-bc-layer value). */
|
|
110
|
+
layer: EditorViewLayerType;
|
|
111
|
+
/** Optional className. */
|
|
112
|
+
className?: string;
|
|
113
|
+
/** Optional style. */
|
|
114
|
+
style?: React.CSSProperties;
|
|
115
|
+
children?: React.ReactNode;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Props for named overlay layers (DecoratorLayer, SelectionLayer, ContextLayer, CustomLayer). */
|
|
119
|
+
export interface EditorViewOverlayLayerProps {
|
|
120
|
+
className?: string;
|
|
121
|
+
style?: React.CSSProperties;
|
|
122
|
+
children?: React.ReactNode;
|
|
123
|
+
}
|