/** * Portable Text Editor * * TipTap-based rich text editor that stores content as Portable Text. * Handles conversion between ProseMirror JSON and Portable Text automatically. * * Features: * - BubbleMenu for inline formatting * - Link popover for editing URLs (no window.prompt) * - Slash commands for block insertion * - Floating menu on empty lines */ import { Button, Dialog, Input, Select, Switch } from "@cloudflare/kumo"; import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core"; import type { DragEndEvent } from "@dnd-kit/core"; import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { Element } from "@emdash-cms/blocks"; import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react"; import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { TextB, TextItalic, TextUnderline, TextStrikethrough, Code, TextHOne, TextHTwo, TextHThree, List, ListNumbers, Quotes, Link as LinkIcon, Image as ImageIcon, ArrowUUpLeft, ArrowUUpRight, TextAlignLeft, TextAlignCenter, TextAlignRight, Minus, LinkBreak, ArrowSquareOut, BracketsAngle, CodeBlock, Stack, Eye, Table as TableIcon, Plus, Trash, Rows, Columns, DotsSixVertical, CaretDown, type Icon, } from "@phosphor-icons/react"; import { X } from "@phosphor-icons/react"; import { Extension, type Range } from "@tiptap/core"; import CharacterCount from "@tiptap/extension-character-count"; import Focus from "@tiptap/extension-focus"; import Placeholder from "@tiptap/extension-placeholder"; import { Table } from "@tiptap/extension-table"; import { TableCell } from "@tiptap/extension-table-cell"; import { TableHeader } from "@tiptap/extension-table-header"; import { TableRow } from "@tiptap/extension-table-row"; import TextAlign from "@tiptap/extension-text-align"; import Typography from "@tiptap/extension-typography"; import { useEditor, EditorContent, useEditorState, 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 type { MediaItem } from "../lib/api"; import type { Section } from "../lib/api"; import { cn } from "../lib/utils"; import { CaretNext } from "./ArrowIcons.js"; import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField"; import { CodeBlockExtension } from "./editor/CodeBlockNode"; import { DragHandleWrapper } from "./editor/DragHandleWrapper"; import { HtmlBlockExtension } from "./editor/HtmlBlockNode"; import { ImageExtension } from "./editor/ImageNode"; import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension"; import { type PluginBlockDef, PluginBlockExtension, registerPluginBlocks, resolveIcon, } from "./editor/PluginBlockNode"; import { MediaPickerModal } from "./MediaPickerModal"; import { SectionPickerModal } from "./SectionPickerModal"; // Import converters from inline module since we can't import from emdash package // These will be duplicated here until we set up proper package exports interface PortableTextSpan { _type: "span"; _key: string; text: string; marks?: string[]; } interface PortableTextMarkDef { _type: string; _key: string; [key: string]: unknown; } interface PortableTextTextBlock { _type: "block"; _key: string; style?: "normal" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote"; listItem?: "bullet" | "number"; level?: number; children: PortableTextSpan[]; markDefs?: PortableTextMarkDef[]; textAlign?: "left" | "center" | "right" | "justify"; } interface PortableTextImageBlock { _type: "image"; _key: string; asset: { _ref: string; url?: string; meta?: Record }; alt?: string; caption?: string; width?: number; height?: number; /** LQIP blurhash — first-class field (legacy snapshots store it in `asset.meta`). */ blurhash?: string; /** LQIP dominant color — first-class field (legacy snapshots store it in `asset.meta`). */ dominantColor?: string; displayWidth?: number; displayHeight?: number; alignment?: "left" | "center" | "right" | "wide" | "full"; } interface PortableTextCodeBlock { _type: "code"; _key: string; code: string; language?: string; } interface PortableTextHtmlBlock { _type: "htmlBlock"; _key: string; html: string; } type PortableTextBlock = | PortableTextTextBlock | PortableTextImageBlock | PortableTextCodeBlock | PortableTextHtmlBlock | { _type: string; _key: string; [key: string]: unknown }; // Generate unique key function generateKey(): string { return Math.random().toString(36).substring(2, 11); } // Helpers for safely extracting typed values from ProseMirror attrs (Record) const attrStr = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined); const attrNum = (v: unknown): number | undefined => (typeof v === "number" && v ? v : undefined); // ProseMirror to Portable Text converter function prosemirrorToPortableText(doc: { type: string; content?: Array<{ type: string; attrs?: Record; content?: unknown[]; marks?: unknown[]; text?: string; }>; }): PortableTextBlock[] { if (!doc || doc.type !== "doc" || !doc.content) { return []; } const blocks: PortableTextBlock[] = []; for (const node of doc.content) { const converted = convertPMNode(node); if (converted) { if (Array.isArray(converted)) { blocks.push(...converted); } else { blocks.push(converted); } } } return blocks; } function convertPMNode(node: { type: string; attrs?: Record; content?: unknown[]; marks?: unknown[]; text?: string; }): PortableTextBlock | PortableTextBlock[] | null { switch (node.type) { case "paragraph": { const { children, markDefs } = convertInlineContent(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: generateKey(), style: "normal", children, markDefs: markDefs.length > 0 ? markDefs : undefined, ...(textAlign ? { textAlign } : {}), }; } case "heading": { const { children, markDefs } = convertInlineContent(node.content || []); const rawLevel = node.attrs?.level; const level = typeof rawLevel === "number" ? rawLevel : 1; if (children.length === 0) return null; const headingStyle = level >= 1 && level <= 6 ? (`h${level}` as PortableTextTextBlock["style"]) : ("h1" as PortableTextTextBlock["style"]); const ta = node.attrs?.textAlign; const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; return { _type: "block", _key: generateKey(), style: headingStyle, children, markDefs: markDefs.length > 0 ? markDefs : undefined, ...(textAlign ? { textAlign } : {}), }; } case "bulletList": return convertList(node.content || [], "bullet"); case "orderedList": return convertList(node.content || [], "number"); case "blockquote": { const blocks: PortableTextTextBlock[] = []; const blockquoteContent = (node.content || []) as Array<{ type: string; content?: unknown[]; }>; for (const child of blockquoteContent) { if (child.type === "paragraph") { const { children, markDefs } = convertInlineContent(child.content || []); if (children.length > 0) { blocks.push({ _type: "block", _key: generateKey(), style: "blockquote", children, markDefs: markDefs.length > 0 ? markDefs : undefined, }); } } } if (blocks.length === 1) { return blocks[0]!; } return blocks.length > 0 ? blocks : null; } case "codeBlock": { const codeContent = (node.content || []) as Array<{ text?: string }>; const code = codeContent.map((n) => n.text || "").join(""); const rawLanguage = node.attrs?.language; return { _type: "code", _key: generateKey(), code, language: typeof rawLanguage === "string" ? rawLanguage : undefined, }; } case "htmlBlock": { const rawHtml = node.attrs?.html; return { _type: "htmlBlock", _key: generateKey(), html: typeof rawHtml === "string" ? rawHtml : "", }; } case "image": { const attrs = node.attrs ?? {}; const provider = attrStr(attrs.provider); const blurhash = attrStr(attrs.blurhash); const dominantColor = attrStr(attrs.dominantColor); // Persist LQIP as first-class block fields, matching the image-field // path (MediaValue.blurhash/dominantColor) so read sites and normalize // don't need a `asset.meta` dual-shape. `asset.meta` is left to carry // only provider-specific data (we don't reconstruct it here, so any // non-LQIP meta keys are never silently dropped on editor round-trip). return { _type: "image", _key: generateKey(), asset: { _ref: attrStr(attrs.mediaId) ?? "", url: attrStr(attrs.src) ?? "", provider: provider && provider !== "local" ? provider : undefined, }, alt: attrStr(attrs.alt), caption: attrStr(attrs.caption) ?? attrStr(attrs.title), width: attrNum(attrs.width), height: attrNum(attrs.height), ...(blurhash ? { blurhash } : {}), ...(dominantColor ? { dominantColor } : {}), displayWidth: attrNum(attrs.displayWidth), displayHeight: attrNum(attrs.displayHeight), alignment: attrStr(attrs.alignment) as PortableTextImageBlock["alignment"], }; } case "horizontalRule": return { _type: "break", _key: generateKey(), style: "lineBreak", }; case "table": { const tableKey = generateKey(); const tableContent = (node.content || []) as Array<{ type: string; content?: Array<{ type: string; content?: unknown[]; }>; }>; const rows = tableContent .filter((row) => row.type === "tableRow") .map((row, rowIndex) => { const cells = (row.content || []).map((cell, cellIndex) => { const isHeader = cell.type === "tableHeader"; const cellContent = (cell.content || []) as Array<{ type: string; content?: unknown[]; }>; const contentSpans: PortableTextSpan[] = []; const cellMarkDefs: PortableTextMarkDef[] = []; for (const paragraph of cellContent) { if (paragraph.type === "paragraph") { const { children, markDefs } = convertInlineContent(paragraph.content || []); contentSpans.push(...children); cellMarkDefs.push(...markDefs); } } if (contentSpans.length === 0) { contentSpans.push({ _type: "span", _key: generateKey(), text: "", }); } return { _type: "tableCell" as const, _key: `${tableKey}_r${rowIndex}_c${cellIndex}`, content: contentSpans, isHeader, markDefs: cellMarkDefs.length > 0 ? cellMarkDefs : undefined, }; }); return { _type: "tableRow" as const, _key: `${tableKey}_r${rowIndex}`, cells, }; }); return { _type: "table", _key: tableKey, rows, hasHeaderRow: rows[0]?.cells.some((cell) => cell.isHeader) ?? false, }; } case "pluginBlock": { const { blockType, id: pluginId, data } = node.attrs ?? {}; return { ...(data && typeof data === "object" ? data : {}), _type: typeof blockType === "string" ? blockType : "embed", _key: generateKey(), id: typeof pluginId === "string" ? pluginId : "", }; } default: return null; } } function convertList( items: unknown[], listItem: "bullet" | "number", level = 1, ): PortableTextTextBlock[] { const blocks: PortableTextTextBlock[] = []; const typedItems = items as Array<{ type: string; content?: unknown[] }>; for (const item of typedItems) { if (item.type === "listItem") { const listItemContent = (item.content || []) as Array<{ type: string; content?: unknown[]; }>; for (const child of listItemContent) { if (child.type === "paragraph") { const { children, markDefs } = convertInlineContent(child.content || []); if (children.length > 0) { blocks.push({ _type: "block", _key: generateKey(), style: "normal", listItem, level, children, markDefs: markDefs.length > 0 ? markDefs : undefined, }); } } else if (child.type === "bulletList") { blocks.push(...convertList(child.content || [], "bullet", level + 1)); } else if (child.type === "orderedList") { blocks.push(...convertList(child.content || [], "number", level + 1)); } } } } return blocks; } function convertInlineContent(nodes: unknown[]): { children: PortableTextSpan[]; markDefs: PortableTextMarkDef[]; } { const children: PortableTextSpan[] = []; const markDefs: PortableTextMarkDef[] = []; const markDefMap = new Map(); const typedNodes = nodes as Array<{ type: string; text?: string; marks?: Array<{ type: string; attrs?: Record }>; }>; for (const node of typedNodes) { if (node.type === "text" && node.text) { const marks: string[] = []; for (const mark of node.marks || []) { const markType = convertMark(mark, markDefs, markDefMap); if (markType) { marks.push(markType); } } children.push({ _type: "span", _key: generateKey(), 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: generateKey(), text: "\n", }); } } } if (children.length === 0) { children.push({ _type: "span", _key: generateKey(), text: "", }); } return { children, markDefs }; } function convertMark( mark: { type: string; attrs?: Record }, markDefs: PortableTextMarkDef[], 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 rawHref = mark.attrs?.href; const href = typeof rawHref === "string" ? rawHref : ""; if (markDefMap.has(href)) { return markDefMap.get(href)!; } const key = generateKey(); markDefs.push({ _type: "link", _key: key, href, blank: mark.attrs?.target === "_blank", }); markDefMap.set(href, key); return key; } default: return mark.type; } } // Type guards for PortableText block variants function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock { return block._type === "block"; } function isImageBlock(block: PortableTextBlock): block is PortableTextImageBlock { return block._type === "image"; } function isCodeBlock(block: PortableTextBlock): block is PortableTextCodeBlock { return block._type === "code"; } // Portable Text to ProseMirror converter function portableTextToProsemirror(blocks: PortableTextBlock[]): { type: "doc"; content: unknown[]; } { if (!blocks || blocks.length === 0) { return { type: "doc", content: [{ type: "paragraph" }], }; } const content: unknown[] = []; let i = 0; while (i < blocks.length) { const block = blocks[i]!; if (isTextBlock(block) && block.listItem) { const listBlocks: PortableTextTextBlock[] = []; const listType = block.listItem; // A list "run" is a level=1 anchor block plus everything that nests // under it (level > 1) or repeats it at the same root level/type. // A level=1 block with a different listItem ends the run. while (i < blocks.length) { const current = blocks[i]!; if (!isTextBlock(current) || !current.listItem) break; const level = current.level || 1; if (level > 1 || current.listItem === listType) { listBlocks.push(current); i++; } else { break; } } content.push(convertPTList(listBlocks, listType)); } else { const converted = convertPTBlock(block); if (converted) { content.push(converted); } i++; } } return { type: "doc", content: content.length > 0 ? content : [{ type: "paragraph" }], }; } function convertPTBlock(block: PortableTextBlock): unknown { switch (block._type) { case "block": { if (!isTextBlock(block)) return null; const { style = "normal", children, markDefs = [], textAlign } = block; const pmContent = convertPTSpans(children, markDefs); switch (style) { case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": { const level = parseInt(style.substring(1), 10); return { type: "heading", attrs: { level, ...(textAlign ? { textAlign } : {}) }, content: pmContent.length > 0 ? pmContent : undefined, }; } case "blockquote": return { type: "blockquote", content: [ { type: "paragraph", content: pmContent.length > 0 ? pmContent : undefined, }, ], }; default: return { type: "paragraph", attrs: textAlign ? { textAlign } : undefined, content: pmContent.length > 0 ? pmContent : undefined, }; } } case "image": { if (!isImageBlock(block)) return null; const imageBlock = block; const meta = imageBlock.asset.meta; // Prefer first-class LQIP fields; fall back to `asset.meta` for legacy // snapshots persisted before LQIP was promoted out of the provider meta bag. const blurhash = typeof imageBlock.blurhash === "string" ? imageBlock.blurhash : typeof meta?.blurhash === "string" ? meta.blurhash : null; const dominantColor = typeof imageBlock.dominantColor === "string" ? imageBlock.dominantColor : typeof meta?.dominantColor === "string" ? meta.dominantColor : null; return { type: "image", attrs: { src: imageBlock.asset.url || `/_emdash/api/media/file/${imageBlock.asset._ref}`, alt: imageBlock.alt || "", title: imageBlock.caption || "", caption: imageBlock.caption || "", mediaId: imageBlock.asset._ref, width: imageBlock.width, height: imageBlock.height, blurhash, dominantColor, displayWidth: imageBlock.displayWidth, displayHeight: imageBlock.displayHeight, alignment: imageBlock.alignment, }, }; } case "code": { if (!isCodeBlock(block)) return null; const codeBlock = block; return { type: "codeBlock", attrs: { language: codeBlock.language || null }, content: codeBlock.code ? [{ type: "text", text: codeBlock.code }] : undefined, }; } case "break": return { type: "horizontalRule" }; case "htmlBlock": { const htmlBlock = block as { _type: "htmlBlock"; _key: string; html?: string }; return { type: "htmlBlock", attrs: { html: htmlBlock.html || "" }, }; } case "table": { const tableBlock = block as { _type: "table"; _key: string; rows?: Array<{ _type: "tableRow"; _key: string; cells: Array<{ _type: "tableCell"; _key: string; content: PortableTextSpan[]; isHeader?: boolean; markDefs?: PortableTextMarkDef[]; }>; }>; hasHeaderRow?: boolean; markDefs?: PortableTextMarkDef[]; }; const tableMarkDefs = tableBlock.markDefs || []; const tableMarkDefsMap = new Map(tableMarkDefs.map((md) => [md._key, md])); const rows = (tableBlock.rows || []).map((row, rowIndex) => { const cells = row.cells.map((cell) => { const cellType = cell.isHeader || (tableBlock.hasHeaderRow && rowIndex === 0) ? "tableHeader" : "tableCell"; const cellMarkDefs = cell.markDefs || []; const markDefsMap = new Map([ ...tableMarkDefsMap, ...cellMarkDefs.map((md) => [md._key, md] as const), ]); const pmContent = convertPTSpans(cell.content, [...markDefsMap.values()]); return { type: cellType, content: [ { type: "paragraph", content: pmContent.length > 0 ? pmContent : undefined, }, ], }; }); return { type: "tableRow", content: cells, }; }); return { type: "table", content: rows, }; } default: { // Treat unknown block types as plugin blocks (embeds) // These have an id field (or url for backwards compat) for the embed source, // OR Block Kit field data stored as top-level keys (e.g., formId for forms plugin) const { _type, _key, id, url, ...rest } = block as Record; // Filter out _-prefixed keys to prevent accumulation across edit cycles const data = Object.fromEntries(Object.entries(rest).filter(([k]) => !k.startsWith("_"))); const hasFieldData = Object.keys(data).length > 0; if (id || url || hasFieldData) { return { type: "pluginBlock", attrs: { blockType: _type, id: id || url || "", data, }, }; } // Truly unknown blocks with no data at all return { type: "paragraph", content: [ { type: "text", text: `[Unknown block type: ${block._type}]`, marks: [{ type: "code" }], }, ], }; } } } function convertPTList(items: PortableTextTextBlock[], listType: "bullet" | "number"): unknown { // Group items into root-level items (level === 1) and their nested // descendants (level > 1). For each root item, all subsequent items with // level > 1 belong to its nested subtree — recurse on them with level // decremented so the inner pass sees them as its own root level. const rootItems: unknown[] = []; let i = 0; while (i < items.length) { const item = items[i]!; const level = item.level || 1; if (level === 1) { const nestedItems: PortableTextTextBlock[] = []; i++; while (i < items.length && (items[i]!.level || 1) > 1) { nestedItems.push(items[i]!); i++; } rootItems.push(convertPTListItem(item, nestedItems, listType)); } else { // Orphan nested item with no preceding level=1 anchor — treat as root // so we don't drop content. rootItems.push(convertPTListItem(item, [], listType)); i++; } } return { type: listType === "bullet" ? "bulletList" : "orderedList", content: rootItems, }; } function convertPTListItem( item: PortableTextTextBlock, nestedItems: PortableTextTextBlock[], parentListType: "bullet" | "number", ): unknown { const content: unknown[] = []; const pmContent = convertPTSpans(item.children, item.markDefs || []); content.push({ type: "paragraph", content: pmContent.length > 0 ? pmContent : undefined, }); if (nestedItems.length > 0) { // The shallowest level in `nestedItems` is the effective root of this // item's nested subtree. A new sub-list only starts when we hit // another block at that root level with a different `listItem` type; // deeper blocks (level > minLevel) belong to the current group as // descendants regardless of their own `listItem`. The previous // grouping broke on any type change at any depth, so a deep mixed // tree like `bullet L1 → number L2 → bullet L3 → number L2` would // emit C(L3) as a sibling list under A(L1) instead of nesting it // under B(L2), then degrade C to L2 on round-trip. let minLevel = Infinity; for (const ni of nestedItems) { const level = ni.level || 2; if (level < minLevel) minLevel = level; } let j = 0; while (j < nestedItems.length) { const anchorType: "bullet" | "number" = nestedItems[j]!.listItem || parentListType; const nestedGroup: PortableTextTextBlock[] = []; do { nestedGroup.push(nestedItems[j]!); j++; } while ( j < nestedItems.length && ((nestedItems[j]!.level || 2) > minLevel || (nestedItems[j]!.listItem || parentListType) === anchorType) ); if (nestedGroup.length > 0) { const adjustedGroup = nestedGroup.map((ni) => ({ ...ni, level: (ni.level || 2) - 1, })); content.push(convertPTList(adjustedGroup, anchorType)); } } } return { type: "listItem", content, }; } function convertPTSpans(spans: PortableTextSpan[], markDefs: PortableTextMarkDef[]): unknown[] { const nodes: unknown[] = []; const markDefsMap = 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.length > 0) { const marks = convertPTMarks(span.marks || [], markDefsMap); const node: { type: string; text: string; marks?: unknown[] } = { type: "text", text, }; if (marks.length > 0) { node.marks = marks; } nodes.push(node); } if (i < parts.length - 1) { nodes.push({ type: "hardBreak" }); } } } return nodes; } function convertPTMarks(marks: string[], markDefs: Map): unknown[] { const pmMarks: unknown[] = []; for (const mark of marks) { switch (mark) { case "strong": pmMarks.push({ type: "bold" }); break; case "em": pmMarks.push({ type: "italic" }); break; case "underline": pmMarks.push({ type: "underline" }); break; case "strike-through": pmMarks.push({ type: "strike" }); break; case "code": pmMarks.push({ type: "code" }); break; default: { const markDef = markDefs.get(mark); if (markDef && markDef._type === "link") { pmMarks.push({ type: "link", attrs: { href: markDef.href, target: markDef.blank ? "_blank" : null, }, }); } break; } } } return pmMarks; } // ============================================================================= // Slash Commands // ============================================================================= /** * Slash command item definition */ interface SlashCommandItem { id: string; /** Built-in commands use `msg`; plugin/API-sourced titles stay plain `string`. */ title: MessageDescriptor | string; description: MessageDescriptor | string; icon: Icon | React.ComponentType<{ className?: string }>; command: (props: { editor: Editor; range: Range }) => void; aliases?: string[]; /** * Display category. Built-in commands use `msg`-tagged descriptors; * plugin-supplied categories arrive as plain strings via the manifest * and are passed through verbatim when rendered. */ category?: MessageDescriptor | string; } /** * Default slash commands for built-in block types */ const defaultSlashCommands: SlashCommandItem[] = [ { id: "heading1", title: msg`Heading 1`, description: msg`Large section heading`, icon: TextHOne, aliases: ["h1", "title"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); }, }, { id: "heading2", title: msg`Heading 2`, description: msg`Medium section heading`, icon: TextHTwo, aliases: ["h2", "subtitle"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); }, }, { id: "heading3", title: msg`Heading 3`, description: msg`Small section heading`, icon: TextHThree, aliases: ["h3"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); }, }, { id: "bulletList", title: msg`Bullet List`, description: msg`Create a bullet list`, icon: List, aliases: ["ul", "unordered"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleBulletList().run(); }, }, { id: "numberedList", title: msg`Numbered List`, description: msg`Create a numbered list`, icon: ListNumbers, aliases: ["ol", "ordered"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleOrderedList().run(); }, }, { id: "quote", title: msg`Quote`, description: msg`Insert a blockquote`, icon: Quotes, aliases: ["blockquote", "cite"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleBlockquote().run(); }, }, { id: "codeBlock", title: msg`Code Block`, description: msg`Insert a code block`, icon: CodeBlock, aliases: ["code", "pre", "```"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); }, }, { id: "htmlBlock", title: msg`HTML`, description: msg`Insert raw HTML`, icon: BracketsAngle, aliases: ["html", "raw", "markup"], command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .insertContent({ type: "htmlBlock", attrs: { html: "" } }) .run(); }, }, { id: "divider", title: msg`Divider`, description: msg`Insert a horizontal rule`, icon: Minus, aliases: ["hr", "---", "separator"], command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, }, { id: "table", title: msg`Table`, description: msg`Insert a table`, icon: TableIcon, aliases: ["grid", "spreadsheet"], command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .run(); }, }, ]; /** * Slash menu state */ interface SlashMenuState { isOpen: boolean; items: SlashCommandItem[]; selectedIndex: number; clientRect: (() => DOMRect | null) | null; range: Range | null; } /** * Create the slash commands TipTap extension */ 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 = props as SlashCommandItem; 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 })); }, }; }, }), ]; }, }); } /** * Slash command menu component using Floating UI */ function SlashCommandMenu({ state, onCommand, onClose: _onClose, setSelectedIndex, }: { state: SlashMenuState; onCommand: (item: SlashCommandItem) => void; onClose: () => void; setSelectedIndex: (index: number) => void; }) { const { t } = useLingui(); const containerRef = React.useRef(null); const { refs, floatingStyles } = useFloating({ open: state.isOpen, placement: "bottom-start", middleware: [offset(8), flip(), shift({ padding: 8 })], whileElementsMounted: autoUpdate, }); // Sync virtual reference from TipTap's clientRect React.useEffect(() => { if (state.clientRect) { const clientRectFn = state.clientRect; refs.setReference({ getBoundingClientRect: () => clientRectFn() ?? new DOMRect(), }); } }, [state.clientRect, refs]); // Scroll selected item into view React.useEffect(() => { if (!state.isOpen) return; const container = containerRef.current; if (!container) return; const selected = container.querySelector(`[data-index="${state.selectedIndex}"]`); if (selected) { selected.scrollIntoView({ block: "nearest" }); } }, [state.selectedIndex, state.isOpen]); // Track whether the mouse has actually moved since the menu opened. // The menu typically opens right at the text cursor, which may sit under // a stationary mouse pointer. Reacting to mouseenter immediately would // reset the selection to whichever item happens to be under the pointer // the moment the menu renders -- overriding the keyboard-driven default // (selectedIndex: 0) and any subsequent arrow-key navigation. // // Only flip the gate on mousemove, which fires only on real pointer // movement, not on elements appearing under a stationary pointer. const hasMouseMovedRef = React.useRef(false); React.useEffect(() => { if (!state.isOpen) { hasMouseMovedRef.current = false; } }, [state.isOpen]); if (!state.isOpen) return null; return createPortal(
{ containerRef.current = node; refs.setFloating(node); }} style={floatingStyles} className="z-[100] rounded-lg border bg-kumo-overlay p-1 shadow-lg min-w-[220px] max-h-[300px] overflow-y-auto" onPointerMove={() => { hasMouseMovedRef.current = true; }} > {state.items.length === 0 ? (

{t`No results`}

) : ( state.items.map((item, index) => ( )) )}
, document.body, ); } function getPluginBlockDefaultValues(fields?: Element[]): Record { const defaults: Record = {}; for (const field of fields ?? []) { const initialValue = "initial_value" in field ? field.initial_value : undefined; if (initialValue !== undefined) { defaults[field.action_id] = initialValue; } } return defaults; } function buildPluginBlockFormValues( block: PluginBlockDef | null, initialValues?: Record, ): Record { const defaults = getPluginBlockDefaultValues(block?.fields); return initialValues ? { ...defaults, ...initialValues } : defaults; } function hasPluginBlockFormData(values: Record): boolean { return Object.values(values).some( (value) => value !== undefined && value !== null && value !== "", ); } /** * Plugin block insertion/editing modal. * When the block has `fields`, renders Block Kit elements. * Otherwise falls back to a simple URL input. */ function PluginBlockModal({ block, initialValues, onClose, onInsert, }: { block: PluginBlockDef | null; /** Pre-populated values when editing an existing block */ initialValues?: Record; onClose: () => void; onInsert: (values: Record) => void; }) { const [formValues, setFormValues] = React.useState>({}); const inputRef = React.useRef(null); const { t } = useLingui(); React.useEffect(() => { if (block) { setFormValues(buildPluginBlockFormValues(block, initialValues)); if (!block.fields || block.fields.length === 0) { setTimeout(() => inputRef.current?.focus(), 0); } } }, [block, initialValues]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (block?.fields && block.fields.length > 0) { onInsert(formValues); } else { const url = typeof formValues.id === "string" ? formValues.id.trim() : ""; if (url) { onInsert({ id: url }); } } }; const handleFieldChange = (actionId: string, value: unknown) => { setFormValues((prev) => ({ ...prev, [actionId]: value })); }; const isEditing = !!initialValues; const hasFields = block?.fields && block.fields.length > 0; // For simple URL mode, check if the URL is non-empty // For Block Kit fields, require at least one field to have a value const canSubmit = hasFields ? hasPluginBlockFormData(formValues) : typeof formValues.id === "string" && formValues.id.trim().length > 0; // Size the dialog based on field complexity. The default `sm` is right for // simple URL embeds (one field) but cramps Block Kit forms with several // fields or a repeater, which need room for inline sub-field inputs. const dialogSize = (() => { if (!hasFields) return "sm"; const fields = block?.fields ?? []; if (fields.some((f) => f.type === "repeater")) return "xl"; if (fields.length > 3) return "lg"; return "base"; })(); return ( !open && onClose()}>
{isEditing ? t`Edit ${block?.label || ""}` : t`Insert ${block?.label || ""}`} ( )} />
{hasFields ? ( block.fields!.map((field) => ( )) ) : ( handleFieldChange("id", e.target.value)} /> )}
); } /** * Renders a single Block Kit field element. * Supports text_input, number_input, select (with optional async options), and toggle. */ function BlockKitField({ field, pluginId, value, onChange, }: { field: Element; pluginId?: string; value: unknown; onChange: (actionId: string, value: unknown) => void; }) { switch (field.type) { case "text_input": { const multiline = !!field.multiline; const placeholder = typeof field.placeholder === "string" ? field.placeholder : undefined; const Tag = multiline ? "textarea" : "input"; return (
{multiline ? ( onChange(field.action_id, e.target.value)} /> ) : ( onChange(field.action_id, e.target.value)} /> )}
); } case "number_input": { const min = typeof field.min === "number" ? field.min : undefined; const max = typeof field.max === "number" ? field.max : undefined; return (
onChange(field.action_id, e.target.value ? Number(e.target.value) : undefined) } />
); } case "select": { return ; } case "toggle": { return ( onChange(field.action_id, checked)} label={{field.label}} /> ); } case "repeater": { return ( ); } case "media_picker": { return ( ); } default: return
Unknown field type: {field.type}
; } } // ── Repeater support ───────────────────────────────────────────────────────── type RepeaterItem = Record & { _key: string }; function ensureKeys(items: unknown[]): RepeaterItem[] { return items.map((item, i) => { const obj = (typeof item === "object" && item !== null ? item : {}) as Record; return { ...obj, _key: (obj._key as string) || `item-${i}-${Date.now()}` }; }); } function stripKeys(items: RepeaterItem[]): Record[] { return items.map(({ _key, ...rest }) => rest); } function BlockKitRepeater({ field, pluginId, value, onChange, }: { field: Extract; pluginId?: string; value: unknown; onChange: (actionId: string, value: unknown) => void; }) { const { t } = useLingui(); const rawItems = React.useMemo(() => (Array.isArray(value) ? value : []), [value]); const [items, setItems] = React.useState(() => ensureKeys(rawItems)); const [expanded, setExpanded] = React.useState>(new Set()); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); // Track the array we just emitted upstream. When `value` flows back as // the same reference, the resync below is a no-op and we skip the // setState round-trip that would otherwise reseed local state on every // keystroke. const lastEmittedRef = React.useRef(null); // Preserve each item's _key by position so round-trips through onChange // (which strips _key) don't remount children and flip them back to // collapsed on every keystroke. React.useEffect(() => { if (lastEmittedRef.current === rawItems) return; setItems((prev) => rawItems.map((item, i) => { const obj = (typeof item === "object" && item !== null ? item : {}) as Record< string, unknown >; const existingKey = (obj._key as string) || prev[i]?._key; return { ...obj, _key: existingKey || `item-${i}-${Date.now()}`, }; }), ); }, [rawItems]); const minItems = field.min_items ?? 0; const maxItems = field.max_items; const canAdd = maxItems === undefined || items.length < maxItems; const canRemove = items.length > minItems; // Only interpolate plugin-provided labels into translations; otherwise // use a self-contained `Add item` string so message extractors and // translators see whole, inflectable phrases. const addButtonLabel = field.item_label ? t`Add ${field.item_label}` : t`Add item`; const emit = (next: RepeaterItem[]) => { setItems(next); const stripped = stripKeys(next); lastEmittedRef.current = stripped; onChange(field.action_id, stripped); }; const handleAdd = () => { if (!canAdd) return; const newItem: RepeaterItem = { _key: `item-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, }; for (const sf of field.fields) { switch (sf.type) { case "toggle": newItem[sf.action_id] = false; break; case "number_input": newItem[sf.action_id] = undefined; break; default: newItem[sf.action_id] = ""; } } setExpanded((prev) => { const next = new Set(prev); next.add(newItem._key); return next; }); emit([...items, newItem]); }; const handleRemove = (key: string) => { if (!canRemove) return; setExpanded((prev) => { if (!prev.has(key)) return prev; const next = new Set(prev); next.delete(key); return next; }); emit(items.filter((it) => it._key !== key)); }; const handleItemChange = (key: string, subActionId: string, subValue: unknown) => { emit(items.map((it) => (it._key === key ? { ...it, [subActionId]: subValue } : it))); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = items.findIndex((it) => it._key === active.id); const newIndex = items.findIndex((it) => it._key === over.id); if (oldIndex === -1 || newIndex === -1) return; emit(arrayMove(items, oldIndex, newIndex)); }; const toggleExpanded = (key: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; return (
{canAdd && ( )}
{items.length === 0 ? (

{t`No items yet`}

{canAdd && ( )}
) : ( it._key)} strategy={verticalListSortingStrategy} >
{items.map((item, index) => ( toggleExpanded(item._key)} onRemove={canRemove ? () => handleRemove(item._key) : undefined} onChange={(subActionId, v) => handleItemChange(item._key, subActionId, v)} /> ))}
)}
); } function BlockKitRepeaterItem({ item, index, fields, pluginId, isCollapsed, onToggleCollapse, onRemove, onChange, }: { item: RepeaterItem; index: number; fields: Extract["fields"]; pluginId?: string; isCollapsed: boolean; onToggleCollapse: () => void; onRemove?: () => void; onChange: (subActionId: string, value: unknown) => void; }) { const { t } = useLingui(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item._key, }); const style = { transform: CSS.Transform.toString(transform), transition, }; // Summary label: value of the first text_input sub-field, falling back to "Item N". const summaryField = fields.find((f) => f.type === "text_input"); const summaryValue = summaryField && typeof item[summaryField.action_id] === "string" ? (item[summaryField.action_id] as string) : ""; const summaryLabel = summaryValue.trim() || t`Item ${index + 1}`; return (
{onRemove && ( )}
{!isCollapsed && (
{fields.map((sf) => ( onChange(actionId, v)} /> ))}
)}
); } /** * Select field that supports loading options dynamically via `optionsRoute`. * When `optionsRoute` is set, fetches `{ items: [{ id, name }] }` from the plugin route. */ function DynamicSelect({ field, pluginId, value, onChange, }: { field: Extract; pluginId?: string; value: unknown; onChange: (actionId: string, value: unknown) => void; }) { const [dynamicOptions, setDynamicOptions] = React.useState | null>(null); const [loading, setLoading] = React.useState(false); const { t } = useLingui(); React.useEffect(() => { if (!field.optionsRoute || !pluginId) return; const controller = new AbortController(); setLoading(true); void (async () => { try { const res = await fetch(`/_emdash/api/plugins/${pluginId}/${field.optionsRoute}`, { method: "POST", headers: { "Content-Type": "application/json", "X-EmDash-Request": "1", }, body: JSON.stringify({}), signal: controller.signal, }); if (res.ok) { const body = (await res.json()) as { data: { items?: Array<{ id: string; name: string }> }; }; if (body.data?.items) { setDynamicOptions( body.data.items.map((item) => ({ label: item.name, value: item.id })), ); } } } catch { // Failed to load options or aborted — static options will be used } finally { if (!controller.signal.aborted) { setLoading(false); } } })(); return () => controller.abort(); }, [field.optionsRoute, pluginId]); const options = dynamicOptions ?? field.options; return (
{loading ? (
{t`Loading...`}
) : ( setLinkUrl(e.target.value)} onKeyDown={handleKeyDown} className="h-8 w-48 text-sm" /> {editor.isActive("link") && ( )}
) : ( <> editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} title={t`Bold`} > editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} title={t`Italic`} > editor.chain().focus().toggleUnderline().run()} active={editor.isActive("underline")} title={t`Underline`} > editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")} title={t`Strikethrough`} > editor.chain().focus().toggleCode().run()} active={editor.isActive("code")} title={t`Code`} >
setShowLinkInput(true)} active={editor.isActive("link")} title={editor.isActive("link") ? t`Edit link` : t`Add link`} > )} ); } /** * Table Bubble Menu - appears when cursor is in a table. * Shows table editing options: add/remove rows/columns, toggle header, delete table. */ function TableBubbleMenu({ editor }: { editor: Editor }) { const { t } = useLingui(); if (!editor.isActive("table")) { return null; } return ( activeEditor.isActive("table")} className="z-[100] flex items-center gap-0.5 rounded-lg border bg-kumo-base p-1 shadow-lg" > editor.chain().focus().addColumnBefore().run()} title={t`Add column before`} > editor.chain().focus().addColumnAfter().run()} title={t`Add column after`} > editor.chain().focus().deleteColumn().run()} title={t`Delete column`} >
editor.chain().focus().addRowBefore().run()} title={t`Add row before`} > editor.chain().focus().addRowAfter().run()} title={t`Add row after`} > editor.chain().focus().deleteRow().run()} title={t`Delete row`}>
editor.chain().focus().toggleHeaderRow().run()} active={editor.isActive("tableHeader")} title={t`Toggle header row`} > editor.chain().focus().deleteTable().run()} title={t`Delete table`} > ); } function BubbleButton({ onClick, active, title, children, }: { onClick: () => void; active?: boolean; title: string; children: React.ReactNode; }) { return ( ); } /** * Editor Toolbar * * Implements WAI-ARIA toolbar pattern with proper keyboard navigation. * Arrow keys move focus between buttons, Home/End jump to first/last. */ function EditorToolbar({ editor, focusMode, onFocusModeChange, }: { editor: Editor; focusMode: FocusMode; onFocusModeChange: (mode: FocusMode) => void; }) { const { t } = useLingui(); const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false); const [showLinkPopover, setShowLinkPopover] = React.useState(false); const [linkUrl, setLinkUrl] = React.useState(""); const toolbarRef = React.useRef(null); const linkInputRef = React.useRef(null); // Subscribe to editor state changes for reactive button states const editorState = useEditorState({ editor, selector: (ctx) => ({ isBold: ctx.editor.isActive("bold"), isItalic: ctx.editor.isActive("italic"), isUnderline: ctx.editor.isActive("underline"), isStrike: ctx.editor.isActive("strike"), isCode: ctx.editor.isActive("code"), isHeading1: ctx.editor.isActive("heading", { level: 1 }), isHeading2: ctx.editor.isActive("heading", { level: 2 }), isHeading3: ctx.editor.isActive("heading", { level: 3 }), isBulletList: ctx.editor.isActive("bulletList"), isOrderedList: ctx.editor.isActive("orderedList"), isBlockquote: ctx.editor.isActive("blockquote"), isCodeBlock: ctx.editor.isActive("codeBlock"), isAlignLeft: ctx.editor.isActive({ textAlign: "left" }), isAlignCenter: ctx.editor.isActive({ textAlign: "center" }), isAlignRight: ctx.editor.isActive({ textAlign: "right" }), isLink: ctx.editor.isActive("link"), canUndo: ctx.editor.can().undo(), canRedo: ctx.editor.can().redo(), }), }); // Populate link URL when opening popover React.useEffect(() => { if (showLinkPopover) { const existingUrl = editor.getAttributes("link").href || ""; setLinkUrl(existingUrl); setTimeout(() => linkInputRef.current?.focus(), 0); } }, [showLinkPopover, editor]); const handleSetLink = () => { if (linkUrl.trim() === "") { editor.chain().focus().extendMarkRange("link").unsetLink().run(); } else { editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl.trim() }).run(); } setShowLinkPopover(false); setLinkUrl(""); }; const handleRemoveLink = () => { editor.chain().focus().extendMarkRange("link").unsetLink().run(); setShowLinkPopover(false); setLinkUrl(""); }; const handleLinkKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleSetLink(); } else if (e.key === "Escape") { setShowLinkPopover(false); setLinkUrl(""); editor.commands.focus(); } }; const handleImageSelect = React.useCallback( (item: MediaItem) => { editor .chain() .focus() .setImage({ src: item.url, alt: item.alt || item.filename, mediaId: item.id, width: item.width, height: item.height, blurhash: item.blurhash, dominantColor: item.dominantColor, }) .run(); }, [editor], ); // Keyboard navigation for toolbar (WAI-ARIA toolbar pattern) const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { const toolbar = toolbarRef.current; if (!toolbar) return; const buttons = [ ...toolbar.querySelectorAll( 'button:not([disabled]), [role="button"]:not([disabled])', ), ]; const currentIndex = buttons.findIndex((btn) => btn === document.activeElement); if (currentIndex === -1) return; let nextIndex: number | null = null; switch (e.key) { case "ArrowRight": case "ArrowDown": nextIndex = (currentIndex + 1) % buttons.length; break; case "ArrowLeft": case "ArrowUp": nextIndex = (currentIndex - 1 + buttons.length) % buttons.length; break; case "Home": nextIndex = 0; break; case "End": nextIndex = buttons.length - 1; break; default: return; } if (nextIndex !== null) { e.preventDefault(); buttons[nextIndex]?.focus(); } }, []); return (
{/* Text formatting */} editor.chain().focus().toggleBold().run()} active={editorState.isBold} title={t`Bold`} > editor.chain().focus().toggleItalic().run()} active={editorState.isItalic} title={t`Italic`} > editor.chain().focus().toggleUnderline().run()} active={editorState.isUnderline} title={t`Underline`} > editor.chain().focus().toggleStrike().run()} active={editorState.isStrike} title={t`Strikethrough`} > editor.chain().focus().toggleCode().run()} active={editorState.isCode} title={t`Inline Code`} >