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:
2026-06-15 16:54:06 +07:00
parent 55e78786d6
commit 263c32002f
149 changed files with 1563 additions and 1748 deletions
+180
View File
@@ -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;
};