/** * Plugin Block Node for TipTap * * Renders embed blocks (YouTube, Vimeo, tweets, etc.) with: * - Selection indicator with ring * - Inline URL editing via popover * - Drag handle in left gutter * - Action buttons on hover/selection * - Keyboard support */ import { Button, Input } from "@cloudflare/kumo"; import type { Element } from "@emdash-cms/blocks"; import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { DotsSixVertical, Trash, Pencil, X, Check, ArrowSquareOut, YoutubeLogo, LinkSimple, Code, Copy, Cube, ListBullets, } from "@phosphor-icons/react"; import { Node, mergeAttributes } from "@tiptap/core"; import type { NodeViewProps } from "@tiptap/react"; import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; import * as React from "react"; import { cn } from "../../lib/utils"; /** * Plugin block definition for slash commands */ export interface PluginBlockDef { type: string; pluginId: string; label: string; icon?: string; description?: string; placeholder?: string; /** Block Kit form fields. If declared, replaces the simple URL input. */ fields?: Element[]; /** * Optional display category in the slash menu. Defaults to "Embeds" when omitted. */ category?: string; } // ============================================================================= // Plugin Block Registry (stored per-editor instance via TipTap extension storage) // ============================================================================= /** Register plugin block definitions into editor storage so the node view can look up metadata */ export function registerPluginBlocks( editor: { storage: Record> }, blocks: PluginBlockDef[], ): void { const registry = new Map(); for (const block of blocks) { registry.set(block.type, block); } const storage = editor.storage.pluginBlock as Record | undefined; if (storage) { storage.registry = registry; } } /** Read the registry from editor storage */ function getRegistry(editor: { storage: Record>; }): Map { const storage = editor.storage.pluginBlock as Record | undefined; return (storage?.registry as Map) ?? new Map(); } /** Named icon map: icon key → React component */ const ICON_MAP: Record> = { video: YoutubeLogo, code: Code, link: LinkSimple, "link-external": ArrowSquareOut, form: ListBullets, }; /** Resolve an icon key to a React component */ function resolveIcon(iconKey?: string): React.ComponentType<{ className?: string }> { if (iconKey && ICON_MAP[iconKey]) { return ICON_MAP[iconKey]; } return Cube; } /** * Get icon component and metadata for embed block types. * Reads from the plugin block registry in editor storage. */ function getEmbedMeta( blockType: string, registry: Map, ): { Icon: React.ComponentType<{ className?: string }>; label: string; color: string; /** Plugin-supplied placeholder (rendered as-is, not translated). */ placeholder?: string; /** Translated fallback when no plugin placeholder is supplied. */ placeholderFallback: MessageDescriptor; } { const def = registry.get(blockType); if (def) { return { Icon: resolveIcon(def.icon), label: def.label, color: "text-kumo-subtle", placeholder: def.placeholder, placeholderFallback: msg`Enter URL...`, }; } // Fallback for unregistered block types return { Icon: Cube, label: blockType.charAt(0).toUpperCase() + blockType.slice(1), color: "text-kumo-subtle", placeholderFallback: msg`Enter URL...`, }; } /** * Extract display ID from URL for cleaner presentation */ function getDisplayId(id: string, blockType: string): string { try { const url = new URL(id); switch (blockType) { case "youtube": { // youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID const videoId = url.searchParams.get("v") || url.pathname.split("/").pop(); return videoId || id; } case "vimeo": { // vimeo.com/VIDEO_ID return url.pathname.split("/").find(Boolean) || id; } case "tweet": { // twitter.com/user/status/TWEET_ID const parts = url.pathname.split("/"); const statusIndex = parts.indexOf("status"); const tweetId = parts[statusIndex + 1]; if (statusIndex !== -1 && tweetId) { return `@${parts[1]}/${tweetId.slice(0, 8)}...`; } return id; } case "gist": { // gist.github.com/user/GIST_ID const parts = url.pathname.split("/").filter(Boolean); if (parts.length >= 2 && parts[0] && parts[1]) { return `${parts[0]}/${parts[1].slice(0, 8)}...`; } return id; } default: // Show hostname + truncated path return url.hostname + (url.pathname.length > 20 ? "..." : url.pathname); } } catch { // Not a valid URL, show as-is but truncated return id.length > 30 ? id.slice(0, 27) + "..." : id; } } /** * React component for the plugin block node view */ function PluginBlockNodeView({ node, updateAttributes, selected, deleteNode, editor, getPos, }: NodeViewProps) { const { t } = useLingui(); const blockType = typeof node.attrs.blockType === "string" ? node.attrs.blockType : ""; const id = typeof node.attrs.id === "string" ? node.attrs.id : ""; const data = typeof node.attrs.data === "object" && node.attrs.data !== null ? (node.attrs.data as Record) : {}; const registry = getRegistry( editor as unknown as { storage: Record> }, ); const { Icon, label, color, placeholder, placeholderFallback } = getEmbedMeta( blockType, registry, ); // Check if this block type has fields defined in the registry const blockDef = registry.get(blockType); const hasFields = blockDef?.fields && blockDef.fields.length > 0; const [isEditing, setIsEditing] = React.useState(false); const [editValue, setEditValue] = React.useState(id || ""); const inputRef = React.useRef(null); // Focus input when editing starts React.useEffect(() => { if (isEditing) { setEditValue(id || ""); setTimeout(() => inputRef.current?.focus(), 0); } }, [isEditing, id]); const handleSave = () => { if (editValue.trim()) { updateAttributes({ id: editValue.trim() }); } setIsEditing(false); }; const handleCancel = () => { setEditValue(id || ""); setIsEditing(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleSave(); } else if (e.key === "Escape") { e.preventDefault(); handleCancel(); } }; const handleCopyUrl = () => { void navigator.clipboard.writeText(id); }; const handleOpenExternal = () => { window.open(id, "_blank", "noopener,noreferrer"); }; const displayId = id ? getDisplayId(id, blockType) : Object.values(data) .filter((v) => typeof v === "string" && v.length > 0) .join(", ") || blockType; return (
{/* Drag handle - appears in left gutter */}
{/* Main block content */}
{/* Header with icon, label, and actions */}
{/* Icon */}
{/* Label and ID */}
{label}
{!isEditing && (
{displayId}
)}
{/* Action buttons - visible on hover or when selected */}
{id && ( <> )}
{/* Inline URL editor - slides down when editing */} {isEditing && (
setEditValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={placeholder ?? t(placeholderFallback)} className="flex-1 h-9 text-sm font-mono" />
)}
); } /** * TipTap Node extension for plugin blocks (embeds) */ export const PluginBlockExtension = Node.create({ name: "pluginBlock", group: "block", atom: true, draggable: true, selectable: true, addAttributes() { return { blockType: { default: null, }, id: { default: null, }, data: { default: {}, parseHTML: (el: HTMLElement) => JSON.parse(el.getAttribute("data-plugin-data") || "{}"), renderHTML: (attrs: Record) => ({ "data-plugin-data": JSON.stringify(attrs.data), }), }, }; }, addStorage() { return { /** Per-editor registry of plugin block definitions */ registry: new Map(), /** Callback set by PortableTextEditor to open the Block Kit modal for editing */ onEditBlock: null as | ((attrs: { blockType: string; id: string; data: Record; pos: number; }) => void) | null, }; }, parseHTML() { return [ { tag: "div[data-plugin-block]", }, ]; }, renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes(HTMLAttributes, { "data-plugin-block": "" })]; }, addNodeView() { return ReactNodeViewRenderer(PluginBlockNodeView); }, addKeyboardShortcuts() { return { // Delete block on backspace when selected (not editing) Backspace: () => { const { selection } = this.editor.state; const node = this.editor.state.doc.nodeAt(selection.from); if (node?.type.name === "pluginBlock") { this.editor.commands.deleteSelection(); return true; } return false; }, // Also handle Delete key Delete: () => { const { selection } = this.editor.state; const node = this.editor.state.doc.nodeAt(selection.from); if (node?.type.name === "pluginBlock") { this.editor.commands.deleteSelection(); return true; } return false; }, }; }, }); // Re-export helpers for use elsewhere export { getEmbedMeta, resolveIcon };