diff --git a/web/cm_plugins/admonition.ts b/web/cm_plugins/admonition.ts new file mode 100644 index 00000000..2e41cf07 --- /dev/null +++ b/web/cm_plugins/admonition.ts @@ -0,0 +1,172 @@ +import { + Decoration, + EditorState, + EditorView, + SyntaxNodeRef, + syntaxTree, + WidgetType, +} from "../deps.ts"; +import { Editor } from "../editor.tsx"; +import { decoratorStateField, isCursorInRange } from "./util.ts"; + +type AdmonitionType = "note" | "warning"; + +const ADMONITION_REGEX = /^>( *)\*{2}(Note|Warning)\*{2}( *)(.*)(?:\n([\s\S]*))?/im; +const ADMONITION_LINE_SPLIT_REGEX = /\n>/gm; + +class AdmonitionIconWidget extends WidgetType { + constructor( + readonly pos: number, + readonly type: AdmonitionType, + readonly editorView: EditorView, + ) { + super(); + } + + toDOM(): HTMLElement { + const outerDiv = document.createElement("div"); + outerDiv.classList.add("sb-admonition-icon"); + outerDiv.addEventListener("click", (e) => { + this.editorView.dispatch({ + selection: { + anchor: this.pos, + }, + }); + }); + + switch (this.type) { + case "note": + outerDiv.insertAdjacentHTML("beforeend", ''); + break; + case "warning": + outerDiv.insertAdjacentHTML("beforeend", ''); + break; + default: + // + } + + return outerDiv; + } +} + +type AdmonitionFields = { + preSpaces: string; + admonitionType: AdmonitionType; + 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 +// +function extractAdmonitionFields(rawText: string): AdmonitionFields | null { + const regexResults = rawText.match(ADMONITION_REGEX); + + if (regexResults) { + const preSpaces = regexResults[1] || ''; + const admonitionType = regexResults[2].toLowerCase() as AdmonitionType; + const postSpaces = regexResults[3] || ''; + const admonitionTitle: string = regexResults[4] || ''; + const admonitionContent: string = regexResults[5] || ''; + + return { preSpaces, admonitionType, postSpaces, admonitionTitle, admonitionContent }; + } + + return null; +} + +export function admonitionPlugin(editor: Editor) { + return decoratorStateField((state: EditorState) => { + const widgets: any[] = []; + + 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; + } + + const { preSpaces, admonitionType, postSpaces } = extractedFields; + + // 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; + lines.forEach(line => { + fromOffsets.push(accum); + accum += line.length + 2; + }); + + // `from` and `to` range info for switching out **info|warning** text with correct + // icon further down. + const iconRange = { + from: from + 1, + to: from + preSpaces.length + 2 + admonitionType.length + 2 + postSpaces.length + 1, + }; + + const classes = ["sb-admonition"]; + switch (admonitionType) { + case 'note': + classes.push("sb-admonition-note"); + break; + case 'warning': + classes.push("sb-admonition-warning"); + break; + default: + // + } + + // The first div is the title, attach relevant css classes + widgets.push( + Decoration.line({ class: "sb-admonition-title " + classes.join(" ") }).range(fromOffsets[0]), + ); + + // If cursor is not within the first line, replace the **note|warning** text + // with the correct icon + if (!isCursorInRange(state, [from, fromOffsets.length > 1 ? fromOffsets[1] : to])) { + widgets.push( + Decoration.replace({ + widget: new AdmonitionIconWidget( + iconRange.from + 1, + admonitionType, + editor.editorView!, + ), + inclusive: true, + }).range(iconRange.from, iconRange.to) + ); + } + + // Each line of the blockquote is spread across separate divs, attach + // relevant css classes here. + fromOffsets.slice(1).forEach(fromOffset => { + widgets.push( + Decoration.line({ class: classes.join(" ") }).range(fromOffset), + ); + }); + } + }, + }); + + return Decoration.set(widgets, true); + }); +} diff --git a/web/cm_plugins/clean.ts b/web/cm_plugins/clean.ts index db0c61ad..e16208c9 100644 --- a/web/cm_plugins/clean.ts +++ b/web/cm_plugins/clean.ts @@ -2,6 +2,7 @@ import type { ClickEvent } from "../../plug-api/app_event.ts"; import type { Extension } from "../deps.ts"; import { Editor } from "../editor.tsx"; import { blockquotePlugin } from "./block_quote.ts"; +import { admonitionPlugin } from "./admonition.ts"; import { directivePlugin } from "./directive.ts"; import { hideHeaderMarkPlugin, hideMarksPlugin } from "./hide_mark.ts"; import { cleanBlockPlugin } from "./block.ts"; @@ -17,6 +18,7 @@ export function cleanModePlugins(editor: Editor) { linkPlugin(editor), directivePlugin(), blockquotePlugin(), + admonitionPlugin(editor), hideMarksPlugin(), hideHeaderMarkPlugin(), cleanBlockPlugin(), diff --git a/web/styles/editor.scss b/web/styles/editor.scss index 59738fb8..bf06ef48 100644 --- a/web/styles/editor.scss +++ b/web/styles/editor.scss @@ -207,4 +207,4 @@ .cm-scroller { // Give some breathing space at the bottom of the screen padding-bottom: 20em; -} \ No newline at end of file +} diff --git a/web/styles/theme.scss b/web/styles/theme.scss index 394b280e..bff7029e 100644 --- a/web/styles/theme.scss +++ b/web/styles/theme.scss @@ -327,6 +327,42 @@ padding-left: 2ch; } +.sb-admonition { + border-left-width: 4px !important; + border-left-style: solid; +} + +.sb-admonition-icon { + display: inline-flex; + vertical-align: middle; + padding-left: 16px; + padding-right: 8px; +} + +.sb-admonition.sb-admonition-note { + border-left-color: rgb(0, 184, 212); +} + +.sb-admonition.sb-admonition-warning { + border-left-color: rgb(255, 145, 0); +} + +.sb-admonition-title.sb-admonition-note { + background-color: rgba(0, 184, 212, 0.1); +} + +.sb-admonition-title.sb-admonition-warning { + background-color: rgba(255, 145, 0, 0.1); +} + +.sb-admonition-note .sb-admonition-icon { + color: rgb(0, 184, 212); +} + +sb-admonition-warning .sb-admonition-icon { + color: rgb(255, 145, 0); +} + .sb-frontmatter { background-color: rgba(255, 246, 189, 0.5); color: #676767;