Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -644,8 +644,65 @@ html.dark {
color: var(--color-text-muted);
border-left-color: var(--color-border);
border-inline-start-color: var(--color-border);
font-weight: normal;
font-style: normal;
}

/* Remove Tailwind Typography default quote marks on blockquote paragraphs */
.prose
:where(blockquote p:first-of-type):not(
:where([class~="not-prose"], [class~="not-prose"] *)
)::before,
.prose
:where(blockquote p:last-of-type):not(
:where([class~="not-prose"], [class~="not-prose"] *)
)::after {
content: "";
}

/* Alert blockquotes */
.prose blockquote.alert {
font-weight: normal;
font-style: normal;
color: var(--color-text);
border-inline-start-width: 3px;
border-inline-start-style: solid;
padding: 0.6em 1em;
margin: 1em 0;
border-start-end-radius: 4px;
border-end-end-radius: 4px;
background-color: var(--color-bg-muted);
}

.prose blockquote.alert::before {
display: block;
font-weight: 600;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.4em;
}

.prose blockquote.alert p {
margin: 0;
color: var(--color-text);
}

.prose blockquote.alert-note { border-inline-start-color: #4493f8; }
.prose blockquote.alert-note::before { content: "Note"; color: #4493f8; }

.prose blockquote.alert-tip { border-inline-start-color: #3fb950; }
.prose blockquote.alert-tip::before { content: "Tip"; color: #3fb950; }

.prose blockquote.alert-important { border-inline-start-color: #ab7df8; }
.prose blockquote.alert-important::before { content: "Important"; color: #ab7df8; }

.prose blockquote.alert-warning { border-inline-start-color: #d29922; }
.prose blockquote.alert-warning::before { content: "Warning"; color: #d29922; }

.prose blockquote.alert-caution { border-inline-start-color: #f85149; }
.prose blockquote.alert-caution::before { content: "Caution"; color: #f85149; }

/* Search match highlighting - uses the selection color */
.search-match {
background-color: color-mix(in srgb, var(--color-selection), transparent 50%);
Expand Down
130 changes: 130 additions & 0 deletions src/components/editor/AlertBlockquote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Node, mergeAttributes, InputRule, type JSONContent, type MarkdownToken } from "@tiptap/core";

export type AlertType = "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION";

export const ALERT_TYPES: AlertType[] = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];

// Single source of truth for label/color, shared by the toolbar buttons,
// slash commands, and the accessible label rendered on each alert.
export const ALERT_META: Record<AlertType, { label: string; color: string }> = {
NOTE: { label: "Note", color: "#4493f8" },
TIP: { label: "Tip", color: "#3fb950" },
IMPORTANT: { label: "Important", color: "#ab7df8" },
WARNING: { label: "Warning", color: "#d29922" },
CAUTION: { label: "Caution", color: "#f85149" },
};

function normalizeAlertType(value: unknown): AlertType {
const upper = typeof value === "string" ? value.toUpperCase() : "";
return (ALERT_TYPES as string[]).includes(upper) ? (upper as AlertType) : "NOTE";
}

// Built from ALERT_TYPES so the tokenizer always matches exactly the types we support
const ALERT_TOKEN_RE = new RegExp(
`^> \\[!(${ALERT_TYPES.join("|")})\\][ \\t]*\\r?\\n?((?:>[ \\t]?[^\\n]*\\r?\\n?)*)`,
"i",
);


export const AlertBlockquote = Node.create({
name: "alertBlockquote",
group: "block",
content: "block+",
defining: true,

addAttributes() {
return {
alertType: {
default: "NOTE",
parseHTML: (el) => normalizeAlertType(el.getAttribute("data-alert-type")),
renderHTML: (attrs) => ({ "data-alert-type": attrs.alertType }),
},
};
},

parseHTML() {
return [{ tag: "blockquote[data-alert-type]" }];
},

renderHTML({ node, HTMLAttributes }) {
const alertType = normalizeAlertType(node.attrs.alertType);
const { label } = ALERT_META[alertType];
return [
"blockquote",
mergeAttributes(HTMLAttributes, {
class: `alert alert-${alertType.toLowerCase()}`,
"aria-label": `${label} callout`,
}),
0,
];
},

addInputRules() {
const nodeType = this.type;
return ALERT_TYPES.map((alertType) =>
new InputRule({
find: new RegExp(`^\\[!${alertType}\\]\\s$`, "i"),
handler: ({ state, range, commands }) => {
const { $from } = state.selection;
let bqPos = -1;
for (let d = $from.depth; d >= 1; d--) {
if ($from.node(d).type.name === "blockquote") {
bqPos = $from.before(d);
break;
}
}
if (bqPos === -1) return;

commands.command(({ tr }) => {
tr.delete(range.from, range.to);
const bq = tr.doc.nodeAt(bqPos);
if (!bq) return true;
const alertNode = nodeType.create({ alertType }, bq.content);
tr.replaceWith(bqPos, bqPos + bq.nodeSize, alertNode);
return true;
});
},
}),
);
},

markdownTokenName: "alertBlockquote",

markdownTokenizer: {
name: "alertBlockquote",
level: "block" as const,
start: "> [!",
tokenize(src: string, _tokens: MarkdownToken[]) {
const match = src.match(ALERT_TOKEN_RE);
if (!match) return undefined;
const text = (match[2] || "").replace(/^>[ \t]?/gm, "").replace(/\s+$/, "");
return {
type: "alertBlockquote",
raw: match[0],
alertType: normalizeAlertType(match[1]),
text,
};
},
},

parseMarkdown(token: MarkdownToken, helpers) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const t = token as any;
const alertType = normalizeAlertType(t.alertType);
const text: string = t.text || "";
// Split on blank lines → multiple paragraphs; soft newlines → space
const blocks = text.split(/\n\n+/).map((p) => p.replace(/\n/g, " ").trim()).filter(Boolean);
const paraNodes = blocks.length > 0
? blocks.map((p) => helpers.createNode("paragraph", {}, [helpers.createTextNode(p)]))
: [helpers.createNode("paragraph", {}, [])];
return helpers.createNode("alertBlockquote", { alertType }, paraNodes);
},

renderMarkdown(node: JSONContent, helpers) {
const alertType = normalizeAlertType(node.attrs?.alertType);
const raw = node.content ? helpers.renderChildren(node.content) : "";
const inner = raw.replace(/\s+$/, "");
const lines = inner.split("\n").map((l) => (l ? `> ${l}` : ">"));
return `> [!${alertType}]\n${lines.join("\n")}`;
},
});
23 changes: 23 additions & 0 deletions src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { Wikilink, type WikilinkStorage } from "./Wikilink";
import { WikilinkSuggestion } from "./WikilinkSuggestion";
import { EditorWidthHandles } from "./EditorWidthHandle";
import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions";
import { AlertBlockquote, ALERT_TYPES, ALERT_META } from "./AlertBlockquote";
import { cn } from "../../lib/utils";
import { plainTextFromMarkdown } from "../../lib/plainText";
import { Button, IconButton, ToolbarButton, Tooltip } from "../ui";
Expand Down Expand Up @@ -345,6 +346,27 @@ function FormatBar({
>
<QuoteIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</ToolbarButton>
{ALERT_TYPES.map((alertType) => {
const { label, color } = ALERT_META[alertType];
return (
<ToolbarButton
key={alertType}
onClick={() =>
editor.chain().focus().wrapIn("alertBlockquote", { alertType }).run()
}
isActive={editor.isActive("alertBlockquote", { alertType })}
title={`Alert: ${label}`}
>
<span className="relative flex items-center justify-center">
<QuoteIcon className="w-4.5 h-4.5 stroke-[1.5]" />
<span
className="absolute -bottom-0.5 -right-0.5 size-1.5 rounded-full"
style={{ background: color }}
/>
</span>
</ToolbarButton>
);
})}
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive("code")}
Expand Down Expand Up @@ -1109,6 +1131,7 @@ export function Editor({
},
}),
Frontmatter,
AlertBlockquote,
Markdown.configure({}),
SearchHighlight.configure({
matches: [],
Expand Down
26 changes: 26 additions & 0 deletions src/components/editor/SlashCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ import {
BracketsIcon,
WorkflowIcon,
} from "../icons";
import { ALERT_TYPES, ALERT_META, type AlertType } from "./AlertBlockquote";

const ALERT_ALIASES: Record<AlertType, string[]> = {
NOTE: ["note", "info"],
TIP: ["tip", "hint"],
IMPORTANT: ["important"],
WARNING: ["warning", "warn"],
CAUTION: ["caution", "danger"],
};
import { SlashCommandList, type SlashCommandListRef } from "./SlashCommandList";

export interface SlashCommandItem {
Expand Down Expand Up @@ -115,6 +124,23 @@ const SLASH_COMMANDS: SlashCommandItem[] = [
editor.chain().focus().toggleBlockquote().run();
},
},
...ALERT_TYPES.map((type) => {
const { label, color } = ALERT_META[type];
return {
title: `Alert: ${label}`,
description: `${label} alert callout`,
icon: (
<div style={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}>
<QuoteIcon />
<span style={{ position: "absolute", bottom: -2, right: -3, width: 6, height: 6, borderRadius: "50%", background: color }} />
</div>
),
aliases: [...ALERT_ALIASES[type], "alert", "callout"],
command: (editor: TiptapEditor) => {
editor.chain().focus().wrapIn("alertBlockquote", { alertType: type }).run();
},
};
}),
{
title: "Code Block",
description: "Fenced code block",
Expand Down