Files
edgeever/apps/web/src/lib/editor-shortcuts.ts
T

271 lines
6.7 KiB
TypeScript

import { TextSelection, type EditorState, type Transaction } from "@tiptap/pm/state";
import { wrapInList } from "@tiptap/pm/schema-list";
const TAB_DELEGATED_NODE_TYPES = new Set([
"listItem",
"taskItem",
"tableCell",
"tableHeader",
]);
const hasDelegatedTabAncestor = (state: EditorState, position: number) => {
const resolved = state.doc.resolve(position);
for (let depth = resolved.depth; depth >= 0; depth -= 1) {
if (TAB_DELEGATED_NODE_TYPES.has(resolved.node(depth).type.name)) {
return true;
}
}
return false;
};
const selectedTextBlockStarts = (state: EditorState) => {
const { from, to } = state.selection;
const starts: number[] = [];
const rangeEnd = Math.max(from, to - 1);
state.doc.nodesBetween(from, rangeEnd, (node, position) => {
if (!node.isTextblock) {
return true;
}
starts.push(position + 1);
return false;
});
return starts;
};
/**
* Handles Tab as text indentation while leaving structural contexts to
* Tiptap's list and table keymaps.
*/
export const applyPlainTextTab = (
state: EditorState,
dispatch: ((transaction: Transaction) => void) | undefined,
shiftKey = false,
) => {
const { selection } = state;
if (
!selection.$from.parent.isTextblock
|| !selection.$to.parent.isTextblock
|| hasDelegatedTabAncestor(state, selection.from)
|| hasDelegatedTabAncestor(state, selection.to)
) {
return false;
}
if (!dispatch) {
return true;
}
if (selection.empty) {
if (!shiftKey) {
dispatch(state.tr.insertText("\t", selection.from));
return true;
}
const blockStart = selection.$from.start();
const precedingCharacter = selection.from > blockStart
? state.doc.textBetween(selection.from - 1, selection.from)
: "";
if (precedingCharacter === "\t") {
dispatch(state.tr.delete(selection.from - 1, selection.from));
return true;
}
if (state.doc.textBetween(blockStart, blockStart + 1) === "\t") {
dispatch(state.tr.delete(blockStart, blockStart + 1));
}
return true;
}
const blockStarts = selectedTextBlockStarts(state);
if (blockStarts.some((position) => hasDelegatedTabAncestor(state, position))) {
return false;
}
const transaction = state.tr;
for (const position of blockStarts.reverse()) {
if (shiftKey) {
if (state.doc.textBetween(position, position + 1) === "\t") {
transaction.delete(position, position + 1);
}
} else {
transaction.insertText("\t", position);
}
}
dispatch(transaction);
return true;
};
/**
* Removes the marker from a newly-created empty list item while preserving
* its visual/content indentation inside the previous item. Typing a list
* marker in the resulting paragraph then creates a nested list, matching
* Typora's Backspace workflow.
*/
export const preserveEmptyListIndentOnBackspace = (
state: EditorState,
dispatch: ((transaction: Transaction) => void) | undefined,
) => {
const { selection } = state;
if (
!selection.empty
|| selection.$from.parent.type.name !== "paragraph"
|| selection.$from.parent.content.size !== 0
|| selection.$from.parentOffset !== 0
) {
return false;
}
let listItemDepth = -1;
for (let depth = selection.$from.depth - 1; depth > 0; depth -= 1) {
const nodeName = selection.$from.node(depth).type.name;
if (nodeName === "listItem" || nodeName === "taskItem") {
listItemDepth = depth;
break;
}
}
if (listItemDepth < 1) {
return false;
}
const listDepth = listItemDepth - 1;
const itemIndex = selection.$from.index(listDepth);
const currentItem = selection.$from.node(listItemDepth);
const list = selection.$from.node(listDepth);
if (
itemIndex === 0
|| currentItem.childCount !== 1
|| currentItem.firstChild !== selection.$from.parent
) {
return false;
}
const previousItem = list.child(itemIndex - 1);
if (previousItem.type !== currentItem.type) {
return false;
}
if (!dispatch) {
return true;
}
const currentItemStart = selection.$from.before(listItemDepth);
const previousItemStart = currentItemStart - previousItem.nodeSize;
const mergedItem = previousItem.copy(previousItem.content.append(currentItem.content));
const transaction = state.tr.replaceWith(
previousItemStart,
currentItemStart + currentItem.nodeSize,
mergedItem,
);
const paragraphCursor = previousItemStart + previousItem.content.size + 2;
transaction.setSelection(TextSelection.create(transaction.doc, paragraphCursor));
dispatch(transaction.scrollIntoView());
return true;
};
/**
* Turns a direct paragraph below a list item's leading paragraph into a
* nested list. This is the structural state produced by the Typora-style
* Backspace behavior above.
*/
export const wrapIndentedParagraphInList = (
state: EditorState,
dispatch: ((transaction: Transaction) => void) | undefined,
requestedListType?: string,
) => {
const { selection } = state;
if (
selection.$from.parent.type.name !== "paragraph"
|| selection.$to.parent !== selection.$from.parent
) {
return false;
}
let listItemDepth = -1;
for (let depth = selection.$from.depth - 1; depth > 0; depth -= 1) {
const nodeName = selection.$from.node(depth).type.name;
if (nodeName === "listItem" || nodeName === "taskItem") {
listItemDepth = depth;
break;
}
}
if (
listItemDepth < 1
|| selection.$from.index(listItemDepth) === 0
) {
return false;
}
const listTypeName = requestedListType ?? selection.$from.node(listItemDepth - 1).type.name;
const listType = state.schema.nodes[listTypeName];
return Boolean(listType && wrapInList(listType)(state, dispatch));
};
export const saveAndSyncEditor = async ({
hasUnsavedChanges,
save,
sync,
}: {
hasUnsavedChanges: boolean;
save: () => Promise<unknown>;
sync: () => Promise<unknown>;
}) => {
if (hasUnsavedChanges) {
await save();
}
await sync();
};
export const getAiSlashCommandStart = ({
caretPosition,
insertedText,
textBefore,
}: {
caretPosition: number;
insertedText: string;
textBefore: string;
}) => {
if (insertedText.toLowerCase() !== "i" || !/(?:^|\s)\/a$/i.test(textBefore)) {
return null;
}
return caretPosition - 2;
};
export const shouldOpenAiFromSpace = ({
altKey,
ctrlKey,
isComposing,
isEmptyParagraph,
key,
keyCode,
metaKey,
repeat,
selectionEmpty,
shiftKey,
}: {
altKey: boolean;
ctrlKey: boolean;
isComposing: boolean;
isEmptyParagraph: boolean;
key: string;
keyCode: number;
metaKey: boolean;
repeat: boolean;
selectionEmpty: boolean;
shiftKey: boolean;
}) => key === " "
&& !altKey
&& !ctrlKey
&& !metaKey
&& !shiftKey
&& !repeat
&& !isComposing
&& keyCode !== 229
&& selectionEmpty
&& isEmptyParagraph;