feat(writekit): rename @robonen/editor to @robonen/writekit
Rename the rich-text editor package and all Editor* exports to Writekit*; remove the old vue/editor tree.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isInteractiveTarget } from '../view/interactive';
|
||||
|
||||
describe('isInteractiveTarget', () => {
|
||||
it('matches atom controls and contenteditable=false islands, not writekit text', () => {
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('contenteditable', 'true');
|
||||
root.innerHTML = '<p class="text">hi</p><figure contenteditable="false"><input class="cap"></figure>';
|
||||
document.body.append(root);
|
||||
|
||||
expect(isInteractiveTarget(root.querySelector('input.cap'))).toBe(true);
|
||||
expect(isInteractiveTarget(root.querySelector('figure'))).toBe(true);
|
||||
expect(isInteractiveTarget(root.querySelector('p.text'))).toBe(false);
|
||||
expect(isInteractiveTarget(root)).toBe(false);
|
||||
expect(isInteractiveTarget(null)).toBe(false);
|
||||
|
||||
root.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createBlockElementRegistry, createSelectionBridge } from '../view/selection';
|
||||
|
||||
/**
|
||||
* Builds the single-contenteditable DOM shape the view produces: one
|
||||
* `[data-writekit-content]` root containing plain `[data-block-content]` block
|
||||
* elements. Runs in jsdom (logic project) to prove the cross-block selection
|
||||
* mapping without a real browser.
|
||||
*/
|
||||
function buildDoc() {
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('data-writekit-content', '');
|
||||
|
||||
const a = document.createElement('p');
|
||||
a.setAttribute('data-block-content', '');
|
||||
a.setAttribute('data-block-id', 'a');
|
||||
a.textContent = 'hello';
|
||||
|
||||
const b = document.createElement('p');
|
||||
b.setAttribute('data-block-content', '');
|
||||
b.setAttribute('data-block-id', 'b');
|
||||
b.textContent = 'world';
|
||||
|
||||
root.append(a, b);
|
||||
document.body.replaceChildren(root);
|
||||
|
||||
const registry = createBlockElementRegistry();
|
||||
registry.set('a', a);
|
||||
registry.set('b', b);
|
||||
|
||||
return { root, a, b, registry };
|
||||
}
|
||||
|
||||
describe('selection bridge (jsdom)', () => {
|
||||
it('round-trips offset ↔ DOM point within a block', () => {
|
||||
const { root, a, registry } = buildDoc();
|
||||
const bridge = createSelectionBridge(() => root, registry);
|
||||
|
||||
const point = bridge.offsetToDomPoint(a, 3);
|
||||
expect(bridge.domPointToOffset(a, point.node, point.offset)).toBe(3);
|
||||
});
|
||||
|
||||
it('reads a cross-block native selection as a cross-block model range', () => {
|
||||
const { root, a, b, registry } = buildDoc();
|
||||
const bridge = createSelectionBridge(() => root, registry);
|
||||
|
||||
const sel = globalThis.getSelection!()!;
|
||||
sel.removeAllRanges();
|
||||
const range = document.createRange();
|
||||
range.setStart(a.firstChild!, 1);
|
||||
range.setEnd(b.firstChild!, 3);
|
||||
sel.addRange(range);
|
||||
|
||||
const model = bridge.read();
|
||||
expect(model?.kind).toBe('text');
|
||||
if (model?.kind === 'text') {
|
||||
expect(model.anchor).toEqual({ blockId: 'a', offset: 1 });
|
||||
expect(model.focus).toEqual({ blockId: 'b', offset: 3 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDefaultRegistry } from '../preset';
|
||||
import { getSlashItems } from '../view/ui';
|
||||
|
||||
describe('getSlashItems', () => {
|
||||
it('returns every block with meta when the query is empty', () => {
|
||||
const items = getSlashItems(createDefaultRegistry());
|
||||
const types = items.map(item => item.type);
|
||||
expect(types).toContain('heading');
|
||||
expect(types).toContain('image');
|
||||
expect(items.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('filters by title and keywords', () => {
|
||||
const registry = createDefaultRegistry();
|
||||
expect(getSlashItems(registry, 'quote').some(item => item.type === 'blockquote')).toBe(true);
|
||||
expect(getSlashItems(registry, 'h1').some(item => item.type === 'heading')).toBe(true);
|
||||
expect(getSlashItems(registry, 'zzzz')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockComponentProps } from '../registry';
|
||||
|
||||
defineProps<BlockComponentProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<hr data-writekit-divider="" />
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { BlockComponentProps } from '../registry';
|
||||
|
||||
const props = defineProps<BlockComponentProps>();
|
||||
|
||||
const src = computed(() => String(props.node.attrs['src'] ?? ''));
|
||||
const alt = computed(() => String(props.node.attrs['alt'] ?? ''));
|
||||
const caption = computed(() => String(props.node.attrs['caption'] ?? ''));
|
||||
const editing = computed(() => props.editable && props.selected);
|
||||
|
||||
function set(key: string, event: Event): void {
|
||||
props.update({ [key]: (event.target as HTMLInputElement).value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure data-writekit-image="" :data-selected="selected ? '' : undefined">
|
||||
<img v-if="src" :src="src" :alt="alt" draggable="false" />
|
||||
<div v-else class="image-placeholder">No image — add a URL below</div>
|
||||
|
||||
<figcaption v-if="caption && !editing">{{ caption }}</figcaption>
|
||||
|
||||
<div v-if="editing" class="image-fields" contenteditable="false">
|
||||
<input :value="src" placeholder="Image URL" @input="set('src', $event)" >
|
||||
<input :value="alt" placeholder="Alt text" @input="set('alt', $event)" >
|
||||
<input :value="caption" placeholder="Caption" @input="set('caption', $event)" >
|
||||
</div>
|
||||
</figure>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
|
||||
import { applyInputRule, joinBackward, splitBlock, toggleMark } from '../../commands';
|
||||
import { createDefaultRegistry } from '../../preset';
|
||||
import { createWritekit, createWritekitState } from '../../state';
|
||||
|
||||
function writekitWith(node: ReturnType<typeof createNode>, selection?: ReturnType<typeof caret>) {
|
||||
const registry = createDefaultRegistry();
|
||||
return createWritekit({ state: createWritekitState({ registry, doc: createDoc([node]), selection }) });
|
||||
}
|
||||
|
||||
describe('default registry', () => {
|
||||
it('registers the full block and mark set', () => {
|
||||
const registry = createDefaultRegistry();
|
||||
for (const type of ['paragraph', 'heading', 'blockquote', 'code-block', 'callout', 'bulleted-list', 'numbered-list', 'todo-list', 'divider', 'image'])
|
||||
expect(registry.hasBlock(type)).toBe(true);
|
||||
for (const mark of ['bold', 'italic', 'underline', 'strike', 'highlight', 'code', 'link'])
|
||||
expect(registry.hasMark(mark)).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the list blocks as group "list" and gives to-do a checked attr', () => {
|
||||
const registry = createDefaultRegistry();
|
||||
expect(registry.getBlock('bulleted-list')?.spec.group).toBe('list');
|
||||
expect(registry.getBlock('todo-list')?.spec.attrs?.['checked']).toBeDefined();
|
||||
});
|
||||
|
||||
it('inline code mark excludes all others', () => {
|
||||
expect(createDefaultRegistry().getMark('code')?.spec.excludes).toBe('_all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('code block', () => {
|
||||
it('Enter inserts a newline instead of splitting', () => {
|
||||
const writekit = writekitWith(createNode('code-block', { id: 'c', content: [{ text: 'ab', marks: [] }] }), caret('c', 2));
|
||||
expect(writekit.command(splitBlock)).toBe(true);
|
||||
expect(writekit.state.doc.content.length).toBe(1);
|
||||
expect(nodeText(writekit.state.doc.content[0]!)).toBe('ab\n');
|
||||
});
|
||||
|
||||
it('disallows inline marks', () => {
|
||||
const writekit = writekitWith(
|
||||
createNode('code-block', { id: 'c', content: [{ text: 'abc', marks: [] }] }),
|
||||
textSelection({ blockId: 'c', offset: 0 }, { blockId: 'c', offset: 3 }),
|
||||
);
|
||||
expect(writekit.command(toggleMark('bold'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not absorb disallowed marks when another block merges into it', () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const writekit = createWritekit({
|
||||
state: createWritekitState({
|
||||
registry,
|
||||
doc: createDoc([
|
||||
createNode('code-block', { id: 'c', content: [{ text: 'x', marks: [] }] }),
|
||||
createNode('paragraph', { id: 'p', content: [{ text: 'B', marks: [{ type: 'bold' }] }] }),
|
||||
]),
|
||||
selection: caret('p', 0),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(writekit.command(joinBackward)).toBe(true);
|
||||
const merged = writekit.state.doc.content[0]!;
|
||||
expect(merged.type).toBe('code-block');
|
||||
expect(nodeText(merged)).toBe('xB');
|
||||
expect(nodeInline(merged).every(run => run.marks.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input rules', () => {
|
||||
it('"# " converts a paragraph to a level-1 heading and strips the marker', () => {
|
||||
const writekit = writekitWith(createNode('paragraph', { id: 'p', content: [{ text: '# ', marks: [] }] }), caret('p', 2));
|
||||
expect(writekit.command(applyInputRule)).toBe(true);
|
||||
const block = writekit.state.doc.content[0]!;
|
||||
expect(block.type).toBe('heading');
|
||||
expect(block.attrs['level']).toBe(1);
|
||||
expect(nodeText(block)).toBe('');
|
||||
});
|
||||
|
||||
it('"- " converts a paragraph to a bulleted list', () => {
|
||||
const writekit = writekitWith(createNode('paragraph', { id: 'p', content: [{ text: '- ', marks: [] }] }), caret('p', 2));
|
||||
expect(writekit.command(applyInputRule)).toBe(true);
|
||||
expect(writekit.state.doc.content[0]!.type).toBe('bulleted-list');
|
||||
});
|
||||
|
||||
it('does not re-fire when the block is already the target type', () => {
|
||||
const writekit = writekitWith(createNode('blockquote', { id: 'q', content: [{ text: '> ', marks: [] }] }), caret('q', 2));
|
||||
expect(writekit.command(applyInputRule)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('to-do list', () => {
|
||||
it('starts a new item unchecked when splitting a checked one', () => {
|
||||
const writekit = writekitWith(
|
||||
createNode('todo-list', { id: 't', attrs: { checked: true, indent: 0 }, content: [{ text: 'done', marks: [] }] }),
|
||||
caret('t', 4),
|
||||
);
|
||||
|
||||
expect(writekit.command(splitBlock)).toBe(true);
|
||||
expect(writekit.state.doc.content.length).toBe(2);
|
||||
const created = writekit.state.doc.content[1]!;
|
||||
expect(created.type).toBe('todo-list');
|
||||
expect(created.attrs['checked']).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineBlock } from '../registry';
|
||||
|
||||
export const blockquote = defineBlock({
|
||||
type: 'blockquote',
|
||||
spec: {
|
||||
content: { kind: 'text' },
|
||||
group: 'block',
|
||||
toDOM: () => ['blockquote', 0],
|
||||
parseDOM: [{ tag: 'blockquote' }],
|
||||
},
|
||||
inputRules: [{ match: /^>\s$/ }],
|
||||
meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic' },
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Node } from '../model';
|
||||
import { defineBlock } from '../registry';
|
||||
|
||||
export const callout = defineBlock({
|
||||
type: 'callout',
|
||||
spec: {
|
||||
content: { kind: 'text' },
|
||||
group: 'block',
|
||||
attrs: { variant: { default: 'info' } },
|
||||
toDOM: (node: Node) => ['div', { 'data-callout': String(node.attrs['variant'] ?? 'info') }, 0],
|
||||
parseDOM: [{
|
||||
tag: 'div[data-callout]',
|
||||
getAttrs: (el: HTMLElement) => ({ variant: el.getAttribute('data-callout') ?? 'info' }),
|
||||
}],
|
||||
},
|
||||
meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic' },
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Node } from '../model';
|
||||
import { defineBlock } from '../registry';
|
||||
|
||||
export const codeBlock = defineBlock({
|
||||
type: 'code-block',
|
||||
spec: {
|
||||
content: { kind: 'text', marks: 'none' }, // raw text, no inline formatting
|
||||
group: 'block',
|
||||
code: true, // Enter inserts a newline instead of splitting
|
||||
defining: true,
|
||||
attrs: { language: { default: 'plain' } },
|
||||
toDOM: (node: Node) => ['pre', { 'data-language': String(node.attrs['language'] ?? 'plain') }, 0],
|
||||
parseDOM: [{ tag: 'pre' }],
|
||||
},
|
||||
meta: { title: 'Code block', icon: 'code', keywords: ['code', 'pre', 'snippet'], group: 'basic' },
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineBlock } from '../registry';
|
||||
import DividerBlock from './DividerBlock.vue';
|
||||
|
||||
export const divider = defineBlock({
|
||||
type: 'divider',
|
||||
spec: {
|
||||
content: { kind: 'atom' },
|
||||
group: 'block',
|
||||
toDOM: () => ['hr'],
|
||||
parseDOM: [{ tag: 'hr' }],
|
||||
},
|
||||
component: DividerBlock,
|
||||
meta: { title: 'Divider', icon: 'minus', keywords: ['divider', 'hr', 'rule', 'separator'], group: 'media' },
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineBlock } from '../registry';
|
||||
|
||||
const LEVELS = [1, 2, 3, 4, 5, 6] as const;
|
||||
|
||||
export const heading = defineBlock({
|
||||
type: 'heading',
|
||||
spec: {
|
||||
content: { kind: 'text' },
|
||||
group: 'block',
|
||||
attrs: {
|
||||
level: { default: 1, validate: value => typeof value === 'number' && value >= 1 && value <= 6 },
|
||||
},
|
||||
toDOM: node => [`h${node.attrs['level'] ?? 1}`, 0],
|
||||
parseDOM: LEVELS.map(level => ({ tag: `h${level}`, attrs: { level } })),
|
||||
},
|
||||
inputRules: LEVELS.map(level => ({ match: new RegExp(`^#{${level}}\\s$`), attrs: { level } })),
|
||||
meta: { title: 'Heading', icon: 'heading', keywords: ['heading', 'title', 'h1', 'h2', 'h3'], group: 'basic' },
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Node } from '../model';
|
||||
import { defineBlock } from '../registry';
|
||||
import ImageBlock from './ImageBlock.vue';
|
||||
|
||||
export const image = defineBlock({
|
||||
type: 'image',
|
||||
spec: {
|
||||
content: { kind: 'atom' },
|
||||
group: 'block',
|
||||
attrs: {
|
||||
src: { default: '' },
|
||||
alt: { default: '' },
|
||||
caption: { default: '' },
|
||||
},
|
||||
toDOM: (node: Node) => [
|
||||
'figure',
|
||||
['img', { src: String(node.attrs['src'] ?? ''), alt: String(node.attrs['alt'] ?? '') }],
|
||||
['figcaption', String(node.attrs['caption'] ?? '')],
|
||||
],
|
||||
parseDOM: [{
|
||||
tag: 'img',
|
||||
getAttrs: (el: HTMLElement) => ({ src: el.getAttribute('src') ?? '', alt: el.getAttribute('alt') ?? '' }),
|
||||
}],
|
||||
},
|
||||
component: ImageBlock,
|
||||
meta: { title: 'Image', icon: 'image', keywords: ['image', 'img', 'picture', 'photo'], group: 'media' },
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export { paragraph } from './paragraph';
|
||||
export { heading } from './heading';
|
||||
export { blockquote } from './blockquote';
|
||||
export { codeBlock } from './code-block';
|
||||
export { callout } from './callout';
|
||||
export { bulletedList, numberedList, todoList } from './list';
|
||||
export { divider } from './divider';
|
||||
export { image } from './image';
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Node } from '../model';
|
||||
import type { AttrsSpec } from '../schema';
|
||||
import { defineBlock } from '../registry';
|
||||
|
||||
type ListType = 'bullet' | 'ordered' | 'todo';
|
||||
|
||||
function indentOf(node: Node): number {
|
||||
return typeof node.attrs['indent'] === 'number' ? node.attrs['indent'] : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* DRY factory for the three list variants. Lists are **flat-with-indent**: each
|
||||
* item is its own top-level text block carrying an `indent` attribute (and
|
||||
* `checked` for to-dos). Markers/numbering and indentation are presentation
|
||||
* (CSS), so the model stays a simple flat block list that maps cleanly to a CRDT.
|
||||
*/
|
||||
function defineListBlock(options: { type: string; listType: ListType; title: string; keywords: readonly string[] }) {
|
||||
const todo = options.listType === 'todo';
|
||||
|
||||
const attrs: AttrsSpec = {
|
||||
indent: { default: 0 },
|
||||
...(todo ? { checked: { default: false } } : {}),
|
||||
};
|
||||
|
||||
const inputRules = options.listType === 'bullet'
|
||||
? [{ match: /^[-*]\s$/ }]
|
||||
: options.listType === 'ordered'
|
||||
? [{ match: /^\d+\.\s$/ }]
|
||||
: [{ match: /^\[\s?\]\s$/ }];
|
||||
|
||||
return defineBlock({
|
||||
type: options.type,
|
||||
spec: {
|
||||
content: { kind: 'text' },
|
||||
group: 'list',
|
||||
attrs,
|
||||
toDOM: (node: Node) => ['div', {
|
||||
'data-list': options.listType,
|
||||
// margin shifts the item per indent level; padding leaves a gutter for the marker.
|
||||
style: `margin-left:${indentOf(node) * 1.5}em;padding-left:1.5em`,
|
||||
...(todo ? { 'data-checked': node.attrs['checked'] ? 'true' : 'false' } : {}),
|
||||
}, 0],
|
||||
parseDOM: [{ tag: `[data-list='${options.listType}']` }],
|
||||
},
|
||||
inputRules,
|
||||
meta: { title: options.title, icon: 'list', keywords: options.keywords, group: 'lists' },
|
||||
});
|
||||
}
|
||||
|
||||
export const bulletedList = defineListBlock({ type: 'bulleted-list', listType: 'bullet', title: 'Bulleted list', keywords: ['ul', 'bullet', 'unordered', 'list'] });
|
||||
export const numberedList = defineListBlock({ type: 'numbered-list', listType: 'ordered', title: 'Numbered list', keywords: ['ol', 'number', 'ordered', 'list'] });
|
||||
export const todoList = defineListBlock({ type: 'todo-list', listType: 'todo', title: 'To-do list', keywords: ['todo', 'task', 'checkbox', 'check'] });
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineBlock } from '../registry';
|
||||
|
||||
export const paragraph = defineBlock({
|
||||
type: 'paragraph',
|
||||
spec: {
|
||||
content: { kind: 'text' },
|
||||
group: 'block',
|
||||
toDOM: () => ['p', 0],
|
||||
parseDOM: [{ tag: 'p' }],
|
||||
},
|
||||
placeholder: 'Write something…',
|
||||
meta: { title: 'Paragraph', icon: 'text', keywords: ['paragraph', 'text', 'p'], group: 'basic' },
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
|
||||
import { createDefaultRegistry } from '../../preset';
|
||||
import { createWritekit, createWritekitState } from '../../state';
|
||||
import { joinBackward, splitBlock, toggleMark } from '..';
|
||||
|
||||
function para(id: string, text: string) {
|
||||
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
|
||||
}
|
||||
|
||||
function writekitWith(blocks: Array<ReturnType<typeof para>>, selection?: ReturnType<typeof caret>) {
|
||||
const registry = createDefaultRegistry();
|
||||
return createWritekit({ state: createWritekitState({ registry, doc: createDoc(blocks), selection }) });
|
||||
}
|
||||
|
||||
describe('commands', () => {
|
||||
it('toggleMark applies then removes bold on a range', () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const writekit = createWritekit({
|
||||
state: createWritekitState({
|
||||
registry,
|
||||
doc: createDoc([para('a', 'abc')]),
|
||||
selection: textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 3 }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(writekit.command(toggleMark('bold'))).toBe(true);
|
||||
expect(nodeInline(writekit.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [{ type: 'bold' }] }]);
|
||||
|
||||
writekit.command(toggleMark('bold'));
|
||||
expect(nodeInline(writekit.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [] }]);
|
||||
});
|
||||
|
||||
it('splitBlock splits at the caret', () => {
|
||||
const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
|
||||
expect(writekit.command(splitBlock)).toBe(true);
|
||||
expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['he', 'llo']);
|
||||
expect(writekit.state.selection.kind).toBe('text');
|
||||
});
|
||||
|
||||
it('joinBackward merges into the previous block', () => {
|
||||
const writekit = writekitWith([para('a', 'foo'), para('b', 'bar')], caret('b', 0));
|
||||
expect(writekit.command(joinBackward)).toBe(true);
|
||||
expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']);
|
||||
});
|
||||
|
||||
it('undo restores the document after a split', () => {
|
||||
const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
|
||||
writekit.command(splitBlock);
|
||||
expect(writekit.state.doc.content.length).toBe(2);
|
||||
expect(writekit.undo()).toBe(true);
|
||||
expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['hello']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Attrs } from '../model';
|
||||
import { blockById, blockIndex } from '../model';
|
||||
import type { Command } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
import { focusBlock, isBlockActive, selectionBlockId } from './util';
|
||||
|
||||
/** Convert the focused block to `type` (preserving inline content). */
|
||||
export function setBlockType(type: string, attrs?: Attrs): Command {
|
||||
return (state, dispatch) => {
|
||||
const blockId = selectionBlockId(state);
|
||||
|
||||
if (!blockId || !state.registry.hasBlock(type))
|
||||
return false;
|
||||
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).setBlockType(blockId, type, attrs).setSelection(state.selection));
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the focused block between `type` (with `attrs`) and a fallback type
|
||||
* (default `paragraph`). Powers heading shortcuts and conversion toggles.
|
||||
*/
|
||||
export function toggleBlockType(type: string, attrs?: Attrs, fallback = 'paragraph'): Command {
|
||||
return (state, dispatch) => {
|
||||
const blockId = selectionBlockId(state);
|
||||
|
||||
if (!blockId)
|
||||
return false;
|
||||
|
||||
const active = isBlockActive(state, type, attrs);
|
||||
const target = active ? fallback : type;
|
||||
const targetAttrs = active ? undefined : attrs;
|
||||
|
||||
if (!state.registry.hasBlock(target))
|
||||
return false;
|
||||
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).setBlockType(blockId, target, targetAttrs).setSelection(state.selection));
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function moveFocusedBlock(delta: number): Command {
|
||||
return (state, dispatch) => {
|
||||
const block = focusBlock(state);
|
||||
|
||||
if (!block)
|
||||
return false;
|
||||
|
||||
const index = blockIndex(state.doc, block.id);
|
||||
const target = index + delta;
|
||||
|
||||
if (index === -1 || target < 0 || target >= state.doc.content.length)
|
||||
return false;
|
||||
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).moveBlock(block.id, target).setSelection(state.selection));
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** Move the focused block one position earlier. */
|
||||
export const moveBlockUp: Command = moveFocusedBlock(-1);
|
||||
|
||||
/** Move the focused block one position later. */
|
||||
export const moveBlockDown: Command = moveFocusedBlock(1);
|
||||
|
||||
/** Indent a list item by raising its `indent` attr (lists only). */
|
||||
export const indentListItem: Command = (state, dispatch) => {
|
||||
const block = focusBlock(state);
|
||||
|
||||
if (!block || state.schema.nodeSpec(block.type)?.group !== 'list')
|
||||
return false;
|
||||
|
||||
const indent = typeof block.attrs.indent === 'number' ? block.attrs.indent : 0;
|
||||
|
||||
if (indent >= 8)
|
||||
return false;
|
||||
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).setAttrs(block.id, { indent: indent + 1 }).setSelection(state.selection));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Outdent a list item by lowering its `indent` attr (lists only). */
|
||||
export const outdentListItem: Command = (state, dispatch) => {
|
||||
const block = focusBlock(state);
|
||||
|
||||
if (!block || state.schema.nodeSpec(block.type)?.group !== 'list')
|
||||
return false;
|
||||
|
||||
const indent = typeof block.attrs.indent === 'number' ? block.attrs.indent : 0;
|
||||
|
||||
if (indent <= 0)
|
||||
return false;
|
||||
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).setAttrs(block.id, { indent: indent - 1 }).setSelection(state.selection));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Toggle the `checked` attribute of the focused to-do item. */
|
||||
export const toggleChecked: Command = (state, dispatch) => {
|
||||
const block = focusBlock(state);
|
||||
|
||||
if (!block || !('checked' in block.attrs))
|
||||
return false;
|
||||
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).setAttrs(block.id, { checked: !block.attrs['checked'] }).setSelection(state.selection));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Delete a specific block by id (used by atom-block UIs). */
|
||||
export function removeBlock(blockId: string): Command {
|
||||
return (state, dispatch) => {
|
||||
if (!blockById(state.doc, blockId))
|
||||
return false;
|
||||
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).removeBlock(blockId));
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Command } from '../state';
|
||||
|
||||
/**
|
||||
* Combine commands into one that runs them in order and stops at the first that
|
||||
* applies (returns `true`). The standard way to bind several fallbacks to a key.
|
||||
*/
|
||||
export function chainCommands(...commands: readonly Command[]): Command {
|
||||
return (state, dispatch, view) => {
|
||||
for (const command of commands) {
|
||||
if (command(state, dispatch, view))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './util';
|
||||
export * from './chain';
|
||||
export * from './marks';
|
||||
export * from './structure';
|
||||
export * from './blocks';
|
||||
export * from './selection';
|
||||
export * from './input-rules';
|
||||
@@ -0,0 +1,48 @@
|
||||
import { blockById, caret, inlineText, isCollapsed, nodeInline } from '../model';
|
||||
import type { Command } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
|
||||
/**
|
||||
* Apply the first matching block input-rule at the caret. Rules live on block
|
||||
* definitions (`inputRules`) and match the text from the block start to the
|
||||
* caret — e.g. `'# '` → heading, `'- '` → bulleted list, `'> '` → quote. Run
|
||||
* from the input flow after each text change.
|
||||
*/
|
||||
export const applyInputRule: Command = (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind !== 'text' || !isCollapsed(sel))
|
||||
return false;
|
||||
|
||||
const block = blockById(state.doc, sel.focus.blockId);
|
||||
const spec = block && state.schema.nodeSpec(block.type);
|
||||
|
||||
if (!block || spec?.content.kind !== 'text' || spec.code)
|
||||
return false;
|
||||
|
||||
const before = inlineText(nodeInline(block)).slice(0, sel.focus.offset);
|
||||
|
||||
for (const def of state.registry.listBlocks()) {
|
||||
for (const rule of def.inputRules ?? []) {
|
||||
const match = rule.match.exec(before);
|
||||
|
||||
if (!match)
|
||||
continue;
|
||||
|
||||
const targetType = rule.type ?? def.type;
|
||||
if (block.type === targetType)
|
||||
return false; // already that type — leave the typed text alone
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(createTransaction(state)
|
||||
.deleteText(block.id, 0, match[0].length)
|
||||
.setBlockType(block.id, targetType, rule.attrs ?? state.schema.defaultAttrs(targetType))
|
||||
.setSelection(caret(block.id, 0)));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Attrs, Mark } from '../model';
|
||||
import { blockById, isCollapsed, marksAt, nodeInline, normalizeMarks, orderedSelection, rangeHasMarkType } from '../model';
|
||||
import { marksAllowed } from '../schema';
|
||||
import type { Command, WritekitState } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
|
||||
/** Whether the focused block permits a mark of `type` (false for code blocks, etc.). */
|
||||
function markAllowedAtFocus(state: WritekitState, type: string): boolean {
|
||||
if (state.selection.kind !== 'text')
|
||||
return false;
|
||||
|
||||
const block = blockById(state.doc, state.selection.focus.blockId);
|
||||
const spec = block && state.schema.nodeSpec(block.type);
|
||||
return spec ? marksAllowed(spec, type) : false;
|
||||
}
|
||||
|
||||
function excludedTypes(state: Parameters<Command>[0], type: string): readonly string[] {
|
||||
const excludes = state.schema.markSpec(type)?.excludes;
|
||||
|
||||
if (excludes === '_all')
|
||||
return state.registry.listMarks().map(def => def.type).filter(other => other !== type);
|
||||
|
||||
return excludes ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a mark. On a collapsed caret it flips the stored marks (applied to the
|
||||
* next typed character); on a range it adds/removes the mark across it, honoring
|
||||
* the mark's `excludes`. Cross-block ranges are deferred to M2 (returns false).
|
||||
*/
|
||||
export function toggleMark(type: string, attrs?: Attrs): Command {
|
||||
return (state, dispatch) => {
|
||||
if (!state.registry.hasMark(type) || !markAllowedAtFocus(state, type))
|
||||
return false;
|
||||
|
||||
const sel = state.selection;
|
||||
if (sel.kind !== 'text')
|
||||
return false;
|
||||
|
||||
const mark: Mark = attrs ? { type, attrs } : { type };
|
||||
|
||||
if (isCollapsed(sel)) {
|
||||
if (dispatch) {
|
||||
const block = blockById(state.doc, sel.focus.blockId);
|
||||
const current = state.storedMarks ?? (block ? marksAt(nodeInline(block), sel.focus.offset) : []);
|
||||
const has = current.some(m => m.type === type);
|
||||
const next = has
|
||||
? current.filter(m => m.type !== type)
|
||||
: normalizeMarks([...current.filter(m => !excludedTypes(state, type).includes(m.type)), mark]);
|
||||
dispatch(createTransaction(state).setStoredMarks(next));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sel.anchor.blockId !== sel.focus.blockId)
|
||||
return false;
|
||||
|
||||
const block = blockById(state.doc, sel.focus.blockId);
|
||||
if (!block)
|
||||
return false;
|
||||
|
||||
const { from, to } = orderedSelection(sel, state.doc);
|
||||
const active = rangeHasMarkType(nodeInline(block), from.offset, to.offset, type);
|
||||
|
||||
if (dispatch) {
|
||||
const tr = createTransaction(state);
|
||||
|
||||
if (active) {
|
||||
tr.removeMark(block.id, from.offset, to.offset, mark);
|
||||
}
|
||||
else {
|
||||
for (const ex of excludedTypes(state, type))
|
||||
tr.removeMark(block.id, from.offset, to.offset, { type: ex });
|
||||
tr.addMark(block.id, from.offset, to.offset, mark);
|
||||
}
|
||||
|
||||
tr.setSelection(sel);
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** Add a mark across the current (same-block) range. */
|
||||
export function addMark(type: string, attrs?: Attrs): Command {
|
||||
return (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (!state.registry.hasMark(type) || !markAllowedAtFocus(state, type) || sel.kind !== 'text' || isCollapsed(sel) || sel.anchor.blockId !== sel.focus.blockId)
|
||||
return false;
|
||||
|
||||
if (dispatch) {
|
||||
const { from, to } = orderedSelection(sel, state.doc);
|
||||
dispatch(createTransaction(state)
|
||||
.addMark(from.blockId, from.offset, to.offset, attrs ? { type, attrs } : { type })
|
||||
.setSelection(sel));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove a mark across the current (same-block) range. */
|
||||
export function removeMark(type: string): Command {
|
||||
return (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind !== 'text' || isCollapsed(sel) || sel.anchor.blockId !== sel.focus.blockId)
|
||||
return false;
|
||||
|
||||
if (dispatch) {
|
||||
const { from, to } = orderedSelection(sel, state.doc);
|
||||
dispatch(createTransaction(state)
|
||||
.removeMark(from.blockId, from.offset, to.offset, { type })
|
||||
.setSelection(sel));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
blockById,
|
||||
blockIndex,
|
||||
caret,
|
||||
createNode,
|
||||
inlineLength,
|
||||
isAcrossBlocks,
|
||||
isCollapsed,
|
||||
nodeInline,
|
||||
nodeSelection,
|
||||
orderedSelection,
|
||||
previousBlock,
|
||||
textSelection,
|
||||
} from '../model';
|
||||
import type { Command, WritekitState } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
|
||||
function defaultTextType(state: WritekitState): string {
|
||||
if (state.registry.hasBlock('paragraph'))
|
||||
return 'paragraph';
|
||||
|
||||
for (const def of state.registry.listBlocks()) {
|
||||
if (def.spec.content.kind === 'text')
|
||||
return def.type;
|
||||
}
|
||||
|
||||
return state.registry.listBlocks()[0]?.type ?? 'paragraph';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current selection. Handles a node (block-level) selection, a
|
||||
* same-block range, and a cross-block range (delete the partial ends, drop the
|
||||
* blocks in between, merge the last block into the first). Never leaves an empty
|
||||
* document — a fresh paragraph is inserted if everything was removed.
|
||||
*/
|
||||
export const deleteSelection: Command = (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind === 'node') {
|
||||
if (sel.ids.length === 0)
|
||||
return false;
|
||||
|
||||
if (dispatch) {
|
||||
const before = previousBlock(state.doc, sel.ids[0]!);
|
||||
const tr = createTransaction(state);
|
||||
|
||||
for (const id of sel.ids)
|
||||
tr.removeBlock(id);
|
||||
|
||||
if (tr.doc.content.length === 0) {
|
||||
const type = defaultTextType(state);
|
||||
const node = createNode(type, { attrs: state.schema.defaultAttrs(type) });
|
||||
tr.insertBlock(node, 0).setSelection(caret(node.id, 0));
|
||||
}
|
||||
else if (before) {
|
||||
tr.setSelection(caret(before.id, inlineLength(nodeInline(before))));
|
||||
}
|
||||
else {
|
||||
tr.setSelection(caret(tr.doc.content[0]!.id, 0));
|
||||
}
|
||||
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isCollapsed(sel))
|
||||
return false;
|
||||
|
||||
const { from, to } = orderedSelection(sel, state.doc);
|
||||
|
||||
if (!isAcrossBlocks(sel)) {
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).deleteText(from.blockId, from.offset, to.offset).setSelection(caret(from.blockId, from.offset)));
|
||||
return true;
|
||||
}
|
||||
|
||||
const a = blockById(state.doc, from.blockId);
|
||||
const b = blockById(state.doc, to.blockId);
|
||||
|
||||
if (!a || !b || state.schema.nodeSpec(a.type)?.content.kind !== 'text' || state.schema.nodeSpec(b.type)?.content.kind !== 'text')
|
||||
return false;
|
||||
|
||||
if (dispatch) {
|
||||
const tr = createTransaction(state);
|
||||
tr.deleteText(a.id, from.offset, inlineLength(nodeInline(a)));
|
||||
tr.deleteText(b.id, 0, to.offset);
|
||||
|
||||
const ai = blockIndex(state.doc, a.id);
|
||||
const bi = blockIndex(state.doc, b.id);
|
||||
for (const mid of state.doc.content.slice(ai + 1, bi))
|
||||
tr.removeBlock(mid.id);
|
||||
|
||||
tr.mergeBlock(b.id, a.id).setSelection(caret(a.id, from.offset));
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Progressive select-all (Mod+A): first press selects the current block's text,
|
||||
* a second press selects every block.
|
||||
*/
|
||||
export const selectAll: Command = (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind === 'text' && !isAcrossBlocks(sel)) {
|
||||
const block = blockById(state.doc, sel.focus.blockId);
|
||||
|
||||
if (block) {
|
||||
const length = inlineLength(nodeInline(block));
|
||||
const { from, to } = orderedSelection(sel, state.doc);
|
||||
const wholeBlock = from.offset === 0 && to.offset === length;
|
||||
|
||||
if (!wholeBlock && length > 0) {
|
||||
if (dispatch) {
|
||||
dispatch(createTransaction(state).setSelection(
|
||||
textSelection({ blockId: block.id, offset: 0 }, { blockId: block.id, offset: length }),
|
||||
));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.doc.content.length === 0)
|
||||
return false;
|
||||
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).setSelection(nodeSelection(state.doc.content.map(block => block.id))));
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Attrs, Node } from '../model';
|
||||
import {
|
||||
blockById,
|
||||
caret,
|
||||
inlineLength,
|
||||
isAcrossBlocks,
|
||||
isCollapsed,
|
||||
nextBlock,
|
||||
nodeInline,
|
||||
nodeSelection,
|
||||
orderedSelection,
|
||||
previousBlock,
|
||||
} from '../model';
|
||||
import type { Command, WritekitState } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
|
||||
/** Type/attrs for the block created when splitting `block` at `offset`. */
|
||||
function continuation(state: WritekitState, block: Node, offset: number): { type?: string; attrs?: Attrs } {
|
||||
const spec = state.schema.nodeSpec(block.type);
|
||||
|
||||
// Defining blocks (e.g. code-block) keep their identity across a split.
|
||||
if (spec?.defining)
|
||||
return { type: block.type, attrs: block.attrs };
|
||||
|
||||
// Pressing Enter at the end of a heading starts a fresh paragraph.
|
||||
if (block.type === 'heading' && offset >= inlineLength(nodeInline(block)) && state.registry.hasBlock('paragraph'))
|
||||
return { type: 'paragraph' };
|
||||
|
||||
// A new to-do item always starts unchecked (don't inherit the checked state).
|
||||
if (block.type === 'todo-list')
|
||||
return { type: block.type, attrs: { ...block.attrs, checked: false } };
|
||||
|
||||
// Otherwise continue the same type (lists keep their indent/listType attrs).
|
||||
return { type: block.type, attrs: block.attrs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the current text block at the caret (Enter). A non-collapsed same-block
|
||||
* selection is deleted first. Caret lands at the start of the new block.
|
||||
*/
|
||||
export const splitBlock: Command = (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind !== 'text' || isAcrossBlocks(sel))
|
||||
return false;
|
||||
|
||||
const block = blockById(state.doc, sel.focus.blockId);
|
||||
const spec = block && state.schema.nodeSpec(block.type);
|
||||
|
||||
if (!block || spec?.content.kind !== 'text')
|
||||
return false;
|
||||
|
||||
// Code blocks never split — Enter inserts a literal newline.
|
||||
if (spec.code) {
|
||||
if (dispatch) {
|
||||
const tr = createTransaction(state);
|
||||
let pos = sel.focus;
|
||||
|
||||
if (!isCollapsed(sel)) {
|
||||
const { from, to } = orderedSelection(sel, state.doc);
|
||||
tr.deleteText(block.id, from.offset, to.offset);
|
||||
pos = from;
|
||||
}
|
||||
|
||||
tr.insertText(pos, '\n', []).setSelection(caret(block.id, pos.offset + 1));
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
const tr = createTransaction(state);
|
||||
let pos = sel.focus;
|
||||
|
||||
if (!isCollapsed(sel)) {
|
||||
const { from, to } = orderedSelection(sel, state.doc);
|
||||
tr.deleteText(block.id, from.offset, to.offset);
|
||||
pos = from;
|
||||
}
|
||||
|
||||
const cont = continuation(state, block, pos.offset);
|
||||
tr.splitBlock(pos, cont.type, cont.attrs);
|
||||
tr.setSelection(caret(tr.lastSplitId!, 0));
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Insert a hard line break (Shift+Enter) inside the current block. */
|
||||
export const insertHardBreak: Command = (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind !== 'text' || !isCollapsed(sel))
|
||||
return false;
|
||||
|
||||
const block = blockById(state.doc, sel.focus.blockId);
|
||||
const spec = block && state.schema.nodeSpec(block.type);
|
||||
|
||||
if (!block || spec?.content.kind !== 'text')
|
||||
return false;
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(createTransaction(state)
|
||||
.insertText(sel.focus, '\n', state.storedMarks ?? [])
|
||||
.setSelection(caret(block.id, sel.focus.offset + 1)));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Backspace at the start of a block: merge it into the previous text block, or
|
||||
* select a preceding atom block (image/divider) so a second Backspace deletes it.
|
||||
*/
|
||||
export const joinBackward: Command = (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind !== 'text' || !isCollapsed(sel) || sel.focus.offset !== 0)
|
||||
return false;
|
||||
|
||||
const current = blockById(state.doc, sel.focus.blockId);
|
||||
const prev = previousBlock(state.doc, sel.focus.blockId);
|
||||
|
||||
if (!current || !prev)
|
||||
return false;
|
||||
|
||||
const currentSpec = state.schema.nodeSpec(current.type);
|
||||
const prevSpec = state.schema.nodeSpec(prev.type);
|
||||
|
||||
if (currentSpec?.isolating || prevSpec?.isolating)
|
||||
return false;
|
||||
|
||||
if (prevSpec?.content.kind !== 'text') {
|
||||
if (dispatch)
|
||||
dispatch(createTransaction(state).setSelection(nodeSelection([prev.id])));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentSpec?.content.kind !== 'text')
|
||||
return false;
|
||||
|
||||
if (dispatch) {
|
||||
const caretOffset = inlineLength(nodeInline(prev));
|
||||
dispatch(createTransaction(state).mergeBlock(current.id, prev.id).setSelection(caret(prev.id, caretOffset)));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Delete at the end of a block: merge the next text block into it. */
|
||||
export const joinForward: Command = (state, dispatch) => {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind !== 'text' || !isCollapsed(sel))
|
||||
return false;
|
||||
|
||||
const current = blockById(state.doc, sel.focus.blockId);
|
||||
|
||||
if (!current || sel.focus.offset !== inlineLength(nodeInline(current)))
|
||||
return false;
|
||||
|
||||
const next = nextBlock(state.doc, current.id);
|
||||
if (!next)
|
||||
return false;
|
||||
|
||||
const currentSpec = state.schema.nodeSpec(current.type);
|
||||
const nextSpec = state.schema.nodeSpec(next.type);
|
||||
|
||||
if (currentSpec?.isolating || nextSpec?.isolating || currentSpec?.content.kind !== 'text' || nextSpec?.content.kind !== 'text')
|
||||
return false;
|
||||
|
||||
if (dispatch) {
|
||||
const caretOffset = inlineLength(nodeInline(current));
|
||||
dispatch(createTransaction(state).mergeBlock(next.id, current.id).setSelection(caret(current.id, caretOffset)));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Attrs, Node } from '../model';
|
||||
import { blockById, isCollapsed, marksAt, nodeInline, orderedSelection, rangeHasMarkType } from '../model';
|
||||
import type { WritekitState } from '../state';
|
||||
|
||||
/** Block id the selection's focus is in (or the first node-selected block). */
|
||||
export function selectionBlockId(state: WritekitState): string | undefined {
|
||||
const sel = state.selection;
|
||||
return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
|
||||
}
|
||||
|
||||
/** The block the selection currently focuses, or `null`. */
|
||||
export function focusBlock(state: WritekitState): Node | null {
|
||||
const id = selectionBlockId(state);
|
||||
return id ? blockById(state.doc, id) : null;
|
||||
}
|
||||
|
||||
/** Whether a block type holds inline (text) content. */
|
||||
export function isTextBlockType(state: WritekitState, type: string): boolean {
|
||||
return state.schema.nodeSpec(type)?.content.kind === 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a mark is active for the current selection — used by `toggleMark` and
|
||||
* by toolbars (call a command without `dispatch` for the same answer).
|
||||
*/
|
||||
export function isMarkActive(state: WritekitState, type: string): boolean {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind !== 'text')
|
||||
return false;
|
||||
|
||||
if (isCollapsed(sel)) {
|
||||
if (state.storedMarks)
|
||||
return state.storedMarks.some(mark => mark.type === type);
|
||||
|
||||
const block = blockById(state.doc, sel.focus.blockId);
|
||||
return block ? marksAt(nodeInline(block), sel.focus.offset).some(mark => mark.type === type) : false;
|
||||
}
|
||||
|
||||
if (sel.anchor.blockId !== sel.focus.blockId)
|
||||
return false;
|
||||
|
||||
const { from, to } = orderedSelection(sel, state.doc);
|
||||
const block = blockById(state.doc, sel.focus.blockId);
|
||||
return block ? rangeHasMarkType(nodeInline(block), from.offset, to.offset, type) : false;
|
||||
}
|
||||
|
||||
/** Whether the focused block matches a type (and optionally a subset of attrs). */
|
||||
export function isBlockActive(state: WritekitState, type: string, attrs?: Attrs): boolean {
|
||||
const block = focusBlock(state);
|
||||
|
||||
if (!block || block.type !== type)
|
||||
return false;
|
||||
|
||||
if (!attrs)
|
||||
return true;
|
||||
|
||||
return Object.keys(attrs).every(key => block.attrs[key] === attrs[key]);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { caret, createDoc, createNode, nodeSelection, nodeText } from '../../model';
|
||||
import { deleteSelection } from '../../commands';
|
||||
import { createDefaultRegistry } from '../../preset';
|
||||
import { createTransaction, createWritekit, createWritekitState } from '../../state';
|
||||
import { bindCrdt } from '../binding';
|
||||
import type { RemoteCursor } from '../types';
|
||||
import { createNativeProvider } from '../native/provider';
|
||||
|
||||
function makePeer(seedDoc?: ReturnType<typeof createDoc>) {
|
||||
const registry = createDefaultRegistry();
|
||||
const writekit = createWritekit({ state: createWritekitState({ registry, doc: seedDoc }) });
|
||||
const provider = createNativeProvider({ schema: registry.schema, doc: seedDoc ? writekit.state.doc : undefined });
|
||||
bindCrdt(writekit, provider);
|
||||
return { writekit, provider };
|
||||
}
|
||||
|
||||
/** Live two-way, in-memory transport between two providers. */
|
||||
function connect(a: ReturnType<typeof makePeer>, b: ReturnType<typeof makePeer>) {
|
||||
a.provider.onLocalOps(bytes => b.provider.applyUpdate(bytes));
|
||||
b.provider.onLocalOps(bytes => a.provider.applyUpdate(bytes));
|
||||
}
|
||||
|
||||
function text(peer: ReturnType<typeof makePeer>): string {
|
||||
return peer.writekit.state.doc.content.map(block => nodeText(block)).join('\n');
|
||||
}
|
||||
|
||||
describe('crdt convergence (two writekits)', () => {
|
||||
it('a joining peer syncs the initial document, then concurrent edits converge', () => {
|
||||
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello', marks: [] }] })]);
|
||||
|
||||
const a = makePeer(doc);
|
||||
const b = makePeer(); // empty; joins by syncing A's full state
|
||||
b.provider.applyUpdate(a.provider.encodeDelta());
|
||||
|
||||
expect(text(b)).toBe('Hello');
|
||||
|
||||
connect(a, b);
|
||||
|
||||
// Concurrent edits at opposite ends of the same block.
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'p', offset: 5 }, '!', []).setSelection(caret('p', 6)));
|
||||
b.writekit.dispatch(createTransaction(b.writekit.state).insertText({ blockId: 'p', offset: 0 }, '>', []).setSelection(caret('p', 1)));
|
||||
|
||||
expect(text(a)).toBe(text(b));
|
||||
expect(text(a)).toBe('>Hello!');
|
||||
});
|
||||
|
||||
it('keeps offsets aligned for astral characters (emoji) across replicas', () => {
|
||||
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'a👍b', marks: [] }] })]);
|
||||
const a = makePeer(doc);
|
||||
const b = makePeer();
|
||||
b.provider.applyUpdate(a.provider.encodeDelta());
|
||||
connect(a, b);
|
||||
|
||||
// 'b' sits at UTF-16 offset 3 (the emoji occupies offsets 1..3).
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).deleteText('p', 3, 4).setSelection(caret('p', 3)));
|
||||
|
||||
expect(text(a)).toBe('a👍');
|
||||
expect(text(b)).toBe('a👍');
|
||||
});
|
||||
|
||||
it('undo of a select-all delete does not duplicate content on the other replica', () => {
|
||||
const doc = createDoc([
|
||||
createNode('paragraph', { id: 'a', content: [{ text: 'AAA', marks: [] }] }),
|
||||
createNode('paragraph', { id: 'b', content: [{ text: 'BBB', marks: [] }] }),
|
||||
]);
|
||||
const a = makePeer(doc);
|
||||
const b = makePeer();
|
||||
b.provider.applyUpdate(a.provider.encodeDelta());
|
||||
connect(a, b);
|
||||
expect(text(b)).toBe('AAA\nBBB');
|
||||
|
||||
// Select every block and delete (inserts one fresh empty paragraph).
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).setSelection(nodeSelection(['a', 'b'])));
|
||||
expect(a.writekit.command(deleteSelection)).toBe(true);
|
||||
expect(text(a)).toBe('');
|
||||
|
||||
// Undo must restore the blocks without duplicating them on either replica.
|
||||
expect(a.writekit.undo()).toBe(true);
|
||||
expect(text(a)).toBe('AAA\nBBB');
|
||||
expect(text(b)).toBe('AAA\nBBB');
|
||||
});
|
||||
|
||||
it('awareness: a remote cursor anchor stays on its character when text is inserted before it', () => {
|
||||
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello', marks: [] }] })]);
|
||||
const a = makePeer(doc);
|
||||
const b = makePeer();
|
||||
b.provider.applyUpdate(a.provider.encodeDelta());
|
||||
|
||||
let cursors: RemoteCursor[] = [];
|
||||
a.provider.onAwareness((next) => {
|
||||
cursors = next;
|
||||
});
|
||||
b.provider.onLocalAwareness(bytes => a.provider.applyAwareness(bytes));
|
||||
|
||||
// B places its caret after "Hello" (offset 5).
|
||||
b.writekit.dispatch(createTransaction(b.writekit.state).setSelection(caret('p', 5)));
|
||||
expect(cursors[0]?.selection?.kind).toBe('text');
|
||||
expect(cursors[0]?.selection?.kind === 'text' && cursors[0].selection.focus.offset).toBe(5);
|
||||
|
||||
// A edits locally; A's view of B's cursor (anchored after 'o') re-resolves to offset 7.
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'p', offset: 0 }, '>>', []).setSelection(caret('p', 2)));
|
||||
expect(cursors[0]?.selection?.kind === 'text' && cursors[0].selection.focus.offset).toBe(7);
|
||||
});
|
||||
|
||||
it('converges across splits, bold, and a second block', () => {
|
||||
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'abcdef', marks: [] }] })]);
|
||||
const a = makePeer(doc);
|
||||
const b = makePeer();
|
||||
b.provider.applyUpdate(a.provider.encodeDelta());
|
||||
connect(a, b);
|
||||
|
||||
// A bolds "ab" (stays in the head block); B splits after "abc" — concurrently.
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).addMark('p', 0, 2, { type: 'bold' }).setSelection(caret('p', 2)));
|
||||
b.writekit.dispatch(createTransaction(b.writekit.state).splitBlock({ blockId: 'p', offset: 3 }, undefined, undefined, 'p2').setSelection(caret('p2', 0)));
|
||||
|
||||
expect(text(a)).toBe(text(b));
|
||||
// Document text is preserved across both edits (split inserts a block boundary).
|
||||
expect(text(a).replace('\n', '')).toBe('abcdef');
|
||||
|
||||
// The bold mark survived on both replicas (somewhere in the doc).
|
||||
const hasBold = (peer: ReturnType<typeof makePeer>) =>
|
||||
peer.writekit.state.doc.content.some(block =>
|
||||
Array.isArray(block.content) && block.content.some(run => 'marks' in run && run.marks.some((m: { type: string }) => m.type === 'bold')));
|
||||
expect(hasBold(a)).toBe(true);
|
||||
expect(hasBold(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('per-block patching: a remote edit keeps untouched block node identities', () => {
|
||||
const doc = createDoc([
|
||||
createNode('paragraph', { id: 'a', content: [{ text: 'AAA', marks: [] }] }),
|
||||
createNode('paragraph', { id: 'b', content: [{ text: 'BBB', marks: [] }] }),
|
||||
]);
|
||||
const a = makePeer(doc);
|
||||
const b = makePeer();
|
||||
b.provider.applyUpdate(a.provider.encodeDelta());
|
||||
connect(a, b);
|
||||
|
||||
const bBefore = b.writekit.state.doc.content.find(node => node.id === 'b')!;
|
||||
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'a', offset: 3 }, '!', []).setSelection(caret('a', 4)));
|
||||
|
||||
expect(nodeText(b.writekit.state.doc.content.find(node => node.id === 'a')!)).toBe('AAA!'); // changed block updated
|
||||
expect(b.writekit.state.doc.content.find(node => node.id === 'b')!).toBe(bBefore); // untouched block reused identity
|
||||
});
|
||||
|
||||
it('tombstone GC compacts deleted content, preserving the document and convergence', () => {
|
||||
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello World', marks: [] }] })]);
|
||||
const a = makePeer(doc);
|
||||
const b = makePeer();
|
||||
b.provider.applyUpdate(a.provider.encodeDelta());
|
||||
connect(a, b);
|
||||
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).deleteText('p', 5, 11).setSelection(caret('p', 5)));
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).addMark('p', 0, 5, { type: 'bold' }).setSelection(caret('p', 5)));
|
||||
expect(text(a)).toBe('Hello');
|
||||
expect(text(b)).toBe('Hello');
|
||||
|
||||
// Quiesced + fully synced → GC is safe on both replicas.
|
||||
const removed = a.provider.gc();
|
||||
b.provider.gc();
|
||||
expect(removed.chars).toBeGreaterThanOrEqual(6); // the deleted " World"
|
||||
|
||||
// The compacted CRDT still materializes the right content and formatting.
|
||||
const reloaded = a.provider.load();
|
||||
expect(nodeText(reloaded.content[0]!)).toBe('Hello');
|
||||
const runs = reloaded.content[0]!.content;
|
||||
expect(Array.isArray(runs) && runs.length > 0 && 'marks' in runs[0]! && runs[0]!.marks.some((m: { type: string }) => m.type === 'bold')).toBe(true);
|
||||
|
||||
// A further edit still converges across replicas.
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'p', offset: 5 }, '!', []).setSelection(caret('p', 6)));
|
||||
expect(text(a)).toBe('Hello!');
|
||||
expect(text(b)).toBe('Hello!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { opId } from '@robonen/crdt';
|
||||
import { createDoc, createNode } from '../../model';
|
||||
import { createDefaultRegistry } from '../../preset';
|
||||
import { DocumentCrdt } from '../native/document-crdt';
|
||||
|
||||
describe('documentCrdt.applyOp', () => {
|
||||
it('buffers (returns false for) a text-delete whose dependency is missing', () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const doc = new DocumentCrdt(registry.schema);
|
||||
let counter = 0;
|
||||
doc.setIdFactory(() => opId('x', ++counter));
|
||||
|
||||
for (const op of doc.seedFromDocument(createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'ab', marks: [] }] })])))
|
||||
doc.applyOp(op);
|
||||
|
||||
// Delete referencing a char id we've never seen → not ready (must buffer).
|
||||
expect(doc.applyOp({ id: opId('r', 1), kind: 'text-delete', blockId: 'p', charId: opId('r', 99) })).toBe(false);
|
||||
// Delete referencing a missing block → also not ready.
|
||||
expect(doc.applyOp({ id: opId('r', 2), kind: 'text-delete', blockId: 'gone', charId: opId('x', 1) })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Transaction, Writekit } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
import { reconcileDoc } from './reconcile';
|
||||
import type { CrdtProvider } from './types';
|
||||
import { REMOTE_ORIGIN } from './types';
|
||||
|
||||
export interface CrdtBinding {
|
||||
detach: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a {@link CrdtProvider} to an {@link Writekit}: local transactions flow into
|
||||
* the CRDT, and remote ops are reflected back as a single history-bypassing
|
||||
* `setDoc` transaction. The provider's `onLocalOps`/`applyUpdate` are connected
|
||||
* to a transport by the caller.
|
||||
*/
|
||||
export function bindCrdt(writekit: Writekit, provider: CrdtProvider): CrdtBinding {
|
||||
function onTransaction(tr: Transaction): void {
|
||||
if (tr.getMeta('origin') !== REMOTE_ORIGIN)
|
||||
provider.applyLocal(tr); // never echo a remote-sourced change back into the CRDT
|
||||
provider.setLocalSelection(writekit.state.selection); // presence (local edits + remapped remote)
|
||||
}
|
||||
|
||||
writekit.on('transaction', onTransaction);
|
||||
provider.setLocalSelection(writekit.state.selection);
|
||||
|
||||
const offRemote = provider.onRemoteApplied(() => {
|
||||
// Reuse unchanged block identities so only the blocks a remote edit touched
|
||||
// repaint (and the local caret in untouched blocks stays put).
|
||||
const next = reconcileDoc(writekit.state.doc, provider.load());
|
||||
if (next === writekit.state.doc)
|
||||
return; // remote ops didn't change the visible document
|
||||
|
||||
writekit.dispatch(createTransaction(writekit.state)
|
||||
.setDoc(next)
|
||||
.setMeta('origin', REMOTE_ORIGIN)
|
||||
.setMeta('addToHistory', false));
|
||||
});
|
||||
|
||||
return {
|
||||
detach: () => {
|
||||
writekit.off('transaction', onTransaction);
|
||||
offRemote();
|
||||
provider.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './types';
|
||||
export * from './binding';
|
||||
export * from './reconcile';
|
||||
export { createNativeProvider } from './native/provider';
|
||||
export type { NativeProviderOptions } from './native/provider';
|
||||
export { DocumentCrdt } from './native/document-crdt';
|
||||
export type { WritekitOp } from './native/document-crdt';
|
||||
@@ -0,0 +1,488 @@
|
||||
import type { MarkValue, OpId, VersionVector } from '@robonen/crdt';
|
||||
import { LwwRegister, MarkStore, Rga, keyBetween, opIdEq, opIdToString } from '@robonen/crdt';
|
||||
import type { Attrs, Inline, InlineNode, Mark, Node, Selection, WritekitDocument } from '../../model';
|
||||
import { createDoc, nodeSelection, normalizeInline, normalizeMarks, textSelection } from '../../model';
|
||||
import type { Schema } from '../../schema';
|
||||
import type { Step } from '../../state';
|
||||
import type { SelectionAnchor } from '../types';
|
||||
|
||||
/**
|
||||
* The CRDT operation log entry. Each carries an op id for the oplog; structural
|
||||
* ops address blocks by their stable string id, text ops by character op ids.
|
||||
*/
|
||||
export type WritekitOp
|
||||
= | { readonly id: OpId; readonly kind: 'block-insert'; readonly blockId: string; readonly blockType: string; readonly attrs: Attrs; readonly posKey: string; readonly isText: boolean }
|
||||
| { readonly id: OpId; readonly kind: 'block-remove'; readonly blockId: string }
|
||||
| { readonly id: OpId; readonly kind: 'block-move'; readonly blockId: string; readonly posKey: string }
|
||||
| { readonly id: OpId; readonly kind: 'block-attrs'; readonly blockId: string; readonly attrs: Attrs }
|
||||
| { readonly id: OpId; readonly kind: 'block-type'; readonly blockId: string; readonly blockType: string; readonly attrs: Attrs }
|
||||
| { readonly id: OpId; readonly kind: 'text-insert'; readonly blockId: string; readonly afterId: OpId | null; readonly ch: string }
|
||||
| { readonly id: OpId; readonly kind: 'text-delete'; readonly blockId: string; readonly charId: OpId }
|
||||
| { readonly id: OpId; readonly kind: 'mark-add'; readonly blockId: string; readonly markType: string; readonly value: MarkValue; readonly startId: OpId; readonly endId: OpId };
|
||||
|
||||
interface BlockState {
|
||||
present: LwwRegister<boolean>;
|
||||
posKey: LwwRegister<string>;
|
||||
type: LwwRegister<string>;
|
||||
attrs: LwwRegister<Attrs>;
|
||||
isText: boolean;
|
||||
rga: Rga<string>;
|
||||
marks: MarkStore;
|
||||
}
|
||||
|
||||
function markToValue(mark: Mark): MarkValue {
|
||||
return mark.attrs && Object.keys(mark.attrs).length > 0 ? (mark.attrs as MarkValue) : true;
|
||||
}
|
||||
|
||||
function valueToMark(type: string, value: MarkValue): Mark {
|
||||
return value && typeof value === 'object' ? { type, attrs: value as Attrs } : { type };
|
||||
}
|
||||
|
||||
/**
|
||||
* The writekit's document CRDT: a fractional-ordered set of blocks, each a text
|
||||
* RGA + a mark store (or an attribute-only atom). It translates the writekit's
|
||||
* offset-based {@link Step}s into id-based CRDT ops ({@link translateStep}),
|
||||
* integrates ops from any replica ({@link applyOp}), and materializes an
|
||||
* {@link WritekitDocument} ({@link toDocument}).
|
||||
*/
|
||||
export class DocumentCrdt {
|
||||
private readonly blocks = new Map<string, BlockState>();
|
||||
private nextId: () => OpId = () => { throw new Error('DocumentCrdt: id factory not set'); };
|
||||
|
||||
constructor(private readonly schema: Schema) {}
|
||||
|
||||
/** Wire the replica's id generator (called once by the provider). */
|
||||
setIdFactory(factory: () => OpId): void {
|
||||
this.nextId = factory;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- integrate
|
||||
|
||||
/** Apply one op (local or remote). Returns false if a causal dependency is missing. */
|
||||
applyOp(op: WritekitOp): boolean {
|
||||
switch (op.kind) {
|
||||
case 'block-insert': {
|
||||
if (!this.blocks.has(op.blockId)) {
|
||||
this.blocks.set(op.blockId, {
|
||||
present: register(true, op.id),
|
||||
posKey: register(op.posKey, op.id),
|
||||
type: register(op.blockType, op.id),
|
||||
attrs: register(op.attrs, op.id),
|
||||
isText: op.isText,
|
||||
rga: new Rga<string>(),
|
||||
marks: new MarkStore(),
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Re-activating a tombstoned block (e.g. undo of a removal): its RGA
|
||||
// and marks are intact, so we only restore presence/position/attrs.
|
||||
const block = this.blocks.get(op.blockId)!;
|
||||
block.present.set(true, op.id);
|
||||
block.posKey.set(op.posKey, op.id);
|
||||
block.type.set(op.blockType, op.id);
|
||||
block.attrs.set(op.attrs, op.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'block-remove': {
|
||||
const block = this.blocks.get(op.blockId);
|
||||
if (!block)
|
||||
return false;
|
||||
block.present.set(false, op.id);
|
||||
return true;
|
||||
}
|
||||
case 'block-move': {
|
||||
const block = this.blocks.get(op.blockId);
|
||||
if (!block)
|
||||
return false;
|
||||
block.posKey.set(op.posKey, op.id);
|
||||
return true;
|
||||
}
|
||||
case 'block-attrs': {
|
||||
const block = this.blocks.get(op.blockId);
|
||||
if (!block)
|
||||
return false;
|
||||
block.attrs.set(op.attrs, op.id);
|
||||
return true;
|
||||
}
|
||||
case 'block-type': {
|
||||
const block = this.blocks.get(op.blockId);
|
||||
if (!block)
|
||||
return false;
|
||||
block.type.set(op.blockType, op.id);
|
||||
block.attrs.set(op.attrs, op.id);
|
||||
return true;
|
||||
}
|
||||
case 'text-insert': {
|
||||
const block = this.blocks.get(op.blockId);
|
||||
if (!block)
|
||||
return false;
|
||||
return block.rga.integrateInsert(op.id, op.ch, op.afterId);
|
||||
}
|
||||
case 'text-delete': {
|
||||
const block = this.blocks.get(op.blockId);
|
||||
if (!block)
|
||||
return false;
|
||||
// Propagate false so the Replica buffers a delete that arrives before its
|
||||
// target insert (deleting an already-tombstoned id still returns true).
|
||||
return block.rga.integrateDelete(op.charId);
|
||||
}
|
||||
case 'mark-add': {
|
||||
const block = this.blocks.get(op.blockId);
|
||||
if (!block)
|
||||
return false;
|
||||
block.marks.add({ id: op.id, type: op.markType, value: op.value, start: op.startId, end: op.endId });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- materialize
|
||||
|
||||
toDocument(): WritekitDocument {
|
||||
const content: Node[] = [];
|
||||
for (const blockId of this.orderedBlockIds()) {
|
||||
const block = this.blocks.get(blockId)!;
|
||||
content.push({
|
||||
id: blockId,
|
||||
type: block.type.get(),
|
||||
attrs: block.attrs.get(),
|
||||
content: block.isText ? this.materialize(block) : null,
|
||||
});
|
||||
}
|
||||
return createDoc(content);
|
||||
}
|
||||
|
||||
private orderedBlockIds(): string[] {
|
||||
return [...this.blocks.entries()]
|
||||
.filter(([, block]) => block.present.get())
|
||||
.sort(([idA, a], [idB, b]) => {
|
||||
const ka = a.posKey.get();
|
||||
const kb = b.posKey.get();
|
||||
if (ka !== kb)
|
||||
return ka < kb ? -1 : 1;
|
||||
return idA < idB ? -1 : idA > idB ? 1 : 0;
|
||||
})
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
private materialize(block: BlockState): Inline {
|
||||
const nodes = block.rga.visible();
|
||||
const marksPerChar = block.marks.resolve(nodes.map(node => node.id));
|
||||
const runs: InlineNode[] = [];
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const marks = normalizeMarks([...marksPerChar[i]!].map(([type, value]) => valueToMark(type, value)));
|
||||
runs.push({ text: nodes[i]!.value, marks });
|
||||
}
|
||||
|
||||
return normalizeInline(runs);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- maintenance
|
||||
|
||||
/**
|
||||
* Compact the CRDT: drop tombstoned characters and fully-removed blocks that
|
||||
* are covered by `stable`. Mark-span endpoints are preserved so formatting
|
||||
* survives. Call ONLY at quiescence — every replica fully synced, nothing in
|
||||
* flight — or a late op referencing dropped content can no longer integrate.
|
||||
*/
|
||||
gc(stable: VersionVector): { blocks: number; chars: number } {
|
||||
let blocks = 0;
|
||||
let chars = 0;
|
||||
|
||||
for (const [id, block] of this.blocks) {
|
||||
const removedAt = block.present.timestamp;
|
||||
if (!block.present.get() && removedAt && stable.has(removedAt)) {
|
||||
this.blocks.delete(id);
|
||||
blocks += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const keep = new Set<string>();
|
||||
for (const span of block.marks.all()) {
|
||||
keep.add(opIdToString(span.start));
|
||||
keep.add(opIdToString(span.end));
|
||||
}
|
||||
chars += block.rga.gc(stable, charId => keep.has(opIdToString(charId)));
|
||||
}
|
||||
|
||||
return { blocks, chars };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ awareness
|
||||
|
||||
/** Whether a block currently exists and is visible. */
|
||||
hasBlock(blockId: string): boolean {
|
||||
return this.blocks.get(blockId)?.present.get() ?? false;
|
||||
}
|
||||
|
||||
/** The char id a caret at `offset` sits after (null at block start) — a stable cursor anchor. */
|
||||
private anchorAt(blockId: string, offset: number): OpId | null {
|
||||
const block = this.blocks.get(blockId);
|
||||
return block?.isText ? block.rga.idAt(offset - 1) : null;
|
||||
}
|
||||
|
||||
/** Resolve a char-id anchor back to an offset in the current state. */
|
||||
private offsetOf(blockId: string, afterCharId: OpId | null): number {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block?.isText)
|
||||
return 0;
|
||||
if (afterCharId === null)
|
||||
return 0;
|
||||
const visible = block.rga.visible();
|
||||
const index = visible.findIndex(node => opIdEq(node.id, afterCharId));
|
||||
return index === -1 ? visible.length : index + 1;
|
||||
}
|
||||
|
||||
/** Convert a model selection into a char-id anchor (for presence broadcast). */
|
||||
toAnchor(selection: Selection): SelectionAnchor {
|
||||
if (selection.kind === 'node')
|
||||
return { kind: 'node', ids: selection.ids };
|
||||
return {
|
||||
kind: 'text',
|
||||
anchor: { blockId: selection.anchor.blockId, afterCharId: this.anchorAt(selection.anchor.blockId, selection.anchor.offset) },
|
||||
focus: { blockId: selection.focus.blockId, afterCharId: this.anchorAt(selection.focus.blockId, selection.focus.offset) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve an anchor back into a model selection against the current document. */
|
||||
resolveAnchor(anchor: SelectionAnchor | null): Selection | null {
|
||||
if (!anchor)
|
||||
return null;
|
||||
if (anchor.kind === 'node')
|
||||
return anchor.ids.length > 0 ? nodeSelection(anchor.ids) : null;
|
||||
if (!this.hasBlock(anchor.anchor.blockId) || !this.hasBlock(anchor.focus.blockId))
|
||||
return null;
|
||||
return textSelection(
|
||||
{ blockId: anchor.anchor.blockId, offset: this.offsetOf(anchor.anchor.blockId, anchor.anchor.afterCharId) },
|
||||
{ blockId: anchor.focus.blockId, offset: this.offsetOf(anchor.focus.blockId, anchor.focus.afterCharId) },
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- translate
|
||||
|
||||
/** Generate the ops for a local step, reading current state for ids/positions. */
|
||||
translateStep(step: Step): WritekitOp[] {
|
||||
switch (step.type) {
|
||||
case 'insertInline':
|
||||
return this.insertInlineOps(step.blockId, step.offset, step.content);
|
||||
case 'deleteText':
|
||||
return this.deleteTextOps(step.blockId, step.from, step.to);
|
||||
case 'replaceInline':
|
||||
return [...this.deleteTextOps(step.blockId, step.from, step.to), ...this.insertInlineOps(step.blockId, step.from, step.content)];
|
||||
case 'addMark':
|
||||
return this.markOps(step.blockId, step.from, step.to, step.mark.type, markToValue(step.mark));
|
||||
case 'removeMark':
|
||||
return this.markOps(step.blockId, step.from, step.to, step.mark.type, null);
|
||||
case 'setAttrs':
|
||||
return this.blocks.has(step.blockId) ? [{ id: this.nextId(), kind: 'block-attrs', blockId: step.blockId, attrs: step.attrs }] : [];
|
||||
case 'setType':
|
||||
return this.blocks.has(step.blockId) ? [{ id: this.nextId(), kind: 'block-type', blockId: step.blockId, blockType: step.blockType, attrs: step.attrs }] : [];
|
||||
case 'insertBlock':
|
||||
return this.insertBlockOps(step.node, step.index);
|
||||
case 'removeBlock':
|
||||
return this.blocks.has(step.blockId) ? [{ id: this.nextId(), kind: 'block-remove', blockId: step.blockId }] : [];
|
||||
case 'moveBlock':
|
||||
return this.moveOps(step.blockId, step.toIndex);
|
||||
case 'splitBlock':
|
||||
return this.splitOps(step.blockId, step.offset, step.newId, step.newType, step.newAttrs);
|
||||
case 'mergeBlock':
|
||||
return this.mergeOps(step.blockId, step.intoId);
|
||||
case 'setDoc':
|
||||
return []; // whole-doc replace is local-only (e.g. applying a remote snapshot); not re-emitted
|
||||
}
|
||||
}
|
||||
|
||||
/** Ops to seed the CRDT from an initial document. */
|
||||
seedFromDocument(doc: WritekitDocument): WritekitOp[] {
|
||||
const ops: WritekitOp[] = [];
|
||||
let prevKey: string | null = null;
|
||||
|
||||
for (const node of doc.content) {
|
||||
const posKey = keyBetween(prevKey, null);
|
||||
prevKey = posKey;
|
||||
ops.push(...this.blockOps(node, posKey));
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ op builders
|
||||
|
||||
/** Ops for an `insertBlock` step: reactivate if the block already exists (undo), else create. */
|
||||
private insertBlockOps(node: Node, index: number): WritekitOp[] {
|
||||
const posKey = this.posKeyForIndex(index);
|
||||
|
||||
if (this.blocks.has(node.id)) {
|
||||
const isText = this.schema.nodeSpec(node.type)?.content.kind === 'text';
|
||||
return [{ id: this.nextId(), kind: 'block-insert', blockId: node.id, blockType: node.type, attrs: node.attrs, posKey, isText }];
|
||||
}
|
||||
|
||||
return this.blockOps(node, posKey);
|
||||
}
|
||||
|
||||
private blockOps(node: Node, posKey: string): WritekitOp[] {
|
||||
const isText = this.schema.nodeSpec(node.type)?.content.kind === 'text';
|
||||
const ops: WritekitOp[] = [{
|
||||
id: this.nextId(),
|
||||
kind: 'block-insert',
|
||||
blockId: node.id,
|
||||
blockType: node.type,
|
||||
attrs: node.attrs,
|
||||
posKey,
|
||||
isText,
|
||||
}];
|
||||
|
||||
if (isText && Array.isArray(node.content))
|
||||
ops.push(...this.inlineOps(node.id, node.content as Inline, null));
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Insert inline `content` after the char at `afterId` (null = block start). */
|
||||
private inlineOps(blockId: string, content: Inline, afterId: OpId | null): WritekitOp[] {
|
||||
const ops: WritekitOp[] = [];
|
||||
let after = afterId;
|
||||
|
||||
for (const run of content) {
|
||||
const charIds: OpId[] = [];
|
||||
// Iterate UTF-16 code units (not code points) to match the writekit's
|
||||
// offset space — one RGA node per unit keeps offsets aligned for astral chars.
|
||||
for (let i = 0; i < run.text.length; i++) {
|
||||
const id = this.nextId();
|
||||
ops.push({ id, kind: 'text-insert', blockId, afterId: after, ch: run.text[i]! });
|
||||
after = id;
|
||||
charIds.push(id);
|
||||
}
|
||||
|
||||
if (charIds.length > 0) {
|
||||
for (const mark of run.marks)
|
||||
ops.push({ id: this.nextId(), kind: 'mark-add', blockId, markType: mark.type, value: markToValue(mark), startId: charIds[0]!, endId: charIds[charIds.length - 1]! });
|
||||
}
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
private insertInlineOps(blockId: string, offset: number, content: Inline): WritekitOp[] {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block || !block.isText)
|
||||
return [];
|
||||
return this.inlineOps(blockId, content, block.rga.idAt(offset - 1));
|
||||
}
|
||||
|
||||
private deleteTextOps(blockId: string, from: number, to: number): WritekitOp[] {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block || !block.isText)
|
||||
return [];
|
||||
return block.rga.visible().slice(from, to)
|
||||
.map(node => ({ id: this.nextId(), kind: 'text-delete' as const, blockId, charId: node.id }));
|
||||
}
|
||||
|
||||
private markOps(blockId: string, from: number, to: number, markType: string, value: MarkValue): WritekitOp[] {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block || !block.isText || from >= to)
|
||||
return [];
|
||||
|
||||
const visible = block.rga.visible();
|
||||
const start = visible[from]?.id;
|
||||
const end = visible[to - 1]?.id;
|
||||
if (!start || !end)
|
||||
return [];
|
||||
|
||||
return [{ id: this.nextId(), kind: 'mark-add', blockId, markType, value, startId: start, endId: end }];
|
||||
}
|
||||
|
||||
private posKeyForIndex(index: number): string {
|
||||
const order = this.orderedBlockIds();
|
||||
const before = index > 0 ? this.blocks.get(order[index - 1]!)?.posKey.get() ?? null : null;
|
||||
const after = index < order.length ? this.blocks.get(order[index]!)?.posKey.get() ?? null : null;
|
||||
return keyBetween(before, after);
|
||||
}
|
||||
|
||||
private moveOps(blockId: string, toIndex: number): WritekitOp[] {
|
||||
if (!this.blocks.has(blockId))
|
||||
return [];
|
||||
|
||||
const order = this.orderedBlockIds().filter(id => id !== blockId);
|
||||
const before = toIndex > 0 ? this.blocks.get(order[toIndex - 1]!)?.posKey.get() ?? null : null;
|
||||
const after = toIndex < order.length ? this.blocks.get(order[toIndex]!)?.posKey.get() ?? null : null;
|
||||
return [{ id: this.nextId(), kind: 'block-move', blockId, posKey: keyBetween(before, after) }];
|
||||
}
|
||||
|
||||
private splitOps(blockId: string, offset: number, newId: string, newType?: string, newAttrs?: Attrs): WritekitOp[] {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block || !block.isText)
|
||||
return [];
|
||||
|
||||
const order = this.orderedBlockIds();
|
||||
const index = order.indexOf(blockId);
|
||||
const nextKey = index >= 0 && index + 1 < order.length ? this.blocks.get(order[index + 1]!)!.posKey.get() : null;
|
||||
const posKey = keyBetween(block.posKey.get(), nextKey);
|
||||
|
||||
// Undo of a merge: the split's target block already exists (tombstoned) with
|
||||
// its original content intact. Reactivate it and drop the merged-in tail from
|
||||
// the source instead of recreating content (which would duplicate it).
|
||||
const existing = this.blocks.get(newId);
|
||||
if (existing) {
|
||||
const reactivate: WritekitOp[] = [{ id: this.nextId(), kind: 'block-insert', blockId: newId, blockType: existing.type.get(), attrs: existing.attrs.get(), posKey, isText: existing.isText }];
|
||||
for (const node of block.rga.visible().slice(offset))
|
||||
reactivate.push({ id: this.nextId(), kind: 'text-delete', blockId, charId: node.id });
|
||||
return reactivate;
|
||||
}
|
||||
|
||||
const type = newType ?? block.type.get();
|
||||
const attrs = newAttrs ?? (newType ? this.schema.defaultAttrs(newType) : block.attrs.get());
|
||||
const isText = this.schema.nodeSpec(type)?.content.kind === 'text';
|
||||
|
||||
const ops: WritekitOp[] = [{ id: this.nextId(), kind: 'block-insert', blockId: newId, blockType: type, attrs, posKey, isText }];
|
||||
|
||||
// Re-create the tail (offset..end) in the new block, then tombstone it in the old.
|
||||
const tail = block.rga.visible().slice(offset);
|
||||
const marksPerChar = block.marks.resolve(block.rga.visible().map(node => node.id));
|
||||
let after: OpId | null = null;
|
||||
|
||||
for (let k = 0; k < tail.length; k++) {
|
||||
const id = this.nextId();
|
||||
ops.push({ id, kind: 'text-insert', blockId: newId, afterId: after, ch: tail[k]!.value });
|
||||
after = id;
|
||||
for (const [markType, value] of marksPerChar[offset + k]!)
|
||||
ops.push({ id: this.nextId(), kind: 'mark-add', blockId: newId, markType, value, startId: id, endId: id });
|
||||
}
|
||||
|
||||
for (const node of tail)
|
||||
ops.push({ id: this.nextId(), kind: 'text-delete', blockId, charId: node.id });
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
private mergeOps(blockId: string, intoId: string): WritekitOp[] {
|
||||
const source = this.blocks.get(blockId);
|
||||
const target = this.blocks.get(intoId);
|
||||
if (!source || !target || !source.isText || !target.isText)
|
||||
return [];
|
||||
|
||||
const ops: WritekitOp[] = [];
|
||||
const sourceChars = source.rga.visible();
|
||||
const marksPerChar = source.marks.resolve(sourceChars.map(node => node.id));
|
||||
let after = target.rga.idAt(target.rga.length - 1);
|
||||
|
||||
for (let k = 0; k < sourceChars.length; k++) {
|
||||
const id = this.nextId();
|
||||
ops.push({ id, kind: 'text-insert', blockId: intoId, afterId: after, ch: sourceChars[k]!.value });
|
||||
after = id;
|
||||
for (const [markType, value] of marksPerChar[k]!)
|
||||
ops.push({ id: this.nextId(), kind: 'mark-add', blockId: intoId, markType, value, startId: id, endId: id });
|
||||
}
|
||||
|
||||
ops.push({ id: this.nextId(), kind: 'block-remove', blockId });
|
||||
return ops;
|
||||
}
|
||||
}
|
||||
|
||||
function register<T>(value: T, id: OpId): LwwRegister<T> {
|
||||
const reg = new LwwRegister<T>(value);
|
||||
reg.set(value, id);
|
||||
return reg;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Replica, VersionVector, createSiteId, decodeJson, decodeOps, decodeStateVector, encodeJson, encodeOps, encodeStateVector } from '@robonen/crdt';
|
||||
import { PubSub } from '@robonen/stdlib';
|
||||
import type { Selection, WritekitDocument } from '../../model';
|
||||
import type { Schema } from '../../schema';
|
||||
import type { Transaction } from '../../state';
|
||||
import type { AwarenessState, CrdtProvider, CursorUser, RemoteCursor } from '../types';
|
||||
import type { WritekitOp } from './document-crdt';
|
||||
import { DocumentCrdt } from './document-crdt';
|
||||
|
||||
export interface NativeProviderOptions {
|
||||
/** Schema (block/mark specs) — needed to know which blocks hold text. */
|
||||
schema: Schema;
|
||||
/** Seed the CRDT from this document (use for the FIRST replica only; joiners sync instead). */
|
||||
doc?: WritekitDocument;
|
||||
/** Replica/site id (defaults to a random one). */
|
||||
site?: string;
|
||||
/** Identity broadcast with this replica's cursor. */
|
||||
user?: CursorUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider event map. A mapped type (not the `interface`) satisfies PubSub's
|
||||
* `Record<string, …>` constraint — same trick as the writekit's event bus.
|
||||
*/
|
||||
interface ProviderEvents {
|
||||
/** A batch of locally-produced ops, encoded for broadcast. */
|
||||
localOps: (bytes: Uint8Array) => void;
|
||||
/** Remote ops were applied to the document. */
|
||||
remoteApplied: () => void;
|
||||
/** This replica's presence/awareness state, encoded for broadcast. */
|
||||
localAwareness: (bytes: Uint8Array) => void;
|
||||
/** Resolved remote cursors changed. */
|
||||
awareness: (cursors: RemoteCursor[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in CRDT provider backed by `@robonen/crdt`: a fractional-ordered set
|
||||
* of blocks, each a text RGA + mark store. Writekit steps map to CRDT ops via
|
||||
* {@link DocumentCrdt}; ops sync as op batches over any transport.
|
||||
*/
|
||||
export function createNativeProvider(options: NativeProviderOptions): CrdtProvider {
|
||||
const document = new DocumentCrdt(options.schema);
|
||||
const site = options.site ?? createSiteId();
|
||||
const replica = new Replica<WritekitOp>({ integrate: op => document.applyOp(op) }, site);
|
||||
document.setIdFactory(() => replica.nextId());
|
||||
|
||||
const bus = new PubSub<{ [K in keyof ProviderEvents]: ProviderEvents[K] }>();
|
||||
const remoteStates = new Map<string, AwarenessState>();
|
||||
|
||||
if (options.doc) {
|
||||
for (const op of document.seedFromDocument(options.doc))
|
||||
replica.commitLocal(op);
|
||||
}
|
||||
|
||||
function resolveCursors(): RemoteCursor[] {
|
||||
const cursors: RemoteCursor[] = [];
|
||||
for (const state of remoteStates.values()) {
|
||||
if (state.clientId === site)
|
||||
continue;
|
||||
cursors.push({ clientId: state.clientId, user: state.user, selection: document.resolveAnchor(state.anchor) });
|
||||
}
|
||||
return cursors;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'native',
|
||||
|
||||
load: () => document.toDocument(),
|
||||
|
||||
applyLocal: (tr: Transaction) => {
|
||||
const ops: WritekitOp[] = [];
|
||||
for (const step of tr.steps) {
|
||||
for (const op of document.translateStep(step)) {
|
||||
replica.commitLocal(op);
|
||||
ops.push(op);
|
||||
}
|
||||
}
|
||||
if (ops.length > 0) {
|
||||
bus.emit('localOps', encodeOps(ops));
|
||||
// Local edits shifted the document — re-resolve remote cursor positions.
|
||||
if (remoteStates.size > 0)
|
||||
bus.emit('awareness', resolveCursors());
|
||||
}
|
||||
},
|
||||
|
||||
applyUpdate: (bytes) => {
|
||||
const applied = replica.receive(decodeOps<WritekitOp>(bytes));
|
||||
if (applied.length > 0) {
|
||||
bus.emit('remoteApplied');
|
||||
// Remote ops shifted the document — re-resolve cursors against new positions.
|
||||
if (remoteStates.size > 0)
|
||||
bus.emit('awareness', resolveCursors());
|
||||
}
|
||||
},
|
||||
|
||||
encodeStateVector: () => encodeStateVector(replica.version),
|
||||
encodeDelta: remote => encodeOps(replica.delta(remote ? decodeStateVector(remote) : new VersionVector())),
|
||||
|
||||
onLocalOps: (listener) => {
|
||||
bus.on('localOps', listener);
|
||||
return () => bus.off('localOps', listener);
|
||||
},
|
||||
onRemoteApplied: (listener) => {
|
||||
bus.on('remoteApplied', listener);
|
||||
return () => bus.off('remoteApplied', listener);
|
||||
},
|
||||
|
||||
setLocalSelection: (selection: Selection | null) => {
|
||||
const state: AwarenessState = { clientId: site, user: options.user, anchor: selection ? document.toAnchor(selection) : null };
|
||||
bus.emit('localAwareness', encodeJson(state));
|
||||
},
|
||||
|
||||
onLocalAwareness: (listener) => {
|
||||
bus.on('localAwareness', listener);
|
||||
return () => bus.off('localAwareness', listener);
|
||||
},
|
||||
|
||||
applyAwareness: (bytes) => {
|
||||
const state = decodeJson<AwarenessState>(bytes);
|
||||
remoteStates.set(state.clientId, state);
|
||||
bus.emit('awareness', resolveCursors());
|
||||
},
|
||||
|
||||
onAwareness: (listener) => {
|
||||
bus.on('awareness', listener);
|
||||
return () => bus.off('awareness', listener);
|
||||
},
|
||||
|
||||
gc: stable => document.gc(stable ? decodeStateVector(stable) : replica.version),
|
||||
|
||||
destroy: () => {
|
||||
bus.clear('localOps');
|
||||
bus.clear('remoteApplied');
|
||||
bus.clear('localAwareness');
|
||||
bus.clear('awareness');
|
||||
remoteStates.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Content, Node, WritekitDocument } from '../model';
|
||||
import { attrsEq, createDoc, isInlineContent, marksEq } from '../model';
|
||||
|
||||
function contentEq(a: Content, b: Content): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
if (a === null || b === null)
|
||||
return false;
|
||||
|
||||
if (isInlineContent(a) && isInlineContent(b)) {
|
||||
if (a.length !== b.length)
|
||||
return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i]!.text !== b[i]!.text || !marksEq(a[i]!.marks, b[i]!.marks))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // container Node[] — unused in the flat model; treat as changed
|
||||
}
|
||||
|
||||
function nodeEq(a: Node, b: Node): boolean {
|
||||
return a.id === b.id && a.type === b.type && attrsEq(a.attrs, b.attrs) && contentEq(a.content, b.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a document equal to `next` but reusing block-node identities from `prev`
|
||||
* wherever a block is deep-equal — so applying a remote change repaints only the
|
||||
* blocks that actually changed (others keep their reference, and the local caret
|
||||
* in them is undisturbed). Returns `prev` unchanged when nothing differs.
|
||||
*/
|
||||
export function reconcileDoc(prev: WritekitDocument, next: WritekitDocument): WritekitDocument {
|
||||
const prevById = new Map(prev.content.map(node => [node.id, node]));
|
||||
let changed = prev.content.length !== next.content.length;
|
||||
|
||||
const content = next.content.map((node, index) => {
|
||||
const before = prevById.get(node.id);
|
||||
if (before && nodeEq(before, node)) {
|
||||
if (prev.content[index]?.id !== node.id)
|
||||
changed = true; // same block, new position
|
||||
return before; // reuse identity → no repaint
|
||||
}
|
||||
changed = true;
|
||||
return node;
|
||||
});
|
||||
|
||||
return changed ? createDoc(content) : prev;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { OpId } from '@robonen/crdt';
|
||||
import type { Selection, WritekitDocument } from '../model';
|
||||
import type { Transaction } from '../state';
|
||||
|
||||
/** Marks transactions that apply remote CRDT changes (so they bypass local history). */
|
||||
export const REMOTE_ORIGIN = 'crdt-remote';
|
||||
|
||||
export interface CursorUser {
|
||||
readonly name?: string;
|
||||
readonly color?: string;
|
||||
}
|
||||
|
||||
/** A caret point anchored to the character op id it sits after (stable under remote edits). */
|
||||
export interface PointAnchor {
|
||||
readonly blockId: string;
|
||||
readonly afterCharId: OpId | null;
|
||||
}
|
||||
|
||||
/** A selection anchored to char ids rather than offsets, for awareness. */
|
||||
export type SelectionAnchor
|
||||
= | { readonly kind: 'text'; readonly anchor: PointAnchor; readonly focus: PointAnchor }
|
||||
| { readonly kind: 'node'; readonly ids: readonly string[] };
|
||||
|
||||
/** Ephemeral per-client presence (cursor + identity), sent over the awareness channel. */
|
||||
export interface AwarenessState {
|
||||
readonly clientId: string;
|
||||
readonly user?: CursorUser;
|
||||
readonly anchor: SelectionAnchor | null;
|
||||
}
|
||||
|
||||
/** A remote participant's cursor, resolved back into local model coordinates. */
|
||||
export interface RemoteCursor {
|
||||
readonly clientId: string;
|
||||
readonly user?: CursorUser;
|
||||
readonly selection: Selection | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pluggable CRDT backend. The writekit core stays CRDT-agnostic behind this
|
||||
* interface; {@link bindCrdt} wires it to an {@link Writekit}, and any transport
|
||||
* (BroadcastChannel, WebSocket, …) is layered on via the op + awareness hooks.
|
||||
*/
|
||||
export interface CrdtProvider {
|
||||
readonly name: string;
|
||||
/** The current document materialized from CRDT state. */
|
||||
load: () => WritekitDocument;
|
||||
/** Translate a local transaction's steps into CRDT ops and apply them. */
|
||||
applyLocal: (tr: Transaction) => void;
|
||||
/** Merge a remote update (encoded ops) into the CRDT. */
|
||||
applyUpdate: (bytes: Uint8Array, origin?: unknown) => void;
|
||||
/** Encode this replica's version vector for a sync handshake. */
|
||||
encodeStateVector: () => Uint8Array;
|
||||
/** Encode ops a remote is missing (by its state vector); omit for a full snapshot. */
|
||||
encodeDelta: (remoteStateVector?: Uint8Array) => Uint8Array;
|
||||
/** Subscribe to locally-produced op batches (to broadcast). Returns unsubscribe. */
|
||||
onLocalOps: (listener: (bytes: Uint8Array) => void) => () => void;
|
||||
/** Subscribe to "remote ops were applied" (to reflect into writekit state). Returns unsubscribe. */
|
||||
onRemoteApplied: (listener: () => void) => () => void;
|
||||
|
||||
// --- awareness (ephemeral; not part of the persistent document) ---
|
||||
/** Publish the local selection as presence (anchored to char ids). */
|
||||
setLocalSelection: (selection: Selection | null) => void;
|
||||
/** Subscribe to locally-produced awareness frames (to broadcast). Returns unsubscribe. */
|
||||
onLocalAwareness: (listener: (bytes: Uint8Array) => void) => () => void;
|
||||
/** Merge a remote awareness frame. */
|
||||
applyAwareness: (bytes: Uint8Array) => void;
|
||||
/** Subscribe to the resolved set of remote cursors. Returns unsubscribe. */
|
||||
onAwareness: (listener: (cursors: RemoteCursor[]) => void) => () => void;
|
||||
|
||||
/**
|
||||
* Compact tombstones and removed blocks covered by `stableStateVector` (or this
|
||||
* replica's version when omitted). Safe only at quiescence — all peers fully
|
||||
* synced, nothing in flight. Returns how much was dropped.
|
||||
*/
|
||||
gc: (stableStateVector?: Uint8Array) => { blocks: number; chars: number };
|
||||
|
||||
destroy: () => void;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
const __DEV__: boolean;
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
type ComponentCustomProps = Record<`data${string}`, unknown>;
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
type HTMLAttributes = Record<`data-${string}`, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './model';
|
||||
export * from './schema';
|
||||
export * from './registry';
|
||||
export * from './state';
|
||||
export * from './commands';
|
||||
export * from './keymap';
|
||||
export * from './view';
|
||||
export * from './blocks';
|
||||
export * from './marks';
|
||||
export * from './preset';
|
||||
export * from './crdt';
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Command } from '../state';
|
||||
import type { Platform } from '../view/config';
|
||||
import type { Keymap } from './types';
|
||||
import { normalizeCombo } from './normalize';
|
||||
|
||||
/**
|
||||
* Merge ordered keymaps into a single normalized lookup. Earlier keymaps win, so
|
||||
* pass user overrides before the defaults: `compileKeymaps([user, defaults], …)`.
|
||||
*/
|
||||
export function compileKeymaps(keymaps: readonly Keymap[], platform: Platform): Map<string, Command> {
|
||||
const compiled = new Map<string, Command>();
|
||||
|
||||
for (const keymap of keymaps) {
|
||||
for (const combo in keymap) {
|
||||
const normalized = normalizeCombo(combo, platform);
|
||||
if (!compiled.has(normalized))
|
||||
compiled.set(normalized, keymap[combo]!);
|
||||
}
|
||||
}
|
||||
|
||||
return compiled;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
chainCommands,
|
||||
deleteSelection,
|
||||
indentListItem,
|
||||
insertHardBreak,
|
||||
joinBackward,
|
||||
joinForward,
|
||||
moveBlockDown,
|
||||
moveBlockUp,
|
||||
outdentListItem,
|
||||
selectAll,
|
||||
setBlockType,
|
||||
splitBlock,
|
||||
toggleBlockType,
|
||||
toggleMark,
|
||||
} from '../commands';
|
||||
import type { Command, Writekit } from '../state';
|
||||
import type { Keymap } from './types';
|
||||
|
||||
/**
|
||||
* The standard writekit keymap. Mark/heading shortcuts are no-ops when the mark or
|
||||
* block type isn't registered. Enter/Backspace/Delete are no-ops except at block
|
||||
* boundaries, so ordinary intra-block editing stays native. Arrow navigation and
|
||||
* cross-block selection are fully native (one contenteditable spans the doc).
|
||||
*/
|
||||
export function defaultKeymap(writekit: Writekit): Keymap {
|
||||
const undo: Command = () => writekit.undo();
|
||||
const redo: Command = () => writekit.redo();
|
||||
|
||||
const keymap: Keymap = {
|
||||
'Mod-b': toggleMark('bold'),
|
||||
'Mod-i': toggleMark('italic'),
|
||||
'Mod-u': toggleMark('underline'),
|
||||
'Mod-Shift-s': toggleMark('strike'),
|
||||
'Mod-e': toggleMark('code'),
|
||||
'Mod-z': undo,
|
||||
'Mod-Shift-z': redo,
|
||||
'Mod-y': redo,
|
||||
Enter: splitBlock,
|
||||
'Shift-Enter': insertHardBreak,
|
||||
Backspace: chainCommands(deleteSelection, joinBackward),
|
||||
Delete: chainCommands(deleteSelection, joinForward),
|
||||
'Mod-a': selectAll,
|
||||
Tab: indentListItem,
|
||||
'Shift-Tab': outdentListItem,
|
||||
'Mod-Shift-ArrowUp': moveBlockUp,
|
||||
'Mod-Shift-ArrowDown': moveBlockDown,
|
||||
'Mod-Alt-0': setBlockType('paragraph'),
|
||||
};
|
||||
|
||||
for (let level = 1; level <= 6; level++)
|
||||
keymap[`Mod-Alt-${level}`] = toggleBlockType('heading', { level });
|
||||
|
||||
return keymap;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Command, CommandView, Dispatch, WritekitState } from '../state';
|
||||
import { eventToCombo } from './normalize';
|
||||
|
||||
/**
|
||||
* Look up and run the command bound to a keydown event. Returns `true` when a
|
||||
* command handled it (the caller should then `preventDefault`).
|
||||
*/
|
||||
export function runKeydown(
|
||||
event: KeyboardEvent,
|
||||
compiled: Map<string, Command>,
|
||||
state: WritekitState,
|
||||
dispatch: Dispatch,
|
||||
view: CommandView,
|
||||
): boolean {
|
||||
const command = compiled.get(eventToCombo(event));
|
||||
return command ? command(state, dispatch, view) : false;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './types';
|
||||
export * from './normalize';
|
||||
export * from './compile';
|
||||
export * from './defaults';
|
||||
export * from './dispatcher';
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Platform } from '../view/config';
|
||||
|
||||
const MOD_ORDER = ['Ctrl', 'Alt', 'Shift', 'Meta'] as const;
|
||||
|
||||
function modAlias(token: string, platform: Platform): string | null {
|
||||
switch (token.toLowerCase()) {
|
||||
case 'mod': return platform === 'mac' ? 'Meta' : 'Ctrl';
|
||||
case 'cmd':
|
||||
case 'command':
|
||||
case 'meta': return 'Meta';
|
||||
case 'ctrl':
|
||||
case 'control': return 'Ctrl';
|
||||
case 'alt':
|
||||
case 'option': return 'Alt';
|
||||
case 'shift': return 'Shift';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a human combo (`'Mod-Shift-z'`) to a canonical, platform-resolved
|
||||
* form (`'Shift-Meta-z'` on mac). Modifiers are ordered deterministically so a
|
||||
* keydown event maps to the same string via {@link eventToCombo}.
|
||||
*/
|
||||
export function normalizeCombo(combo: string, platform: Platform): string {
|
||||
const parts = combo.split(/[-+]/).map(part => part.trim()).filter(Boolean);
|
||||
const key = parts.pop() ?? '';
|
||||
const mods = new Set<string>();
|
||||
|
||||
for (const part of parts) {
|
||||
const mod = modAlias(part, platform);
|
||||
if (mod)
|
||||
mods.add(mod);
|
||||
}
|
||||
|
||||
const order = MOD_ORDER.filter(mod => mods.has(mod));
|
||||
const normalizedKey = key.length === 1 ? key.toLowerCase() : key;
|
||||
return [...order, normalizedKey].join('-');
|
||||
}
|
||||
|
||||
/** Canonical combo string for a keydown event (matches {@link normalizeCombo}). */
|
||||
export function eventToCombo(event: KeyboardEvent): string {
|
||||
const mods: string[] = [];
|
||||
|
||||
if (event.ctrlKey) mods.push('Ctrl');
|
||||
if (event.altKey) mods.push('Alt');
|
||||
if (event.shiftKey) mods.push('Shift');
|
||||
if (event.metaKey) mods.push('Meta');
|
||||
|
||||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
return [...mods, key].join('-');
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { Command } from '../state';
|
||||
|
||||
/** A keymap: normalized (or human) key-combos mapped to commands. */
|
||||
export type Keymap = Record<string, Command>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineMark } from '../registry';
|
||||
|
||||
export const bold = defineMark({
|
||||
type: 'bold',
|
||||
spec: {
|
||||
inclusive: true,
|
||||
rank: 1,
|
||||
toDOM: () => ['strong', 0],
|
||||
parseDOM: [{ tag: 'strong' }, { tag: 'b' }],
|
||||
},
|
||||
meta: { title: 'Bold', icon: 'bold', hotkey: 'Mod-b' },
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineMark } from '../registry';
|
||||
|
||||
export const code = defineMark({
|
||||
type: 'code',
|
||||
spec: {
|
||||
inclusive: false,
|
||||
rank: 9,
|
||||
excludes: '_all', // inline code wins: it strips every other mark on the range
|
||||
toDOM: () => ['code', 0],
|
||||
parseDOM: [{ tag: 'code' }],
|
||||
},
|
||||
meta: { title: 'Inline code', icon: 'code', hotkey: 'Mod-e' },
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineMark } from '../registry';
|
||||
|
||||
export const highlight = defineMark({
|
||||
type: 'highlight',
|
||||
spec: {
|
||||
inclusive: true,
|
||||
rank: 5,
|
||||
toDOM: () => ['mark', 0],
|
||||
parseDOM: [{ tag: 'mark' }],
|
||||
},
|
||||
meta: { title: 'Highlight', icon: 'highlighter' },
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export { bold } from './bold';
|
||||
export { italic } from './italic';
|
||||
export { underline } from './underline';
|
||||
export { strike } from './strike';
|
||||
export { highlight } from './highlight';
|
||||
export { code } from './code';
|
||||
export { link } from './link';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineMark } from '../registry';
|
||||
|
||||
export const italic = defineMark({
|
||||
type: 'italic',
|
||||
spec: {
|
||||
inclusive: true,
|
||||
rank: 2,
|
||||
toDOM: () => ['em', 0],
|
||||
parseDOM: [{ tag: 'em' }, { tag: 'i' }],
|
||||
},
|
||||
meta: { title: 'Italic', icon: 'italic', hotkey: 'Mod-i' },
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Mark } from '../model';
|
||||
import { defineMark } from '../registry';
|
||||
|
||||
export const link = defineMark({
|
||||
type: 'link',
|
||||
spec: {
|
||||
inclusive: false, // typing past a link's end does not extend it
|
||||
rank: 10,
|
||||
attrs: {
|
||||
href: { default: '' },
|
||||
target: { default: '_blank' },
|
||||
},
|
||||
toDOM: (mark: Mark) => [
|
||||
'a',
|
||||
{
|
||||
href: String(mark.attrs?.['href'] ?? ''),
|
||||
target: String(mark.attrs?.['target'] ?? '_blank'),
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
0,
|
||||
],
|
||||
parseDOM: [{
|
||||
tag: 'a[href]',
|
||||
getAttrs: (el: HTMLElement) => ({
|
||||
href: el.getAttribute('href') ?? '',
|
||||
target: el.getAttribute('target') ?? '_blank',
|
||||
}),
|
||||
}],
|
||||
},
|
||||
meta: { title: 'Link', icon: 'link', hotkey: 'Mod-k' },
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineMark } from '../registry';
|
||||
|
||||
export const strike = defineMark({
|
||||
type: 'strike',
|
||||
spec: {
|
||||
inclusive: true,
|
||||
rank: 4,
|
||||
toDOM: () => ['s', 0],
|
||||
parseDOM: [{ tag: 's' }, { tag: 'del' }],
|
||||
},
|
||||
meta: { title: 'Strikethrough', icon: 'strikethrough', hotkey: 'Mod-Shift-s' },
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineMark } from '../registry';
|
||||
|
||||
export const underline = defineMark({
|
||||
type: 'underline',
|
||||
spec: {
|
||||
inclusive: true,
|
||||
rank: 3,
|
||||
toDOM: () => ['u', 0],
|
||||
parseDOM: [{ tag: 'u' }],
|
||||
},
|
||||
meta: { title: 'Underline', icon: 'underline', hotkey: 'Mod-u' },
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Inline } from '../inline';
|
||||
import {
|
||||
addMarkInline,
|
||||
deleteTextInline,
|
||||
inlineText,
|
||||
insertTextInline,
|
||||
marksAt,
|
||||
normalizeInline,
|
||||
removeMarkInline,
|
||||
} from '../inline';
|
||||
|
||||
const bold = { type: 'bold' };
|
||||
|
||||
describe('inline', () => {
|
||||
it('merges adjacent equal-mark runs and drops empties', () => {
|
||||
const input: Inline = [{ text: 'a', marks: [] }, { text: '', marks: [] }, { text: 'b', marks: [] }];
|
||||
expect(normalizeInline(input)).toEqual([{ text: 'ab', marks: [] }]);
|
||||
});
|
||||
|
||||
it('inserts text at an offset', () => {
|
||||
expect(inlineText(insertTextInline([{ text: 'ac', marks: [] }], 1, 'b', []))).toBe('abc');
|
||||
});
|
||||
|
||||
it('adds a mark over a range, splitting runs', () => {
|
||||
expect(addMarkInline([{ text: 'abc', marks: [] }], 1, 2, bold)).toEqual([
|
||||
{ text: 'a', marks: [] },
|
||||
{ text: 'b', marks: [bold] },
|
||||
{ text: 'c', marks: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a mark over a range', () => {
|
||||
expect(removeMarkInline([{ text: 'abc', marks: [bold] }], 0, 3, 'bold')).toEqual([{ text: 'abc', marks: [] }]);
|
||||
});
|
||||
|
||||
it('deletes a range', () => {
|
||||
expect(inlineText(deleteTextInline([{ text: 'abcd', marks: [] }], 1, 3))).toBe('ad');
|
||||
});
|
||||
|
||||
it('reads marks at a caret as the preceding character marks', () => {
|
||||
const marked: Inline = [{ text: 'ab', marks: [bold] }, { text: 'c', marks: [] }];
|
||||
expect(marksAt(marked, 2)).toEqual([bold]);
|
||||
expect(marksAt(marked, 3)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Attribute values are JSON-serializable so documents round-trip losslessly and
|
||||
* a CRDT adapter can map them onto its own primitives without special-casing.
|
||||
*/
|
||||
export type AttrValue
|
||||
= | string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| readonly AttrValue[]
|
||||
| { readonly [key: string]: AttrValue };
|
||||
|
||||
export type Attrs = Readonly<Record<string, AttrValue>>;
|
||||
|
||||
/**
|
||||
* Structural equality for two attribute values. Order-insensitive for object
|
||||
* keys, deep for arrays/objects. Used by mark/attr deduplication and tests.
|
||||
*/
|
||||
export function attrValueEq(a: AttrValue | undefined, b: AttrValue | undefined): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
|
||||
if (a === null || b === null || a === undefined || b === undefined)
|
||||
return a === b;
|
||||
|
||||
const aArr = Array.isArray(a);
|
||||
const bArr = Array.isArray(b);
|
||||
|
||||
if (aArr || bArr) {
|
||||
if (!aArr || !bArr || a.length !== b.length)
|
||||
return false;
|
||||
|
||||
return a.every((v, i) => attrValueEq(v, b[i]));
|
||||
}
|
||||
|
||||
if (typeof a === 'object' && typeof b === 'object')
|
||||
return attrsEq(a as Attrs, b as Attrs);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural equality for attribute bags. `undefined` and `{}` are equivalent
|
||||
* so `{ type: 'bold' }` equals `{ type: 'bold', attrs: {} }`.
|
||||
*/
|
||||
export function attrsEq(a?: Attrs, b?: Attrs): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
|
||||
const aKeys = a ? Object.keys(a) : [];
|
||||
const bKeys = b ? Object.keys(b) : [];
|
||||
|
||||
if (aKeys.length !== bKeys.length)
|
||||
return false;
|
||||
|
||||
return aKeys.every(key => attrValueEq(a![key], b?.[key]));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Node } from './node';
|
||||
|
||||
/**
|
||||
* The writekit document: an ordered list of top-level blocks. Default blocks are
|
||||
* flat (lists use indent attributes, not nesting), so document helpers operate
|
||||
* on the top-level array.
|
||||
*/
|
||||
export interface WritekitDocument {
|
||||
readonly type: 'doc';
|
||||
readonly content: readonly Node[];
|
||||
}
|
||||
|
||||
/** Construct a document from blocks. */
|
||||
export function createDoc(content: readonly Node[] = []): WritekitDocument {
|
||||
return { type: 'doc', content };
|
||||
}
|
||||
|
||||
/** Index of a block by id, or `-1` if absent. */
|
||||
export function blockIndex(doc: WritekitDocument, id: string): number {
|
||||
return doc.content.findIndex(block => block.id === id);
|
||||
}
|
||||
|
||||
/** A block and its index, or `null` if absent. */
|
||||
export function findBlock(doc: WritekitDocument, id: string): { node: Node; index: number } | null {
|
||||
const index = blockIndex(doc, id);
|
||||
return index === -1 ? null : { node: doc.content[index]!, index };
|
||||
}
|
||||
|
||||
/** A block by id, or `null`. */
|
||||
export function blockById(doc: WritekitDocument, id: string): Node | null {
|
||||
return doc.content.find(block => block.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** The block before `id` in document order, or `null`. */
|
||||
export function previousBlock(doc: WritekitDocument, id: string): Node | null {
|
||||
const index = blockIndex(doc, id);
|
||||
return index > 0 ? doc.content[index - 1]! : null;
|
||||
}
|
||||
|
||||
/** The block after `id` in document order, or `null`. */
|
||||
export function nextBlock(doc: WritekitDocument, id: string): Node | null {
|
||||
const index = blockIndex(doc, id);
|
||||
return index !== -1 && index < doc.content.length - 1 ? doc.content[index + 1]! : null;
|
||||
}
|
||||
|
||||
/** First block, or `null` for an empty document. */
|
||||
export function firstBlock(doc: WritekitDocument): Node | null {
|
||||
return doc.content[0] ?? null;
|
||||
}
|
||||
|
||||
/** Last block, or `null` for an empty document. */
|
||||
export function lastBlock(doc: WritekitDocument): Node | null {
|
||||
return doc.content[doc.content.length - 1] ?? null;
|
||||
}
|
||||
|
||||
/** Return a copy of `doc` with a different block list. */
|
||||
export function replaceBlocks(doc: WritekitDocument, content: readonly Node[]): WritekitDocument {
|
||||
return { ...doc, content };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Stable, collision-resistant identifier for blocks. Block ids survive
|
||||
* split/merge/move and are how positions, selections, and the CRDT address a
|
||||
* block — so they must be unique and never reused.
|
||||
*/
|
||||
export function createId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||
return crypto.randomUUID();
|
||||
|
||||
// Fallback for exotic runtimes without WebCrypto (Node >= 19 and all target
|
||||
// browsers provide `crypto.randomUUID`, so this is effectively dead code).
|
||||
return `b-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './attrs';
|
||||
export * from './marks';
|
||||
export * from './inline';
|
||||
export * from './id';
|
||||
export * from './node';
|
||||
export * from './document';
|
||||
export * from './position';
|
||||
export * from './selection';
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Mark, Marks } from './marks';
|
||||
import { marksEq, normalizeMarks } from './marks';
|
||||
|
||||
/**
|
||||
* A run of text sharing the same marks. The chosen inline representation
|
||||
* ("marked runs") renders to per-block contenteditable as a span list and maps
|
||||
* isomorphically onto a character-sequence CRDT with formatting.
|
||||
*/
|
||||
export interface InlineNode {
|
||||
readonly text: string;
|
||||
readonly marks: Marks;
|
||||
}
|
||||
|
||||
/** A block's inline content: an ordered, normalized list of runs. */
|
||||
export type Inline = readonly InlineNode[];
|
||||
|
||||
/** Total length of inline content in UTF-16 code units (DOM-offset compatible). */
|
||||
export function inlineLength(inline: Inline): number {
|
||||
let length = 0;
|
||||
|
||||
for (const run of inline)
|
||||
length += run.text.length;
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
/** Concatenated plain text of inline content. */
|
||||
export function inlineText(inline: Inline): string {
|
||||
let text = '';
|
||||
|
||||
for (const run of inline)
|
||||
text += run.text;
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* One UTF-16 code unit carrying its marks. Operations explode inline content to
|
||||
* chars, splice, then regroup — obviously correct and cheap for small blocks.
|
||||
* UTF-16 units (not code points) keep offsets aligned with the DOM.
|
||||
*/
|
||||
interface Char {
|
||||
readonly ch: string;
|
||||
readonly marks: Marks;
|
||||
}
|
||||
|
||||
function toChars(inline: Inline): Char[] {
|
||||
const chars: Char[] = [];
|
||||
|
||||
for (const run of inline) {
|
||||
const marks = normalizeMarks(run.marks);
|
||||
|
||||
for (let i = 0; i < run.text.length; i++)
|
||||
chars.push({ ch: run.text[i]!, marks });
|
||||
}
|
||||
|
||||
return chars;
|
||||
}
|
||||
|
||||
function fromChars(chars: readonly Char[]): Inline {
|
||||
const runs: InlineNode[] = [];
|
||||
|
||||
for (const { ch, marks } of chars) {
|
||||
const last = runs[runs.length - 1];
|
||||
|
||||
if (last && marksEq(last.marks, marks))
|
||||
runs[runs.length - 1] = { text: last.text + ch, marks: last.marks };
|
||||
else
|
||||
runs.push({ text: ch, marks });
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form: drop empty runs, merge adjacent runs with equal mark sets,
|
||||
* normalize each run's marks. Must be applied after every inline mutation so the
|
||||
* model stays diff-stable and equality stays cheap.
|
||||
*/
|
||||
export function normalizeInline(inline: Inline): Inline {
|
||||
return fromChars(toChars(inline));
|
||||
}
|
||||
|
||||
/** Inline slice between two character offsets `[from, to)`. */
|
||||
export function sliceInline(inline: Inline, from: number, to: number): Inline {
|
||||
return fromChars(toChars(inline).slice(from, to));
|
||||
}
|
||||
|
||||
/** Insert `text` (carrying `marks`) at character `offset`. */
|
||||
export function insertTextInline(inline: Inline, offset: number, text: string, marks: Marks): Inline {
|
||||
if (text.length === 0)
|
||||
return normalizeInline(inline);
|
||||
|
||||
const chars = toChars(inline);
|
||||
const normalized = normalizeMarks(marks);
|
||||
const inserted: Char[] = [];
|
||||
|
||||
for (let i = 0; i < text.length; i++)
|
||||
inserted.push({ ch: text[i]!, marks: normalized });
|
||||
|
||||
chars.splice(offset, 0, ...inserted);
|
||||
return fromChars(chars);
|
||||
}
|
||||
|
||||
/** Insert inline `content` (preserving its marks) at character `offset`. */
|
||||
export function insertInline(inline: Inline, offset: number, content: Inline): Inline {
|
||||
const chars = toChars(inline);
|
||||
chars.splice(offset, 0, ...toChars(content));
|
||||
return fromChars(chars);
|
||||
}
|
||||
|
||||
/** Replace the character range `[from, to)` with inline `content`. */
|
||||
export function replaceInline(inline: Inline, from: number, to: number, content: Inline): Inline {
|
||||
const chars = toChars(inline);
|
||||
chars.splice(from, to - from, ...toChars(content));
|
||||
return fromChars(chars);
|
||||
}
|
||||
|
||||
/** Delete the character range `[from, to)`. */
|
||||
export function deleteTextInline(inline: Inline, from: number, to: number): Inline {
|
||||
const chars = toChars(inline);
|
||||
chars.splice(from, to - from);
|
||||
return fromChars(chars);
|
||||
}
|
||||
|
||||
/** Add `mark` across `[from, to)`, replacing any existing mark of the same type. */
|
||||
export function addMarkInline(inline: Inline, from: number, to: number, mark: Mark): Inline {
|
||||
const chars = toChars(inline);
|
||||
|
||||
for (let i = from; i < to && i < chars.length; i++) {
|
||||
const current = chars[i]!;
|
||||
chars[i] = { ch: current.ch, marks: normalizeMarks([...current.marks.filter(m => m.type !== mark.type), mark]) };
|
||||
}
|
||||
|
||||
return fromChars(chars);
|
||||
}
|
||||
|
||||
/** Remove every mark of `markType` across `[from, to)`. */
|
||||
export function removeMarkInline(inline: Inline, from: number, to: number, markType: string): Inline {
|
||||
const chars = toChars(inline);
|
||||
|
||||
for (let i = from; i < to && i < chars.length; i++) {
|
||||
const current = chars[i]!;
|
||||
chars[i] = { ch: current.ch, marks: current.marks.filter(m => m.type !== markType) };
|
||||
}
|
||||
|
||||
return fromChars(chars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks active at a collapsed caret `offset` — used to seed stored marks and to
|
||||
* decide toggle state. Defaults to the marks of the character before the caret.
|
||||
*/
|
||||
export function marksAt(inline: Inline, offset: number): Marks {
|
||||
const chars = toChars(inline);
|
||||
|
||||
if (chars.length === 0)
|
||||
return [];
|
||||
|
||||
const index = offset > 0 ? offset - 1 : 0;
|
||||
return chars[Math.min(index, chars.length - 1)]?.marks ?? [];
|
||||
}
|
||||
|
||||
/** Whether the whole range `[from, to)` carries a mark of `markType`. */
|
||||
export function rangeHasMarkType(inline: Inline, from: number, to: number, markType: string): boolean {
|
||||
const chars = toChars(inline);
|
||||
|
||||
if (from >= to)
|
||||
return false;
|
||||
|
||||
for (let i = from; i < to && i < chars.length; i++) {
|
||||
if (!chars[i]!.marks.some(m => m.type === markType))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Attrs } from './attrs';
|
||||
import { attrsEq } from './attrs';
|
||||
|
||||
/**
|
||||
* An inline formatting mark applied to a run of text (bold, italic, link, ...).
|
||||
* `type` is the registry key; `attrs` holds mark-specific data (e.g. link href).
|
||||
*/
|
||||
export interface Mark {
|
||||
readonly type: string;
|
||||
readonly attrs?: Attrs;
|
||||
}
|
||||
|
||||
/** A normalized set of marks: at most one per type, sorted by `type`. */
|
||||
export type Marks = readonly Mark[];
|
||||
|
||||
/** Structural equality for two marks (type + attrs). */
|
||||
export function markEq(a: Mark, b: Mark): boolean {
|
||||
return a.type === b.type && attrsEq(a.attrs, b.attrs);
|
||||
}
|
||||
|
||||
/** Ordered structural equality for two normalized mark sets. */
|
||||
export function marksEq(a: Marks, b: Marks): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
|
||||
if (a.length !== b.length)
|
||||
return false;
|
||||
|
||||
return a.every((mark, i) => markEq(mark, b[i]!));
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a mark set: keep the last occurrence per `type` (so a re-applied
|
||||
* mark with new attrs wins) and sort by `type`. The deterministic order is what
|
||||
* makes {@link marksEq} an O(n) comparison and keeps the model diff-stable.
|
||||
*/
|
||||
export function normalizeMarks(marks: Marks): Marks {
|
||||
if (marks.length <= 1)
|
||||
return marks;
|
||||
|
||||
const byType = new Map<string, Mark>();
|
||||
|
||||
for (const mark of marks)
|
||||
byType.set(mark.type, mark);
|
||||
|
||||
return [...byType.values()].sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0));
|
||||
}
|
||||
|
||||
/** Whether `marks` contains a mark structurally equal to `mark`. */
|
||||
export function hasMark(marks: Marks, mark: Mark): boolean {
|
||||
return marks.some(m => markEq(m, mark));
|
||||
}
|
||||
|
||||
/** Whether `marks` contains any mark of the given `type`. */
|
||||
export function hasMarkType(marks: Marks, type: string): boolean {
|
||||
return marks.some(m => m.type === type);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Attrs } from './attrs';
|
||||
import type { Inline } from './inline';
|
||||
import { inlineText } from './inline';
|
||||
import { createId } from './id';
|
||||
|
||||
/**
|
||||
* A block's content. Three shapes, chosen by the block's schema:
|
||||
* - `Inline` for text blocks (paragraph, heading, list item),
|
||||
* - `readonly Node[]` for container blocks (reserved; no default block uses it),
|
||||
* - `null` for atom/void blocks (image, divider).
|
||||
*/
|
||||
export type Content = Inline | readonly Node[] | null;
|
||||
|
||||
/** A document block. `id` is stable across split/merge/move. */
|
||||
export interface Node {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
readonly attrs: Attrs;
|
||||
readonly content: Content;
|
||||
}
|
||||
|
||||
export interface CreateNodeOptions {
|
||||
readonly id?: string;
|
||||
readonly attrs?: Attrs;
|
||||
readonly content?: Content;
|
||||
}
|
||||
|
||||
/** Construct a {@link Node}, generating an id when not supplied. */
|
||||
export function createNode(type: string, options: CreateNodeOptions = {}): Node {
|
||||
return {
|
||||
id: options.id ?? createId(),
|
||||
type,
|
||||
attrs: options.attrs ?? {},
|
||||
content: options.content ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort runtime check for inline (text-block) content. The authoritative
|
||||
* answer comes from the schema; this is a convenience for model-level helpers.
|
||||
*/
|
||||
export function isInlineContent(content: Content): content is Inline {
|
||||
return Array.isArray(content) && (content.length === 0 || 'text' in (content[0] as object));
|
||||
}
|
||||
|
||||
/** Inline content of a node, or `[]` when the node is not a text block. */
|
||||
export function nodeInline(node: Node): Inline {
|
||||
return isInlineContent(node.content) ? node.content : [];
|
||||
}
|
||||
|
||||
/** Plain text of a node, or `''` when the node has no inline content. */
|
||||
export function nodeText(node: Node): string {
|
||||
return isInlineContent(node.content) ? inlineText(node.content) : '';
|
||||
}
|
||||
|
||||
/** Return a copy of `node` with new content. */
|
||||
export function withContent(node: Node, content: Content): Node {
|
||||
return { ...node, content };
|
||||
}
|
||||
|
||||
/** Return a copy of `node` with new attrs. */
|
||||
export function withAttrs(node: Node, attrs: Attrs): Node {
|
||||
return { ...node, attrs };
|
||||
}
|
||||
|
||||
/** Return a copy of `node` with a new type (and optionally new attrs). */
|
||||
export function withType(node: Node, type: string, attrs?: Attrs): Node {
|
||||
return { ...node, type, attrs: attrs ?? node.attrs };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* A position inside the document, addressed by block id + a UTF-16 character
|
||||
* offset into that block's inline content. Offsets are UTF-16 code units to line
|
||||
* up with the DOM `Selection`/`Range` API, so the view bridge maps 1:1.
|
||||
*/
|
||||
export interface Position {
|
||||
readonly blockId: string;
|
||||
readonly offset: number;
|
||||
}
|
||||
|
||||
/** Construct a {@link Position}. */
|
||||
export function position(blockId: string, offset: number): Position {
|
||||
return { blockId, offset };
|
||||
}
|
||||
|
||||
/** Whether two positions address the same block and offset. */
|
||||
export function positionEq(a: Position, b: Position): boolean {
|
||||
return a.blockId === b.blockId && a.offset === b.offset;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Position } from './position';
|
||||
import { positionEq } from './position';
|
||||
import type { WritekitDocument } from './document';
|
||||
import { blockIndex } from './document';
|
||||
|
||||
/** A text selection: caret when `anchor === focus`, range otherwise. May span blocks. */
|
||||
export interface TextSelection {
|
||||
readonly kind: 'text';
|
||||
readonly anchor: Position;
|
||||
readonly focus: Position;
|
||||
}
|
||||
|
||||
/** A block-level selection of one or more whole blocks (atoms, Mod+A stage 2). */
|
||||
export interface NodeSelection {
|
||||
readonly kind: 'node';
|
||||
readonly ids: readonly string[];
|
||||
}
|
||||
|
||||
export type Selection = TextSelection | NodeSelection;
|
||||
|
||||
/** Construct a text selection (focus defaults to anchor → collapsed caret). */
|
||||
export function textSelection(anchor: Position, focus: Position = anchor): TextSelection {
|
||||
return { kind: 'text', anchor, focus };
|
||||
}
|
||||
|
||||
/** Construct a collapsed caret selection. */
|
||||
export function caret(blockId: string, offset: number): TextSelection {
|
||||
const point: Position = { blockId, offset };
|
||||
return { kind: 'text', anchor: point, focus: point };
|
||||
}
|
||||
|
||||
/** Construct a block-level selection. */
|
||||
export function nodeSelection(ids: readonly string[]): NodeSelection {
|
||||
return { kind: 'node', ids };
|
||||
}
|
||||
|
||||
export function isTextSelection(sel: Selection): sel is TextSelection {
|
||||
return sel.kind === 'text';
|
||||
}
|
||||
|
||||
export function isNodeSelection(sel: Selection): sel is NodeSelection {
|
||||
return sel.kind === 'node';
|
||||
}
|
||||
|
||||
/** Whether the selection is a collapsed caret. */
|
||||
export function isCollapsed(sel: Selection): boolean {
|
||||
return sel.kind === 'text' && positionEq(sel.anchor, sel.focus);
|
||||
}
|
||||
|
||||
/** Whether the selection spans more than one block. */
|
||||
export function isAcrossBlocks(sel: Selection): boolean {
|
||||
return sel.kind === 'text' && sel.anchor.blockId !== sel.focus.blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints of a text selection in document order (`from` before `to`). Within
|
||||
* one block they are ordered by offset; across blocks by block index.
|
||||
*/
|
||||
export function orderedSelection(sel: TextSelection, doc: WritekitDocument): { from: Position; to: Position } {
|
||||
const { anchor, focus } = sel;
|
||||
|
||||
if (anchor.blockId === focus.blockId)
|
||||
return anchor.offset <= focus.offset ? { from: anchor, to: focus } : { from: focus, to: anchor };
|
||||
|
||||
return blockIndex(doc, anchor.blockId) <= blockIndex(doc, focus.blockId)
|
||||
? { from: anchor, to: focus }
|
||||
: { from: focus, to: anchor };
|
||||
}
|
||||
|
||||
/** Structural equality for two selections. */
|
||||
export function selectionEq(a: Selection, b: Selection): boolean {
|
||||
if (a.kind !== b.kind)
|
||||
return false;
|
||||
|
||||
if (a.kind === 'text' && b.kind === 'text')
|
||||
return positionEq(a.anchor, b.anchor) && positionEq(a.focus, b.focus);
|
||||
|
||||
if (a.kind === 'node' && b.kind === 'node')
|
||||
return a.ids.length === b.ids.length && a.ids.every((id, i) => id === b.ids[i]);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createRegistry } from './registry';
|
||||
import {
|
||||
blockquote,
|
||||
bulletedList,
|
||||
callout,
|
||||
codeBlock,
|
||||
divider,
|
||||
heading,
|
||||
image,
|
||||
numberedList,
|
||||
paragraph,
|
||||
todoList,
|
||||
} from './blocks';
|
||||
import { bold, code, highlight, italic, link, strike, underline } from './marks';
|
||||
|
||||
/** The block definitions bundled in the default preset (registration order = menu order). */
|
||||
export const defaultBlocks = [
|
||||
paragraph,
|
||||
heading,
|
||||
blockquote,
|
||||
codeBlock,
|
||||
callout,
|
||||
bulletedList,
|
||||
numberedList,
|
||||
todoList,
|
||||
divider,
|
||||
image,
|
||||
];
|
||||
|
||||
/** The mark definitions bundled in the default preset. */
|
||||
export const defaultMarks = [bold, italic, underline, strike, highlight, code, link];
|
||||
|
||||
/** Batteries-included registry with the default blocks and marks. */
|
||||
export function createDefaultRegistry() {
|
||||
return createRegistry({ blocks: defaultBlocks, marks: defaultMarks });
|
||||
}
|
||||
|
||||
/** Lightweight registry — basic text blocks and the common marks only. */
|
||||
export function createBasicRegistry() {
|
||||
return createRegistry({
|
||||
blocks: [paragraph, heading, blockquote, bulletedList, numberedList],
|
||||
marks: [bold, italic, link],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { heading, paragraph } from '../../blocks';
|
||||
import { bold } from '../../marks';
|
||||
import { createRegistry } from '../registry';
|
||||
|
||||
describe('registry', () => {
|
||||
it('projects a schema from definitions', () => {
|
||||
const registry = createRegistry({ blocks: [paragraph, heading], marks: [bold] });
|
||||
expect(registry.schema.nodeSpec('paragraph')).toBeDefined();
|
||||
expect(registry.schema.markSpec('bold')).toBeDefined();
|
||||
expect(registry.getBlock('heading')?.meta?.title).toBe('Heading');
|
||||
expect(registry.listBlocks().map(block => block.type)).toEqual(['paragraph', 'heading']);
|
||||
});
|
||||
|
||||
it('throws on a duplicate type by default', () => {
|
||||
expect(() => createRegistry({ blocks: [paragraph, paragraph] })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Component } from 'vue';
|
||||
import type { Attrs, Content, Node } from '../model';
|
||||
import type { NodeSpec } from '../schema';
|
||||
import type { CommandFactory } from '../state/command';
|
||||
import type { InputRuleSpec } from './input-rule';
|
||||
|
||||
/** Props passed to an atom/void block's Vue `component`. */
|
||||
export interface BlockComponentProps {
|
||||
/** The block's model node (read its `attrs`). */
|
||||
node: Node;
|
||||
/** Whether the block is currently node-selected. */
|
||||
selected: boolean;
|
||||
/** Writekit-level editable flag. */
|
||||
editable: boolean;
|
||||
/** Merge new attrs into the block (e.g. image src/caption). */
|
||||
update: (attrs: Attrs) => void;
|
||||
}
|
||||
|
||||
/** Presentational/discovery metadata: powers slash menu, conversion, toolbars. */
|
||||
export interface BlockMeta {
|
||||
readonly title: string;
|
||||
readonly icon?: string;
|
||||
readonly keywords?: readonly string[];
|
||||
readonly group?: string;
|
||||
}
|
||||
|
||||
/** Optional block-specific behaviors used by core commands. */
|
||||
export interface BlockBehavior {
|
||||
/** Content for a fresh empty block of this type (defaults to empty inline). */
|
||||
readonly empty?: () => Content;
|
||||
/** Plain-text extraction (defaults to inline text). */
|
||||
readonly toText?: (node: Node) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A block definition: schema contribution + behavior + an opaque Vue component.
|
||||
* Non-view layers treat `component` as an opaque value; only the view resolves
|
||||
* it. The type is `Component` purely for authoring ergonomics (type-only import).
|
||||
*/
|
||||
export interface BlockDefinition {
|
||||
readonly type: string;
|
||||
readonly spec: NodeSpec;
|
||||
readonly component?: Component;
|
||||
readonly meta?: BlockMeta;
|
||||
readonly behavior?: BlockBehavior;
|
||||
readonly commands?: Record<string, CommandFactory>;
|
||||
/** Wrapper element tag for the block (default `'div'`). */
|
||||
readonly as?: string;
|
||||
/** Placeholder text shown when an empty text block has focus. */
|
||||
readonly placeholder?: string;
|
||||
readonly inputRules?: readonly InputRuleSpec[];
|
||||
}
|
||||
|
||||
/** Identity factory that narrows a block definition's literal type (cf. `definePlugin`). */
|
||||
export function defineBlock<const D extends BlockDefinition>(def: D): D {
|
||||
return def;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MarkSpec } from '../schema';
|
||||
import type { InputRuleSpec } from './input-rule';
|
||||
|
||||
/** Presentational/discovery metadata for a mark (toolbar label, shortcut hint). */
|
||||
export interface MarkMeta {
|
||||
readonly title: string;
|
||||
readonly icon?: string;
|
||||
readonly hotkey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mark definition: schema contribution (attrs, exclusivity, rank, toDOM,
|
||||
* parseDOM) + metadata. Marks are data-only — the view renders/parses them via
|
||||
* the spec, which is what makes them fully modular through the registry.
|
||||
*/
|
||||
export interface MarkDefinition {
|
||||
readonly type: string;
|
||||
readonly spec: MarkSpec;
|
||||
readonly meta?: MarkMeta;
|
||||
readonly inputRules?: readonly InputRuleSpec[];
|
||||
}
|
||||
|
||||
/** Identity factory that narrows a mark definition's literal type (cf. `definePlugin`). */
|
||||
export function defineMark<const D extends MarkDefinition>(def: D): D {
|
||||
return def;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './input-rule';
|
||||
export * from './define-block';
|
||||
export * from './define-mark';
|
||||
export * from './registry';
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Attrs } from '../model';
|
||||
|
||||
/**
|
||||
* A pattern that transforms the current block or marks when typed (e.g. `'# '`
|
||||
* → heading, `'**x**'` → bold). The matching engine lands in M2; the type is
|
||||
* declared now so block/mark definitions can carry their rules as data.
|
||||
*/
|
||||
export interface InputRuleSpec {
|
||||
/** Pattern tested against the text ending at the caret. */
|
||||
readonly match: RegExp;
|
||||
/** Target block/mark type to apply on match. */
|
||||
readonly type?: string;
|
||||
/** Attrs to apply with the transformation. */
|
||||
readonly attrs?: Attrs;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { MarkSpec, NodeSpec, Schema } from '../schema';
|
||||
import { createSchema } from '../schema';
|
||||
import type { BlockDefinition } from './define-block';
|
||||
import type { MarkDefinition } from './define-mark';
|
||||
|
||||
/** How to resolve two definitions registered under the same type. */
|
||||
export type ConflictPolicy = 'throw' | 'last-wins' | 'first-wins';
|
||||
|
||||
/**
|
||||
* The single source of truth for which block and mark types exist and how they
|
||||
* behave. Immutable: built once via {@link createRegistry}; {@link extendRegistry}
|
||||
* returns a new registry. The {@link Schema} is projected from the definitions.
|
||||
*/
|
||||
export interface Registry {
|
||||
readonly blocks: ReadonlyMap<string, BlockDefinition>;
|
||||
readonly marks: ReadonlyMap<string, MarkDefinition>;
|
||||
readonly schema: Schema;
|
||||
getBlock: (type: string) => BlockDefinition | undefined;
|
||||
getMark: (type: string) => MarkDefinition | undefined;
|
||||
/** Definitions in registration order (drives slash menu / toolbars). */
|
||||
listBlocks: () => readonly BlockDefinition[];
|
||||
listMarks: () => readonly MarkDefinition[];
|
||||
/** Alias of {@link listMarks}, for the inline renderer/parser. */
|
||||
allMarks: () => readonly MarkDefinition[];
|
||||
hasBlock: (type: string) => boolean;
|
||||
hasMark: (type: string) => boolean;
|
||||
}
|
||||
|
||||
export interface CreateRegistryOptions {
|
||||
readonly blocks?: readonly BlockDefinition[];
|
||||
readonly marks?: readonly MarkDefinition[];
|
||||
readonly onConflict?: ConflictPolicy;
|
||||
}
|
||||
|
||||
function buildMap<D extends { readonly type: string }>(
|
||||
items: readonly D[],
|
||||
onConflict: ConflictPolicy,
|
||||
kind: string,
|
||||
): Map<string, D> {
|
||||
const map = new Map<string, D>();
|
||||
|
||||
for (const item of items) {
|
||||
if (map.has(item.type)) {
|
||||
if (onConflict === 'throw')
|
||||
throw new Error(`Writekit registry: duplicate ${kind} type '${item.type}'`);
|
||||
|
||||
if (onConflict === 'first-wins')
|
||||
continue;
|
||||
}
|
||||
|
||||
map.set(item.type, item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Build an immutable {@link Registry} from block and mark definitions. */
|
||||
export function createRegistry(options: CreateRegistryOptions = {}): Registry {
|
||||
const onConflict = options.onConflict ?? 'throw';
|
||||
const blocks = buildMap(options.blocks ?? [], onConflict, 'block');
|
||||
const marks = buildMap(options.marks ?? [], onConflict, 'mark');
|
||||
|
||||
const nodeSpecs = new Map<string, NodeSpec>();
|
||||
for (const [type, def] of blocks)
|
||||
nodeSpecs.set(type, def.spec);
|
||||
|
||||
const markSpecs = new Map<string, MarkSpec>();
|
||||
for (const [type, def] of marks)
|
||||
markSpecs.set(type, def.spec);
|
||||
|
||||
const schema = createSchema({ nodes: nodeSpecs, marks: markSpecs });
|
||||
const blockList = [...blocks.values()];
|
||||
const markList = [...marks.values()];
|
||||
|
||||
return {
|
||||
blocks,
|
||||
marks,
|
||||
schema,
|
||||
getBlock: type => blocks.get(type),
|
||||
getMark: type => marks.get(type),
|
||||
listBlocks: () => blockList,
|
||||
listMarks: () => markList,
|
||||
allMarks: () => markList,
|
||||
hasBlock: type => blocks.has(type),
|
||||
hasMark: type => marks.has(type),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return a new registry extending `base` with extra blocks/marks (override wins). */
|
||||
export function extendRegistry(
|
||||
base: Registry,
|
||||
add: { blocks?: readonly BlockDefinition[]; marks?: readonly MarkDefinition[]; onConflict?: ConflictPolicy },
|
||||
): Registry {
|
||||
return createRegistry({
|
||||
blocks: [...base.listBlocks(), ...(add.blocks ?? [])],
|
||||
marks: [...base.listMarks(), ...(add.marks ?? [])],
|
||||
onConflict: add.onConflict ?? 'last-wins',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AttrValue } from '../model';
|
||||
|
||||
/** Specification for a single attribute: default, requiredness, validation. */
|
||||
export interface AttrSpec<V extends AttrValue = AttrValue> {
|
||||
readonly default?: V;
|
||||
readonly required?: boolean;
|
||||
readonly validate?: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
/** Map of attribute name → {@link AttrSpec}. */
|
||||
export type AttrsSpec = Readonly<Record<string, AttrSpec>>;
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The content model of a block — a deliberately small, closed union instead of
|
||||
* ProseMirror's content-expression grammar (KISS).
|
||||
*
|
||||
* - `text`: holds inline content; `marks` whitelists which marks may apply,
|
||||
* - `container`: holds child blocks (reserved; no default block uses it yet),
|
||||
* - `atom`: holds no editable content (image, divider).
|
||||
*/
|
||||
export type ContentKind
|
||||
= | { readonly kind: 'text'; readonly marks?: 'all' | 'none' | readonly string[] }
|
||||
| { readonly kind: 'container'; readonly allow?: readonly string[]; readonly group?: string }
|
||||
| { readonly kind: 'atom' };
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Attrs } from '../model';
|
||||
|
||||
/** Placeholder marking where a node/mark's content should be spliced in. */
|
||||
export type DOMOutputHole = 0;
|
||||
|
||||
export type DOMOutputChild = DOMOutputSpec | DOMOutputHole;
|
||||
|
||||
/**
|
||||
* A serializable description of DOM output (ProseMirror-style), kept free of
|
||||
* real DOM so the schema layer stays pure. The view realizes it into elements.
|
||||
*
|
||||
* - `'text'` → a text node,
|
||||
* - `['tag', { attr: 'v' }, 0]` → `<tag attr="v">…content…</tag>`,
|
||||
* - the attrs object is optional; `0` is the content hole.
|
||||
*
|
||||
* The array part is an interface so the recursion (an element may contain nested
|
||||
* elements) is well-founded for the type checker.
|
||||
*/
|
||||
export type DOMOutputSpec = string | DOMOutputArray;
|
||||
|
||||
export interface DOMOutputArray extends ReadonlyArray<string | Record<string, string> | DOMOutputChild> {}
|
||||
|
||||
/**
|
||||
* A rule for parsing DOM (paste / HTML import) into a block or mark.
|
||||
* `getAttrs` receives a real `HTMLElement` (only ever called by the view); the
|
||||
* type reference is compile-time only and introduces no runtime DOM dependency.
|
||||
*/
|
||||
export interface ParseRule {
|
||||
/** CSS selector to match, e.g. `'a[href]'`, `'strong'`, `'h1'`. */
|
||||
readonly tag?: string;
|
||||
/** Inline-style match, e.g. `'font-weight=700'` (reserved for M2). */
|
||||
readonly style?: string;
|
||||
/** Static attrs applied when the rule matches. */
|
||||
readonly attrs?: Attrs;
|
||||
/** Derive attrs from the matched element; `false`/`null` rejects the match. */
|
||||
readonly getAttrs?: (el: HTMLElement) => Attrs | false | null;
|
||||
/** Higher priority rules are tried first. */
|
||||
readonly priority?: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './attr-spec';
|
||||
export * from './content-kind';
|
||||
export * from './dom';
|
||||
export * from './node-spec';
|
||||
export * from './mark-spec';
|
||||
export * from './schema';
|
||||
export * from './validate';
|
||||
export * from './normalize';
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Mark } from '../model';
|
||||
import type { AttrsSpec } from './attr-spec';
|
||||
import type { DOMOutputSpec, ParseRule } from './dom';
|
||||
|
||||
/** Schema contribution of a mark type. */
|
||||
export interface MarkSpec {
|
||||
/** Attribute specs (defaults + validation), e.g. link `href`. */
|
||||
readonly attrs?: AttrsSpec;
|
||||
/** Whether typing at the mark's boundary extends it (bold yes, link no). */
|
||||
readonly inclusive?: boolean;
|
||||
/** Marks that cannot coexist with this one; `'_all'` excludes every other. */
|
||||
readonly excludes?: readonly string[] | '_all';
|
||||
/** Nesting order in {@link DOMOutputSpec}: lower = outer wrapper. */
|
||||
readonly rank?: number;
|
||||
/** Serialize the mark to a DOM description wrapping its content. */
|
||||
readonly toDOM: (mark: Mark) => DOMOutputSpec;
|
||||
/** Rules for parsing DOM into this mark (paste / import). */
|
||||
readonly parseDOM: readonly ParseRule[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Node } from '../model';
|
||||
import type { AttrsSpec } from './attr-spec';
|
||||
import type { ContentKind } from './content-kind';
|
||||
import type { DOMOutputSpec, ParseRule } from './dom';
|
||||
|
||||
/** Schema contribution of a block type. */
|
||||
export interface NodeSpec {
|
||||
/** Content model (text / container / atom). */
|
||||
readonly content: ContentKind;
|
||||
/** Attribute specs (defaults + validation). */
|
||||
readonly attrs?: AttrsSpec;
|
||||
/** Group name for membership tests (e.g. `'block'`, `'list'`). */
|
||||
readonly group?: string;
|
||||
/** Keep this block's type/identity when merged into (e.g. code-block). */
|
||||
readonly defining?: boolean;
|
||||
/** Raw multiline text: Enter inserts a newline instead of splitting (code-block). */
|
||||
readonly code?: boolean;
|
||||
/** Selection and merge cannot cross this block's boundary. */
|
||||
readonly isolating?: boolean;
|
||||
/** Serialize a node of this type to a DOM description (HTML export). */
|
||||
readonly toDOM?: (node: Node) => DOMOutputSpec;
|
||||
/** Rules for parsing DOM into a node of this type (paste / import). */
|
||||
readonly parseDOM?: readonly ParseRule[];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Inline, Node, WritekitDocument } from '../model';
|
||||
import { isInlineContent, normalizeInline, replaceBlocks } from '../model';
|
||||
import type { NodeSpec } from './node-spec';
|
||||
import type { Schema } from './schema';
|
||||
import { marksAllowed } from './schema';
|
||||
|
||||
function filterRunMarks(inline: Inline, spec: NodeSpec, schema: Schema): Inline {
|
||||
return inline.map(run => ({
|
||||
text: run.text,
|
||||
marks: run.marks.filter(mark => schema.markSpec(mark.type) !== undefined && marksAllowed(spec, mark.type)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a document to canonical form against a schema: coerce attrs, normalize
|
||||
* inline content, drop marks that are unknown or disallowed in their block, and
|
||||
* drop blocks of unknown type. This is the single funnel every document passes
|
||||
* through before it becomes writekit state.
|
||||
*/
|
||||
export function normalizeDocument(doc: WritekitDocument, schema: Schema): WritekitDocument {
|
||||
const content: Node[] = [];
|
||||
|
||||
for (const block of doc.content) {
|
||||
const spec = schema.nodeSpec(block.type);
|
||||
|
||||
if (!spec)
|
||||
continue; // drop unknown block types
|
||||
|
||||
const attrs = schema.coerceAttrs(block.type, block.attrs);
|
||||
|
||||
if (spec.content.kind === 'text') {
|
||||
const inline = isInlineContent(block.content) ? block.content : [];
|
||||
content.push({ ...block, attrs, content: normalizeInline(filterRunMarks(inline, spec, schema)) });
|
||||
}
|
||||
else if (spec.content.kind === 'atom') {
|
||||
content.push({ ...block, attrs, content: block.content ?? null });
|
||||
}
|
||||
else {
|
||||
content.push({ ...block, attrs });
|
||||
}
|
||||
}
|
||||
|
||||
return replaceBlocks(doc, content);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AttrValue, Attrs } from '../model';
|
||||
import type { AttrsSpec } from './attr-spec';
|
||||
import type { NodeSpec } from './node-spec';
|
||||
import type { MarkSpec } from './mark-spec';
|
||||
|
||||
/**
|
||||
* The compiled schema: the set of known node/mark specs plus attribute
|
||||
* coercion helpers. Projected from the registry (the registry is the SSOT).
|
||||
*/
|
||||
export interface Schema {
|
||||
readonly nodes: ReadonlyMap<string, NodeSpec>;
|
||||
readonly marks: ReadonlyMap<string, MarkSpec>;
|
||||
nodeSpec: (type: string) => NodeSpec | undefined;
|
||||
markSpec: (type: string) => MarkSpec | undefined;
|
||||
/** Default attrs for a block type (all defaults applied). */
|
||||
defaultAttrs: (type: string) => Attrs;
|
||||
/** Fill defaults and drop unknown keys for a block type. */
|
||||
coerceAttrs: (type: string, attrs?: Attrs) => Attrs;
|
||||
/** Default attrs for a mark type. */
|
||||
defaultMarkAttrs: (type: string) => Attrs;
|
||||
/** Fill defaults and drop unknown keys for a mark type. */
|
||||
coerceMarkAttrs: (type: string, attrs?: Attrs) => Attrs;
|
||||
}
|
||||
|
||||
function coerceWithSpec(spec: AttrsSpec | undefined, attrs?: Attrs): Attrs {
|
||||
if (!spec)
|
||||
return {};
|
||||
|
||||
const result: Record<string, AttrValue> = {};
|
||||
|
||||
for (const key in spec) {
|
||||
const provided = attrs?.[key];
|
||||
|
||||
if (provided !== undefined)
|
||||
result[key] = provided;
|
||||
else if (spec[key]!.default !== undefined)
|
||||
result[key] = spec[key]!.default!;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Build a {@link Schema} from node and mark spec maps. */
|
||||
export function createSchema(input: {
|
||||
nodes: ReadonlyMap<string, NodeSpec>;
|
||||
marks: ReadonlyMap<string, MarkSpec>;
|
||||
}): Schema {
|
||||
const { nodes, marks } = input;
|
||||
|
||||
return {
|
||||
nodes,
|
||||
marks,
|
||||
nodeSpec: type => nodes.get(type),
|
||||
markSpec: type => marks.get(type),
|
||||
defaultAttrs: type => coerceWithSpec(nodes.get(type)?.attrs),
|
||||
coerceAttrs: (type, attrs) => coerceWithSpec(nodes.get(type)?.attrs, attrs),
|
||||
defaultMarkAttrs: type => coerceWithSpec(marks.get(type)?.attrs),
|
||||
coerceMarkAttrs: (type, attrs) => coerceWithSpec(marks.get(type)?.attrs, attrs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a block spec holds inline (text) content. */
|
||||
export function isTextBlock(spec: NodeSpec): boolean {
|
||||
return spec.content.kind === 'text';
|
||||
}
|
||||
|
||||
/** Whether a block spec is an atom/void block. */
|
||||
export function isAtomBlock(spec: NodeSpec): boolean {
|
||||
return spec.content.kind === 'atom';
|
||||
}
|
||||
|
||||
/** Whether a block spec is a container of child blocks. */
|
||||
export function isContainerBlock(spec: NodeSpec): boolean {
|
||||
return spec.content.kind === 'container';
|
||||
}
|
||||
|
||||
/** Whether a mark of `markType` is allowed inside a block with this spec. */
|
||||
export function marksAllowed(spec: NodeSpec, markType: string): boolean {
|
||||
if (spec.content.kind !== 'text')
|
||||
return false;
|
||||
|
||||
const allowed = spec.content.marks;
|
||||
|
||||
if (allowed === undefined || allowed === 'all')
|
||||
return true;
|
||||
|
||||
if (allowed === 'none')
|
||||
return false;
|
||||
|
||||
return allowed.includes(markType);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { WritekitDocument } from '../model';
|
||||
import type { Schema } from './schema';
|
||||
|
||||
export interface ValidationResult {
|
||||
readonly valid: boolean;
|
||||
readonly errors: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural validation of a document against a schema. Reports unknown block
|
||||
* types, missing required attrs, and failed attr validators. Used in tests and
|
||||
* as a guard around untrusted input; runtime mutation paths rely on
|
||||
* {@link normalizeDocument} instead.
|
||||
*/
|
||||
export function validateDocument(doc: WritekitDocument, schema: Schema): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const block of doc.content) {
|
||||
const spec = schema.nodeSpec(block.type);
|
||||
|
||||
if (!spec) {
|
||||
errors.push(`unknown block type: '${block.type}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!spec.attrs)
|
||||
continue;
|
||||
|
||||
for (const key in spec.attrs) {
|
||||
const attr = spec.attrs[key]!;
|
||||
const value = block.attrs[key];
|
||||
|
||||
if (attr.required && value === undefined && attr.default === undefined)
|
||||
errors.push(`block '${block.type}' is missing required attr '${key}'`);
|
||||
|
||||
if (attr.validate && value !== undefined && !attr.validate(value))
|
||||
errors.push(`block '${block.type}' has invalid attr '${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDoc, createNode, nodeText } from '../../model';
|
||||
import { createDefaultRegistry } from '../../preset';
|
||||
import { applyStep } from '../step';
|
||||
|
||||
const schema = createDefaultRegistry().schema;
|
||||
|
||||
function para(id: string, text: string) {
|
||||
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
|
||||
}
|
||||
|
||||
describe('applyStep', () => {
|
||||
it('inserts and inverts to the original', () => {
|
||||
const doc = createDoc([para('a', 'hi')]);
|
||||
const inserted = applyStep(doc, { type: 'insertInline', blockId: 'a', offset: 2, content: [{ text: '!', marks: [] }] }, schema);
|
||||
expect(nodeText(inserted.doc.content[0]!)).toBe('hi!');
|
||||
|
||||
const back = applyStep(inserted.doc, inserted.inverted, schema);
|
||||
expect(nodeText(back.doc.content[0]!)).toBe('hi');
|
||||
});
|
||||
|
||||
it('splits a block, and its inverse merges back', () => {
|
||||
const doc = createDoc([para('a', 'hello')]);
|
||||
const split = applyStep(doc, { type: 'splitBlock', blockId: 'a', offset: 2, newId: 'b' }, schema);
|
||||
expect(split.doc.content.map(block => nodeText(block))).toEqual(['he', 'llo']);
|
||||
|
||||
const merged = applyStep(split.doc, split.inverted, schema);
|
||||
expect(merged.doc.content.map(block => nodeText(block))).toEqual(['hello']);
|
||||
});
|
||||
|
||||
it('moves a block, and its inverse restores order', () => {
|
||||
const doc = createDoc([para('a', '1'), para('b', '2'), para('c', '3')]);
|
||||
const moved = applyStep(doc, { type: 'moveBlock', blockId: 'a', toIndex: 2 }, schema);
|
||||
expect(moved.doc.content.map(block => block.id)).toEqual(['b', 'c', 'a']);
|
||||
|
||||
const back = applyStep(moved.doc, moved.inverted, schema);
|
||||
expect(back.doc.content.map(block => block.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('adds a mark and inverts to the prior inline state', () => {
|
||||
const doc = createDoc([para('a', 'abc')]);
|
||||
const marked = applyStep(doc, { type: 'addMark', blockId: 'a', from: 0, to: 3, mark: { type: 'bold' } }, schema);
|
||||
expect(marked.doc.content[0]!.content).toEqual([{ text: 'abc', marks: [{ type: 'bold' }] }]);
|
||||
|
||||
const back = applyStep(marked.doc, marked.inverted, schema);
|
||||
expect(back.doc.content[0]!.content).toEqual([{ text: 'abc', marks: [] }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { WritekitState } from './writekit-state';
|
||||
import type { Transaction } from './transaction';
|
||||
|
||||
/** Applies a transaction, updating writekit state and notifying subscribers. */
|
||||
export type Dispatch = (tr: Transaction) => void;
|
||||
|
||||
/**
|
||||
* Minimal view surface a command may use to move real DOM focus across blocks.
|
||||
* The Vue `WritekitContext` is structurally compatible; pure logic/tests can pass
|
||||
* a stub. Keeps the command layer free of any Vue/DOM dependency.
|
||||
*/
|
||||
export interface CommandView {
|
||||
focusBlock: (blockId: string, offset: number | 'start' | 'end') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A command in the ProseMirror style: returns `true` when applicable (and
|
||||
* dispatches when `dispatch` is provided), `false` otherwise so the keymap can
|
||||
* fall through to native behavior. Called without `dispatch` it is a dry run for
|
||||
* computing UI enabled/active state.
|
||||
*/
|
||||
export type Command = (state: WritekitState, dispatch?: Dispatch, view?: CommandView) => boolean;
|
||||
|
||||
/** A parameterized command constructor. */
|
||||
export type CommandFactory<Args extends readonly unknown[] = readonly unknown[]> = (...args: Args) => Command;
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Selection } from '../model';
|
||||
import type { Step } from './step';
|
||||
|
||||
/**
|
||||
* One undoable change: the steps it applied, their inverses, and the selection
|
||||
* before and after. Undo replays `inverted` (reversed); redo replays `steps`.
|
||||
*/
|
||||
export interface HistoryEntry {
|
||||
readonly steps: readonly Step[];
|
||||
readonly inverted: readonly Step[];
|
||||
readonly selectionBefore: Selection;
|
||||
readonly selectionAfter: Selection;
|
||||
}
|
||||
|
||||
export interface HistoryOptions {
|
||||
/** Maximum number of undo entries to retain (default 200). */
|
||||
readonly maxSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo/redo stacks of inverse-step entries. Borrows the ergonomics of stdlib's
|
||||
* command history (bounded size, redo cleared on a new edit) but stores data
|
||||
* (inverse steps) rather than closures — which is what makes it serializable and
|
||||
* collab-friendly.
|
||||
*/
|
||||
export interface History {
|
||||
/** Record a new edit, clearing the redo stack. */
|
||||
record: (entry: HistoryEntry) => void;
|
||||
/** Pop the latest undo entry (and push it onto the redo stack). */
|
||||
undo: () => HistoryEntry | undefined;
|
||||
/** Pop the latest redo entry (and push it back onto the undo stack). */
|
||||
redo: () => HistoryEntry | undefined;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function createHistory(options: HistoryOptions = {}): History {
|
||||
const maxSize = options.maxSize ?? 200;
|
||||
const undoStack: HistoryEntry[] = [];
|
||||
const redoStack: HistoryEntry[] = [];
|
||||
|
||||
return {
|
||||
record(entry) {
|
||||
undoStack.push(entry);
|
||||
if (undoStack.length > maxSize)
|
||||
undoStack.shift();
|
||||
redoStack.length = 0;
|
||||
},
|
||||
undo() {
|
||||
const entry = undoStack.pop();
|
||||
if (entry)
|
||||
redoStack.push(entry);
|
||||
return entry;
|
||||
},
|
||||
redo() {
|
||||
const entry = redoStack.pop();
|
||||
if (entry)
|
||||
undoStack.push(entry);
|
||||
return entry;
|
||||
},
|
||||
canUndo: () => undoStack.length > 0,
|
||||
canRedo: () => redoStack.length > 0,
|
||||
clear() {
|
||||
undoStack.length = 0;
|
||||
redoStack.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './command';
|
||||
export * from './writekit-state';
|
||||
export * from './step';
|
||||
export * from './transaction';
|
||||
export * from './history';
|
||||
export * from './writekit';
|
||||
@@ -0,0 +1,223 @@
|
||||
import { clamp, move } from '@robonen/stdlib';
|
||||
import type { Attrs, Inline, Mark, Node, WritekitDocument } from '../model';
|
||||
import {
|
||||
addMarkInline,
|
||||
blockById,
|
||||
blockIndex,
|
||||
createNode,
|
||||
deleteTextInline,
|
||||
findBlock,
|
||||
inlineLength,
|
||||
insertInline,
|
||||
nodeInline,
|
||||
normalizeInline,
|
||||
removeMarkInline,
|
||||
replaceBlocks,
|
||||
replaceInline,
|
||||
sliceInline,
|
||||
withAttrs,
|
||||
withContent,
|
||||
withType,
|
||||
} from '../model';
|
||||
import type { Schema } from '../schema';
|
||||
import { marksAllowed } from '../schema';
|
||||
|
||||
/**
|
||||
* The atomic, invertible, serializable unit of change. Steps are the contract
|
||||
* shared by the undo history (each carries its exact inverse) and the CRDT
|
||||
* adapter (each maps to a CRDT operation). Keeping the set small (~12) means a
|
||||
* new block type never needs a new step.
|
||||
*/
|
||||
export type Step
|
||||
= | { readonly type: 'insertInline'; readonly blockId: string; readonly offset: number; readonly content: Inline }
|
||||
| { readonly type: 'deleteText'; readonly blockId: string; readonly from: number; readonly to: number }
|
||||
| { readonly type: 'replaceInline'; readonly blockId: string; readonly from: number; readonly to: number; readonly content: Inline }
|
||||
| { readonly type: 'addMark'; readonly blockId: string; readonly from: number; readonly to: number; readonly mark: Mark }
|
||||
| { readonly type: 'removeMark'; readonly blockId: string; readonly from: number; readonly to: number; readonly mark: Mark }
|
||||
| { readonly type: 'setAttrs'; readonly blockId: string; readonly attrs: Attrs }
|
||||
| { readonly type: 'setType'; readonly blockId: string; readonly blockType: string; readonly attrs: Attrs }
|
||||
| { readonly type: 'splitBlock'; readonly blockId: string; readonly offset: number; readonly newId: string; readonly newType?: string; readonly newAttrs?: Attrs }
|
||||
| { readonly type: 'mergeBlock'; readonly blockId: string; readonly intoId: string }
|
||||
| { readonly type: 'insertBlock'; readonly node: Node; readonly index: number }
|
||||
| { readonly type: 'removeBlock'; readonly blockId: string }
|
||||
| { readonly type: 'moveBlock'; readonly blockId: string; readonly toIndex: number }
|
||||
| { readonly type: 'setDoc'; readonly doc: WritekitDocument };
|
||||
|
||||
export interface StepResult {
|
||||
readonly doc: WritekitDocument;
|
||||
readonly inverted: Step;
|
||||
}
|
||||
|
||||
function mapBlock(doc: WritekitDocument, blockId: string, fn: (node: Node) => Node): WritekitDocument {
|
||||
return replaceBlocks(doc, doc.content.map(block => (block.id === blockId ? fn(block) : block)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single step to a document, returning the next document and the exact
|
||||
* inverse step (so undo is correct by construction). Pure: never mutates input.
|
||||
* If the addressed block is missing the step is a no-op (defends against remote
|
||||
* steps referencing concurrently-removed blocks).
|
||||
*/
|
||||
export function applyStep(doc: WritekitDocument, step: Step, schema: Schema): StepResult {
|
||||
switch (step.type) {
|
||||
case 'insertInline': {
|
||||
const block = blockById(doc, step.blockId);
|
||||
if (!block)
|
||||
return { doc, inverted: step };
|
||||
|
||||
const next = normalizeInline(insertInline(nodeInline(block), step.offset, step.content));
|
||||
return {
|
||||
doc: mapBlock(doc, step.blockId, b => withContent(b, next)),
|
||||
inverted: { type: 'deleteText', blockId: step.blockId, from: step.offset, to: step.offset + inlineLength(step.content) },
|
||||
};
|
||||
}
|
||||
|
||||
case 'deleteText': {
|
||||
const block = blockById(doc, step.blockId);
|
||||
if (!block)
|
||||
return { doc, inverted: step };
|
||||
|
||||
const inline = nodeInline(block);
|
||||
const removed = sliceInline(inline, step.from, step.to);
|
||||
return {
|
||||
doc: mapBlock(doc, step.blockId, b => withContent(b, normalizeInline(deleteTextInline(inline, step.from, step.to)))),
|
||||
inverted: { type: 'insertInline', blockId: step.blockId, offset: step.from, content: removed },
|
||||
};
|
||||
}
|
||||
|
||||
case 'replaceInline': {
|
||||
const block = blockById(doc, step.blockId);
|
||||
if (!block)
|
||||
return { doc, inverted: step };
|
||||
|
||||
const inline = nodeInline(block);
|
||||
const removed = sliceInline(inline, step.from, step.to);
|
||||
return {
|
||||
doc: mapBlock(doc, step.blockId, b => withContent(b, normalizeInline(replaceInline(inline, step.from, step.to, step.content)))),
|
||||
inverted: { type: 'replaceInline', blockId: step.blockId, from: step.from, to: step.from + inlineLength(step.content), content: removed },
|
||||
};
|
||||
}
|
||||
|
||||
case 'addMark':
|
||||
case 'removeMark': {
|
||||
const block = blockById(doc, step.blockId);
|
||||
if (!block)
|
||||
return { doc, inverted: step };
|
||||
|
||||
const inline = nodeInline(block);
|
||||
const removed = sliceInline(inline, step.from, step.to); // exact prior state of the range
|
||||
const next = step.type === 'addMark'
|
||||
? addMarkInline(inline, step.from, step.to, step.mark)
|
||||
: removeMarkInline(inline, step.from, step.to, step.mark.type);
|
||||
return {
|
||||
doc: mapBlock(doc, step.blockId, b => withContent(b, normalizeInline(next))),
|
||||
// Length is unchanged, so restoring the saved slice over [from, to) is an exact inverse.
|
||||
inverted: { type: 'replaceInline', blockId: step.blockId, from: step.from, to: step.to, content: removed },
|
||||
};
|
||||
}
|
||||
|
||||
case 'setAttrs': {
|
||||
const block = blockById(doc, step.blockId);
|
||||
if (!block)
|
||||
return { doc, inverted: step };
|
||||
|
||||
return {
|
||||
doc: mapBlock(doc, step.blockId, b => withAttrs(b, step.attrs)),
|
||||
inverted: { type: 'setAttrs', blockId: step.blockId, attrs: block.attrs },
|
||||
};
|
||||
}
|
||||
|
||||
case 'setType': {
|
||||
const block = blockById(doc, step.blockId);
|
||||
if (!block)
|
||||
return { doc, inverted: step };
|
||||
|
||||
return {
|
||||
doc: mapBlock(doc, step.blockId, b => withType(b, step.blockType, step.attrs)),
|
||||
inverted: { type: 'setType', blockId: step.blockId, blockType: block.type, attrs: block.attrs },
|
||||
};
|
||||
}
|
||||
|
||||
case 'splitBlock': {
|
||||
const found = findBlock(doc, step.blockId);
|
||||
if (!found)
|
||||
return { doc, inverted: step };
|
||||
|
||||
const { node, index } = found;
|
||||
const inline = nodeInline(node);
|
||||
const head = normalizeInline(sliceInline(inline, 0, step.offset));
|
||||
const tail = normalizeInline(sliceInline(inline, step.offset, inlineLength(inline)));
|
||||
const newAttrs = step.newAttrs ?? (step.newType ? schema.defaultAttrs(step.newType) : node.attrs);
|
||||
const newNode = createNode(step.newType ?? node.type, { id: step.newId, attrs: newAttrs, content: tail });
|
||||
const content = [...doc.content.slice(0, index), withContent(node, head), newNode, ...doc.content.slice(index + 1)];
|
||||
return {
|
||||
doc: replaceBlocks(doc, content),
|
||||
inverted: { type: 'mergeBlock', blockId: step.newId, intoId: step.blockId },
|
||||
};
|
||||
}
|
||||
|
||||
case 'mergeBlock': {
|
||||
const source = findBlock(doc, step.blockId);
|
||||
const target = findBlock(doc, step.intoId);
|
||||
if (!source || !target)
|
||||
return { doc, inverted: step };
|
||||
|
||||
const targetInline = nodeInline(target.node);
|
||||
const splitOffset = inlineLength(targetInline);
|
||||
// Drop source marks the target block disallows (e.g. merging styled text
|
||||
// into a code-block must not smuggle in marks past `marks: 'none'`).
|
||||
const targetSpec = schema.nodeSpec(target.node.type);
|
||||
const sourceInline = targetSpec
|
||||
? nodeInline(source.node).map(run => ({ text: run.text, marks: run.marks.filter(m => marksAllowed(targetSpec, m.type)) }))
|
||||
: nodeInline(source.node);
|
||||
const mergedInline = normalizeInline([...targetInline, ...sourceInline]);
|
||||
const content = doc.content
|
||||
.map(block => (block.id === step.intoId ? withContent(target.node, mergedInline) : block))
|
||||
.filter(block => block.id !== step.blockId);
|
||||
return {
|
||||
doc: replaceBlocks(doc, content),
|
||||
inverted: { type: 'splitBlock', blockId: step.intoId, offset: splitOffset, newId: source.node.id, newType: source.node.type, newAttrs: source.node.attrs },
|
||||
};
|
||||
}
|
||||
|
||||
case 'insertBlock': {
|
||||
const index = clamp(step.index, 0, doc.content.length);
|
||||
const content = [...doc.content.slice(0, index), step.node, ...doc.content.slice(index)];
|
||||
return {
|
||||
doc: replaceBlocks(doc, content),
|
||||
inverted: { type: 'removeBlock', blockId: step.node.id },
|
||||
};
|
||||
}
|
||||
|
||||
case 'removeBlock': {
|
||||
const found = findBlock(doc, step.blockId);
|
||||
if (!found)
|
||||
return { doc, inverted: step };
|
||||
|
||||
return {
|
||||
doc: replaceBlocks(doc, doc.content.filter(block => block.id !== step.blockId)),
|
||||
inverted: { type: 'insertBlock', node: found.node, index: found.index },
|
||||
};
|
||||
}
|
||||
|
||||
case 'moveBlock': {
|
||||
const from = blockIndex(doc, step.blockId);
|
||||
if (from === -1)
|
||||
return { doc, inverted: step };
|
||||
|
||||
const arr = move(doc.content, from, step.toIndex);
|
||||
return {
|
||||
doc: replaceBlocks(doc, arr),
|
||||
inverted: { type: 'moveBlock', blockId: step.blockId, toIndex: from },
|
||||
};
|
||||
}
|
||||
|
||||
case 'setDoc': {
|
||||
// Replace the whole document (used to apply a remote CRDT snapshot).
|
||||
return {
|
||||
doc: step.doc,
|
||||
inverted: { type: 'setDoc', doc },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
import type { Attrs, Inline, Mark, Marks, Node, Position, Selection, WritekitDocument } from '../model';
|
||||
import { blockById, caret, createId, firstBlock, inlineLength, nodeInline } from '../model';
|
||||
import type { Schema } from '../schema';
|
||||
import type { WritekitState } from './writekit-state';
|
||||
import type { Step } from './step';
|
||||
import { applyStep } from './step';
|
||||
|
||||
/**
|
||||
* A mutable builder that accumulates atomic {@link Step}s over a working copy of
|
||||
* the document. Each builder method applies its step immediately (so later
|
||||
* builders see prior effects) and records the exact inverse for undo. Dispatch
|
||||
* turns the finished transaction into a new {@link WritekitState}.
|
||||
*/
|
||||
export class Transaction {
|
||||
readonly before: WritekitState;
|
||||
readonly steps: Step[] = [];
|
||||
/** Inverse of each step, in application order (reversed when undoing). */
|
||||
readonly inverted: Step[] = [];
|
||||
readonly meta = new Map<string, unknown>();
|
||||
/** Working document after the steps applied so far. */
|
||||
doc: WritekitDocument;
|
||||
/** Selection to apply after this transaction. */
|
||||
selection: Selection;
|
||||
/** `undefined` = leave stored marks to default handling; otherwise set them. */
|
||||
storedMarks: Marks | null | undefined = undefined;
|
||||
/** Id of the block created by the most recent {@link splitBlock}. */
|
||||
lastSplitId: string | undefined;
|
||||
|
||||
private readonly schema: Schema;
|
||||
|
||||
constructor(state: WritekitState) {
|
||||
this.before = state;
|
||||
this.doc = state.doc;
|
||||
this.selection = state.selection;
|
||||
this.schema = state.schema;
|
||||
}
|
||||
|
||||
/** Apply a raw step (also used by undo/redo to replay stored steps). */
|
||||
step(step: Step): this {
|
||||
const result = applyStep(this.doc, step, this.schema);
|
||||
this.doc = result.doc;
|
||||
this.steps.push(step);
|
||||
this.inverted.push(result.inverted);
|
||||
return this;
|
||||
}
|
||||
|
||||
insertText(pos: Position, text: string, marks: Marks = []): this {
|
||||
return this.step({ type: 'insertInline', blockId: pos.blockId, offset: pos.offset, content: text ? [{ text, marks }] : [] });
|
||||
}
|
||||
|
||||
insertInline(pos: Position, content: Inline): this {
|
||||
return this.step({ type: 'insertInline', blockId: pos.blockId, offset: pos.offset, content });
|
||||
}
|
||||
|
||||
deleteText(blockId: string, from: number, to: number): this {
|
||||
return this.step({ type: 'deleteText', blockId, from, to });
|
||||
}
|
||||
|
||||
replaceInline(blockId: string, from: number, to: number, content: Inline): this {
|
||||
return this.step({ type: 'replaceInline', blockId, from, to, content });
|
||||
}
|
||||
|
||||
/** Replace a block's entire inline content (used by the input flush path). */
|
||||
setBlockContent(blockId: string, content: Inline): this {
|
||||
const block = blockById(this.doc, blockId);
|
||||
const length = block ? inlineLength(nodeInline(block)) : 0;
|
||||
return this.step({ type: 'replaceInline', blockId, from: 0, to: length, content });
|
||||
}
|
||||
|
||||
addMark(blockId: string, from: number, to: number, mark: Mark): this {
|
||||
return this.step({ type: 'addMark', blockId, from, to, mark });
|
||||
}
|
||||
|
||||
removeMark(blockId: string, from: number, to: number, mark: Mark): this {
|
||||
return this.step({ type: 'removeMark', blockId, from, to, mark });
|
||||
}
|
||||
|
||||
/** Merge `attrs` into the block's existing attrs. */
|
||||
setAttrs(blockId: string, attrs: Attrs): this {
|
||||
const block = blockById(this.doc, blockId);
|
||||
return this.step({ type: 'setAttrs', blockId, attrs: { ...(block?.attrs ?? {}), ...attrs } });
|
||||
}
|
||||
|
||||
/** Convert a block to another type, preserving its inline content. */
|
||||
setBlockType(blockId: string, type: string, attrs?: Attrs): this {
|
||||
return this.step({ type: 'setType', blockId, blockType: type, attrs: attrs ?? this.schema.defaultAttrs(type) });
|
||||
}
|
||||
|
||||
splitBlock(pos: Position, newType?: string, newAttrs?: Attrs, newId: string = createId()): this {
|
||||
this.lastSplitId = newId;
|
||||
return this.step({ type: 'splitBlock', blockId: pos.blockId, offset: pos.offset, newId, newType, newAttrs });
|
||||
}
|
||||
|
||||
mergeBlock(blockId: string, intoId: string): this {
|
||||
return this.step({ type: 'mergeBlock', blockId, intoId });
|
||||
}
|
||||
|
||||
insertBlock(node: Node, index: number): this {
|
||||
return this.step({ type: 'insertBlock', node, index });
|
||||
}
|
||||
|
||||
removeBlock(blockId: string): this {
|
||||
return this.step({ type: 'removeBlock', blockId });
|
||||
}
|
||||
|
||||
moveBlock(blockId: string, toIndex: number): this {
|
||||
return this.step({ type: 'moveBlock', blockId, toIndex });
|
||||
}
|
||||
|
||||
/** Replace the whole document (used to apply a remote CRDT snapshot). */
|
||||
setDoc(doc: WritekitDocument): this {
|
||||
return this.step({ type: 'setDoc', doc });
|
||||
}
|
||||
|
||||
setSelection(selection: Selection): this {
|
||||
this.selection = selection;
|
||||
return this;
|
||||
}
|
||||
|
||||
setStoredMarks(marks: Marks | null): this {
|
||||
this.storedMarks = marks;
|
||||
return this;
|
||||
}
|
||||
|
||||
setMeta(key: string, value: unknown): this {
|
||||
this.meta.set(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
getMeta(key: string): unknown {
|
||||
return this.meta.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a transaction from the current writekit state. */
|
||||
export function createTransaction(state: WritekitState): Transaction {
|
||||
return new Transaction(state);
|
||||
}
|
||||
|
||||
function clampPoint(point: Position, doc: WritekitDocument): Position | null {
|
||||
const block = blockById(doc, point.blockId);
|
||||
|
||||
if (!block)
|
||||
return null;
|
||||
|
||||
const length = inlineLength(nodeInline(block));
|
||||
return { blockId: point.blockId, offset: clamp(point.offset, 0, length) };
|
||||
}
|
||||
|
||||
function clampSelection(selection: Selection, doc: WritekitDocument): Selection {
|
||||
if (selection.kind === 'node')
|
||||
return selection;
|
||||
|
||||
const anchor = clampPoint(selection.anchor, doc);
|
||||
const focus = clampPoint(selection.focus, doc);
|
||||
|
||||
if (!anchor || !focus) {
|
||||
const first = firstBlock(doc);
|
||||
return first ? caret(first.id, 0) : selection;
|
||||
}
|
||||
|
||||
return { kind: 'text', anchor, focus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the next writekit state from a transaction. Stored marks are kept when
|
||||
* explicitly set, cleared on any content change, and otherwise preserved.
|
||||
*/
|
||||
export function applyTransaction(state: WritekitState, tr: Transaction): WritekitState {
|
||||
const storedMarks = tr.storedMarks !== undefined
|
||||
? tr.storedMarks
|
||||
: (tr.steps.length > 0 ? null : state.storedMarks);
|
||||
|
||||
return {
|
||||
...state,
|
||||
doc: tr.doc,
|
||||
selection: clampSelection(tr.selection, tr.doc),
|
||||
storedMarks,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Marks, Selection, WritekitDocument } from '../model';
|
||||
import { caret, createDoc, createNode, firstBlock, nodeSelection } from '../model';
|
||||
import type { Registry } from '../registry';
|
||||
import type { Schema } from '../schema';
|
||||
import { normalizeDocument } from '../schema';
|
||||
|
||||
/** Immutable snapshot of everything the writekit renders and commands read. */
|
||||
export interface WritekitState {
|
||||
readonly doc: WritekitDocument;
|
||||
readonly selection: Selection;
|
||||
readonly schema: Schema;
|
||||
readonly registry: Registry;
|
||||
/** Marks to apply to the next typed character (toggle-before-type). */
|
||||
readonly storedMarks: Marks | null;
|
||||
}
|
||||
|
||||
export interface CreateWritekitStateOptions {
|
||||
readonly registry: Registry;
|
||||
readonly doc?: WritekitDocument;
|
||||
readonly selection?: Selection;
|
||||
}
|
||||
|
||||
function defaultBlockType(registry: Registry): string | undefined {
|
||||
if (registry.hasBlock('paragraph'))
|
||||
return 'paragraph';
|
||||
|
||||
for (const def of registry.listBlocks()) {
|
||||
if (def.spec.content.kind === 'text')
|
||||
return def.type;
|
||||
}
|
||||
|
||||
return registry.listBlocks()[0]?.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the initial writekit state: normalize the document against the schema and
|
||||
* ensure it has at least one editable block to place the caret in.
|
||||
*/
|
||||
export function createWritekitState(options: CreateWritekitStateOptions): WritekitState {
|
||||
const { registry } = options;
|
||||
const schema = registry.schema;
|
||||
|
||||
let doc = normalizeDocument(options.doc ?? createDoc(), schema);
|
||||
|
||||
if (doc.content.length === 0) {
|
||||
const type = defaultBlockType(registry);
|
||||
if (type)
|
||||
doc = createDoc([createNode(type, { attrs: schema.defaultAttrs(type) })]);
|
||||
}
|
||||
|
||||
const first = firstBlock(doc);
|
||||
const selection = options.selection ?? (first ? caret(first.id, 0) : nodeSelection([]));
|
||||
|
||||
return { doc, selection, schema, registry, storedMarks: null };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PubSub } from '@robonen/stdlib';
|
||||
import { selectionEq } from '../model';
|
||||
import type { Command, Dispatch } from './command';
|
||||
import type { WritekitState } from './writekit-state';
|
||||
import type { HistoryOptions } from './history';
|
||||
import { createHistory } from './history';
|
||||
import type { Transaction } from './transaction';
|
||||
import { applyTransaction, createTransaction } from './transaction';
|
||||
|
||||
/**
|
||||
* Writekit event map. A `type` (not `interface`) so it satisfies the
|
||||
* `Record<string, ...>` constraint of {@link PubSub}.
|
||||
*/
|
||||
export interface WritekitEvents {
|
||||
/** Fired for every applied transaction (local, undo/redo, or remote). */
|
||||
transaction: (tr: Transaction, next: WritekitState, prev: WritekitState) => void;
|
||||
/** Fired when the document changed. */
|
||||
docChange: (next: WritekitState, prev: WritekitState) => void;
|
||||
/** Fired when the selection changed. */
|
||||
selectionChange: (next: WritekitState, prev: WritekitState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The headless writekit controller: owns live state, the undo history, and a
|
||||
* typed event bus. The Vue layer wraps it; the CRDT adapter subscribes to it.
|
||||
*/
|
||||
export interface Writekit {
|
||||
readonly state: WritekitState;
|
||||
dispatch: Dispatch;
|
||||
/** Run a command against the current state, dispatching if it applies. */
|
||||
command: (cmd: Command) => boolean;
|
||||
undo: () => boolean;
|
||||
redo: () => boolean;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
on: <K extends keyof WritekitEvents>(event: K, listener: WritekitEvents[K]) => void;
|
||||
off: <K extends keyof WritekitEvents>(event: K, listener: WritekitEvents[K]) => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export interface CreateWritekitOptions {
|
||||
readonly state: WritekitState;
|
||||
readonly history?: HistoryOptions;
|
||||
}
|
||||
|
||||
/** Create an {@link Writekit} around an initial state. */
|
||||
export function createWritekit(options: CreateWritekitOptions): Writekit {
|
||||
let state = options.state;
|
||||
// A mapped type (not the `interface`) satisfies PubSub's `Record<string, …>` constraint.
|
||||
const bus = new PubSub<{ [K in keyof WritekitEvents]: WritekitEvents[K] }>();
|
||||
const history = createHistory(options.history);
|
||||
|
||||
const dispatch: Dispatch = (tr) => {
|
||||
const prev = state;
|
||||
const next = applyTransaction(prev, tr);
|
||||
state = next;
|
||||
|
||||
if (tr.meta.get('addToHistory') !== false && tr.steps.length > 0) {
|
||||
history.record({
|
||||
steps: tr.steps,
|
||||
inverted: tr.inverted,
|
||||
selectionBefore: prev.selection,
|
||||
selectionAfter: next.selection,
|
||||
});
|
||||
}
|
||||
|
||||
bus.emit('transaction', tr, next, prev);
|
||||
if (next.doc !== prev.doc)
|
||||
bus.emit('docChange', next, prev);
|
||||
if (!selectionEq(next.selection, prev.selection))
|
||||
bus.emit('selectionChange', next, prev);
|
||||
};
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
dispatch,
|
||||
command: cmd => cmd(state, dispatch),
|
||||
undo() {
|
||||
const entry = history.undo();
|
||||
if (!entry)
|
||||
return false;
|
||||
|
||||
const tr = createTransaction(state);
|
||||
for (let i = entry.inverted.length - 1; i >= 0; i--)
|
||||
tr.step(entry.inverted[i]!);
|
||||
|
||||
tr.setSelection(entry.selectionBefore).setMeta('addToHistory', false).setMeta('history', 'undo');
|
||||
dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
redo() {
|
||||
const entry = history.redo();
|
||||
if (!entry)
|
||||
return false;
|
||||
|
||||
const tr = createTransaction(state);
|
||||
for (const step of entry.steps)
|
||||
tr.step(step);
|
||||
|
||||
tr.setSelection(entry.selectionAfter).setMeta('addToHistory', false).setMeta('history', 'redo');
|
||||
dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
canUndo: history.canUndo,
|
||||
canRedo: history.canRedo,
|
||||
on(event, listener) {
|
||||
bus.on(event, listener);
|
||||
},
|
||||
off(event, listener) {
|
||||
bus.off(event, listener);
|
||||
},
|
||||
destroy() {
|
||||
bus.clear('transaction').clear('docChange').clear('selectionChange');
|
||||
history.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import type { Attrs, Node } from '../model';
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IntrinsicElementAttributes } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { nodeSelection } from '../model';
|
||||
import { createTransaction } from '../state';
|
||||
import { Primitive } from './primitive';
|
||||
import { useWritekitContext } from './context';
|
||||
import TextBlockHost from './TextBlockHost.vue';
|
||||
|
||||
export interface BlockViewProps {
|
||||
block: Node;
|
||||
}
|
||||
|
||||
const { block } = defineProps<BlockViewProps>();
|
||||
const ctx = useWritekitContext();
|
||||
|
||||
const def = computed(() => ctx.registry.getBlock(block.type));
|
||||
const wrapperTag = computed<keyof IntrinsicElementAttributes>(() => (def.value?.as ?? 'div') as keyof IntrinsicElementAttributes);
|
||||
const isText = computed(() => def.value?.spec.content.kind === 'text');
|
||||
const atomComponent = computed(() => def.value?.component);
|
||||
const isSelected = computed(() => {
|
||||
const sel = ctx.state.value.selection;
|
||||
return sel.kind === 'node' && sel.ids.includes(block.id);
|
||||
});
|
||||
|
||||
function updateAttrs(attrs: Attrs): void {
|
||||
ctx.dispatch(createTransaction(ctx.writekit.state).setAttrs(block.id, attrs).setSelection(ctx.writekit.state.selection));
|
||||
}
|
||||
|
||||
/** Clicking an atom block selects it as a node (so Backspace/Delete remove it). */
|
||||
function onMousedown(event: MouseEvent): void {
|
||||
if (isText.value)
|
||||
return;
|
||||
|
||||
// Don't hijack interactive controls inside the atom (e.g. image fields).
|
||||
if ((event.target as HTMLElement).closest('input, textarea, button, a, select'))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
ctx.dispatch(createTransaction(ctx.writekit.state).setSelection(nodeSelection([block.id])));
|
||||
ctx.contentRoot.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
const DND_TYPE = 'application/x-robonen-writekit-block';
|
||||
|
||||
function onDragStart(event: DragEvent): void {
|
||||
event.dataTransfer?.setData(DND_TYPE, block.id);
|
||||
if (event.dataTransfer)
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent): void {
|
||||
if (event.dataTransfer?.types.includes(DND_TYPE))
|
||||
event.preventDefault(); // allow drop
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): void {
|
||||
const draggedId = event.dataTransfer?.getData(DND_TYPE);
|
||||
if (!draggedId || draggedId === block.id)
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
const toIndex = ctx.writekit.state.doc.content.findIndex(candidate => candidate.id === block.id);
|
||||
if (toIndex !== -1)
|
||||
ctx.dispatch(createTransaction(ctx.writekit.state).moveBlock(draggedId, toIndex).setSelection(ctx.writekit.state.selection));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="wrapperTag"
|
||||
:data-block-id="block.id"
|
||||
:data-block-type="block.type"
|
||||
:data-selected="isSelected ? '' : undefined"
|
||||
:data-draggable="ctx.config.draggable ? '' : undefined"
|
||||
:contenteditable="isText ? undefined : 'false'"
|
||||
@mousedown="onMousedown"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<span
|
||||
v-if="ctx.config.draggable"
|
||||
class="writekit-drag-handle"
|
||||
data-writekit-drag-handle=""
|
||||
contenteditable="false"
|
||||
draggable="true"
|
||||
aria-label="Drag to reorder"
|
||||
@mousedown.stop
|
||||
@dragstart="onDragStart"
|
||||
>⠿</span>
|
||||
<TextBlockHost
|
||||
v-if="isText && def"
|
||||
:block="block"
|
||||
:definition="def"
|
||||
/>
|
||||
<component
|
||||
:is="atomComponent || 'div'"
|
||||
v-else-if="atomComponent"
|
||||
:node="block"
|
||||
:selected="isSelected"
|
||||
:editable="ctx.config.editable"
|
||||
:update="updateAttrs"
|
||||
/>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import type { Node } from '../model';
|
||||
import type { BlockDefinition } from '../registry';
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IntrinsicElementAttributes } from 'vue';
|
||||
import { computed, onBeforeUnmount, watch } from 'vue';
|
||||
import { inlineLength, nodeInline } from '../model';
|
||||
import { Primitive } from './primitive';
|
||||
import { useWritekitContext } from './context';
|
||||
import { renderRuns } from './inline-content';
|
||||
|
||||
export interface TextBlockHostProps {
|
||||
block: Node;
|
||||
definition: BlockDefinition;
|
||||
}
|
||||
|
||||
const { block, definition } = defineProps<TextBlockHostProps>();
|
||||
const ctx = useWritekitContext();
|
||||
|
||||
let hostEl: HTMLElement | null = null;
|
||||
|
||||
/** The element's tag — derived from the block's `toDOM` (h1, p, …). */
|
||||
const tag = computed<keyof IntrinsicElementAttributes>(() => {
|
||||
const out = definition.spec.toDOM?.(block);
|
||||
if (typeof out === 'string')
|
||||
return out as keyof IntrinsicElementAttributes;
|
||||
if (Array.isArray(out) && typeof out[0] === 'string')
|
||||
return out[0] as keyof IntrinsicElementAttributes;
|
||||
return (definition.as ?? 'div') as keyof IntrinsicElementAttributes;
|
||||
});
|
||||
|
||||
const isEmpty = computed(() => inlineLength(nodeInline(block)) === 0);
|
||||
|
||||
/** Element attributes contributed by the block's `toDOM` (e.g. callout/list styling). */
|
||||
const hostAttrs = computed<Record<string, string>>(() => {
|
||||
const out = definition.spec.toDOM?.(block);
|
||||
if (Array.isArray(out) && out.length > 1) {
|
||||
const second = out[1];
|
||||
if (second && typeof second === 'object' && !Array.isArray(second))
|
||||
return second as Record<string, string>;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
/**
|
||||
* Function ref: fires when the element is created or replaced (e.g. a heading
|
||||
* level change swaps the tag). Registers the element and paints its inline
|
||||
* content imperatively. The element is NOT itself contenteditable — the single
|
||||
* editable root is the ancestor WritekitContent — but Vue must never diff this
|
||||
* inner DOM, which is what keeps the caret stable.
|
||||
*/
|
||||
function setHost(el: unknown): void {
|
||||
const node = (el as HTMLElement | null) ?? null;
|
||||
hostEl = node;
|
||||
|
||||
if (node) {
|
||||
ctx.blockElements.set(block.id, node);
|
||||
renderRuns(node, nodeInline(block), ctx.registry);
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => ctx.blockElements.delete(block.id));
|
||||
|
||||
// Re-paint only for foreign changes (undo/redo, commands, remote, another block)
|
||||
// — never for our own keystrokes (origin === block.id), where the DOM is current.
|
||||
watch(
|
||||
() => block.content,
|
||||
() => {
|
||||
if (ctx.composing.value || ctx.lastOrigin.value === block.id || !hostEl)
|
||||
return;
|
||||
renderRuns(hostEl, nodeInline(block), ctx.registry);
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="setHost"
|
||||
:as="tag"
|
||||
v-bind="hostAttrs"
|
||||
data-block-content=""
|
||||
:data-block-id="block.id"
|
||||
:data-empty="isEmpty ? '' : undefined"
|
||||
:data-placeholder="definition.placeholder"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from './primitive';
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { blockById, caret, inlineLength, isCollapsed, nodeInline } from '../model';
|
||||
import { applyInputRule, deleteSelection, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
|
||||
import { createTransaction } from '../state';
|
||||
import { Primitive } from './primitive';
|
||||
import { useWritekitContext } from './context';
|
||||
import { isInteractiveTarget } from './interactive';
|
||||
import { parseRuns } from './inline-content';
|
||||
import BlockView from './BlockView.vue';
|
||||
|
||||
export interface WritekitContentProps extends PrimitiveProps {}
|
||||
|
||||
const { as = 'div' } = defineProps<WritekitContentProps>();
|
||||
const ctx = useWritekitContext();
|
||||
|
||||
function setContentRoot(el: unknown): void {
|
||||
ctx.contentRoot.value = (el as HTMLElement | null) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept content mutations that the browser would handle incorrectly across
|
||||
* the single editable root: ranged edits (which could corrupt cross-block DOM)
|
||||
* and structural edits at block boundaries. Plain intra-block typing/deletion is
|
||||
* left to the browser and synced from the DOM on `input`.
|
||||
*/
|
||||
function onBeforeInput(event: InputEvent): void {
|
||||
if (ctx.composing.value || isInteractiveTarget(event.target))
|
||||
return;
|
||||
|
||||
const type = event.inputType;
|
||||
if (!type.startsWith('insert') && !type.startsWith('delete'))
|
||||
return;
|
||||
|
||||
const sel = ctx.selection.read();
|
||||
if (!sel || sel.kind !== 'text')
|
||||
return;
|
||||
|
||||
// Ranged selection — we own it so cross-block deletes/inserts stay consistent.
|
||||
if (!isCollapsed(sel)) {
|
||||
event.preventDefault();
|
||||
ctx.writekit.command(deleteSelection);
|
||||
|
||||
const after = ctx.writekit.state.selection;
|
||||
if (after.kind !== 'text')
|
||||
return;
|
||||
|
||||
if ((type === 'insertText' || type === 'insertReplacementText' || type === 'insertCompositionText') && event.data) {
|
||||
ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
|
||||
.insertText(after.focus, event.data, ctx.writekit.state.storedMarks ?? [])
|
||||
.setSelection(caret(after.focus.blockId, after.focus.offset + event.data.length)));
|
||||
}
|
||||
else if (type === 'insertParagraph') {
|
||||
ctx.writekit.command(splitBlock);
|
||||
}
|
||||
else if (type === 'insertLineBreak') {
|
||||
ctx.writekit.command(insertHardBreak);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapsed — take over only structural edits and block-boundary deletions.
|
||||
const block = blockById(ctx.writekit.state.doc, sel.focus.blockId);
|
||||
const atStart = sel.focus.offset === 0;
|
||||
const atEnd = block ? sel.focus.offset === inlineLength(nodeInline(block)) : false;
|
||||
|
||||
switch (type) {
|
||||
case 'insertParagraph':
|
||||
event.preventDefault();
|
||||
ctx.writekit.command(splitBlock);
|
||||
break;
|
||||
case 'insertLineBreak':
|
||||
event.preventDefault();
|
||||
ctx.writekit.command(insertHardBreak);
|
||||
break;
|
||||
case 'deleteContentBackward':
|
||||
if (atStart) {
|
||||
event.preventDefault();
|
||||
ctx.writekit.command(joinBackward);
|
||||
}
|
||||
break; // else: native deletes within the block, synced on `input`
|
||||
case 'deleteContentForward':
|
||||
if (atEnd) {
|
||||
event.preventDefault();
|
||||
ctx.writekit.command(joinForward);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break; // insertText etc. → native, synced on `input`
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync the model from the DOM after a native intra-block edit (one block only). */
|
||||
function onInput(event?: Event): void {
|
||||
if (ctx.composing.value || (event && isInteractiveTarget(event.target)))
|
||||
return;
|
||||
|
||||
const sel = ctx.selection.read();
|
||||
if (!sel || sel.kind !== 'text')
|
||||
return;
|
||||
|
||||
const host = ctx.blockElements.get(sel.focus.blockId);
|
||||
const block = blockById(ctx.writekit.state.doc, sel.focus.blockId);
|
||||
if (!host || !block || ctx.writekit.state.schema.nodeSpec(block.type)?.content.kind !== 'text')
|
||||
return;
|
||||
|
||||
const runs = parseRuns(host, ctx.registry);
|
||||
ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
|
||||
.setBlockContent(sel.focus.blockId, runs)
|
||||
.setSelection(sel)
|
||||
.setMeta('origin', sel.focus.blockId)); // suppress repaint of the block we just typed in
|
||||
|
||||
// Markdown-style shortcuts: '# ' → heading, '- ' → list, '> ' → quote, …
|
||||
ctx.writekit.command(applyInputRule);
|
||||
}
|
||||
|
||||
function onCompositionStart(event: CompositionEvent): void {
|
||||
if (isInteractiveTarget(event.target))
|
||||
return;
|
||||
ctx.composing.value = true;
|
||||
}
|
||||
|
||||
function onCompositionEnd(event: CompositionEvent): void {
|
||||
if (isInteractiveTarget(event.target))
|
||||
return;
|
||||
ctx.composing.value = false;
|
||||
onInput();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="setContentRoot"
|
||||
:as="as"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
:aria-readonly="!ctx.config.editable || undefined"
|
||||
data-writekit-content=""
|
||||
:contenteditable="ctx.config.editable ? 'true' : 'false'"
|
||||
:spellcheck="ctx.config.spellcheck"
|
||||
@beforeinput="onBeforeInput"
|
||||
@input="onInput"
|
||||
@compositionstart="onCompositionStart"
|
||||
@compositionend="onCompositionEnd"
|
||||
>
|
||||
<BlockView
|
||||
v-for="block in ctx.state.value.doc.content"
|
||||
:key="block.id"
|
||||
:block="block"
|
||||
/>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from './primitive';
|
||||
import type { Keymap } from '../keymap';
|
||||
import type { Command, CommandView, Transaction, Writekit, WritekitState } from '../state';
|
||||
import type { Platform } from './config';
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, shallowRef } from 'vue';
|
||||
import { blockById, caret, inlineLength, nodeInline, selectionEq } from '../model';
|
||||
import { compileKeymaps, defaultKeymap, runKeydown } from '../keymap';
|
||||
import { createTransaction } from '../state';
|
||||
import { Primitive } from './primitive';
|
||||
import { provideWritekitContext } from './context';
|
||||
import { resolveConfig } from './config';
|
||||
import { createBlockElementRegistry, createSelectionBridge } from './selection';
|
||||
import { useEventListener } from './composables';
|
||||
import { isInteractiveTarget } from './interactive';
|
||||
import WritekitContent from './WritekitContent.vue';
|
||||
|
||||
export interface WritekitRootProps extends PrimitiveProps {
|
||||
/** The headless controller (create with `createWritekit(createWritekitState(...))`). */
|
||||
writekit: Writekit;
|
||||
/** User keymaps, merged over the defaults (earlier wins). */
|
||||
keymaps?: Keymap[];
|
||||
editable?: boolean;
|
||||
dir?: 'ltr' | 'rtl';
|
||||
spellcheck?: boolean;
|
||||
platform?: Platform;
|
||||
/** Show per-block drag handles for reordering. */
|
||||
draggable?: boolean;
|
||||
/** Focus the start of the document on mount. */
|
||||
autofocus?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
writekit,
|
||||
keymaps,
|
||||
as = 'div',
|
||||
editable = true,
|
||||
dir = 'ltr',
|
||||
spellcheck = true,
|
||||
platform,
|
||||
draggable = false,
|
||||
autofocus = false,
|
||||
} = defineProps<WritekitRootProps>();
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const contentRoot = shallowRef<HTMLElement | null>(null);
|
||||
const state = shallowRef<WritekitState>(writekit.state);
|
||||
const composing = ref(false);
|
||||
const lastOrigin = ref<string | undefined>(undefined);
|
||||
const blockElements = createBlockElementRegistry();
|
||||
const selection = createSelectionBridge(() => contentRoot.value, blockElements);
|
||||
const config = resolveConfig({ editable, dir, spellcheck, platform, draggable });
|
||||
const compiled = compileKeymaps([...(keymaps ?? []), defaultKeymap(writekit)], config.platform);
|
||||
|
||||
let suppressSelectionSync = false;
|
||||
|
||||
function focusBlock(blockId: string, offset: number | 'start' | 'end'): void {
|
||||
const block = blockById(writekit.state.doc, blockId);
|
||||
const length = block ? inlineLength(nodeInline(block)) : 0;
|
||||
const resolved = offset === 'start' ? 0 : offset === 'end' ? length : offset;
|
||||
selection.write(caret(blockId, resolved));
|
||||
}
|
||||
|
||||
const view: CommandView = { focusBlock };
|
||||
|
||||
provideWritekitContext({
|
||||
writekit,
|
||||
state,
|
||||
registry: writekit.state.registry,
|
||||
config,
|
||||
contentRoot,
|
||||
blockElements,
|
||||
selection,
|
||||
composing,
|
||||
lastOrigin,
|
||||
dispatch: writekit.dispatch,
|
||||
exec: (command: Command) => writekit.command(command),
|
||||
focusBlock,
|
||||
});
|
||||
|
||||
/** After a transaction, reflect the model selection back into the DOM caret. */
|
||||
function reconcileSelection(): void {
|
||||
if (composing.value)
|
||||
return;
|
||||
|
||||
// The user is editing an atom's control (e.g. an image caption input); writing
|
||||
// the model selection here would focus the writekit and yank focus out of it.
|
||||
if (typeof document !== 'undefined' && isInteractiveTarget(document.activeElement))
|
||||
return;
|
||||
|
||||
const current = selection.read();
|
||||
if (current && selectionEq(current, writekit.state.selection))
|
||||
return;
|
||||
|
||||
suppressSelectionSync = true;
|
||||
selection.write(writekit.state.selection);
|
||||
nextTick(() => {
|
||||
suppressSelectionSync = false;
|
||||
});
|
||||
}
|
||||
|
||||
function onTransaction(tr: Transaction, next: WritekitState): void {
|
||||
state.value = next;
|
||||
lastOrigin.value = tr.getMeta('origin') as string | undefined;
|
||||
// Defer to nextTick so block content re-renders (TextBlockHost) run first.
|
||||
nextTick(reconcileSelection);
|
||||
}
|
||||
|
||||
writekit.on('transaction', onTransaction);
|
||||
onBeforeUnmount(() => writekit.off('transaction', onTransaction));
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (composing.value || event.isComposing || !editable || isInteractiveTarget(event.target))
|
||||
return; // let atom controls (e.g. image caption inputs) handle their own keys
|
||||
|
||||
if (runKeydown(event, compiled, writekit.state, writekit.dispatch, view)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectionChange(): void {
|
||||
if (composing.value || suppressSelectionSync)
|
||||
return;
|
||||
|
||||
// Ignore selection changes that belong to an atom's own control (e.g. an image
|
||||
// caption input) — reading/writing them would steal focus back into the writekit.
|
||||
if (typeof document !== 'undefined' && isInteractiveTarget(document.activeElement))
|
||||
return;
|
||||
|
||||
const sel = selection.read();
|
||||
if (!sel || selectionEq(sel, writekit.state.selection))
|
||||
return;
|
||||
|
||||
writekit.dispatch(createTransaction(writekit.state).setSelection(sel).setMeta('selectionOnly', true));
|
||||
}
|
||||
|
||||
useEventListener(root, 'keydown', onKeydown as (event: Event) => void, { capture: true });
|
||||
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', onSelectionChange);
|
||||
|
||||
if (autofocus) {
|
||||
nextTick(() => {
|
||||
const first = writekit.state.doc.content[0];
|
||||
if (first)
|
||||
focusBlock(first.id, 'start');
|
||||
});
|
||||
}
|
||||
|
||||
function setRoot(el: unknown): void {
|
||||
root.value = (el as HTMLElement | null) ?? null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="setRoot"
|
||||
:as="as"
|
||||
role="group"
|
||||
data-writekit-root=""
|
||||
:data-editable="editable ? '' : undefined"
|
||||
:dir="dir"
|
||||
>
|
||||
<slot><WritekitContent /></slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
import { render } from 'vitest-browser-vue';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import { createDoc, createNode, textSelection } from '../../model';
|
||||
import { createDefaultRegistry } from '../../preset';
|
||||
import { createTransaction, createWritekit, createWritekitState } from '../../state';
|
||||
import WritekitRoot from '../WritekitRoot.vue';
|
||||
|
||||
function para(id: string, text: string) {
|
||||
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
|
||||
}
|
||||
|
||||
function mount(blocks: Array<ReturnType<typeof para>>) {
|
||||
const registry = createDefaultRegistry();
|
||||
const writekit = createWritekit({ state: createWritekitState({ registry, doc: createDoc(blocks) }) });
|
||||
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
|
||||
return writekit;
|
||||
}
|
||||
|
||||
function selectNative(anchor: { node: Node; offset: number }, focus: { node: Node; offset: number }) {
|
||||
const sel = getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
const range = document.createRange();
|
||||
range.setStart(anchor.node, anchor.offset);
|
||||
range.setEnd(focus.node, focus.offset);
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
describe('WritekitRoot (single contenteditable)', () => {
|
||||
it('renders ONE editable root containing non-editable block elements', async () => {
|
||||
mount([para('a', 'hello')]);
|
||||
await nextTick();
|
||||
|
||||
const ce = document.querySelector('[data-writekit-content]')!;
|
||||
expect(ce.getAttribute('contenteditable')).toBe('true');
|
||||
|
||||
const host = document.querySelector('[data-block-content]') as HTMLElement;
|
||||
expect(host.textContent).toBe('hello');
|
||||
// The block element itself is NOT a separate editing host.
|
||||
expect(host.getAttribute('contenteditable')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a cross-block native selection to a cross-block model range', async () => {
|
||||
const writekit = mount([para('a', 'hello'), para('b', 'world')]);
|
||||
await nextTick();
|
||||
|
||||
const hosts = document.querySelectorAll('[data-block-content]');
|
||||
const aText = hosts[0]!.firstChild!; // text node "hello"
|
||||
const bText = hosts[1]!.firstChild!; // text node "world"
|
||||
|
||||
selectNative({ node: aText, offset: 1 }, { node: bText, offset: 3 });
|
||||
|
||||
// `selectionchange` is dispatched on a macrotask, so awaiting microtasks
|
||||
// (nextTick) isn't enough — poll until the writekit has synced the model.
|
||||
const sel = await vi.waitFor(() => {
|
||||
const s = writekit.state.selection;
|
||||
if (s.kind !== 'text' || s.anchor.offset !== 1)
|
||||
throw new Error('selection not synced yet');
|
||||
return s;
|
||||
});
|
||||
|
||||
expect(sel.anchor.blockId).toBe('a');
|
||||
expect(sel.anchor.offset).toBe(1);
|
||||
expect(sel.focus.blockId).toBe('b');
|
||||
expect(sel.focus.offset).toBe(3);
|
||||
});
|
||||
|
||||
it('writes a cross-block model selection back to a native range spanning blocks', async () => {
|
||||
const writekit = mount([para('a', 'hello'), para('b', 'world')]);
|
||||
await nextTick();
|
||||
|
||||
writekit.dispatch(createTransaction(writekit.state).setSelection(
|
||||
textSelection({ blockId: 'a', offset: 2 }, { blockId: 'b', offset: 4 }),
|
||||
));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const sel = getSelection()!;
|
||||
const hosts = document.querySelectorAll('[data-block-content]');
|
||||
expect(hosts[0]!.contains(sel.anchorNode)).toBe(true);
|
||||
expect(hosts[1]!.contains(sel.focusNode)).toBe(true);
|
||||
expect(sel.isCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('applies bold via Mod-b to a selected range', async () => {
|
||||
const writekit = mount([para('a', 'hello')]);
|
||||
await nextTick();
|
||||
|
||||
writekit.dispatch(createTransaction(writekit.state).setSelection(
|
||||
textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 5 }),
|
||||
));
|
||||
await nextTick();
|
||||
|
||||
const root = document.querySelector<HTMLElement>('[data-writekit-root]')!;
|
||||
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(document.querySelector('[data-block-content] strong')?.textContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('splits a block on Enter', async () => {
|
||||
const writekit = mount([para('a', 'hello')]);
|
||||
await nextTick();
|
||||
|
||||
writekit.dispatch(createTransaction(writekit.state).setSelection(textSelection({ blockId: 'a', offset: 2 })));
|
||||
await nextTick();
|
||||
|
||||
const root = document.querySelector<HTMLElement>('[data-writekit-root]')!;
|
||||
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const hosts = document.querySelectorAll('[data-block-content]');
|
||||
expect(hosts.length).toBe(2);
|
||||
expect(hosts[0]!.textContent).toBe('he');
|
||||
expect(hosts[1]!.textContent).toBe('llo');
|
||||
});
|
||||
|
||||
it('merges into the previous block on Backspace at block start', async () => {
|
||||
const writekit = mount([para('a', 'foo'), para('b', 'bar')]);
|
||||
await nextTick();
|
||||
|
||||
writekit.dispatch(createTransaction(writekit.state).setSelection(textSelection({ blockId: 'b', offset: 0 })));
|
||||
await nextTick();
|
||||
|
||||
const root = document.querySelector<HTMLElement>('[data-writekit-root]')!;
|
||||
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const hosts = document.querySelectorAll('[data-block-content]');
|
||||
expect(hosts.length).toBe(1);
|
||||
expect(hosts[0]!.textContent).toBe('foobar');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { useContextFactory, useEventListener } from '@robonen/vue';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { isIOS, isMac } from '@robonen/platform/browsers';
|
||||
|
||||
export type Platform = 'mac' | 'other';
|
||||
|
||||
/** Writekit-wide configuration provided through the writekit context. */
|
||||
export interface WritekitConfig {
|
||||
/** Whether content is editable (false renders read-only). */
|
||||
editable: boolean;
|
||||
/** Platform for keybinding normalization (`Mod` → Cmd/Ctrl). */
|
||||
platform: Platform;
|
||||
/** Text direction. */
|
||||
dir: 'ltr' | 'rtl';
|
||||
/** Native spellcheck on the contenteditable hosts. */
|
||||
spellcheck: boolean;
|
||||
/** Show per-block drag handles for reordering. */
|
||||
draggable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the platform for keybinding normalization (defaults to `'other'`
|
||||
* off-browser). Delegates UA sniffing to `@robonen/platform`, which also handles
|
||||
* iPadOS masquerading as a Mac; `isMac`/`isIOS` return `undefined` off-browser.
|
||||
*/
|
||||
export function detectPlatform(): Platform {
|
||||
return (isMac() || isIOS()) ? 'mac' : 'other';
|
||||
}
|
||||
|
||||
/** Build a config with sensible defaults. */
|
||||
export function resolveConfig(partial?: Partial<WritekitConfig>): WritekitConfig {
|
||||
return {
|
||||
editable: partial?.editable ?? true,
|
||||
platform: partial?.platform ?? detectPlatform(),
|
||||
dir: partial?.dir ?? 'ltr',
|
||||
spellcheck: partial?.spellcheck ?? true,
|
||||
draggable: partial?.draggable ?? false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Ref, ShallowRef } from 'vue';
|
||||
import type { Registry } from '../registry';
|
||||
import { useContextFactory } from './composables';
|
||||
import type { Command, Dispatch, Writekit, WritekitState } from '../state';
|
||||
import type { WritekitConfig } from './config';
|
||||
import type { BlockElementRegistry, SelectionBridge } from './selection';
|
||||
|
||||
/** Everything child components and the input/selection plumbing need. */
|
||||
export interface WritekitContextValue {
|
||||
/** The headless controller. */
|
||||
writekit: Writekit;
|
||||
/** Reactive mirror of `writekit.state`, replaced wholesale per transaction. */
|
||||
state: ShallowRef<WritekitState>;
|
||||
registry: Registry;
|
||||
config: WritekitConfig;
|
||||
/** The single contenteditable root element (set by WritekitContent). */
|
||||
contentRoot: ShallowRef<HTMLElement | null>;
|
||||
/** Block id → its (non-editable) block-content element. */
|
||||
blockElements: BlockElementRegistry;
|
||||
/** DOM ↔ model selection mapping. */
|
||||
selection: SelectionBridge;
|
||||
/** True while an IME composition is in flight (suppresses model sync). */
|
||||
composing: Ref<boolean>;
|
||||
/** Origin (`meta('origin')`) of the most recent transaction, if any. */
|
||||
lastOrigin: Ref<string | undefined>;
|
||||
dispatch: Dispatch;
|
||||
/** Run a command against the current state. */
|
||||
exec: (command: Command) => boolean;
|
||||
/** Move real DOM focus + caret into a block. */
|
||||
focusBlock: (blockId: string, offset: number | 'start' | 'end') => void;
|
||||
}
|
||||
|
||||
export const {
|
||||
inject: useWritekitContext,
|
||||
provide: provideWritekitContext,
|
||||
} = useContextFactory<WritekitContextValue>('WritekitContext');
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from './primitive';
|
||||
export * from './config';
|
||||
export * from './context';
|
||||
export * from './inline-content';
|
||||
export * from './selection';
|
||||
export * from './ui';
|
||||
|
||||
export { default as WritekitRoot } from './WritekitRoot.vue';
|
||||
export type { WritekitRootProps } from './WritekitRoot.vue';
|
||||
export { default as WritekitContent } from './WritekitContent.vue';
|
||||
export type { WritekitContentProps } from './WritekitContent.vue';
|
||||
export { default as BlockView } from './BlockView.vue';
|
||||
export type { BlockViewProps } from './BlockView.vue';
|
||||
export { default as TextBlockHost } from './TextBlockHost.vue';
|
||||
export type { TextBlockHostProps } from './TextBlockHost.vue';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { renderRuns, FILLER_ATTR } from './render';
|
||||
export { parseRuns } from './parse';
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Inline, InlineNode, Mark } from '../../model';
|
||||
import { normalizeInline, normalizeMarks } from '../../model';
|
||||
import type { Registry } from '../../registry';
|
||||
import { FILLER_ATTR } from './render';
|
||||
|
||||
// Zero-width space, built without embedding the literal character in source.
|
||||
const ZWSP = new RegExp(String.fromCharCode(0x200B), 'g');
|
||||
|
||||
/** Marks contributed by a single element, via each mark's `parseDOM` rules. */
|
||||
function marksForElement(el: HTMLElement, registry: Registry): Mark[] {
|
||||
const marks: Mark[] = [];
|
||||
|
||||
for (const def of registry.allMarks()) {
|
||||
for (const rule of def.spec.parseDOM) {
|
||||
if (!rule.tag || !el.matches(rule.tag))
|
||||
continue;
|
||||
|
||||
let attrs = rule.attrs;
|
||||
|
||||
if (rule.getAttrs) {
|
||||
const got = rule.getAttrs(el);
|
||||
if (got === false || got === null)
|
||||
continue;
|
||||
attrs = { ...(rule.attrs ?? {}), ...got };
|
||||
}
|
||||
|
||||
marks.push(attrs && Object.keys(attrs).length > 0 ? { type: def.type, attrs } : { type: def.type });
|
||||
break; // first matching rule wins for this mark
|
||||
}
|
||||
}
|
||||
|
||||
return marks;
|
||||
}
|
||||
|
||||
function walk(node: Node, marks: readonly Mark[], out: InlineNode[], registry: Registry): void {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = (child.nodeValue ?? '').replaceAll(ZWSP, '');
|
||||
if (text)
|
||||
out.push({ text, marks });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE)
|
||||
continue;
|
||||
|
||||
const el = child as HTMLElement;
|
||||
|
||||
if (el.tagName === 'BR') {
|
||||
if (!el.hasAttribute(FILLER_ATTR))
|
||||
out.push({ text: '\n', marks }); // hard break
|
||||
continue;
|
||||
}
|
||||
|
||||
walk(el, normalizeMarks([...marks, ...marksForElement(el, registry)]), out, registry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a contenteditable host (or any DOM subtree, e.g. pasted HTML) back into
|
||||
* normalized inline runs, resolving marks from the registry's `parseDOM` rules.
|
||||
*/
|
||||
export function parseRuns(host: HTMLElement, registry: Registry): Inline {
|
||||
const out: InlineNode[] = [];
|
||||
walk(host, [], out, registry);
|
||||
return normalizeInline(out);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Inline, Mark } from '../../model';
|
||||
import type { Registry } from '../../registry';
|
||||
import type { DOMOutputSpec } from '../../schema';
|
||||
|
||||
/** Attribute marking the filler `<br>` of an empty block (not a real newline). */
|
||||
export const FILLER_ATTR = 'data-writekit-br-filler';
|
||||
|
||||
function markRank(registry: Registry, mark: Mark): number {
|
||||
return registry.getMark(mark.type)?.spec.rank ?? 0;
|
||||
}
|
||||
|
||||
function isAttrsObject(value: unknown): value is Record<string, string> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Realize a mark's `toDOM` spec into a wrapper element (content appended later). */
|
||||
function createWrapper(spec: DOMOutputSpec, markType: string): HTMLElement {
|
||||
if (typeof spec === 'string') {
|
||||
const el = document.createElement(spec);
|
||||
el.setAttribute('data-mark', markType);
|
||||
return el;
|
||||
}
|
||||
|
||||
const [tag, ...rest] = spec as readonly unknown[];
|
||||
const el = document.createElement(typeof tag === 'string' ? tag : 'span');
|
||||
|
||||
if (rest.length > 0 && isAttrsObject(rest[0])) {
|
||||
for (const [key, value] of Object.entries(rest[0]))
|
||||
el.setAttribute(key, String(value));
|
||||
}
|
||||
|
||||
el.setAttribute('data-mark', markType);
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Build the innermost content for a run, turning `\n` into hard-break `<br>`. */
|
||||
function buildInner(text: string): DocumentFragment {
|
||||
const frag = document.createDocumentFragment();
|
||||
const segments = text.split('\n');
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
if (index > 0)
|
||||
frag.appendChild(document.createElement('br'));
|
||||
if (segment.length > 0)
|
||||
frag.appendChild(document.createTextNode(segment));
|
||||
});
|
||||
|
||||
return frag;
|
||||
}
|
||||
|
||||
function wrapWithMarks(inner: Node, marks: readonly Mark[], registry: Registry): Node {
|
||||
let node = inner;
|
||||
|
||||
for (let i = marks.length - 1; i >= 0; i--) {
|
||||
const def = registry.getMark(marks[i]!.type);
|
||||
if (!def)
|
||||
continue;
|
||||
|
||||
const wrapper = createWrapper(def.spec.toDOM(marks[i]!), marks[i]!.type);
|
||||
wrapper.appendChild(node);
|
||||
node = wrapper;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render inline content into a contenteditable host imperatively (never via
|
||||
* Vue's template diff, which would fight the caret). Marks nest by `rank`
|
||||
* (lower = outer) for stable, deterministic output. An empty block gets a single
|
||||
* filler `<br>` so it has height and a caret target.
|
||||
*/
|
||||
export function renderRuns(host: HTMLElement, inline: Inline, registry: Registry): void {
|
||||
const frag = document.createDocumentFragment();
|
||||
let total = 0;
|
||||
|
||||
for (const run of inline) {
|
||||
total += run.text.length;
|
||||
const marks = [...run.marks].sort((a, b) => markRank(registry, a) - markRank(registry, b));
|
||||
frag.appendChild(wrapWithMarks(buildInner(run.text), marks, registry));
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
const filler = document.createElement('br');
|
||||
filler.setAttribute(FILLER_ATTR, '');
|
||||
frag.appendChild(filler);
|
||||
}
|
||||
|
||||
host.replaceChildren(frag);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Whether a node is (inside) an atom's interactive control — a form field or a
|
||||
* `contenteditable="false"` island. Events from these must NOT be treated as
|
||||
* writekit input: e.g. typing in an image's caption `<input>` bubbles up to the
|
||||
* single contenteditable, and without this guard the writekit would re-sync a text
|
||||
* block and yank the caret to the start of the document.
|
||||
*/
|
||||
export function isInteractiveTarget(node: EventTarget | null): boolean {
|
||||
return node instanceof Element
|
||||
&& node.closest('input, textarea, select, button, [contenteditable="false"]') !== null;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Primitive, Slot } from '@robonen/primitives';
|
||||
export type { PrimitiveProps } from '@robonen/primitives';
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Maps block ids to their contenteditable host elements for selection/focus. */
|
||||
export interface BlockElementRegistry {
|
||||
set: (blockId: string, el: HTMLElement) => void;
|
||||
delete: (blockId: string) => void;
|
||||
get: (blockId: string) => HTMLElement | undefined;
|
||||
}
|
||||
|
||||
export function createBlockElementRegistry(): BlockElementRegistry {
|
||||
const map = new Map<string, HTMLElement>();
|
||||
|
||||
return {
|
||||
set: (blockId, el) => void map.set(blockId, el),
|
||||
delete: blockId => void map.delete(blockId),
|
||||
get: blockId => map.get(blockId),
|
||||
};
|
||||
}
|
||||
|
||||
/** The nearest contenteditable block host containing `node`, or `null`. */
|
||||
export function closestBlockHost(node: Node | null): HTMLElement | null {
|
||||
if (!node)
|
||||
return null;
|
||||
|
||||
const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement;
|
||||
return el?.closest<HTMLElement>('[data-block-content]') ?? null;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './block-host';
|
||||
export * from './selection-bridge';
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Selection } from '../../model';
|
||||
import { textSelection } from '../../model';
|
||||
import { FILLER_ATTR } from '../inline-content';
|
||||
import type { BlockElementRegistry } from './block-host';
|
||||
import { closestBlockHost } from './block-host';
|
||||
|
||||
/** Maps the native `Selection`/`Range` (over the single editable root) to model coordinates and back. */
|
||||
export interface SelectionBridge {
|
||||
/** Read the native selection as a model selection (null if outside writekit). */
|
||||
read: () => Selection | null;
|
||||
/** Apply a model selection to the native selection (focusing the root). */
|
||||
write: (selection: Selection) => void;
|
||||
/** Snapshot the current model selection. */
|
||||
save: () => Selection | null;
|
||||
/** Restore a previously saved selection. */
|
||||
restore: (selection: Selection | null) => void;
|
||||
domPointToOffset: (host: HTMLElement, node: Node, offset: number) => number;
|
||||
offsetToDomPoint: (host: HTMLElement, offset: number) => { node: Node; offset: number };
|
||||
}
|
||||
|
||||
function isFillerBr(node: Node): boolean {
|
||||
return node.nodeType === Node.ELEMENT_NODE
|
||||
&& (node as HTMLElement).tagName === 'BR'
|
||||
&& (node as HTMLElement).hasAttribute(FILLER_ATTR);
|
||||
}
|
||||
|
||||
/** Count model characters in a DOM subtree: text length + 1 per hard-break. */
|
||||
function measureLength(node: Node): number {
|
||||
let length = 0;
|
||||
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
length += (child.nodeValue ?? '').length;
|
||||
}
|
||||
else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
if ((child as HTMLElement).tagName === 'BR')
|
||||
length += isFillerBr(child) ? 0 : 1;
|
||||
else
|
||||
length += measureLength(child);
|
||||
}
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
function indexInParent(el: Node): number {
|
||||
return el.parentNode ? Array.from(el.parentNode.childNodes).indexOf(el as ChildNode) : 0;
|
||||
}
|
||||
|
||||
function getWindow(): Window | null {
|
||||
return globalThis.window === undefined ? null : globalThis.window;
|
||||
}
|
||||
|
||||
export function createSelectionBridge(
|
||||
getRoot: () => HTMLElement | null,
|
||||
blockElements: BlockElementRegistry,
|
||||
): SelectionBridge {
|
||||
/** DOM point → model character offset within one block-content element. */
|
||||
function domPointToOffset(host: HTMLElement, node: Node, offset: number): number {
|
||||
const range = host.ownerDocument.createRange();
|
||||
range.selectNodeContents(host);
|
||||
|
||||
try {
|
||||
range.setEnd(node, offset);
|
||||
}
|
||||
catch {
|
||||
return measureLength(host);
|
||||
}
|
||||
|
||||
return measureLength(range.cloneContents());
|
||||
}
|
||||
|
||||
/** Model character offset → DOM point within one block-content element. */
|
||||
function offsetToDomPoint(host: HTMLElement, offset: number): { node: Node; offset: number } {
|
||||
let remaining = offset;
|
||||
|
||||
function search(node: Node): { node: Node; offset: number } | null {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const length = (child.nodeValue ?? '').length;
|
||||
if (remaining <= length)
|
||||
return { node: child, offset: remaining };
|
||||
remaining -= length;
|
||||
}
|
||||
else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = child as HTMLElement;
|
||||
|
||||
if (el.tagName === 'BR') {
|
||||
if (isFillerBr(el))
|
||||
continue;
|
||||
if (remaining === 0)
|
||||
return { node: el.parentNode!, offset: indexInParent(el) };
|
||||
remaining -= 1;
|
||||
if (remaining === 0)
|
||||
return { node: el.parentNode!, offset: indexInParent(el) + 1 };
|
||||
}
|
||||
else {
|
||||
const found = search(el);
|
||||
if (found)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return search(host) ?? { node: host, offset: 0 };
|
||||
}
|
||||
|
||||
function hostFor(blockId: string): HTMLElement | null {
|
||||
return blockElements.get(blockId) ?? null;
|
||||
}
|
||||
|
||||
function read(): Selection | null {
|
||||
const root = getRoot();
|
||||
const domSel = getWindow()?.getSelection();
|
||||
|
||||
if (!root || !domSel || domSel.rangeCount === 0 || !domSel.anchorNode)
|
||||
return null;
|
||||
|
||||
// Both endpoints must live inside our single editable root.
|
||||
if (!root.contains(domSel.anchorNode))
|
||||
return null;
|
||||
|
||||
const anchorHost = closestBlockHost(domSel.anchorNode);
|
||||
const focusHost = closestBlockHost(domSel.focusNode) ?? anchorHost;
|
||||
|
||||
if (!anchorHost || !focusHost)
|
||||
return null;
|
||||
|
||||
const anchorId = anchorHost.dataset['blockId'];
|
||||
const focusId = focusHost.dataset['blockId'];
|
||||
|
||||
if (!anchorId || !focusId)
|
||||
return null;
|
||||
|
||||
const anchorOffset = domPointToOffset(anchorHost, domSel.anchorNode, domSel.anchorOffset);
|
||||
const focusOffset = domSel.focusNode
|
||||
? domPointToOffset(focusHost, domSel.focusNode, domSel.focusOffset)
|
||||
: anchorOffset;
|
||||
|
||||
return textSelection({ blockId: anchorId, offset: anchorOffset }, { blockId: focusId, offset: focusOffset });
|
||||
}
|
||||
|
||||
function write(selection: Selection): void {
|
||||
const root = getRoot();
|
||||
const domSel = getWindow()?.getSelection();
|
||||
|
||||
if (!root || !domSel)
|
||||
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.
|
||||
domSel.removeAllRanges();
|
||||
if (root.isContentEditable && root.ownerDocument.activeElement !== root)
|
||||
root.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorHost = hostFor(selection.anchor.blockId);
|
||||
const focusHost = hostFor(selection.focus.blockId);
|
||||
|
||||
if (!anchorHost || !focusHost)
|
||||
return;
|
||||
|
||||
// Focus the ONE editable root (not the block) so the caret renders.
|
||||
if (root.isContentEditable && root.ownerDocument.activeElement !== root)
|
||||
root.focus({ preventScroll: true });
|
||||
|
||||
const anchorPoint = offsetToDomPoint(anchorHost, selection.anchor.offset);
|
||||
const focusPoint = offsetToDomPoint(focusHost, selection.focus.offset);
|
||||
|
||||
try {
|
||||
domSel.setBaseAndExtent(anchorPoint.node, anchorPoint.offset, focusPoint.node, focusPoint.offset);
|
||||
}
|
||||
catch {
|
||||
// Invalid DOM points (e.g. mid-reconcile) — ignore; the next reconcile fixes it.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
save: read,
|
||||
restore: (selection) => {
|
||||
if (selection)
|
||||
write(selection);
|
||||
},
|
||||
domPointToOffset,
|
||||
offsetToDomPoint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
|
||||
import { isCollapsed } from '../../model';
|
||||
import { isMarkActive, toggleMark } from '../../commands';
|
||||
import { useWritekitContext } from '../context';
|
||||
import { useEventListener } from '../composables';
|
||||
|
||||
export interface WritekitBubbleMenuProps {
|
||||
/** Marks shown in the default toolbar (ignored when the default slot is used). */
|
||||
marks?: string[];
|
||||
}
|
||||
|
||||
const { marks = ['bold', 'italic', 'underline', 'strike', 'code'] } = defineProps<WritekitBubbleMenuProps>();
|
||||
|
||||
const ctx = useWritekitContext();
|
||||
// Virtual reference (a `Measurable`) anchored to the selection rect — Popper
|
||||
// positions against it with no trigger element. Reassigned on every refresh so
|
||||
// PopperContent re-resolves position as the selection moves.
|
||||
const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>();
|
||||
const open = ref(false);
|
||||
const rev = ref(0);
|
||||
|
||||
function selectionRect(): DOMRect | null {
|
||||
const selection = globalThis.window === undefined ? null : globalThis.getSelection();
|
||||
if (!selection || selection.rangeCount === 0)
|
||||
return null;
|
||||
|
||||
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
||||
return rect.width || rect.height ? rect : null;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
rev.value += 1;
|
||||
const sel = ctx.writekit.state.selection;
|
||||
const rect = selectionRect();
|
||||
open.value = sel.kind === 'text' && !isCollapsed(sel) && !ctx.composing.value && rect !== null;
|
||||
|
||||
if (open.value)
|
||||
reference.value = { getBoundingClientRect: () => selectionRect() ?? new DOMRect() };
|
||||
}
|
||||
|
||||
ctx.writekit.on('transaction', refresh);
|
||||
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
|
||||
onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
|
||||
|
||||
function active(type: string): boolean {
|
||||
return Boolean(rev.value >= 0 && isMarkActive(ctx.writekit.state, type));
|
||||
}
|
||||
|
||||
function toggle(type: string): void {
|
||||
ctx.writekit.command(toggleMark(type));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Portal to="body">
|
||||
<PopperRoot>
|
||||
<PopperContent
|
||||
v-if="open && reference"
|
||||
:reference="reference"
|
||||
side="top"
|
||||
:side-offset="8"
|
||||
:collision-padding="8"
|
||||
>
|
||||
<DismissableLayer
|
||||
class="writekit-bubble-menu"
|
||||
role="toolbar"
|
||||
data-writekit-bubble-menu=""
|
||||
@dismiss="open = false"
|
||||
>
|
||||
<slot :active="active" :toggle="toggle" :writekit="ctx.writekit">
|
||||
<button
|
||||
v-for="mark in marks"
|
||||
:key="mark"
|
||||
type="button"
|
||||
:data-mark="mark"
|
||||
:data-active="active(mark) || undefined"
|
||||
@mousedown.prevent="toggle(mark)"
|
||||
>
|
||||
{{ mark }}
|
||||
</button>
|
||||
</slot>
|
||||
</DismissableLayer>
|
||||
</PopperContent>
|
||||
</PopperRoot>
|
||||
</Portal>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user