Files
multica/packages/views/editor/title-editor.tsx
Naiyuan Qing 6a451c1ce7 fix(editor): rewrite bubble menu and link preview with useFloating
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>
2026-04-14 12:00:17 +08:00

147 lines
4.1 KiB
TypeScript

"use client";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { Extension } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import Placeholder from "@tiptap/extension-placeholder";
import { cn } from "@multica/ui/lib/utils";
import "./title-editor.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TitleEditorProps {
defaultValue?: string;
placeholder?: string;
className?: string;
autoFocus?: boolean;
onSubmit?: () => void;
onBlur?: (value: string) => void;
onChange?: (value: string) => void;
}
interface TitleEditorRef {
getText: () => string;
focus: () => void;
}
// ---------------------------------------------------------------------------
// Single-paragraph document — prevents Enter from creating new lines
// ---------------------------------------------------------------------------
const SingleLineDocument = Document.extend({
content: "paragraph",
});
// ---------------------------------------------------------------------------
// Keyboard shortcuts: Enter → submit, Escape → blur
// ---------------------------------------------------------------------------
function createTitleKeymap(opts: {
onSubmitRef: React.RefObject<(() => void) | undefined>;
}) {
return Extension.create({
name: "titleKeymap",
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
opts.onSubmitRef.current?.();
editor.commands.blur();
return true;
},
"Shift-Enter": () => true, // swallow — no line breaks
Escape: ({ editor }) => {
editor.commands.blur();
return true;
},
};
},
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
function TitleEditor(
{
defaultValue = "",
placeholder: placeholderText = "",
className,
autoFocus = false,
onSubmit,
onBlur,
onChange,
},
ref,
) {
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onChangeRef = useRef(onChange);
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onChangeRef.current = onChange;
const editor = useEditor({
immediatelyRender: false,
content: defaultValue
? { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: defaultValue }] }] }
: "",
extensions: [
SingleLineDocument,
Paragraph,
Text,
Placeholder.configure({
placeholder: placeholderText,
showOnlyCurrent: false,
}),
createTitleKeymap({ onSubmitRef }),
],
editorProps: {
attributes: {
class: cn("title-editor outline-none", className),
role: "textbox",
"aria-multiline": "false",
"aria-label": placeholderText || "Title",
},
},
onUpdate: ({ editor: ed }) => {
onChangeRef.current?.(ed.getText());
},
onBlur: ({ editor: ed }) => {
onBlurRef.current?.(ed.getText());
},
});
// Auto-focus after mount — delay to wait for Dialog open animation
useEffect(() => {
if (autoFocus && editor) {
const timer = setTimeout(() => {
editor.commands.focus("end");
}, 50);
return () => clearTimeout(timer);
}
return undefined;
}, [autoFocus, editor]);
useImperativeHandle(ref, () => ({
getText: () => editor?.getText() ?? "",
focus: () => {
editor?.commands.focus("end");
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { TitleEditor, type TitleEditorProps, type TitleEditorRef };