Files
outline/shared/editor/marks/Code.ts
2025-04-19 16:24:22 -04:00

233 lines
6.3 KiB
TypeScript

import codemark from "prosemirror-codemark";
import { toggleMark } from "prosemirror-commands";
import {
MarkSpec,
MarkType,
Node as ProsemirrorNode,
Mark as ProsemirrorMark,
Slice,
} from "prosemirror-model";
import { Plugin, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { markInputRuleForPattern } from "../lib/markInputRule";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { isInCode } from "../queries/isInCode";
import Mark from "./Mark";
export default class Code extends Mark {
get name() {
return "code_inline";
}
get schema(): MarkSpec {
return {
excludes: "mention placeholder highlight",
parseDOM: [{ tag: "code", preserveWhitespace: true }],
toDOM: () => ["code", { class: "inline", spellCheck: "false" }],
code: true,
};
}
inputRules({ type }: { type: MarkType }) {
return [markInputRuleForPattern("`", type)];
}
keys({ type }: { type: MarkType }) {
return {
// Note: This key binding only works on non-Mac platforms
// https://github.com/ProseMirror/prosemirror/issues/515
"Mod`": toggleMark(type),
"Mod-e": toggleMark(type),
"Mod-Shift-c": toggleMark(type),
};
}
get plugins() {
const codeCursorPlugin = codemark({
markType: this.editor.schema.marks.code_inline,
})[0];
/**
* Helper function to check if cursor is between backticks
* and handle the code marking appropriately
*/
const handleTextBetweenBackticks = (
view: EditorView,
from: number,
to: number,
text: string | Slice
) => {
const { state } = view;
// Prevent access out of document bounds
if (from === 0 || to === state.doc.nodeSize - 1) {
return false;
}
// Skip if we're adding a backtick character
if (typeof text === "string" && text === "`") {
return false;
}
// Check if we're between backticks
if (
state.doc.textBetween(from - 1, from) === "`" &&
state.doc.textBetween(to, to + 1) === "`"
) {
const start = from - 1;
const end = to + 1;
if (typeof text === "string") {
// Handle text input
view.dispatch(
state.tr
.delete(start, end)
.insertText(text, start)
.addMark(
start,
start + text.length,
state.schema.marks.code_inline.create()
)
);
} else {
// Handle paste/slice
view.dispatch(
state.tr
.replaceRange(start, end, text)
.addMark(
start,
start + text.size,
state.schema.marks.code_inline.create()
)
);
}
return true;
}
return false;
};
return [
codeCursorPlugin,
new Plugin({
props: {
// Typing a character inside of two backticks will wrap the character
// in an inline code mark.
handleTextInput: (
view: EditorView,
from: number,
to: number,
text: string
) => {
// Skip this handler during IME composition or it will prevent the
if (view.composing) {
return false;
}
return handleTextBetweenBackticks(view, from, to, text);
},
// Pasting a character inside of two backticks will wrap the character
// in an inline code mark.
handlePaste: (view: EditorView, _event: Event, slice: Slice) => {
const { state } = view;
const { from, to } = state.selection;
return handleTextBetweenBackticks(view, from, to, slice);
},
// Triple clicking inside of an inline code mark will select the entire
// code mark.
handleTripleClickOn: (view: EditorView, pos: number) => {
const { state } = view;
const inCodeMark = isInCode(state, { onlyMark: true });
if (inCodeMark) {
const $pos = state.doc.resolve(pos);
const before = $pos.nodeBefore?.nodeSize ?? 0;
const after = $pos.nodeAfter?.nodeSize ?? 0;
const $from = state.doc.resolve(pos - before);
const $to = state.doc.resolve(pos + after);
view.dispatch(
state.tr.setSelection(TextSelection.between($from, $to))
);
return true;
}
return false;
},
// Handle composition end events for IME input
handleDOMEvents: {
compositionend: (view: EditorView) => {
setTimeout(() => {
const { $cursor } = view.state.selection as TextSelection;
if (!$cursor) {
return;
}
const from = $cursor.pos - 1;
const to = $cursor.pos;
// Process the composed text after IME composition completes
handleTextBetweenBackticks(
view,
from,
to,
view.state.doc.textBetween(from, to)
);
});
},
},
},
}),
];
}
toMarkdown() {
function backticksFor(node: ProsemirrorNode, side: -1 | 1) {
const ticks = /`+/g;
let match: RegExpMatchArray | null;
let len = 0;
if (node.isText) {
while ((match = ticks.exec(node.text || ""))) {
len = Math.max(len, match[0].length);
}
}
let result = len > 0 && side > 0 ? " `" : "`";
for (let i = 0; i < len; i++) {
result += "`";
}
if (len > 0 && side < 0) {
result += " ";
}
return result;
}
return {
open(
_state: MarkdownSerializerState,
_mark: ProsemirrorMark,
parent: ProsemirrorNode,
index: number
) {
return backticksFor(parent.child(index), -1);
},
close(
_state: MarkdownSerializerState,
_mark: ProsemirrorMark,
parent: ProsemirrorNode,
index: number
) {
return backticksFor(parent.child(index - 1), 1);
},
escape: false,
};
}
parseMarkdown() {
return { mark: "code_inline" };
}
}