Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
56 changes: 56 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -644,8 +644,64 @@ 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-left-width: 3px;
border-left-style: solid;
padding: 0.6em 1em;
margin: 1em 0;
border-radius: 0 4px 4px 0;
background-color: var(--color-bg-muted);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

.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-left-color: #4493f8; }
.prose blockquote.alert-note::before { content: "Note"; color: #4493f8; }

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

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

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

.prose blockquote.alert-caution { border-left-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
107 changes: 107 additions & 0 deletions src/components/editor/AlertBlockquote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Node, mergeAttributes, InputRule, type JSONContent, type MarkdownToken } from "@tiptap/core";

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

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


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

addAttributes() {
return {
alertType: {
default: "NOTE",
parseHTML: (el) => (el.getAttribute("data-alert-type") as AlertType) || "NOTE",
renderHTML: (attrs) => ({ "data-alert-type": attrs.alertType }),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},
};
},

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

renderHTML({ node, HTMLAttributes }) {
const type = ((node.attrs.alertType as string) || "NOTE").toLowerCase();
return [
"blockquote",
mergeAttributes(HTMLAttributes, { class: `alert alert-${type}` }),
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(
/^> \[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*\r?\n?((?:>[ \t]?[^\n]*\r?\n?)*)/i,
);
if (!match) return undefined;
const text = (match[2] || "").replace(/^>[ \t]?/gm, "").replace(/\s+$/, "");
return {
type: "alertBlockquote",
raw: match[0],
alertType: match[1].toUpperCase(),
text,
};
},
},

parseMarkdown(token: MarkdownToken, helpers) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const t = token as any;
const alertType: string = t.alertType || "NOTE";
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 = (node.attrs?.alertType as string) || "NOTE";
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")}`;
},
});
28 changes: 28 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 } 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,32 @@ function FormatBar({
>
<QuoteIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</ToolbarButton>
{(
[
{ alertType: "NOTE", label: "Note", color: "#4493f8" },
{ alertType: "TIP", label: "Tip", color: "#3fb950" },
{ alertType: "IMPORTANT", label: "Important", color: "#ab7df8" },
{ alertType: "WARNING", label: "Warning", color: "#d29922" },
{ alertType: "CAUTION", label: "Caution", color: "#f85149" },
] as { alertType: string; label: string; color: string }[]
).map(({ alertType, label, color }) => (
<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 +1136,7 @@ export function Editor({
},
}),
Frontmatter,
AlertBlockquote,
Markdown.configure({}),
SearchHighlight.configure({
matches: [],
Expand Down
21 changes: 21 additions & 0 deletions src/components/editor/SlashCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
BracketsIcon,
WorkflowIcon,
} from "../icons";
import type { AlertType } from "./AlertBlockquote";
import { SlashCommandList, type SlashCommandListRef } from "./SlashCommandList";

export interface SlashCommandItem {
Expand Down Expand Up @@ -115,6 +116,26 @@ const SLASH_COMMANDS: SlashCommandItem[] = [
editor.chain().focus().toggleBlockquote().run();
},
},
...([
{ type: "NOTE", label: "Note", color: "#4493f8", aliases: ["note", "info"] },
{ type: "TIP", label: "Tip", color: "#3fb950", aliases: ["tip", "hint"] },
{ type: "IMPORTANT", label: "Important", color: "#ab7df8", aliases: ["important" ] },
{ type: "WARNING", label: "Warning", color: "#d29922", aliases: ["warning", "warn"] },
{ type: "CAUTION", label: "Caution", color: "#f85149", aliases: ["caution", "danger"] },
] as { type: AlertType; label: string; color: string; aliases: string[] }[]).map(({ type, label, color, aliases }) => ({
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: [...aliases, "alert", "callout"],
command: (editor: TiptapEditor) => {
editor.chain().focus().wrapIn("alertBlockquote", { alertType: type }).run();
},
})),
{
title: "Code Block",
description: "Fenced code block",
Expand Down