/** * Self-contained inline Portable Text editor for visual editing. * * Uses TipTap directly with content extensions — no admin UI deps. * Includes BubbleMenu for inline formatting (bold, italic, etc.) * but no toolbar, no media picker, no section picker. * * Converts between Portable Text and ProseMirror on mount/save. * Auto-saves on blur, dispatches custom events for toolbar integration. */ import { autoUpdate, flip, offset, shift, useFloating } from "@floating-ui/react"; import { Extension, Node, mergeAttributes, type JSONContent, type Range } from "@tiptap/core"; import Focus from "@tiptap/extension-focus"; import Image from "@tiptap/extension-image"; import Link from "@tiptap/extension-link"; import Placeholder from "@tiptap/extension-placeholder"; import TextAlign from "@tiptap/extension-text-align"; import Typography from "@tiptap/extension-typography"; import Underline from "@tiptap/extension-underline"; import { useEditor, EditorContent, type Editor } from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; import StarterKit from "@tiptap/starter-kit"; import Suggestion from "@tiptap/suggestion"; import * as React from "react"; import { createPortal } from "react-dom"; import { computeThumbnailSize } from "../media/thumbnail.js"; import { InlineCodeBlockExtension } from "./inline-code-block.js"; // ── Portable Text types ──────────────────────────────────────────── interface PTSpan { _type: "span"; _key: string; text: string; marks?: string[]; } interface PTMarkDef { _type: string; _key: string; [key: string]: unknown; } interface PTTextBlock { _type: "block"; _key: string; style?: "normal" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote"; listItem?: "bullet" | "number"; level?: number; children: PTSpan[]; markDefs?: PTMarkDef[]; textAlign?: "left" | "center" | "right" | "justify"; } type PTBlock = PTTextBlock | { _type: string; _key: string; [key: string]: unknown }; /** Type guard for PTTextBlock */ function isPTTextBlock(block: PTBlock): block is PTTextBlock { return block._type === "block"; } /** Type guard for ProseMirror JSON document node */ function isPMNode(value: unknown): value is PMNode { return ( typeof value === "object" && value !== null && "type" in value && typeof value.type === "string" ); } // ── Helpers ──────────────────────────────────────────────────────── function k(): string { return Math.random().toString(36).substring(2, 11); } // ── ProseMirror → Portable Text ──────────────────────────────────── type PMNode = { type: string; attrs?: Record; content?: PMNode[]; marks?: Array<{ type: string; attrs?: Record }>; text?: string; }; /** Safely extract a string attribute from ProseMirror attrs */ function attrStr(attrs: Record | undefined, key: string): string { const v = attrs?.[key]; return typeof v === "string" ? v : ""; } /** Safely extract an optional string attribute from ProseMirror attrs */ function attrStrOpt(attrs: Record | undefined, key: string): string | undefined { const v = attrs?.[key]; return typeof v === "string" ? v : undefined; } /** Safely extract a number attribute from ProseMirror attrs */ function attrNum(attrs: Record | undefined, key: string): number | undefined { const v = attrs?.[key]; return typeof v === "number" ? v : undefined; } function pmToPortableText(doc: PMNode): PTBlock[] { if (!doc || doc.type !== "doc" || !doc.content) return []; const blocks: PTBlock[] = []; for (const node of doc.content) { const r = convertPMNode(node); if (r) { if (Array.isArray(r)) blocks.push(...r); else blocks.push(r); } } return blocks; } function convertPMNode(node: PMNode): PTBlock | PTBlock[] | null { switch (node.type) { case "paragraph": { const { children, markDefs } = convertInline(node.content || []); if (children.length === 0) return null; const ta = node.attrs?.textAlign; const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; return { _type: "block", _key: k(), style: "normal", children, markDefs: markDefs.length > 0 ? markDefs : undefined, ...(textAlign ? { textAlign } : {}), }; } case "heading": { const { children, markDefs } = convertInline(node.content || []); const level = attrNum(node.attrs, "level") ?? 1; if (children.length === 0) return null; const headingStyles: Record = { 1: "h1", 2: "h2", 3: "h3", 4: "h4", 5: "h5", 6: "h6", }; const headingStyle = headingStyles[level] ?? "h1"; const ta = node.attrs?.textAlign; const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; return { _type: "block", _key: k(), style: headingStyle, children, markDefs: markDefs.length > 0 ? markDefs : undefined, ...(textAlign ? { textAlign } : {}), }; } case "bulletList": return convertPMList(node.content || [], "bullet"); case "orderedList": return convertPMList(node.content || [], "number"); case "blockquote": { const blocks: PTTextBlock[] = []; for (const child of node.content || []) { if (child.type === "paragraph") { const { children, markDefs } = convertInline(child.content || []); if (children.length > 0) { blocks.push({ _type: "block", _key: k(), style: "blockquote", children, markDefs: markDefs.length > 0 ? markDefs : undefined, }); } } } if (blocks.length === 1) { const first = blocks[0]; return first ?? null; } return blocks.length > 0 ? blocks : null; } case "codeBlock": { const code = (node.content || []).map((n) => n.text || "").join(""); return { _type: "code", _key: k(), code, language: attrStrOpt(node.attrs, "language"), }; } case "htmlBlock": { const rawHtml = node.attrs?.html; return { _type: "htmlBlock", _key: k(), html: typeof rawHtml === "string" ? rawHtml : "", }; } case "image": { const provider = attrStrOpt(node.attrs, "provider"); const blurhash = attrStrOpt(node.attrs, "blurhash"); const dominantColor = attrStrOpt(node.attrs, "dominantColor"); // Persist LQIP as first-class block fields (matching the image-field // MediaValue path) rather than nesting in `asset.meta`, so read sites // and normalize don't need a dual-shape fallback. `asset.meta` is left // to carry only provider-specific data — it isn't reconstructed here, // so non-LQIP meta keys are never silently dropped on editor round-trip. return { _type: "image", _key: k(), asset: { _ref: attrStr(node.attrs, "mediaId"), url: attrStr(node.attrs, "src"), provider: provider && provider !== "local" ? provider : undefined, }, alt: attrStrOpt(node.attrs, "alt"), caption: attrStrOpt(node.attrs, "caption") ?? attrStrOpt(node.attrs, "title"), width: attrNum(node.attrs, "width"), height: attrNum(node.attrs, "height"), ...(blurhash ? { blurhash } : {}), ...(dominantColor ? { dominantColor } : {}), displayWidth: attrNum(node.attrs, "displayWidth"), displayHeight: attrNum(node.attrs, "displayHeight"), }; } case "horizontalRule": return { _type: "break", _key: k(), style: "lineBreak" }; case "pluginBlock": { // Spread the captured data back out so the block round-trips losslessly. // `data` holds every field except _type / _key / id (which live on // dedicated attrs). const { blockType, id, data } = node.attrs ?? {}; return { ...(data && typeof data === "object" ? data : {}), _type: typeof blockType === "string" ? blockType : "embed", _key: k(), id: typeof id === "string" ? id : "", }; } default: return null; } } function convertPMList(items: PMNode[], listItem: "bullet" | "number"): PTTextBlock[] { const blocks: PTTextBlock[] = []; for (const item of items) { if (item.type === "listItem") { for (const child of item.content || []) { if (child.type === "paragraph") { const { children, markDefs } = convertInline(child.content || []); if (children.length > 0) { blocks.push({ _type: "block", _key: k(), style: "normal", listItem, level: 1, children, markDefs: markDefs.length > 0 ? markDefs : undefined, }); } } } } } return blocks; } function convertInline(nodes: PMNode[]): { children: PTSpan[]; markDefs: PTMarkDef[] } { const children: PTSpan[] = []; const markDefs: PTMarkDef[] = []; const markDefMap = new Map(); for (const node of nodes) { if (node.type === "text" && node.text) { const marks: string[] = []; for (const mark of node.marks || []) { const m = convertPMMark(mark, markDefs, markDefMap); if (m) marks.push(m); } children.push({ _type: "span", _key: k(), text: node.text, marks: marks.length > 0 ? marks : undefined, }); } else if (node.type === "hardBreak") { if (children.length > 0) { const last = children.at(-1); if (last) last.text += "\n"; } else { children.push({ _type: "span", _key: k(), text: "\n" }); } } } if (children.length === 0) { children.push({ _type: "span", _key: k(), text: "" }); } return { children, markDefs }; } function convertPMMark( mark: { type: string; attrs?: Record }, markDefs: PTMarkDef[], markDefMap: Map, ): string | null { switch (mark.type) { case "bold": case "strong": return "strong"; case "italic": case "em": return "em"; case "underline": return "underline"; case "strike": case "strikethrough": return "strike-through"; case "code": return "code"; case "link": { const href = attrStr(mark.attrs, "href"); if (markDefMap.has(href)) return markDefMap.get(href)!; const key = k(); markDefs.push({ _type: "link", _key: key, href, blank: mark.attrs?.target === "_blank", }); markDefMap.set(href, key); return key; } default: return mark.type; } } // ── Portable Text → ProseMirror ──────────────────────────────────── function portableTextToPM(blocks: PTBlock[]): JSONContent { if (!blocks || blocks.length === 0) return { type: "doc", content: [{ type: "paragraph" }] }; const content: JSONContent[] = []; let i = 0; while (i < blocks.length) { const block = blocks[i]; if (!block) { i++; continue; } if (isPTTextBlock(block) && block.listItem) { const listBlocks: PTTextBlock[] = []; const listType = block.listItem; while (i < blocks.length) { const cur = blocks[i]; if (cur && isPTTextBlock(cur) && cur.listItem === listType) { listBlocks.push(cur); i++; } else break; } content.push(convertPTList(listBlocks, listType)); } else { const c = convertPTBlock(block); if (c) content.push(c); i++; } } return { type: "doc", content: content.length > 0 ? content : [{ type: "paragraph" }] }; } function convertPTBlock(block: PTBlock): JSONContent | null { if (isPTTextBlock(block)) { const { style = "normal", children, markDefs = [], textAlign } = block; const pmContent = convertPTSpans(children, markDefs); if (style === "blockquote") { return { type: "blockquote", content: [ { type: "paragraph", content: pmContent.length > 0 ? pmContent : undefined, }, ], }; } if (style?.startsWith("h")) { const level = parseInt(style.substring(1), 10); return { type: "heading", attrs: { level, ...(textAlign ? { textAlign } : {}) }, content: pmContent.length > 0 ? pmContent : undefined, }; } return { type: "paragraph", attrs: textAlign ? { textAlign } : undefined, content: pmContent.length > 0 ? pmContent : undefined, }; } if (block._type === "code") { const cb = block as PTBlock & { code?: string; language?: string }; return { type: "codeBlock", attrs: { language: cb.language || null }, content: cb.code ? [{ type: "text", text: cb.code }] : undefined, }; } if (block._type === "break") { return { type: "horizontalRule" }; } if (block._type === "htmlBlock") { const hb = block as PTBlock & { html?: string }; return { type: "htmlBlock", attrs: { html: hb.html || "" }, }; } if (block._type === "image") { const ib = block as PTBlock & { asset?: { _ref?: string; url?: string; provider?: string; meta?: Record; }; url?: string; alt?: string; caption?: string; width?: number; height?: number; /** LQIP — first-class field (legacy snapshots keep it in `asset.meta`). */ blurhash?: string; dominantColor?: string; displayWidth?: number; displayHeight?: number; }; const asset = ib.asset; const meta = asset?.meta; // Prefer first-class LQIP fields; fall back to `asset.meta` for legacy. const blurhash = typeof ib.blurhash === "string" ? ib.blurhash : typeof meta?.blurhash === "string" ? meta.blurhash : null; const dominantColor = typeof ib.dominantColor === "string" ? ib.dominantColor : typeof meta?.dominantColor === "string" ? meta.dominantColor : null; return { type: "image", attrs: { src: asset?.url || ib.url || (asset?._ref ? `/_emdash/api/media/file/${asset._ref}` : ""), alt: ib.alt || "", title: ib.caption || "", caption: ib.caption || "", mediaId: asset?._ref, provider: asset?.provider, width: ib.width, height: ib.height, blurhash, dominantColor, displayWidth: ib.displayWidth, displayHeight: ib.displayHeight, }, }; } // Unknown block types — treat as plugin blocks. Capture every field other // than the well-known ones into `data` so the block round-trips losslessly, // even if no plugin currently registers this type. Matches the admin // editor's behaviour at PortableTextEditor.tsx:572-588. const { _type, _key, id, url, ...rest } = block as { _type: string; _key: string } & Record< string, unknown >; // Filter out _-prefixed keys to prevent accumulation across edit cycles. const data = Object.fromEntries(Object.entries(rest).filter(([key]) => !key.startsWith("_"))); return { type: "pluginBlock", attrs: { blockType: typeof _type === "string" ? _type : "embed", id: typeof id === "string" ? id : typeof url === "string" ? url : "", data, }, }; } function convertPTList(items: PTTextBlock[], listType: "bullet" | "number"): JSONContent { return { type: listType === "bullet" ? "bulletList" : "orderedList", content: items.map((item) => ({ type: "listItem", content: [ { type: "paragraph", content: convertPTSpans(item.children, item.markDefs || []), }, ], })), }; } function convertPTSpans(spans: PTSpan[], markDefs: PTMarkDef[]): JSONContent[] { const nodes: JSONContent[] = []; const mdMap = new Map(markDefs.map((md) => [md._key, md])); for (const span of spans) { if (span._type !== "span") continue; const parts = span.text.split("\n"); for (let i = 0; i < parts.length; i++) { const text = parts[i]; if (text && text.length > 0) { const marks = convertPTMarks(span.marks || [], mdMap); const node: JSONContent = { type: "text", text, }; if (marks.length > 0) node.marks = marks; nodes.push(node); } if (i < parts.length - 1) nodes.push({ type: "hardBreak" }); } } return nodes; } type MarkJSON = { type: string; attrs?: Record; [key: string]: unknown }; function convertPTMarks(marks: string[], markDefs: Map): MarkJSON[] { const pm: MarkJSON[] = []; for (const mark of marks) { switch (mark) { case "strong": pm.push({ type: "bold" }); break; case "em": pm.push({ type: "italic" }); break; case "underline": pm.push({ type: "underline" }); break; case "strike-through": pm.push({ type: "strike" }); break; case "code": pm.push({ type: "code" }); break; default: { const md = markDefs.get(mark); if (md && md._type === "link") { pm.push({ type: "link", attrs: { href: md.href, target: md.blank ? "_blank" : null }, }); } break; } } } return pm; } // ── Inline BubbleMenu ────────────────────────────────────────────── function InlineBubbleMenu({ editor }: { editor: Editor }) { const [showLinkInput, setShowLinkInput] = React.useState(false); const [linkUrl, setLinkUrl] = React.useState(""); const inputRef = React.useRef(null); React.useEffect(() => { if (showLinkInput) { const existingUrl = editor.getAttributes("link").href || ""; setLinkUrl(existingUrl); setTimeout(() => inputRef.current?.focus(), 0); } }, [showLinkInput, editor]); const handleSetLink = () => { if (linkUrl.trim() === "") { editor.chain().focus().extendMarkRange("link").unsetLink().run(); } else { editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl.trim() }).run(); } setShowLinkInput(false); setLinkUrl(""); }; const handleRemoveLink = () => { editor.chain().focus().extendMarkRange("link").unsetLink().run(); setShowLinkInput(false); setLinkUrl(""); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleSetLink(); } else if (e.key === "Escape") { setShowLinkInput(false); setLinkUrl(""); editor.commands.focus(); } }; return ( {showLinkInput ? (
setLinkUrl(e.target.value)} onKeyDown={handleKeyDown} className="emdash-bubble-link-input" /> {editor.isActive("link") && ( )}
) : ( <> )}
); } // ── Slash Menu ────────────────────────────────────────────────────── interface SlashCommandItem { id: string; title: string; description: string; icon: string; command: (props: { editor: Editor; range: Range }) => void; aliases?: string[]; } const slashCommands: SlashCommandItem[] = [ { id: "heading1", title: "Heading 1", description: "Large section heading", icon: "H1", aliases: ["h1", "title"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); }, }, { id: "heading2", title: "Heading 2", description: "Medium section heading", icon: "H2", aliases: ["h2", "subtitle"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); }, }, { id: "heading3", title: "Heading 3", description: "Small section heading", icon: "H3", aliases: ["h3"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); }, }, { id: "bulletList", title: "Bullet List", description: "Create a bullet list", icon: "•", aliases: ["ul", "unordered"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleBulletList().run(); }, }, { id: "numberedList", title: "Numbered List", description: "Create a numbered list", icon: "1.", aliases: ["ol", "ordered"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleOrderedList().run(); }, }, { id: "quote", title: "Quote", description: "Insert a blockquote", icon: "\u201C", aliases: ["blockquote", "cite"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleBlockquote().run(); }, }, { id: "codeBlock", title: "Code Block", description: "Insert a code block", icon: "", aliases: ["code", "pre", "```"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); }, }, { id: "htmlBlock", title: "HTML", description: "Insert raw HTML", icon: "< >", aliases: ["html", "raw", "markup"], command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .insertContent({ type: "htmlBlock", attrs: { html: "" } }) .run(); }, }, { id: "divider", title: "Divider", description: "Insert a horizontal rule", icon: "—", aliases: ["hr", "---", "separator"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, }, { id: "image", title: "Image", description: "Insert an image", icon: "🖼", aliases: ["img", "photo", "picture"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); // Signal the component to open the media picker document.dispatchEvent(new CustomEvent("emdash:open-media-picker")); }, }, ]; interface SlashMenuState { isOpen: boolean; items: SlashCommandItem[]; selectedIndex: number; clientRect: (() => DOMRect | null) | null; range: Range | null; } const initialSlashMenuState: SlashMenuState = { isOpen: false, items: [], selectedIndex: 0, clientRect: null, range: null, }; /** * Minimal `htmlBlock` TipTap node for the inline (visual-editing) editor. * * Like PluginBlockNode below, this renders as a read-only placeholder so the * block's data is preserved across edits in the visual editor. Full editing * (textarea + preview) is only available in the admin editor. */ const HtmlBlockNode = Node.create({ name: "htmlBlock", group: "block", atom: true, selectable: true, draggable: true, addAttributes() { const noDom = { rendered: false, parseHTML: () => null }; return { html: { default: "", ...noDom }, }; }, parseHTML() { return [{ tag: 'div[data-emdash-html-block="true"]' }]; }, renderHTML({ HTMLAttributes }) { return [ "div", mergeAttributes(HTMLAttributes, { "data-emdash-html-block": "true", class: "emdash-plugin-block-placeholder", contenteditable: "false", }), "HTML block (edit in admin)", ]; }, }); /** * Minimal `pluginBlock` TipTap node for the inline (visual-editing) editor. * * Plugin-contributed Portable Text block types (e.g. `marketing.hero`) are * editable in the admin via a Block Kit modal. The visual-editing surface * deliberately does NOT offer that UX — it would need to fetch the manifest, * mount the modal, and round-trip through plugin-block plumbing that lives in * `@emdash-cms/admin`. Instead, the inline editor renders these blocks as a * read-only placeholder so editors can see they exist and edit the surrounding * content without losing the block's data. * * The full block payload is preserved on `data` and round-tripped losslessly * through PT ↔ PM conversion (see convertPTBlock/convertPMNode). Without this * extension, ProseMirror's schema would silently filter unknown nodes on load * and the next save would persist the block's disappearance. */ const PluginBlockNode = Node.create({ name: "pluginBlock", group: "block", atom: true, selectable: true, draggable: true, addAttributes() { // All three attributes are stored on the ProseMirror node but not // rendered as DOM attributes — they're metadata for the round-trip, // not styling or behaviour the placeholder DOM needs to expose. const noDom = { rendered: false, parseHTML: () => null }; return { blockType: { default: "", ...noDom }, id: { default: "", ...noDom }, data: { default: {}, ...noDom }, }; }, parseHTML() { return [{ tag: 'div[data-emdash-plugin-block="true"]' }]; }, renderHTML({ HTMLAttributes, node }) { const blockType = typeof node.attrs.blockType === "string" ? node.attrs.blockType : ""; const label = blockType || "Block"; return [ "div", mergeAttributes(HTMLAttributes, { "data-emdash-plugin-block": "true", "data-block-type": blockType, class: "emdash-plugin-block-placeholder", contenteditable: "false", }), `Plugin block: ${label} (edit in admin)`, ]; }, }); function createSlashCommandsExtension(options: { filterCommands: (query: string) => SlashCommandItem[]; onStateChange: React.Dispatch>; getState: () => SlashMenuState; }) { const { filterCommands, onStateChange, getState } = options; return Extension.create({ name: "slashCommands", addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: "/", startOfLine: true, command: ({ editor, range, props }) => { const item: unknown = props; if ( typeof item === "object" && item !== null && "command" in item && typeof item.command === "function" ) { item.command({ editor, range }); } }, items: ({ query }) => filterCommands(query), render: () => { return { onStart: (props) => { onStateChange({ isOpen: true, items: props.items, selectedIndex: 0, clientRect: props.clientRect ?? null, range: props.range, }); }, onUpdate: (props) => { onStateChange((prev) => ({ ...prev, items: props.items, selectedIndex: 0, clientRect: props.clientRect ?? null, range: props.range, })); }, onKeyDown: (props) => { if (props.event.key === "Escape") { onStateChange((prev) => ({ ...prev, isOpen: false })); return true; } if (props.event.key === "ArrowUp") { onStateChange((prev) => ({ ...prev, selectedIndex: (prev.selectedIndex - 1 + prev.items.length) % prev.items.length, })); return true; } if (props.event.key === "ArrowDown") { onStateChange((prev) => ({ ...prev, selectedIndex: (prev.selectedIndex + 1) % prev.items.length, })); return true; } if (props.event.key === "Enter") { const state = getState(); if (state.items.length > 0 && state.range) { const item = state.items[state.selectedIndex]; if (item) { item.command({ editor: this.editor, range: state.range }); onStateChange((prev) => ({ ...prev, isOpen: false })); return true; } } return false; } return false; }, onExit: () => { onStateChange((prev) => ({ ...prev, isOpen: false })); }, }; }, }), ]; }, }); } function InlineSlashMenu({ state, onCommand, setSelectedIndex, }: { state: SlashMenuState; onCommand: (item: SlashCommandItem) => void; setSelectedIndex: (index: number) => void; }) { const containerRef = React.useRef(null); // Track whether we have a positioned reference to avoid rendering at (0,0) const [hasReference, setHasReference] = React.useState(false); const { refs, floatingStyles } = useFloating({ open: state.isOpen && hasReference, placement: "bottom-start", middleware: [offset(8), flip(), shift({ padding: 8 })], whileElementsMounted: autoUpdate, }); React.useEffect(() => { if (state.clientRect) { const clientRectFn = state.clientRect; refs.setReference({ getBoundingClientRect: () => clientRectFn() ?? new DOMRect(), }); setHasReference(true); } else { setHasReference(false); } }, [state.clientRect, refs]); // Reset reference tracking when menu closes React.useEffect(() => { if (!state.isOpen) setHasReference(false); }, [state.isOpen]); React.useEffect(() => { if (!state.isOpen || !hasReference) return; const container = containerRef.current; if (!container) return; const selected = container.querySelector(`[data-index="${state.selectedIndex}"]`); if (selected instanceof HTMLElement) { // Use scrollIntoView only within the menu container to avoid scrolling the page const containerTop = container.scrollTop; const containerBottom = containerTop + container.clientHeight; const itemTop = selected.offsetTop; const itemBottom = itemTop + selected.offsetHeight; if (itemTop < containerTop) { container.scrollTop = itemTop; } else if (itemBottom > containerBottom) { container.scrollTop = itemBottom - container.clientHeight; } } }, [state.selectedIndex, state.isOpen, hasReference]); if (!state.isOpen || !hasReference) return null; return createPortal(
{ containerRef.current = node; refs.setFloating(node); }} style={{ ...floatingStyles, zIndex: 100, borderRadius: "8px", border: "1px solid #d1d5db", background: "white", padding: "4px", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", minWidth: "220px", maxHeight: "300px", overflowY: "auto", }} className="emdash-slash-menu" > {state.items.length === 0 ? (

No results

) : ( state.items.map((item, index) => ( )) )}
, document.body, ); } // ── Media Picker ─────────────────────────────────────────────────── interface MediaItemData { id: string; filename: string; mimeType: string; url: string; storageKey?: string; width?: number; height?: number; blurhash?: string; dominantColor?: string; alt?: string; provider?: string; previewUrl?: string; meta?: Record; } interface ProviderInfo { id: string; name: string; icon?: string; capabilities: { browse: boolean; search: boolean; upload: boolean; delete: boolean }; } const API_BASE = "/_emdash/api"; async function ecFetch(url: string, init?: RequestInit): Promise { const base = new Headers(init?.headers); base.set("X-EmDash-Request", "1"); return fetch(url, { credentials: "same-origin", ...init, headers: base, }); } function InlineMediaPicker({ open, onClose, onSelect, }: { open: boolean; onClose: () => void; onSelect: (item: MediaItemData) => void; }) { const [providers, setProviders] = React.useState([]); const [activeProvider, setActiveProvider] = React.useState("local"); const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(false); const [uploading, setUploading] = React.useState(false); const [selectedId, setSelectedId] = React.useState(null); const fileInputRef = React.useRef(null); // Fetch providers on open React.useEffect(() => { if (!open) return; setSelectedId(null); setActiveProvider("local"); ecFetch(`${API_BASE}/media/providers`) .then((r) => r.json()) .then((d) => setProviders(d.data.items ?? [])) .catch(() => setProviders([])); }, [open]); // Fetch items when provider changes React.useEffect(() => { if (!open) return; setLoading(true); setSelectedId(null); const url = activeProvider === "local" ? `${API_BASE}/media?mimeType=image/&limit=50` : `${API_BASE}/media/providers/${activeProvider}?mimeType=image/&limit=50`; void (async () => { try { const r = await ecFetch(url); const d = await r.json(); const raw = d.data.items ?? []; // eslint-disable-next-line typescript/no-unsafe-type-assertion -- API response items mapped to MediaItem shape const typedRaw = raw as Array<{ id: string; filename?: string; mimeType?: string; url?: string; previewUrl?: string; storageKey?: string; width?: number; height?: number; blurhash?: string; dominantColor?: string; alt?: string; meta?: Record; }>; setItems( typedRaw.map((item) => ({ id: item.id, filename: item.filename || "", mimeType: item.mimeType || "image/unknown", url: item.url || item.previewUrl || (item.storageKey ? `${API_BASE}/media/file/${item.storageKey}` : ""), storageKey: item.storageKey, width: item.width, height: item.height, blurhash: item.blurhash, dominantColor: item.dominantColor, alt: item.alt, provider: activeProvider === "local" ? undefined : activeProvider, previewUrl: item.previewUrl, meta: item.meta, })), ); } catch { setItems([]); } finally { setLoading(false); } })(); }, [open, activeProvider]); const handleUpload = async (file: File) => { setUploading(true); try { // Detect dimensions and generate a thumbnail for large images to // avoid OOM in server-side blurhash generation on Workers. const dims = await new Promise<{ width?: number; height?: number; thumbnail?: Blob; }>((resolve) => { if (!file.type.startsWith("image/")) return resolve({}); const img = new window.Image(); img.onload = () => { const w = img.naturalWidth; const h = img.naturalHeight; // 32 MB RGBA threshold — matches server MAX_DECODED_BYTES if (w * h * 4 > 32 * 1024 * 1024) { const { width: thumbW, height: thumbH } = computeThumbnailSize(w, h); try { const canvas = document.createElement("canvas"); canvas.width = thumbW; canvas.height = thumbH; const ctx = canvas.getContext("2d"); if (ctx) { ctx.drawImage(img, 0, 0, thumbW, thumbH); canvas.toBlob((blob) => { URL.revokeObjectURL(img.src); resolve({ width: w, height: h, thumbnail: blob ?? undefined }); }, "image/png"); return; } } catch { // Canvas allocation or draw failed — fall through to no-thumbnail path } } URL.revokeObjectURL(img.src); resolve({ width: w, height: h }); }; img.onerror = () => { resolve({}); URL.revokeObjectURL(img.src); }; img.src = URL.createObjectURL(file); }); let item: MediaItemData; if (activeProvider === "local") { const formData = new FormData(); formData.append("file", file); if (dims.width) formData.append("width", String(dims.width)); if (dims.height) formData.append("height", String(dims.height)); if (dims.thumbnail) formData.append("thumbnail", dims.thumbnail, "thumb.png"); const res = await ecFetch(`${API_BASE}/media`, { method: "POST", body: formData }); const data = await res.json(); const unwrapped = data.data ?? data; if (!unwrapped.item) throw new Error("Upload failed"); const raw = unwrapped.item; item = { id: raw.id, filename: raw.filename || file.name, mimeType: raw.mimeType || file.type, url: raw.url || raw.previewUrl || `${API_BASE}/media/file/${raw.storageKey}`, storageKey: raw.storageKey, width: raw.width || dims.width, height: raw.height || dims.height, blurhash: raw.blurhash, dominantColor: raw.dominantColor, alt: raw.alt, }; } else { const formData = new FormData(); formData.append("file", file); const res = await ecFetch(`${API_BASE}/media/providers/${activeProvider}`, { method: "POST", body: formData, }); const data = await res.json(); const unwrapped = data.data ?? data; if (!unwrapped.item) throw new Error("Upload failed"); const raw = unwrapped.item; item = { id: raw.id, filename: raw.filename || file.name, mimeType: raw.mimeType || file.type, url: raw.previewUrl || "", width: raw.width || dims.width, height: raw.height || dims.height, blurhash: raw.blurhash, dominantColor: raw.dominantColor, alt: raw.alt, provider: activeProvider, previewUrl: raw.previewUrl, meta: raw.meta, }; } setItems((prev) => [item, ...prev]); setSelectedId(item.id); } catch (err) { console.error("Upload failed:", err); } finally { setUploading(false); } }; const handleConfirm = () => { const item = items.find((i) => i.id === selectedId); if (item) onSelect(item); }; const providerTabs = React.useMemo(() => { const tabs: Array<{ id: string; name: string; icon?: string }> = [ { id: "local", name: "Library" }, ]; for (const p of providers) { if (p.id !== "local") tabs.push({ id: p.id, name: p.name, icon: p.icon }); } return tabs; }, [providers]); if (!open) return null; return createPortal(
{ if (e.target === e.currentTarget) onClose(); }} >
{/* Header */}
Insert Image
{/* Provider tabs */} {providerTabs.length > 1 && (
{providerTabs.map((tab) => ( ))}
)} {/* Upload bar */}
{loading ? "Loading…" : `${items.length} image${items.length !== 1 ? "s" : ""}`} { const file = e.target.files?.[0]; if (file) void handleUpload(file); if (fileInputRef.current) fileInputRef.current.value = ""; }} />
{/* Grid */}
{loading ? (
Loading…
) : items.length === 0 ? (
🖼
No images found
) : (
{items.map((item) => { const isSelected = selectedId === item.id; const thumb = item.url || item.previewUrl || ""; return ( ); })}
)}
{/* Footer */}
, document.body, ); } // ── Component ────────────────────────────────────────────────────── export interface InlinePortableTextEditorProps { value: PTBlock[]; collection: string; entryId: string; field: string; } export function InlinePortableTextEditor({ value, collection, entryId, field, }: InlinePortableTextEditorProps) { const initialRef = React.useRef(value); const savingRef = React.useRef(false); const editorRef = React.useRef>(null); // Media picker state const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false); // Listen for the slash command's media picker event React.useEffect(() => { const handler = () => setMediaPickerOpen(true); document.addEventListener("emdash:open-media-picker", handler); return () => document.removeEventListener("emdash:open-media-picker", handler); }, []); // Slash menu state — use ref to avoid re-creating the extension on state change const [slashMenuState, setSlashMenuState] = React.useState(initialSlashMenuState); const slashMenuStateRef = React.useRef(slashMenuState); slashMenuStateRef.current = slashMenuState; const filterCommandsRef = React.useRef((query: string): SlashCommandItem[] => { const q = query.toLowerCase(); return slashCommands.filter( (cmd) => cmd.title.toLowerCase().includes(q) || cmd.description.toLowerCase().includes(q) || cmd.aliases?.some((a) => a.toLowerCase().includes(q)), ); }); const initialContent = React.useMemo( () => portableTextToPM(value || []), [], // Only compute once on mount ); const getBlocks = React.useCallback((): PTBlock[] => { const editor = editorRef.current; if (!editor) return initialRef.current; const json: unknown = editor.getJSON(); if (!isPMNode(json)) return initialRef.current; return pmToPortableText(json); }, []); const save = React.useCallback(async () => { if (savingRef.current) return; const current = JSON.stringify(getBlocks()); const initial = JSON.stringify(initialRef.current); if (current === initial) return; savingRef.current = true; try { const res = await fetch( `/_emdash/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(entryId)}`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, body: JSON.stringify({ data: { [field]: getBlocks() } }), }, ); if (res.ok) { initialRef.current = getBlocks(); document.dispatchEvent(new CustomEvent("emdash:save", { detail: { state: "saved" } })); document.dispatchEvent( new CustomEvent("emdash:content-changed", { detail: { collection, id: entryId }, }), ); } else { document.dispatchEvent(new CustomEvent("emdash:save", { detail: { state: "error" } })); console.error("Save failed:", res.status); } } catch (err) { document.dispatchEvent(new CustomEvent("emdash:save", { detail: { state: "error" } })); console.error("Save failed:", err); } finally { savingRef.current = false; } }, [collection, entryId, field, getBlocks]); // Create slash commands extension once — uses refs to avoid re-render loop const slashCommandsExtension = React.useMemo( () => createSlashCommandsExtension({ filterCommands: (query: string) => filterCommandsRef.current(query), onStateChange: setSlashMenuState, getState: () => slashMenuStateRef.current, }), [], ); const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, dropcursor: { color: "#3b82f6", width: 2 }, // Replaced with InlineCodeBlockExtension below (adds language picker). codeBlock: false, }), InlineCodeBlockExtension, Image.extend({ addAttributes() { return { ...this.parent?.(), mediaId: { default: null }, provider: { default: null }, width: { default: null }, height: { default: null }, blurhash: { default: null }, dominantColor: { default: null }, }; }, }), Underline, Link.configure({ openOnClick: false, HTMLAttributes: { class: "underline text-blue-600 dark:text-blue-400" }, }), Placeholder.configure({ includeChildren: true, placeholder: ({ node }) => { if (node.type.name === "paragraph") return "Type / for commands..."; return ""; }, }), TextAlign.configure({ types: ["heading", "paragraph"], }), Focus.configure({ className: "has-focus", mode: "all", }), Typography, HtmlBlockNode, PluginBlockNode, slashCommandsExtension, ], content: initialContent, immediatelyRender: false, editorProps: { attributes: { class: "prose prose-sm sm:prose-base dark:prose-invert max-w-none emdash-inline-editor", dir: "auto", }, }, onUpdate: () => { document.dispatchEvent(new CustomEvent("emdash:save", { detail: { state: "unsaved" } })); }, }); // Store editor ref for getBlocks React.useEffect(() => { editorRef.current = editor; }, [editor]); // Slash menu command handler const handleSlashCommand = React.useCallback( (item: SlashCommandItem) => { if (!editor || !slashMenuStateRef.current.range) return; item.command({ editor, range: slashMenuStateRef.current.range }); setSlashMenuState((prev) => ({ ...prev, isOpen: false })); }, [editor], ); // Handle media selection from the picker const handleMediaSelect = React.useCallback( (item: MediaItemData) => { if (!editor) return; const src = item.url || item.previewUrl || `/_emdash/api/media/file/${item.storageKey || item.id}`; editor .chain() .focus() .setImage({ src, alt: item.alt || item.filename || "", mediaId: item.id, width: item.width, height: item.height, blurhash: item.blurhash, dominantColor: item.dominantColor, }) .run(); setMediaPickerOpen(false); void save(); }, [editor, save], ); // Save on blur — but not when interacting with slash menu or media picker const handleBlur = React.useCallback( (e: React.FocusEvent) => { if (mediaPickerOpen) return; const related = e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null; if (related && e.currentTarget.contains(related)) return; // Don't save if focus moved to the slash menu (portalled to body) if (related?.closest(".emdash-slash-menu")) return; if (related?.closest(".emdash-media-picker")) return; void save(); }, [save, mediaPickerOpen], ); if (!editor) return null; return (
setSlashMenuState((prev) => ({ ...prev, selectedIndex: index })) } /> { setMediaPickerOpen(false); editor?.commands.focus(); }} onSelect={handleMediaSelect} />
); } // Test-only exports for unit tests of the conversion functions. export { pmToPortableText as _pmToPortableText }; export { portableTextToPM as _portableTextToPM };