"use client"; /** * ContentEditor — the rich-text editor used wherever the user TYPES content. * * Architecture decisions (April 2026 refactor): * * 1. EDITING ONLY. Read-only display is handled by `ReadonlyContent` (a * react-markdown renderer), not this component. There used to be an * `editable` prop here that toggled between modes, but every readonly * callsite migrated to ReadonlyContent and the prop only invited * misuse — Tiptap's `useEditor` reads `editable` at mount, so toggling * the prop later silently failed (mounted-as-readonly editors stayed * unfocusable forever). To express "currently disabled", wrap this * component in a layout that sets `pointer-events-none` / `aria-disabled` * — don't reach into the editor. * * 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with * `contentType: 'markdown'` and saved with `editor.getMarkdown()`. * Previously we had a custom `markdownToHtml()` pipeline (Marked library) * for loading and regex post-processing for saving — two asymmetric paths * that caused roundtrip inconsistencies. The @tiptap/markdown extension * (v3.21.0+) handles table cell

wrapping and custom mention tokenizers * natively, eliminating the need for the HTML detour. * * 3. PREPROCESSING is minimal: only legacy mention shortcode migration and * URL linkification (preprocessMarkdown). No HTML conversion. * * Tech: Tiptap v3 (ProseMirror wrapper), @tiptap/markdown for * bidirectional Markdown ↔ ProseMirror JSON conversion. */ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, } from "react"; import { useEditor, EditorContent, type Editor } from "@tiptap/react"; import { cn } from "@multica/ui/lib/utils"; import type { UploadResult } from "@multica/core/hooks/use-file-upload"; import { useWorkspaceSlug } from "@multica/core/paths"; import { useQueryClient } from "@tanstack/react-query"; import { issueIdentifierOptions } from "@multica/core/issues/queries"; import { workspaceListOptions } from "@multica/core/workspace/queries"; import { isIssueIdentifier } from "@multica/ui/markdown"; import type { Attachment } from "@multica/core/types"; import { parseMarkdownChunked, MARKDOWN_CHUNK_THRESHOLD, type MarkdownManagerLike, } from "./utils/parse-markdown-chunked"; import type { MentionItem } from "./extensions/mention-suggestion"; import type { IssueIdentifierResolver } from "./extensions/issue-identifier-autolink"; import { createEditorExtensions } from "./extensions"; import { uploadAndInsertFile } from "./extensions/file-upload"; import { preprocessMarkdown } from "./utils/preprocess"; import { repairEmptyListItems } from "./utils/repair-list-items"; import { openLink, isMentionHref } from "./utils/link-handler"; import { EditorBubbleMenu } from "./bubble-menu"; import { useLinkHover, LinkHoverCard } from "./link-hover-card"; import { AttachmentDownloadProvider } from "./attachment-download-context"; import "katex/dist/katex.min.css"; import "./styles/index.css"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Blob URLs (blob:http://…) are process-local and expire on reload. Strip them * from serialised markdown so they never reach the database. */ const BLOB_IMAGE_RE = /!\[[^\]]*\]\(blob:[^)]*\)\n?/g; function stripBlobUrls(md: string): string { return md.replace(BLOB_IMAGE_RE, ""); } /** Canonical comparison form for a markdown string: drop process-local blob * URLs and trailing blank lines so both sides of a dirty check compare * like-for-like. One definition for the normalization rule — a future tweak * (e.g. stripping another ephemeral token) lands here instead of in the * several call sites it used to be copy-pasted across. */ function normalizeMarkdown(md: string): string { return stripBlobUrls(md).trimEnd(); } /** `normalizeMarkdown` applied to the live editor's serialized content. */ function normalizeEditorMarkdown(editor: Editor): string { return normalizeMarkdown(editor.getMarkdown()); } /** True when any node in the document is mid-upload (`attrs.uploading`). The * `return !found` early-out matches the original inline scans verbatim: in * ProseMirror it only stops descending into the matched node's subtree (not * the whole walk), but once `found` flips true the boolean result is fixed. */ function hasUploadingNode(editor: Editor): boolean { let found = false; editor.state.doc.descendants((node) => { if (node.attrs.uploading) found = true; return !found; }); return found; } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ContentEditorProps { defaultValue?: string; onUpdate?: (markdown: string) => void; placeholder?: string; className?: string; debounceMs?: number; onSubmit?: () => void; onBlur?: () => void; onUploadFile?: (file: File) => Promise; /** Show the floating formatting toolbar on text selection. Defaults true. */ showBubbleMenu?: boolean; /** When true, bare Enter submits (chat-style). Mod-Enter always submits. */ submitOnEnter?: boolean; /** * ID of the issue this editor belongs to. When set, the bubble menu exposes * a "Create sub-issue from selection" action that parents the new issue * under this ID and replaces the selection with a mention link. */ currentIssueId?: string; /** * When true, the `@` suggestion picker is disabled but the mention node * type remains in the schema, so existing mentions pasted in from other * Multica editors still render as the normal pill. Use for editors where * *creating* a new mention has no business meaning (e.g. agent system * prompts) but *preserving* an existing one still matters. */ disableMentions?: boolean; /** Chat can surface current/recent issue/project suggestions. Other editors use default mention behavior. */ mentionMode?: "default" | "context"; mentionContextItems?: MentionItem[]; /** Enable the `/` command picker. Defaults false. */ enableSlashCommands?: boolean; /** * Which `/` menu to show when enableSlashCommands is true: "skill" (default) * lists the active agent's skills (chat); "command" shows the fixed built-in * command menu (issue comments), e.g. /note. */ slashCommandMode?: "skill" | "command"; /** * Attachments referenced by this content. The download buttons on file * cards and images inside the editor look up an attachment by `url` and * fetch a fresh CloudFront signature at click time, so a stale URL * persisted in markdown never opens. Pass `issue.attachments` / * `comment.attachments` etc.; omit when no attachment context is * available (NodeView buttons fall back to opening the raw URL). */ attachments?: Attachment[]; /** * Flush a pending debounced `onUpdate` when the editor unmounts instead of * dropping it. Default false ON PURPOSE: most composers clear their draft * and then unmount (comment edit cancel, create-issue / feedback submit), * and a flush there would hand the discarded content right back to * `onUpdate`, resurrecting the cleared draft. Opt in only where closing * means "keep what the user last saw" — e.g. the issue-detail description * editor, whose 1500ms debounce would otherwise drop a paste made just * before the modal closes. */ flushPendingOnUnmount?: boolean; } interface ContentEditorRef { getMarkdown: () => string; clearContent: () => void; focus: () => void; /** Drop focus from the editor — used by chat after send so the caret * stops competing with the StatusPill / streaming reply for the user's * attention. */ blur: () => void; uploadFile: (file: File) => void; /** True when file uploads are still in progress. */ hasActiveUploads: () => boolean; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- const ContentEditor = forwardRef( function ContentEditor( { defaultValue = "", onUpdate, placeholder: placeholderText = "", className, debounceMs = 300, onSubmit, onBlur, onUploadFile, showBubbleMenu = true, submitOnEnter = false, currentIssueId, disableMentions = false, mentionMode = "default", mentionContextItems, enableSlashCommands = false, slashCommandMode = "skill", attachments, flushPendingOnUnmount = false, }, ref, ) { const debounceRef = useRef>(undefined); const flushPendingOnUnmountRef = useRef(flushPendingOnUnmount); // Markdown serialized at `onUpdate` time, awaiting its debounce fire. The // unmount flush emits this cached copy — it runs mid-teardown and can't // assume the editor instance is still readable. const pendingFlushRef = useRef(null); const onUpdateRef = useRef(onUpdate); const onSubmitRef = useRef(onSubmit); const onBlurRef = useRef(onBlur); const onUploadFileRef = useRef< ((file: File) => Promise) | undefined >(undefined); const mentionContextItemsRef = useRef(mentionContextItems ?? []); const lastEmittedRef = useRef(null); // Live placeholder text. Passed into the Placeholder extension as a getter // (not a static string) so the plugin re-reads it on every decoration pass — // the sync effect below updates this ref and nudges a repaint. Tiptap // snapshots a *string* placeholder at mount, so a getter is what lets it // change without remounting the editor. const placeholderRef = useRef(placeholderText); // In-session record of attachments freshly uploaded through this editor. // Surfaces (like the quick-create modal) that don't have a server-supplied // `attachments` prop still need the AttachmentDownloadProvider to know // about images the user just pasted/dropped — without a record in scope, // Attachment.normalize() can't swap the persisted /api/attachments// // download URL to a freshly-loadable one, and the renders broken in // any environment where the renderer's origin doesn't proxy /api to the // API host (MUL-3192, Desktop/Electron). const [sessionUploads, setSessionUploads] = useState([]); // Wrap the caller-supplied uploader so we can stash each successful result // in `sessionUploads`. The wrapper is rebuilt only when the underlying // `onUploadFile` identity changes, so the inner ref handed to Tiptap stays // stable across renders the way the original passthrough did. const wrappedOnUploadFile = useMemo(() => { if (!onUploadFile) return undefined; return async (file: File): Promise => { const result = await onUploadFile(file); // Only track attachments that carry a persisted id — the no-workspace // avatar branch returns an id-less record that the resolver can't key // off of, and tracking it would just bloat memory without helping // anyone. See useFileUpload's `markdownLink` docstring for why. if (result?.id) { setSessionUploads((prev) => // Deduplicate on id so a re-upload (or a paste-then-drop of the // same blob) doesn't create a parallel record. prev.some((a) => a.id === result.id) ? prev : [...prev, result], ); } return result; }; }, [onUploadFile]); // Merged list fed to AttachmentDownloadProvider. Caller-supplied attachments // (issue / comment editors that pre-load the full attachments[] from the // server) take precedence — we only append session uploads the caller // doesn't already have, so a parent re-render that includes the same record // doesn't end up with two copies. // // One exception on id collision: when the caller's copy has an EMPTY // `download_url` (the create-issue draft strips the short-lived signed URL // before persisting), backfill it from the session upload. The session copy // holds the this-response signed URL, so the just-pasted image first-paints // from it instead of taking an extra redirect hop through `markdown_url`. const providerAttachments = useMemo(() => { if (sessionUploads.length === 0) return attachments; const sessionById = new Map(sessionUploads.map((a) => [a.id, a])); const merged: Attachment[] = []; for (const a of attachments ?? []) { const session = a.id ? sessionById.get(a.id) : undefined; if (session) sessionById.delete(a.id); merged.push( session && !a.download_url ? { ...a, download_url: session.download_url } : a, ); } merged.push(...sessionById.values()); return merged; }, [attachments, sessionUploads]); // Current workspace slug kept in a ref so the click handler always sees the // latest value without recreating the editor. Used by openLink to prefix // legacy /issues/... style paths that lack a workspace slug. const workspaceSlug = useWorkspaceSlug(); const workspaceSlugRef = useRef(workspaceSlug); workspaceSlugRef.current = workspaceSlug; // Keep refs in sync without recreating editor onUpdateRef.current = onUpdate; onSubmitRef.current = onSubmit; onBlurRef.current = onBlur; onUploadFileRef.current = wrappedOnUploadFile; mentionContextItemsRef.current = mentionContextItems ?? []; flushPendingOnUnmountRef.current = flushPendingOnUnmount; const queryClient = useQueryClient(); // Linear-style bare identifier autolink resolver. Fully lazy — it runs only // on user input, never on render, so it adds no query hook to this widely // used component. It reads the current workspace from the query cache (via // the slug ref) and returns null outside a workspace, for non-identifier // tokens, or when the prefix can't match this workspace, so no network call // happens for those; the exact-match filter enforces correctness. const resolveIssueIdentifierRef = useRef( undefined, ); resolveIssueIdentifierRef.current = async (identifier) => { if (!isIssueIdentifier(identifier)) return null; const slug = workspaceSlugRef.current; if (!slug) return null; const workspaces = await queryClient.fetchQuery(workspaceListOptions()); const ws = workspaces.find((w) => w.slug === slug); if (!ws) return null; const prefix = ws.issue_prefix; if ( prefix && !identifier.toUpperCase().startsWith(`${prefix.toUpperCase()}-`) ) { return null; } const issue = await queryClient.fetchQuery( issueIdentifierOptions(ws.id, identifier), ); return issue ? { id: issue.id, identifier: issue.identifier } : null; }; const initialContent = defaultValue ? preprocessMarkdown(defaultValue) : ""; // With `immediatelyRender: false` the Tiptap instance is created after // mount, so an imperative `focus()` fired on the same tick (e.g. chat // auto-focusing a brand-new conversation) would hit a null editor and no-op. // Latch the intent here and honor it in `onCreate` once the editor exists. const focusOnReadyRef = useRef(false); // Large markdown is parsed in chunks to dodge marked's O(n²) tokenizer (see // parseMarkdownChunked). Small docs stay on the single-parse fast path. const mountChunked = initialContent.length > MARKDOWN_CHUNK_THRESHOLD; const editor = useEditor({ immediatelyRender: false, // Explicit for clarity — the real perf win is useEditorState in BubbleMenu. shouldRerenderOnTransaction: false, onCreate: ({ editor: ed }) => { // For large docs we mount empty (below) and parse in chunks here, so the // O(n²) marked tokenizer never sees the whole document at once. if (mountChunked) { const manager = ( ed.storage as { markdown?: { manager?: MarkdownManagerLike } } ).markdown?.manager; if (manager) { ed.commands.setContent( parseMarkdownChunked(manager, initialContent), { emitUpdate: false }, ); } else { ed.commands.setContent(initialContent, { emitUpdate: false, contentType: "markdown", }); } } // A markdown draft ending in an empty list item (e.g. `"1. \n\n"` left // after typing `1.`) parses into a caretless, schema-invalid item; // repair it so the mounted editor has a real cursor in the list. repairEmptyListItems(ed); lastEmittedRef.current = normalizeEditorMarkdown(ed); if (focusOnReadyRef.current) { focusOnReadyRef.current = false; ed.commands.focus("end"); } }, content: mountChunked ? "" : initialContent, contentType: mountChunked ? undefined : defaultValue ? "markdown" : undefined, extensions: createEditorExtensions({ placeholder: () => placeholderRef.current, queryClient, onSubmitRef, onUploadFileRef, submitOnEnter, disableMentions, mentionMode, getMentionContextItems: () => mentionContextItemsRef.current, enableSlashCommands, slashCommandMode, resolveIssueIdentifierRef, }), onUpdate: ({ editor: ed }) => { if (!onUpdateRef.current) return; if (flushPendingOnUnmountRef.current) { pendingFlushRef.current = normalizeEditorMarkdown(ed); } if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { debounceRef.current = undefined; pendingFlushRef.current = null; const md = normalizeEditorMarkdown(ed); if (md === lastEmittedRef.current) return; lastEmittedRef.current = md; onUpdateRef.current?.(md); }, debounceMs); }, onBlur: () => { onBlurRef.current?.(); }, editorProps: { handleDOMEvents: { click(_view, event) { const target = event.target as HTMLElement; // Skip links inside NodeView wrappers — they handle their own clicks if (target.closest("[data-node-view-wrapper]")) return false; const link = target.closest("a"); const href = link?.getAttribute("href"); if (!href || isMentionHref(href)) return false; event.preventDefault(); openLink(href, workspaceSlugRef.current); return true; }, }, attributes: { class: cn("flex-1 rich-text-editor text-sm outline-none", className), }, }, }); // Cleanup on unmount. A pending debounced update is DROPPED by default, // not flushed — see the `flushPendingOnUnmount` prop doc for why. When the // owner opted in, emit the markdown cached at `onUpdate` time so a long // debounce can't swallow the last edit when the surrounding modal closes. useEffect(() => { return () => { if (!debounceRef.current) return; clearTimeout(debounceRef.current); debounceRef.current = undefined; if (!flushPendingOnUnmountRef.current) return; const pending = pendingFlushRef.current; pendingFlushRef.current = null; if (pending === null || pending === lastEmittedRef.current) return; lastEmittedRef.current = pending; onUpdateRef.current?.(pending); }; }, []); // Sync external `defaultValue` changes into the editor. // Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831); // without this effect, a WS-driven description update keeps the editor // showing stale content until the issue is closed and reopened. useEffect(() => { if (!editor || editor.isDestroyed) return; // Guard 0: never clobber an in-flight upload. An external `defaultValue` // change can arrive mid-upload — e.g. chat lazy-creates a session on the // first file upload, which flips `activeSessionId` → the draft key → // `defaultValue`. If we `setContent` over a document that still holds an // `uploading` image/fileCard node, that node is wiped and the upload's // finalize can no longer find it (the file vanishes, leaving an empty // `!file[name]()`). Like the dirty guards below, an uploading node is // local state that an external sync must not overwrite. if (hasUploadingNode(editor)) return; const current = normalizeEditorMarkdown(editor); // "Dirty" = user has local edits not yet flushed through the debounced // `onUpdate`. `lastEmittedRef` is advanced only after a debounce fire, // so a divergence means the editor holds unsaved bytes. const isDirty = lastEmittedRef.current !== null && current !== lastEmittedRef.current; // Guard 1: focused AND dirty — protect bytes the user is actively // typing. Focused-but-clean falls through: applying setContent is safe // (no user input to lose) and necessary, because onBlur has no replay // mechanism and a focused clean editor would otherwise drop this sync // permanently. if (editor.isFocused && isDirty) return; // Guard 2: unfocused-but-dirty — blur happened but the debounce window // (debounceMs, 1500ms for description) hasn't flushed yet. The pending // onUpdate will reach the server and the cache will reconcile; skipping // here avoids overwriting unsaved local edits. if (isDirty) return; const incoming = defaultValue ? preprocessMarkdown(defaultValue) : ""; const incomingNormalized = normalizeMarkdown(incoming); // Guard 3: normalized-equal short-circuit. Avoids a no-op transaction // when the cache reflects a write this same editor just emitted. if (incomingNormalized === current) return; // Guard 4: `emitUpdate: false`. Tiptap v3's setContent defaults to // `emitUpdate: true`; without this we would re-trigger onUpdate → // server save → self-write loop. const { from, to } = editor.state.selection; // Same chunked path on WS-driven re-parse of a large description. const manager = incoming.length > MARKDOWN_CHUNK_THRESHOLD ? (editor.storage as { markdown?: { manager?: MarkdownManagerLike } }) .markdown?.manager : undefined; if (manager) { editor.commands.setContent(parseMarkdownChunked(manager, incoming), { emitUpdate: false, }); } else { editor.commands.setContent(incoming, { emitUpdate: false, contentType: "markdown", }); } // An empty list item in the incoming markdown parses into a caretless, // schema-invalid node; repair it and let it own the caret. Otherwise clamp // the prior selection to the new doc size so the caret doesn't snap to // position 0 after ProseMirror replaces the document. if (!repairEmptyListItems(editor, { from, to })) { const docSize = editor.state.doc.content.size; editor.commands.setTextSelection({ from: Math.min(from, docSize), to: Math.min(to, docSize), }); } lastEmittedRef.current = normalizeEditorMarkdown(editor); }, [defaultValue, editor]); // Sync external `placeholder` changes into the mounted editor. // The Placeholder extension is configured with a getter over `placeholderRef` // (see createEditorExtensions above), which the plugin re-invokes every time // it recomputes its decorations. Update the ref, then dispatch an empty // transaction to force that recompute — the placeholder refreshes without a // remount. Without this, it stays frozen at its mount value: switching // between an archived and an active chat session under the same agent (no // editor remount) leaves the input stuck on "This session is archived" even // though it is usable. useEffect(() => { if (placeholderRef.current === placeholderText) return; placeholderRef.current = placeholderText; if (!editor || editor.isDestroyed) return; // `docChanged` is false on an empty transaction, so onUpdate never fires // and no self-write loop is triggered. editor.view.dispatch(editor.state.tr); }, [editor, placeholderText]); useImperativeHandle(ref, () => ({ // Intentionally NOT routed through `normalizeMarkdown` — this refactor // must preserve the exact current return value (no `trimEnd`). getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""), clearContent: () => { editor?.commands.clearContent(); }, focus: () => { if (editor) editor.commands.focus(); // Editor not mounted yet — defer the focus to `onCreate`. else focusOnReadyRef.current = true; }, blur: () => { editor?.commands.blur(); }, uploadFile: (file: File) => { if (!editor || !onUploadFileRef.current) return; const endPos = editor.state.doc.content.size; uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos); }, hasActiveUploads: () => (editor ? hasUploadingNode(editor) : false), })); // Link hover card — disabled when BubbleMenu is active (has selection) const wrapperRef = useRef(null); const hoverDisabled = !editor?.state.selection.empty; const hover = useLinkHover(wrapperRef, hoverDisabled); const handleContainerMouseDown = (event: ReactMouseEvent) => { if (!editor) return; const target = event.target as HTMLElement; if (target.closest(".ProseMirror")) return; if (target.closest("a, button, input, textarea, [role='button'], [data-node-view-wrapper]")) return; event.preventDefault(); editor.commands.focus("end"); }; if (!editor) return null; return (

{showBubbleMenu && ( )}
); }, ); export { ContentEditor, type ContentEditorProps, type ContentEditorRef };