halo/ui/packages/editor/src/extensions/code-block/code-block.ts

343 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import {
Editor,
type Range,
type CommandProps,
isActive,
findParentNode,
VueNodeViewRenderer,
isNodeActive,
} from "@/tiptap/vue-3";
import {
EditorState,
Plugin,
PluginKey,
TextSelection,
type Transaction,
} from "@/tiptap/pm";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import type { CodeBlockLowlightOptions } from "@tiptap/extension-code-block-lowlight";
import CodeBlockViewRenderer from "./CodeBlockViewRenderer.vue";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiCodeBracesBox from "~icons/mdi/code-braces-box";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import ToolboxItem from "@/components/toolbox/ToolboxItem.vue";
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
import { deleteNode } from "@/utils";
export interface CustomCodeBlockLowlightOptions
extends CodeBlockLowlightOptions {
lowlight: any;
defaultLanguage: string | null | undefined;
}
declare module "@/tiptap" {
interface Commands<ReturnType> {
codeIndent: {
codeIndent: () => ReturnType;
codeOutdent: () => ReturnType;
};
}
}
type IndentType = "indent" | "outdent";
const updateIndent = (tr: Transaction, type: IndentType): Transaction => {
const { doc, selection } = tr;
if (!doc || !selection) return tr;
if (!(selection instanceof TextSelection)) {
return tr;
}
const { from, to } = selection;
doc.nodesBetween(from, to, (node, pos) => {
if (from - to == 0 && type === "indent") {
tr.insertText(" ", from, to);
return false;
}
const precedeContent = doc.textBetween(pos + 1, from, "\n");
const precedeLineBreakPos = precedeContent.lastIndexOf("\n");
const startBetWeenIndex =
precedeLineBreakPos === -1 ? pos + 1 : pos + precedeLineBreakPos + 1;
const text = doc.textBetween(startBetWeenIndex, to, "\n");
if (type === "indent") {
let replacedStr = text.replace(/\n/g, "\n ");
if (startBetWeenIndex === pos + 1) {
replacedStr = " " + replacedStr;
}
tr.insertText(replacedStr, startBetWeenIndex, to);
} else {
let replacedStr = text.replace(/\n {2}/g, "\n");
if (startBetWeenIndex === pos + 1) {
const firstNewLineIndex = replacedStr.indexOf(" ");
if (firstNewLineIndex == 0) {
replacedStr = replacedStr.replace(" ", "");
}
}
tr.insertText(replacedStr, startBetWeenIndex, to);
}
return false;
});
return tr;
};
const getRenderContainer = (node: HTMLElement) => {
let container = node;
// 文本节点
if (container.nodeName === "#text") {
container = node.parentElement as HTMLElement;
}
while (
container &&
container.classList &&
!container.classList.contains("code-node")
) {
container = container.parentElement as HTMLElement;
}
return container;
};
export default CodeBlockLowlight.extend<
CustomCodeBlockLowlightOptions & CodeBlockLowlightOptions
>({
// It needs to have a higher priority than range-selection,
// otherwise the Mod-a shortcut key will be overridden.
priority: 110,
addCommands() {
return {
...this.parent?.(),
codeIndent:
() =>
({ tr, state, dispatch }: CommandProps) => {
const { selection } = state;
tr = tr.setSelection(selection);
tr = updateIndent(tr, "indent");
if (tr.docChanged && dispatch) {
dispatch(tr);
return true;
}
return false;
},
codeOutdent:
() =>
({ tr, state, dispatch }: CommandProps) => {
const { selection } = state;
tr = tr.setSelection(selection);
tr = updateIndent(tr, "outdent");
if (tr.docChanged && dispatch) {
dispatch(tr);
return true;
}
return false;
},
};
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
if (!isNodeActive(editor.state, this.name)) {
return false;
}
const { selection } = editor.state;
// Clear the selected content and adapt to the all-select shortcut key operation.
if (!selection.empty) {
editor
.chain()
.focus()
.deleteSelection()
.setTextSelection(selection.$from.pos)
.run();
return true;
}
const { $anchor } = selection;
const isAtStart = $anchor.parentOffset === 0;
// If the cursor is at the beginning of the code block or the code block is empty, it is not deleted.
if (isAtStart || !$anchor.parent.textContent.length) {
return true;
}
return false;
},
Tab: () => {
if (this.editor.isActive("codeBlock")) {
return this.editor.chain().focus().codeIndent().run();
}
return false;
},
"Shift-Tab": () => {
if (this.editor.isActive("codeBlock")) {
return this.editor.chain().focus().codeOutdent().run();
}
return false;
},
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
const { tr, selection } = this.editor.state;
const codeBlack = findParentNode(
(node) => node.type.name === CodeBlockLowlight.name
)(selection);
if (!codeBlack) {
return false;
}
const head = codeBlack.start;
const anchor = codeBlack.start + codeBlack.node.nodeSize - 1;
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
this.editor.view.dispatch(
tr.setSelection(new TextSelection($head, $anchor))
);
return true;
}
return false;
},
};
},
addNodeView() {
return VueNodeViewRenderer(CodeBlockViewRenderer);
},
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 160,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("codeBlock"),
icon: markRaw(MdiCodeBracesBox),
title: i18n.global.t("editor.common.codeblock.title"),
action: () => editor.chain().focus().toggleCodeBlock().run(),
},
};
},
getCommandMenuItems() {
return {
priority: 80,
icon: markRaw(MdiCodeBracesBox),
title: "editor.common.codeblock.title",
keywords: ["codeblock", "daimakuai"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor.chain().focus().deleteRange(range).setCodeBlock().run();
},
};
},
getToolboxItems({ editor }: { editor: Editor }) {
return [
{
priority: 50,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiCodeBracesBox),
title: i18n.global.t("editor.common.codeblock.title"),
action: () => {
editor.chain().focus().setCodeBlock().run();
},
},
},
];
},
getBubbleMenu() {
return {
pluginKey: "codeBlockBubbleMenu",
shouldShow: ({ state }: { state: EditorState }) => {
return isActive(state, CodeBlockLowlight.name);
},
getRenderContainer: (node: HTMLElement) => {
return getRenderContainer(node);
},
items: [
{
priority: 10,
props: {
icon: markRaw(MdiDeleteForeverOutline),
title: i18n.global.t("editor.common.button.delete"),
action: ({ editor }: { editor: Editor }) =>
deleteNode(CodeBlockLowlight.name, editor),
},
},
],
};
},
getDraggable() {
return {
getRenderContainer({ dom }: { dom: HTMLElement }) {
return {
el: getRenderContainer(dom),
};
},
};
},
};
},
addProseMirrorPlugins() {
return [
// Solve the paste problem. Because the upstream has not been
// able to deal with this problem for a long time, it is
// handled manually locally.
// see: https://github.com/ueberdosis/tiptap/pull/3606
new Plugin({
key: new PluginKey("codeBlockVSCodeHandlerFixPaste"),
props: {
handlePaste: (view, event) => {
if (!event.clipboardData) {
return false;
}
// dont create a new code block within code blocks
if (this.editor.isActive(this.type.name)) {
return false;
}
const text = event.clipboardData.getData("text/plain");
const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
const language = vscodeData?.mode;
if (!text || !language) {
return false;
}
const { tr, schema } = view.state;
// add text to code block
// strip carriage return chars from text pasted as code
// see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd
const contentTextNode = schema.text(text.replace(/\r\n?/g, "\n"));
// create an empty code block
tr.replaceSelectionWith(
this.type.create({ language }, contentTextNode)
);
const { selection } = tr;
// Whether the current position is code block, if not, move forward to code block.
let codeBlockPos = Math.max(0, selection.from - 1);
while (
codeBlockPos > 0 &&
tr.doc.resolve(codeBlockPos).parent.type.name !== this.type.name
) {
codeBlockPos--;
}
// put cursor inside the newly created code block
tr.setSelection(TextSelection.near(tr.doc.resolve(codeBlockPos)));
// store meta information
// this is useful for other plugins that depends on the paste event
// like the paste rule plugin
tr.setMeta("paste", true);
view.dispatch(tr);
return true;
},
},
}),
...(this.parent?.() || []),
];
},
});