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;