263c32002f
Rename the rich-text editor package and all Editor* exports to Writekit*; remove the old vue/editor tree.
181 lines
5.3 KiB
TypeScript
181 lines
5.3 KiB
TypeScript
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;
|
|
};
|