Files
outline/shared/editor/nodes/CodeFence.ts
Apoorv Mishra d8ef7b2892 Include mermaid SVGs in Lightbox (#10146)
* fix: include mermaid svgs in lightbox

* Fixes:
1. Focus isn't restored back to mermaid code block when Lightbox is closed
2. Read-only mode requires extra click on to both open and close Lightbox for mermaid SVGs

* fix: `zoom-in` cursor for SVGs

* fix: make SVGs downloadable

* fix: tsc

* fix: graphite

* fix: zoom-in should span the wrapper

* fix: graphite

* fix: name

* fix: no need to re-render mermaid svg within lightbox

fix: rely on `code-block` as the `svg` is updated upon doc change

* fix: graphite

* fix: lightbox crash when mermaid block is deleted

* fix: render mermaid at pos `0`

* fix: graphite

* fix: refactor to simplify Lightbox

* fix: graphite
2025-10-04 08:16:02 -04:00

294 lines
8.1 KiB
TypeScript

import copy from "copy-to-clipboard";
import { Token } from "markdown-it";
import { textblockTypeInputRule } from "prosemirror-inputrules";
import {
NodeSpec,
NodeType,
Schema,
Node as ProsemirrorNode,
} from "prosemirror-model";
import { Command, Plugin, PluginKey, TextSelection } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { toast } from "sonner";
import { Primitive } from "utility-types";
import type { Dictionary } from "~/hooks/useDictionary";
import { UserPreferences } from "../../types";
import { isMac } from "../../utils/browser";
import backspaceToParagraph from "../commands/backspaceToParagraph";
import {
newlineInCode,
indentInCode,
moveToNextNewline,
moveToPreviousNewline,
outdentInCode,
enterInCode,
splitCodeBlockOnTripleBackticks,
} from "../commands/codeFence";
import { selectAll } from "../commands/selectAll";
import toggleBlockType from "../commands/toggleBlockType";
import { CodeHighlighting } from "../extensions/CodeHighlighting";
import Mermaid from "../extensions/Mermaid";
import {
getRecentlyUsedCodeLanguage,
setRecentlyUsedCodeLanguage,
} from "../lib/code";
import { isCode } from "../lib/isCode";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
import { findParentNode } from "../queries/findParentNode";
import { getMarkRange } from "../queries/getMarkRange";
import { isInCode } from "../queries/isInCode";
import Node from "./Node";
const DEFAULT_LANGUAGE = "javascript";
export default class CodeFence extends Node {
constructor(options: {
dictionary: Dictionary;
userPreferences?: UserPreferences | null;
}) {
super(options);
}
get showLineNumbers(): boolean {
return this.options.userPreferences?.codeBlockLineNumbers ?? true;
}
get name() {
return "code_fence";
}
get schema(): NodeSpec {
return {
attrs: {
language: {
default: DEFAULT_LANGUAGE,
validate: "string",
},
},
content: "text*",
marks: "comment",
group: "block",
code: true,
defining: true,
draggable: false,
parseDOM: [
{
tag: ".code-block",
preserveWhitespace: "full",
contentElement: (node: HTMLElement) =>
node.querySelector("code") || node,
getAttrs: (dom: HTMLDivElement) => ({
language: dom.dataset.language,
}),
},
{
tag: "code",
preserveWhitespace: "full",
getAttrs: (dom) => {
// Only parse code blocks that contain newlines for code fences,
// otherwise the code mark rule will be applied.
if (!dom.textContent?.includes("\n")) {
return false;
}
return { language: dom.dataset.language };
},
},
],
toDOM: (node) => [
"div",
{
class: `code-block ${
this.showLineNumbers ? "with-line-numbers" : ""
}`,
"data-language": node.attrs.language,
},
["pre", ["code", { spellCheck: "false" }, 0]],
],
};
}
commands({ type, schema }: { type: NodeType; schema: Schema }) {
return {
code_block: (attrs: Record<string, Primitive>) => {
if (attrs?.language) {
setRecentlyUsedCodeLanguage(attrs.language as string);
}
return toggleBlockType(type, schema.nodes.paragraph, {
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
...attrs,
});
},
copyToClipboard: (): Command => (state, dispatch) => {
const codeBlock = findParentNode(isCode)(state.selection);
if (codeBlock) {
copy(codeBlock.node.textContent);
toast.message(this.options.dictionary.codeCopied);
return true;
}
const { doc, tr } = state;
const range =
getMarkRange(
doc.resolve(state.selection.from),
this.editor.schema.marks.code_inline
) ||
getMarkRange(
doc.resolve(state.selection.to),
this.editor.schema.marks.code_inline
);
if (range) {
const $end = doc.resolve(range.to);
tr.setSelection(new TextSelection($end, $end));
dispatch?.(tr);
copy(tr.doc.textBetween(state.selection.from, state.selection.to));
toast.message(this.options.dictionary.codeCopied);
return true;
}
return false;
},
};
}
get allowInReadOnly() {
return true;
}
keys({ type, schema }: { type: NodeType; schema: Schema }) {
const output: Record<string, Command> = {
// Both shortcuts work, but Shift-Ctrl-c matches the one in the menu
"Shift-Ctrl-c": toggleBlockType(type, schema.nodes.paragraph),
"Shift-Ctrl-\\": toggleBlockType(type, schema.nodes.paragraph),
"Shift-Tab": outdentInCode,
Tab: indentInCode,
Enter: enterInCode,
Backspace: backspaceToParagraph(type),
"Shift-Enter": newlineInCode,
"Mod-a": selectAll(type),
"Mod-]": indentInCode,
"Mod-[": outdentInCode,
};
if (isMac()) {
return {
...output,
"Ctrl-a": moveToPreviousNewline,
"Ctrl-e": moveToNextNewline,
};
}
return output;
}
get plugins() {
return [
CodeHighlighting({
name: this.name,
lineNumbers: this.showLineNumbers,
}),
Mermaid({
name: this.name,
isDark: this.editor.props.theme.isDark,
editor: this.editor,
}),
new Plugin({
key: new PluginKey("code-fence-split"),
props: {
handleTextInput: (view, _from, _to, text) => {
if (text === "`") {
const { state, dispatch } = view;
return splitCodeBlockOnTripleBackticks(state, dispatch);
}
return false;
},
},
}),
new Plugin({
key: new PluginKey("triple-click"),
props: {
handleDOMEvents: {
mousedown(view, event) {
const { dispatch, state } = view;
const {
selection: { $from, $to },
} = state;
if (
$from.sameParent($to) &&
event.detail === 3 &&
isInCode(view.state, { onlyBlock: true })
) {
dispatch?.(
state.tr
.setSelection(
TextSelection.create(
state.doc,
findPreviousNewline($from),
findNextNewline($from)
)
)
.scrollIntoView()
);
event.preventDefault();
return true;
}
return false;
},
},
},
}),
new Plugin({
props: {
decorations(state) {
const codeBlock = findParentNode(isCode)(state.selection);
if (!codeBlock) {
return null;
}
const decoration = Decoration.node(
codeBlock.pos,
codeBlock.pos + codeBlock.node.nodeSize,
{ class: "code-active" }
);
return DecorationSet.create(state.doc, [decoration]);
},
},
}),
];
}
inputRules({ type }: { type: NodeType }) {
return [
textblockTypeInputRule(/^```$/, type, () => ({
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
})),
];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write("```" + (node.attrs.language || "") + "\n");
state.text(node.textContent, false);
state.ensureNewLine();
state.write("```");
state.closeBlock(node);
}
get markdownToken() {
return "fence";
}
parseMarkdown() {
return {
block: "code_block",
getAttrs: (tok: Token) => ({ language: tok.info }),
noCloseToken: true,
};
}
}