168 lines
4.2 KiB
TypeScript
168 lines
4.2 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { Node, mergeAttributes } from "@tiptap/react";
|
||
|
|
import {
|
||
|
|
NodeViewContent,
|
||
|
|
NodeViewWrapper,
|
||
|
|
ReactNodeViewRenderer,
|
||
|
|
type NodeViewProps,
|
||
|
|
} from "@tiptap/react";
|
||
|
|
import {
|
||
|
|
AlertTriangle,
|
||
|
|
CheckCircle2,
|
||
|
|
Info,
|
||
|
|
XCircle,
|
||
|
|
} from "lucide-react";
|
||
|
|
import * as React from "react";
|
||
|
|
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
export type CalloutType = "info" | "warning" | "success" | "error";
|
||
|
|
|
||
|
|
const TYPE_ICON: Record<
|
||
|
|
CalloutType,
|
||
|
|
React.ComponentType<{ className?: string }>
|
||
|
|
> = {
|
||
|
|
info: Info,
|
||
|
|
warning: AlertTriangle,
|
||
|
|
success: CheckCircle2,
|
||
|
|
error: XCircle,
|
||
|
|
};
|
||
|
|
|
||
|
|
const TYPE_STYLES: Record<
|
||
|
|
CalloutType,
|
||
|
|
{ box: string; icon: string }
|
||
|
|
> = {
|
||
|
|
info: {
|
||
|
|
box: "border border-blue-500/25 bg-blue-500/10 dark:bg-blue-500/15 dark:border-blue-400/30",
|
||
|
|
icon: "text-blue-600 dark:text-blue-400",
|
||
|
|
},
|
||
|
|
warning: {
|
||
|
|
box: "border border-amber-500/30 bg-amber-500/10 dark:bg-amber-500/15 dark:border-amber-400/35",
|
||
|
|
icon: "text-amber-700 dark:text-amber-400",
|
||
|
|
},
|
||
|
|
success: {
|
||
|
|
box: "border border-emerald-500/25 bg-emerald-500/10 dark:bg-emerald-500/15 dark:border-emerald-400/30",
|
||
|
|
icon: "text-emerald-700 dark:text-emerald-400",
|
||
|
|
},
|
||
|
|
error: {
|
||
|
|
box: "border border-red-500/25 bg-red-500/10 dark:bg-red-500/15 dark:border-red-400/30",
|
||
|
|
icon: "text-red-600 dark:text-red-400",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const DEFAULT_EMOJI: Record<CalloutType, string> = {
|
||
|
|
info: "💡",
|
||
|
|
warning: "⚠️",
|
||
|
|
success: "✅",
|
||
|
|
error: "⛔",
|
||
|
|
};
|
||
|
|
|
||
|
|
function CalloutView(props: NodeViewProps) {
|
||
|
|
const { node, updateAttributes, selected } = props;
|
||
|
|
const type = (node.attrs.type as CalloutType) ?? "info";
|
||
|
|
const emoji =
|
||
|
|
typeof node.attrs.emoji === "string" && node.attrs.emoji.length > 0
|
||
|
|
? node.attrs.emoji
|
||
|
|
: DEFAULT_EMOJI[type];
|
||
|
|
const Icon = TYPE_ICON[type];
|
||
|
|
const styles = TYPE_STYLES[type];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<NodeViewWrapper
|
||
|
|
className={cn(
|
||
|
|
"callout-block my-3 flex gap-3 rounded-lg px-3 py-2.5",
|
||
|
|
styles.box,
|
||
|
|
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
|
||
|
|
)}
|
||
|
|
data-type="callout"
|
||
|
|
data-callout-type={type}
|
||
|
|
>
|
||
|
|
<div className="flex shrink-0 flex-col items-center gap-1 pt-0.5">
|
||
|
|
<span
|
||
|
|
className="cursor-pointer select-none text-lg leading-none"
|
||
|
|
contentEditable={false}
|
||
|
|
onClick={() => {
|
||
|
|
const order: CalloutType[] = [
|
||
|
|
"info",
|
||
|
|
"warning",
|
||
|
|
"success",
|
||
|
|
"error",
|
||
|
|
];
|
||
|
|
const next = order[(order.indexOf(type) + 1) % order.length];
|
||
|
|
updateAttributes({
|
||
|
|
type: next,
|
||
|
|
emoji: DEFAULT_EMOJI[next],
|
||
|
|
});
|
||
|
|
}}
|
||
|
|
title="Cycle callout type"
|
||
|
|
>
|
||
|
|
{emoji}
|
||
|
|
</span>
|
||
|
|
<Icon className={cn("size-4", styles.icon)} aria-hidden />
|
||
|
|
</div>
|
||
|
|
<div className="min-w-0 flex-1 [&_.ProseMirror_p]:my-1 [&_.ProseMirror_p:first-child]:mt-0 [&_.ProseMirror_p:last-child]:mb-0">
|
||
|
|
<NodeViewContent className="callout-content outline-none" />
|
||
|
|
</div>
|
||
|
|
</NodeViewWrapper>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export const Callout = Node.create({
|
||
|
|
name: "callout",
|
||
|
|
group: "block",
|
||
|
|
content: "block+",
|
||
|
|
defining: true,
|
||
|
|
|
||
|
|
addAttributes() {
|
||
|
|
return {
|
||
|
|
type: {
|
||
|
|
default: "info",
|
||
|
|
parseHTML: (el) =>
|
||
|
|
(el.getAttribute("data-callout-type") as CalloutType | null) ??
|
||
|
|
"info",
|
||
|
|
renderHTML: (attrs) => ({
|
||
|
|
"data-callout-type": attrs.type ?? "info",
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
emoji: {
|
||
|
|
default: "",
|
||
|
|
parseHTML: (el) => el.getAttribute("data-emoji") ?? "",
|
||
|
|
renderHTML: (attrs) =>
|
||
|
|
attrs.emoji ? { "data-emoji": attrs.emoji } : {},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
|
||
|
|
parseHTML() {
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
tag: 'div[data-type="callout"]',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
},
|
||
|
|
|
||
|
|
renderHTML({ HTMLAttributes }) {
|
||
|
|
return [
|
||
|
|
"div",
|
||
|
|
mergeAttributes(HTMLAttributes, { "data-type": "callout" }),
|
||
|
|
0,
|
||
|
|
];
|
||
|
|
},
|
||
|
|
|
||
|
|
addNodeView() {
|
||
|
|
return ReactNodeViewRenderer(CalloutView);
|
||
|
|
},
|
||
|
|
|
||
|
|
addKeyboardShortcuts() {
|
||
|
|
return {
|
||
|
|
"Mod-Shift-Alt-c": () =>
|
||
|
|
this.editor.commands.insertContent({
|
||
|
|
type: this.name,
|
||
|
|
attrs: { type: "info", emoji: DEFAULT_EMOJI.info },
|
||
|
|
content: [{ type: "paragraph" }],
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
},
|
||
|
|
});
|