fix(writekit): a node selection is a real DOM range, not an absence of one
Publish to NPM / Check version changes and publish (push) Successful in 10m0s
Publish to NPM / Check version changes and publish (push) Successful in 10m0s
Selecting an atom used to clear every native range and leave the editable root focused with NO selection. The browser then invents a caret at the START of the content, `selectionchange` reads it, and the model's node selection is overwritten by a text caret in the first block — so the Enter meant to exit the freshly inserted atom split the opening paragraph, and typing replaced its text. Reproduced live within a minute of using the slash menu. - the bridge now writes a node selection as `range.selectNode(blockEl)`; the read path maps that range to null, so selectionchange keeps its hands off the model - beforeinput guards the node-selection state: browser edits through the element-wrapping range are prevented; delete falls through to deleteSelection, insertParagraph to exitAtom - browser regression test walks the exact race: select atom → DOM range exists → selectionchange rewrites nothing → Enter lands a paragraph below the atom writekit 0.0.4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/writekit",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT",
|
||||
"keywords": [
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PrimitiveProps } from './primitive';
|
||||
|
||||
<script setup lang="ts">
|
||||
import { blockById, caret, createNode, inlineLength, isCollapsed, nodeInline } from '../model';
|
||||
import { applyInputRule, deleteSelection, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
|
||||
import { applyInputRule, deleteSelection, exitAtom, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
|
||||
import { createTransaction } from '../state';
|
||||
import { Primitive } from './primitive';
|
||||
import { useWritekitContext } from './context';
|
||||
@@ -35,6 +35,20 @@ function onBeforeInput(event: InputEvent): void {
|
||||
if (!type.startsWith('insert') && !type.startsWith('delete'))
|
||||
return;
|
||||
|
||||
// With an atom selected the native range wraps the block element; letting the
|
||||
// browser edit through it would rewrite DOM the model never agreed to.
|
||||
const modelSel = ctx.writekit.state.selection;
|
||||
if (modelSel.kind === 'node') {
|
||||
event.preventDefault();
|
||||
|
||||
if (type.startsWith('delete'))
|
||||
ctx.writekit.command(deleteSelection);
|
||||
else if (type === 'insertParagraph')
|
||||
ctx.writekit.command(exitAtom);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = ctx.selection.read();
|
||||
if (!sel || sel.kind !== 'text')
|
||||
return;
|
||||
|
||||
@@ -188,3 +188,43 @@ describe('writing after a trailing atom', () => {
|
||||
expect(sel.kind === 'text' && sel.focus.offset).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node selection survives the DOM', () => {
|
||||
it('is represented as a real range, so selectionchange cannot overwrite it', async () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const writekit = createWritekit({
|
||||
state: createWritekitState({
|
||||
registry,
|
||||
doc: createDoc([para('a', 'first paragraph'), createNode('divider', { id: 'd' })]),
|
||||
}),
|
||||
});
|
||||
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
|
||||
await nextTick();
|
||||
|
||||
writekit.dispatch(createTransaction(writekit.state).setSelection({ kind: 'node', ids: ['d'] }));
|
||||
await nextTick();
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// The selection exists in the DOM as a range around the atom's element —
|
||||
// a focused editable with NO range makes the browser invent a caret at the
|
||||
// start of the content, which used to overwrite the model's node selection.
|
||||
const domSel = getSelection()!;
|
||||
expect(domSel.rangeCount).toBe(1);
|
||||
const selected = domSel.getRangeAt(0).cloneContents().querySelector('[data-block-id="d"]');
|
||||
expect(selected).not.toBeNull();
|
||||
|
||||
// A selectionchange pass over that range must NOT rewrite the model.
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
await nextTick();
|
||||
expect(writekit.state.selection).toEqual({ kind: 'node', ids: ['d'] });
|
||||
|
||||
// Enter on the selected atom exits into a fresh paragraph below it.
|
||||
const root = document.querySelector('[data-writekit-root]') as HTMLElement;
|
||||
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(writekit.state.doc.content.map(block => block.type)).toEqual(['paragraph', 'divider', 'paragraph']);
|
||||
const sel = writekit.state.selection;
|
||||
expect(sel.kind === 'text' && sel.focus.blockId).toBe(writekit.state.doc.content[2]!.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,10 +151,25 @@ export function createSelectionBridge(
|
||||
return;
|
||||
|
||||
if (selection.kind === 'node') {
|
||||
// Block-level selection has no native text range; the visual highlight
|
||||
// comes from [data-selected] on the block wrapper. Keep the editable root
|
||||
// focused so keyboard commands (Backspace/Delete on the node) still reach it.
|
||||
// The node selection must exist in the DOM too, as a range around the
|
||||
// block element. Merely clearing the ranges left a focused editable with
|
||||
// no selection — the browser then invents a caret at the START of the
|
||||
// content, `selectionchange` reads it, and the model's node selection
|
||||
// gets overwritten by a text caret in the first block (the Enter meant
|
||||
// for the atom split the opening paragraph instead).
|
||||
domSel.removeAllRanges();
|
||||
|
||||
const lastId = selection.ids.at(-1);
|
||||
const el = lastId === undefined
|
||||
? null
|
||||
: root.querySelector(`[data-block-id="${CSS.escape(lastId)}"]`);
|
||||
|
||||
if (el) {
|
||||
const range = root.ownerDocument.createRange();
|
||||
range.selectNode(el);
|
||||
domSel.addRange(range);
|
||||
}
|
||||
|
||||
if (root.isContentEditable && root.ownerDocument.activeElement !== root)
|
||||
root.focus({ preventScroll: true });
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user