diff --git a/src/App.css b/src/App.css index 3b3904d9..efaf6605 100644 --- a/src/App.css +++ b/src/App.css @@ -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%); diff --git a/src/components/editor/AlertBlockquote.ts b/src/components/editor/AlertBlockquote.ts new file mode 100644 index 00000000..3096416f --- /dev/null +++ b/src/components/editor/AlertBlockquote.ts @@ -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 = { + 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")}`; + }, +}); diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index dcf07161..d89f9930 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -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"; @@ -345,6 +346,27 @@ function FormatBar({ > + {ALERT_TYPES.map((alertType) => { + const { label, color } = ALERT_META[alertType]; + return ( + + editor.chain().focus().wrapIn("alertBlockquote", { alertType }).run() + } + isActive={editor.isActive("alertBlockquote", { alertType })} + title={`Alert: ${label}`} + > + + + + + + ); + })} editor.chain().focus().toggleCode().run()} isActive={editor.isActive("code")} @@ -1109,6 +1131,7 @@ export function Editor({ }, }), Frontmatter, + AlertBlockquote, Markdown.configure({}), SearchHighlight.configure({ matches: [], diff --git a/src/components/editor/SlashCommand.tsx b/src/components/editor/SlashCommand.tsx index 370a242a..7183e122 100644 --- a/src/components/editor/SlashCommand.tsx +++ b/src/components/editor/SlashCommand.tsx @@ -23,6 +23,15 @@ import { BracketsIcon, WorkflowIcon, } from "../icons"; +import { ALERT_TYPES, ALERT_META, type AlertType } from "./AlertBlockquote"; + +const ALERT_ALIASES: Record = { + NOTE: ["note", "info"], + TIP: ["tip", "hint"], + IMPORTANT: ["important"], + WARNING: ["warning", "warn"], + CAUTION: ["caution", "danger"], +}; import { SlashCommandList, type SlashCommandListRef } from "./SlashCommandList"; export interface SlashCommandItem { @@ -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: ( +
+ + +
+ ), + aliases: [...ALERT_ALIASES[type], "alert", "callout"], + command: (editor: TiptapEditor) => { + editor.chain().focus().wrapIn("alertBlockquote", { alertType: type }).run(); + }, + }; + }), { title: "Code Block", description: "Fenced code block",