silverbullet/web/cm_plugins/admonition.ts

186 lines
5.2 KiB
TypeScript
Raw Normal View History

import {
Decoration,
EditorState,
EditorView,
SyntaxNodeRef,
syntaxTree,
WidgetType,
} from "../deps.ts";
2023-07-14 22:56:20 +08:00
import { Client } from "../client.ts";
import { decoratorStateField, isCursorInRange } from "./util.ts";
2023-01-23 01:53:14 +08:00
const ADMONITION_REGEX =
2024-03-05 01:35:02 +08:00
/^>( *)(?:\*{2}|\[!)(.*?)(\*{2}|\])( *)(.*)(?:\n([\s\S]*))?/im;
const ADMONITION_LINE_SPLIT_REGEX = /\n>/gm;
class AdmonitionIconWidget extends WidgetType {
constructor(
readonly pos: number,
2024-03-05 01:35:02 +08:00
readonly type: string,
readonly editorView: EditorView,
) {
super();
}
toDOM(): HTMLElement {
const outerDiv = document.createElement("div");
outerDiv.classList.add("sb-admonition-icon");
outerDiv.addEventListener("click", () => {
this.editorView.dispatch({
selection: {
anchor: this.pos,
},
});
});
return outerDiv;
}
}
type AdmonitionFields = {
preSpaces: string;
2024-03-05 01:35:02 +08:00
admonitionType: string;
2024-03-08 01:52:04 +08:00
postSyntax: string;
postSpaces: string;
admonitionTitle: string;
admonitionContent: string;
};
// Given the raw text of an entire Blockquote, match an Admonition block.
// If matched, extract relevant fields using regex capture groups and return them
// as an object.
//
// If not matched return null.
//
// Example Admonition block (github formatted):
//
// > **note** I am an Admonition Title
// > admonition text
//
2024-03-05 01:35:02 +08:00
// or
// > [!note] I am an Admonition Title
// > admonition text
function extractAdmonitionFields(rawText: string): AdmonitionFields | null {
const regexResults = rawText.match(ADMONITION_REGEX);
if (regexResults) {
2023-01-23 01:53:14 +08:00
const preSpaces = regexResults[1] || "";
2024-03-05 01:35:02 +08:00
const admonitionType = regexResults[2];
const postSyntax = regexResults[3];
const postSpaces = regexResults[4] || "";
const admonitionTitle: string = regexResults[5] || "";
const admonitionContent: string = regexResults[6] || "";
2023-01-23 01:53:14 +08:00
return {
preSpaces,
admonitionType,
2024-03-05 01:35:02 +08:00
postSyntax,
2023-01-23 01:53:14 +08:00
postSpaces,
admonitionTitle,
admonitionContent,
};
}
return null;
}
2023-07-14 22:56:20 +08:00
export function admonitionPlugin(editor: Client) {
return decoratorStateField((state: EditorState) => {
const widgets: any[] = [];
2024-03-08 01:52:04 +08:00
// Get admonition styles from stylesheets
const allStyles = [...document.styleSheets]
.map((styleSheet) => {
return [...styleSheet.cssRules].map((rule) => rule.cssText).join("");
}).filter(Boolean).join("\n");
const admonitionStyles = allStyles.match(/(?<=admonition=").*?(?=")/g);
syntaxTree(state).iterate({
enter: (node: SyntaxNodeRef) => {
const { type, from, to } = node;
if (type.name === "Blockquote") {
// Extract raw text from admonition block
const rawText = state.sliceDoc(from, to);
// Split text into type, title and content using regex capture groups
const extractedFields = extractAdmonitionFields(rawText);
// Bailout here if we don't have a proper Admonition formatted blockquote
if (!extractedFields) {
return;
}
2024-03-08 01:52:04 +08:00
const { preSpaces, admonitionType, postSyntax, postSpaces } =
extractedFields;
if (!admonitionStyles?.includes(admonitionType)) {
return;
}
// A blockquote is actually rendered as many divs, one per line.
// We need to keep track of the `from` offsets here, so we can attach css
// classes to them further down.
const fromOffsets: number[] = [];
const lines = rawText.slice(1).split(ADMONITION_LINE_SPLIT_REGEX);
let accum = from;
2023-01-23 01:53:14 +08:00
lines.forEach((line) => {
fromOffsets.push(accum);
accum += line.length + 2;
});
2024-03-05 01:35:02 +08:00
// `from` and `to` range info for switching out keyword text with correct
// icon further down.
const iconRange = {
from: from + 1,
2024-03-08 01:52:04 +08:00
to: from + preSpaces.length + 2 + admonitionType.length +
postSyntax.length +
2023-01-23 01:53:14 +08:00
postSpaces.length + 1,
};
2024-03-05 01:35:02 +08:00
// The first div is the title, attach title css class
widgets.push(
2023-01-23 01:53:14 +08:00
Decoration.line({
2024-03-05 01:35:02 +08:00
class: "sb-admonition-title",
2023-01-23 01:53:14 +08:00
}).range(fromOffsets[0]),
);
2024-03-05 01:35:02 +08:00
// If cursor is not within the first line, replace the keyword text
// with the icon
2023-01-23 01:53:14 +08:00
if (
!isCursorInRange(state, [
from,
fromOffsets.length > 1 ? fromOffsets[1] : to,
])
) {
widgets.push(
Decoration.replace({
widget: new AdmonitionIconWidget(
iconRange.from + 1,
admonitionType,
2023-07-27 17:41:44 +08:00
editor.editorView,
),
inclusive: true,
2023-01-23 01:53:14 +08:00
}).range(iconRange.from, iconRange.to),
);
}
// Each line of the blockquote is spread across separate divs, attach
2024-03-05 01:35:02 +08:00
// relevant css classes and attribute here.
fromOffsets.forEach((fromOffset) => {
widgets.push(
2024-03-05 01:35:02 +08:00
Decoration.line({
attributes: { admonition: admonitionType },
class: "sb-admonition",
}).range(fromOffset),
);
});
}
},
});
return Decoration.set(widgets, true);
});
}