ubiquitous-invention/apps/web/components/editor/extensions/embed.tsx

218 lines
5.5 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import { Node, mergeAttributes } from "@tiptap/react";
import {
NodeViewWrapper,
ReactNodeViewRenderer,
type NodeViewProps,
} from "@tiptap/react";
import { ExternalLink, Link2 } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function mockEmbedMeta(url: string): {
title: string;
description: string;
image: string;
} {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, "");
return {
title: host || "Link preview",
description: `Preview for ${url}`,
image: `https://picsum.photos/seed/${encodeURIComponent(host)}/400/200`,
};
} catch {
return {
title: "Link preview",
description: url,
image: "",
};
}
}
function EmbedView(props: NodeViewProps) {
const { node, updateAttributes, selected } = props;
const url = String(node.attrs.url ?? "");
const title = String(node.attrs.title ?? "");
const description = String(node.attrs.description ?? "");
const image = String(node.attrs.image ?? "");
const [draft, setDraft] = React.useState(url);
React.useEffect(() => {
setDraft(url);
}, [url]);
const applyUrl = React.useCallback(() => {
const next = draft.trim();
if (!next) return;
let normalized = next;
if (!/^https?:\/\//i.test(normalized)) {
normalized = `https://${normalized}`;
}
const meta = mockEmbedMeta(normalized);
updateAttributes({
url: normalized,
title: meta.title,
description: meta.description,
image: meta.image,
});
}, [draft, updateAttributes]);
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
applyUrl();
}
};
if (!url) {
return (
<NodeViewWrapper
className={cn(
"embed-block my-4 rounded-lg border border-dashed border-border bg-muted/30 p-4 dark:bg-muted/15",
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
data-type="embed"
>
<div className="flex items-center gap-2 text-muted-foreground">
<Link2 className="size-4 shrink-0" aria-hidden />
<input
className="min-w-0 flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none ring-primary/30 focus:ring-2"
placeholder="Paste a URL and press Enter…"
type="url"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={applyUrl}
onKeyDown={onKeyDown}
/>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper
className={cn(
"embed-block my-4 overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
data-type="embed"
data-url={url}
>
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt=""
className="h-40 w-full object-cover"
src={image}
/>
) : null}
<div className="flex flex-col gap-1 p-4">
<a
className="inline-flex items-center gap-1.5 text-base font-semibold text-primary hover:underline"
href={url}
rel="noopener noreferrer"
target="_blank"
>
{title || url}
<ExternalLink className="size-3.5 opacity-70" aria-hidden />
</a>
{description ? (
<p className="text-sm leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
<p className="truncate text-xs text-muted-foreground/80">{url}</p>
</div>
</NodeViewWrapper>
);
}
export const Embed = Node.create({
name: "embed",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
url: {
default: "",
parseHTML: (el) => el.getAttribute("data-url") ?? "",
renderHTML: (attrs) =>
attrs.url ? { "data-url": attrs.url } : {},
},
title: {
default: "",
parseHTML: (el) => el.querySelector("a")?.textContent?.trim() ?? "",
renderHTML: () => ({}),
},
description: {
default: "",
parseHTML: (el) =>
el.querySelector("p")?.textContent?.trim() ?? "",
renderHTML: () => ({}),
},
image: {
default: "",
parseHTML: () => "",
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="embed"]',
},
];
},
renderHTML({ node, HTMLAttributes }) {
const url = String(node.attrs.url ?? "");
const title = String(node.attrs.title ?? url);
const description = String(node.attrs.description ?? "");
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-type": "embed",
...(url ? { "data-url": url } : {}),
}),
[
"a",
{
href: url || "#",
rel: "noopener noreferrer",
target: "_blank",
},
title,
],
["p", {}, description],
];
},
addNodeView() {
return ReactNodeViewRenderer(EmbedView);
},
addKeyboardShortcuts() {
return {
"Mod-Shift-Alt-e": () =>
this.editor.commands.insertContent({
type: this.name,
attrs: {
url: "",
title: "",
description: "",
image: "",
},
}),
};
},
});