mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
Replace Tiptap's BubbleMenu plugin with @floating-ui/react-dom for
all floating editor UI (formatting toolbar, link preview cards).
Architecture:
- useFloating({ strategy:"fixed" }) + createPortal(body) escapes
all overflow:hidden ancestors (Card component, scroll containers)
- autoUpdate + contextElement monitors all scroll ancestors for
repositioning; manual update() on transaction for virtual ref changes
- open prop resets isPositioned on visibility change (no stale-position
flash at 0,0)
- display:none for hiding (not return null which causes blur/focus
cycle, not visibility:hidden which leaves transition artifacts)
- No blur listener — portal DOM updates cause false editor blurs;
outside-click + scroll + resize + Escape handle all close cases
Bug fixes:
- BubbleMenu: remove all custom visibility hacks, let selection state
drive show/hide
- Link preview: new shared card (Copy + Open) for editable editor and
readonly markdown, portaled to body with fixed positioning
- TitleEditor: use JSON content format (not HTML interpolation that
loses < > characters)
- Blob URLs: strip from getMarkdown output during upload
- Markdown paste: check clipboard.files first to avoid intercepting
file paste events
- FileCard: escape HTML attributes in preprocessing
- Link extension: enable linkOnPaste, set defaultProtocol to https,
switch URL normalization to protocol blocklist (only block
javascript:/data:/vbscript:)
Dependencies: add @floating-ui/react-dom, remove @tiptap/extension-bubble-menu
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
242 lines
8.0 KiB
TypeScript
242 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* ContentEditor — the single rich-text editor for the entire application.
|
|
*
|
|
* Architecture decisions (April 2026 refactor):
|
|
*
|
|
* 1. ONE COMPONENT for both editing and readonly display. The `editable` prop
|
|
* controls the mode. Previously we had RichTextEditor + ReadonlyEditor as
|
|
* separate components with duplicated extension configs — this caused
|
|
* visual inconsistency between edit and display modes.
|
|
*
|
|
* 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 <p> 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.22.1 (ProseMirror wrapper), @tiptap/markdown for
|
|
* bidirectional Markdown ↔ ProseMirror JSON conversion.
|
|
*/
|
|
|
|
import {
|
|
forwardRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useRef,
|
|
} from "react";
|
|
import { useEditor, EditorContent } from "@tiptap/react";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { createEditorExtensions } from "./extensions";
|
|
import { uploadAndInsertFile } from "./extensions/file-upload";
|
|
import { preprocessMarkdown } from "./utils/preprocess";
|
|
import { EditorBubbleMenu } from "./bubble-menu";
|
|
import { EditorLinkPreview } from "./link-preview";
|
|
import "./content-editor.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, "");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ContentEditorProps {
|
|
defaultValue?: string;
|
|
onUpdate?: (markdown: string) => void;
|
|
placeholder?: string;
|
|
editable?: boolean;
|
|
className?: string;
|
|
debounceMs?: number;
|
|
onSubmit?: () => void;
|
|
onBlur?: () => void;
|
|
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
|
}
|
|
|
|
interface ContentEditorRef {
|
|
getMarkdown: () => string;
|
|
clearContent: () => void;
|
|
focus: () => void;
|
|
uploadFile: (file: File) => void;
|
|
/** True when file uploads are still in progress. */
|
|
hasActiveUploads: () => boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
|
function ContentEditor(
|
|
{
|
|
defaultValue = "",
|
|
onUpdate,
|
|
placeholder: placeholderText = "",
|
|
editable = true,
|
|
className,
|
|
debounceMs = 300,
|
|
onSubmit,
|
|
onBlur,
|
|
onUploadFile,
|
|
},
|
|
ref,
|
|
) {
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
const onUpdateRef = useRef(onUpdate);
|
|
const onSubmitRef = useRef(onSubmit);
|
|
const onBlurRef = useRef(onBlur);
|
|
const onUploadFileRef = useRef(onUploadFile);
|
|
const prevContentRef = useRef(defaultValue);
|
|
|
|
// Keep refs in sync without recreating editor
|
|
onUpdateRef.current = onUpdate;
|
|
onSubmitRef.current = onSubmit;
|
|
onBlurRef.current = onBlur;
|
|
onUploadFileRef.current = onUploadFile;
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
const editor = useEditor({
|
|
immediatelyRender: false,
|
|
editable,
|
|
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
|
|
contentType: defaultValue ? "markdown" : undefined,
|
|
extensions: createEditorExtensions({
|
|
editable,
|
|
placeholder: placeholderText,
|
|
queryClient,
|
|
onSubmitRef,
|
|
onUploadFileRef,
|
|
}),
|
|
onUpdate: ({ editor: ed }) => {
|
|
if (!onUpdateRef.current) return;
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
onUpdateRef.current?.(stripBlobUrls(ed.getMarkdown()));
|
|
}, 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 || href.startsWith("mention://")) return false;
|
|
|
|
if (editable) {
|
|
// Edit mode: don't open link on click. ProseMirror's
|
|
// mousedown/mouseup cycle (already completed by the time
|
|
// this click handler runs) places the cursor on the link.
|
|
// LinkBubbleMenu detects cursor-on-link and shows the
|
|
// preview card with Open / Copy / Edit actions.
|
|
return false;
|
|
}
|
|
|
|
// Readonly mode: open immediately.
|
|
event.preventDefault();
|
|
if (href.startsWith("/")) {
|
|
window.dispatchEvent(
|
|
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
|
);
|
|
} else {
|
|
window.open(href, "_blank", "noopener,noreferrer");
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
attributes: {
|
|
class: cn(
|
|
"rich-text-editor text-sm outline-none",
|
|
!editable && "readonly",
|
|
className,
|
|
),
|
|
},
|
|
},
|
|
});
|
|
|
|
// Cleanup debounce on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, []);
|
|
|
|
// Readonly content update: when defaultValue changes and editor is readonly,
|
|
// re-set the content (e.g. after editing a comment, the readonly view updates)
|
|
useEffect(() => {
|
|
if (!editor || editable) return;
|
|
if (defaultValue === prevContentRef.current) return;
|
|
prevContentRef.current = defaultValue;
|
|
const processed = defaultValue ? preprocessMarkdown(defaultValue) : "";
|
|
if (processed) {
|
|
editor.commands.setContent(processed, { contentType: "markdown" });
|
|
} else {
|
|
editor.commands.clearContent();
|
|
}
|
|
}, [editor, editable, defaultValue]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
|
|
clearContent: () => {
|
|
editor?.commands.clearContent();
|
|
},
|
|
focus: () => {
|
|
editor?.commands.focus();
|
|
},
|
|
uploadFile: (file: File) => {
|
|
if (!editor || !onUploadFileRef.current) return;
|
|
const endPos = editor.state.doc.content.size;
|
|
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
|
|
},
|
|
hasActiveUploads: () => {
|
|
if (!editor) return false;
|
|
let uploading = false;
|
|
editor.state.doc.descendants((node) => {
|
|
if (node.attrs.uploading) uploading = true;
|
|
return !uploading;
|
|
});
|
|
return uploading;
|
|
},
|
|
}));
|
|
|
|
if (!editor) return null;
|
|
|
|
return (
|
|
<div className="relative min-h-full">
|
|
<EditorContent editor={editor} />
|
|
{editable && (
|
|
<>
|
|
<EditorBubbleMenu editor={editor} />
|
|
<EditorLinkPreview editor={editor} />
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
export { ContentEditor, type ContentEditorProps, type ContentEditorRef };
|