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>
147 lines
4.1 KiB
TypeScript
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 };
|