mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/editor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aab7d9b80 |
@@ -3,25 +3,17 @@
|
||||
/**
|
||||
* EditorBubbleMenu — floating formatting toolbar for text selection.
|
||||
*
|
||||
* Positioned with @floating-ui/react-dom (useFloating + autoUpdate) and
|
||||
* portaled to document.body via createPortal. This escapes ALL overflow
|
||||
* containers in the ancestor chain (Card overflow:hidden, scrollable
|
||||
* containers, etc.) while autoUpdate monitors every ancestor scroll
|
||||
* container to keep the menu anchored to the selection.
|
||||
*
|
||||
* Previously used Tiptap's <BubbleMenu> component, but that plugin:
|
||||
* - only supports a single scrollTarget (misses nested scroll)
|
||||
* - shows the element before computing position (flash on first show)
|
||||
* - uses position:absolute which gets clipped by overflow:hidden
|
||||
* Uses Tiptap's native <BubbleMenu> component which has battle-tested
|
||||
* focus management (preventHide flag, relatedTarget checks, mousedown
|
||||
* capture). We don't fight the plugin — we use it as designed.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react-dom";
|
||||
import { getOverflowAncestors } from "@floating-ui/dom";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { posToDOMRect } from "@tiptap/core";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { Toggle } from "@multica/ui/components/ui/toggle";
|
||||
import { Separator } from "@multica/ui/components/ui/separator";
|
||||
import {
|
||||
@@ -58,25 +50,33 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visibility logic
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function shouldShowBubbleMenu(editor: Editor): boolean {
|
||||
function shouldShowBubbleMenu({
|
||||
editor,
|
||||
view,
|
||||
state,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
oldState?: EditorState;
|
||||
from: number;
|
||||
to: number;
|
||||
}) {
|
||||
if (!editor.isEditable) return false;
|
||||
// Don't check hasFocus() here — it's unreliable during transaction events.
|
||||
// Focus loss is handled separately by the debounced blur handler.
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
if (selection.empty) return false;
|
||||
const { from, to } = selection;
|
||||
if (!state.doc.textBetween(from, to).length) return false;
|
||||
if (selection instanceof NodeSelection) return false;
|
||||
if (state.selection.empty) return false;
|
||||
if (!state.doc.textBetween(from, to).trim().length) return false;
|
||||
if (state.selection instanceof NodeSelection) return false;
|
||||
if (!view.hasFocus()) return false;
|
||||
const $from = state.doc.resolve(from);
|
||||
if ($from.parent.type.name === "codeBlock") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Detect macOS for keyboard shortcut labels */
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
|
||||
const mod = isMac ? "\u2318" : "Ctrl";
|
||||
@@ -133,7 +133,6 @@ function MarkButton({
|
||||
// URL normalisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Protocols that can execute code in the browser — the only ones we block. */
|
||||
const DANGEROUS_PROTOCOL_RE = /^(javascript|data|vbscript):/i;
|
||||
const HAS_PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:\/?\/?/i;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -174,12 +173,7 @@ function LinkEditBar({
|
||||
if (!href) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange("link")
|
||||
.setLink({ href })
|
||||
.run();
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
||||
}
|
||||
onClose();
|
||||
}, [editor, url, onClose]);
|
||||
@@ -190,10 +184,7 @@ function LinkEditBar({
|
||||
}, [editor, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bubble-menu-link-edit"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="bubble-menu-link-edit" onMouseDown={(e) => e.preventDefault()}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={url}
|
||||
@@ -202,44 +193,19 @@ function LinkEditBar({
|
||||
aria-label="URL"
|
||||
className="h-7 flex-1 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
apply();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
editor.commands.focus();
|
||||
}
|
||||
if (e.key === "Enter") { e.preventDefault(); apply(); }
|
||||
if (e.key === "Escape") { e.preventDefault(); onClose(); editor.commands.focus(); }
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={apply}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Button size="icon-xs" variant="ghost" onClick={apply} onMouseDown={(e) => e.preventDefault()}>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
{existingHref && (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={remove}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Button size="icon-xs" variant="ghost" onClick={remove} onMouseDown={(e) => e.preventDefault()}>
|
||||
<Unlink className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Button size="icon-xs" variant="ghost" onClick={() => { onClose(); editor.commands.focus(); }} onMouseDown={(e) => e.preventDefault()}>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -250,16 +216,8 @@ function LinkEditBar({
|
||||
// Heading Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HeadingDropdown({
|
||||
editor,
|
||||
onOpenChange,
|
||||
}: {
|
||||
editor: Editor;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const activeLevel = [1, 2, 3].find((l) =>
|
||||
editor.isActive("heading", { level: l }),
|
||||
);
|
||||
function HeadingDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
|
||||
const activeLevel = [1, 2, 3].find((l) => editor.isActive("heading", { level: l }));
|
||||
const label = activeLevel ? `H${activeLevel}` : "Text";
|
||||
const items = [
|
||||
{ label: "Normal Text", icon: Type, active: !activeLevel, action: () => editor.chain().focus().setParagraph().run() },
|
||||
@@ -270,10 +228,7 @@ function HeadingDropdown({
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuTrigger className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted" onMouseDown={(e) => e.preventDefault()}>
|
||||
{label}
|
||||
<ChevronDown className="size-3" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -294,28 +249,16 @@ function HeadingDropdown({
|
||||
// List Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListDropdown({
|
||||
editor,
|
||||
onOpenChange,
|
||||
}: {
|
||||
editor: Editor;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
function ListDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
|
||||
const isBullet = editor.isActive("bulletList");
|
||||
const isOrdered = editor.isActive("orderedList");
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted"
|
||||
aria-pressed={isBullet || isOrdered}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TooltipTrigger render={
|
||||
<DropdownMenuTrigger className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted" aria-pressed={isBullet || isOrdered} onMouseDown={(e) => e.preventDefault()} />
|
||||
}>
|
||||
<List className="size-3.5" />
|
||||
<ChevronDown className="size-3" />
|
||||
</TooltipTrigger>
|
||||
@@ -323,13 +266,11 @@ function ListDropdown({
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="bottom" sideOffset={8} align="start" className="w-auto">
|
||||
<DropdownMenuItem onClick={() => editor.chain().focus().toggleBulletList().run()} className="gap-2 text-xs">
|
||||
<List className="size-3.5" />
|
||||
Bullet List
|
||||
<List className="size-3.5" /> Bullet List
|
||||
{isBullet && <Check className="ml-auto size-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => editor.chain().focus().toggleOrderedList().run()} className="gap-2 text-xs">
|
||||
<ListOrdered className="size-3.5" />
|
||||
Ordered List
|
||||
<ListOrdered className="size-3.5" /> Ordered List
|
||||
{isOrdered && <Check className="ml-auto size-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -338,110 +279,100 @@ function ListDropdown({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Bubble Menu — useFloating + portal to body
|
||||
// Main Bubble Menu — native Tiptap <BubbleMenu>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Walk up from `el` to find the nearest ancestor with overflow: auto/scroll. */
|
||||
function getScrollParent(el: HTMLElement): HTMLElement | Window {
|
||||
let parent = el.parentElement;
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent);
|
||||
if (/(auto|scroll)/.test(style.overflow + style.overflowY)) return parent;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
|
||||
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
|
||||
|
||||
// Virtual reference that tracks the text selection.
|
||||
// contextElement tells autoUpdate where to find scroll ancestors.
|
||||
const virtualRef = useMemo(
|
||||
() => ({
|
||||
getBoundingClientRect: () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
return posToDOMRect(editor.view, from, to);
|
||||
},
|
||||
contextElement: editor.view.dom,
|
||||
}),
|
||||
[editor],
|
||||
);
|
||||
|
||||
const { refs, floatingStyles, isPositioned, update } = useFloating({
|
||||
strategy: "fixed",
|
||||
placement: "top",
|
||||
open: visible,
|
||||
middleware: [offset(8), flip(), shift({ padding: 8 })],
|
||||
elements: { reference: virtualRef },
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
// Show/hide based on selection state — no blur/focus handling.
|
||||
// Find the real scroll container once on mount
|
||||
useEffect(() => {
|
||||
const onTransaction = () => {
|
||||
const show = shouldShowBubbleMenu(editor);
|
||||
setVisible(show);
|
||||
// Must call update() manually — autoUpdate can't detect virtual
|
||||
// reference movement (it's not a real DOM element).
|
||||
if (show) update();
|
||||
};
|
||||
editor.on("transaction", onTransaction);
|
||||
return () => { editor.off("transaction", onTransaction); };
|
||||
}, [editor, update]);
|
||||
setScrollTarget(getScrollParent(editor.view.dom));
|
||||
}, [editor]);
|
||||
|
||||
// Close on outside click (mousedown not in editor and not in menu)
|
||||
// Hide when the selection scrolls outside the scroll container's
|
||||
// visible area. The plugin's hide middleware can't detect this because
|
||||
// its virtual reference element has no contextElement — Floating UI
|
||||
// only checks viewport bounds. We use `display` (not managed by the
|
||||
// plugin) as an additive visibility layer.
|
||||
const scrollHiddenRef = useRef(false);
|
||||
const [, forceRender] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (editor.view.dom.contains(target)) return;
|
||||
if (target.closest(".bubble-menu") || target.closest(".bubble-menu-link-edit")) return;
|
||||
setVisible(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handle);
|
||||
return () => document.removeEventListener("mousedown", handle);
|
||||
}, [visible, editor]);
|
||||
if (scrollTarget === window) return;
|
||||
const el = scrollTarget as HTMLElement;
|
||||
|
||||
// Close on any ancestor scroll or window resize
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const close = () => {
|
||||
setVisible(false);
|
||||
const onScroll = () => {
|
||||
if (editor.state.selection.empty) {
|
||||
if (scrollHiddenRef.current) {
|
||||
scrollHiddenRef.current = false;
|
||||
forceRender((n) => n + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const coords = editor.view.coordsAtPos(editor.state.selection.from);
|
||||
const rect = el.getBoundingClientRect();
|
||||
const visible = coords.top >= rect.top && coords.top <= rect.bottom;
|
||||
if (scrollHiddenRef.current !== !visible) {
|
||||
scrollHiddenRef.current = !visible;
|
||||
forceRender((n) => n + 1);
|
||||
}
|
||||
};
|
||||
const ancestors = getOverflowAncestors(editor.view.dom);
|
||||
ancestors.forEach((el) => el.addEventListener("scroll", close, { passive: true }));
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
ancestors.forEach((el) => el.removeEventListener("scroll", close));
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [visible, editor]);
|
||||
|
||||
// Reset mode on selection change
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [editor, scrollTarget]);
|
||||
|
||||
// Reset scroll-hidden when selection changes (new selection = re-evaluate)
|
||||
useEffect(() => {
|
||||
const handler = () => setMode("toolbar");
|
||||
const handler = () => {
|
||||
setMode("toolbar");
|
||||
if (scrollHiddenRef.current) {
|
||||
scrollHiddenRef.current = false;
|
||||
forceRender((n) => n + 1);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => { editor.off("selectionUpdate", handler); };
|
||||
}, [editor]);
|
||||
|
||||
// Refocus editor when Base UI dropdown closes
|
||||
const handleMenuOpenChange = useCallback(
|
||||
(open: boolean) => { if (!open) editor.commands.focus(); },
|
||||
[editor],
|
||||
);
|
||||
|
||||
const openLinkEdit = useCallback(() => setMode("link-edit"), []);
|
||||
const closeLinkEdit = useCallback(() => {
|
||||
setMode("toolbar");
|
||||
editor.commands.focus();
|
||||
}, [editor]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={shouldShowBubbleMenu}
|
||||
updateDelay={0}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: 50,
|
||||
// display:none when hidden — no residual animations from children.
|
||||
// Also hide until Floating UI has computed position (isPositioned)
|
||||
// to avoid a flash at top:0 left:0.
|
||||
display: visible && isPositioned ? undefined : "none",
|
||||
display: scrollHiddenRef.current ? "none" : undefined,
|
||||
}}
|
||||
options={{
|
||||
strategy: "fixed",
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: true,
|
||||
shift: { padding: 8 },
|
||||
hide: true,
|
||||
scrollTarget,
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{mode === "link-edit" ? (
|
||||
<LinkEditBar editor={editor} onClose={closeLinkEdit} />
|
||||
<LinkEditBar editor={editor} onClose={() => { setMode("toolbar"); editor.commands.focus(); }} />
|
||||
) : (
|
||||
<TooltipProvider delay={300}>
|
||||
<div className="bubble-menu">
|
||||
@@ -449,40 +380,22 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} />
|
||||
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} />
|
||||
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} />
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("link")}
|
||||
onPressedChange={openLinkEdit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TooltipTrigger render={
|
||||
<Toggle size="sm" pressed={editor.isActive("link")} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
|
||||
}>
|
||||
<Link2 className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>Link</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
|
||||
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
|
||||
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("blockquote")}
|
||||
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TooltipTrigger render={
|
||||
<Toggle size="sm" pressed={editor.isActive("blockquote")} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
|
||||
}>
|
||||
<Quote className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>Quote</TooltipContent>
|
||||
@@ -490,8 +403,7 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -488,19 +488,3 @@
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
/* Link preview card — portaled to body with position: fixed to escape
|
||||
* overflow:hidden containers (Card component, scrollable editors, etc.). */
|
||||
.link-preview-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.25rem 0.25rem 0.5rem;
|
||||
background: var(--popover);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
border-radius: var(--radius);
|
||||
box-shadow:
|
||||
0 4px 12px color-mix(in srgb, black 12%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, black 4%, transparent);
|
||||
max-width: min(360px, calc(100vw - 2rem));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -146,16 +145,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
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.
|
||||
// Open the link. Internal paths use multica:navigate
|
||||
// (Electron hash-router safe), external open in new tab.
|
||||
event.preventDefault();
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
@@ -227,12 +218,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
return (
|
||||
<div className="relative min-h-full">
|
||||
<EditorContent editor={editor} />
|
||||
{editable && (
|
||||
<>
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
<EditorLinkPreview editor={editor} />
|
||||
</>
|
||||
)}
|
||||
{editable && <EditorBubbleMenu editor={editor} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Link preview system — floating card for link inspection.
|
||||
*
|
||||
* Two entry points, same UI:
|
||||
* - EditorLinkPreview: editable ContentEditor (cursor on link)
|
||||
* - ReadonlyLinkWrapper: ReadonlyContent (click on link)
|
||||
*
|
||||
* Both use @floating-ui/react-dom (useFloating + autoUpdate) portaled
|
||||
* to document.body. This escapes ALL overflow:hidden ancestors while
|
||||
* autoUpdate keeps the card anchored across any ancestor scroll.
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react-dom";
|
||||
import { getOverflowAncestors } from "@floating-ui/dom";
|
||||
import { ExternalLink, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openLink(href: string) {
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateUrl(url: string, max = 48): string {
|
||||
if (url.length <= max) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const origin = u.origin;
|
||||
const rest = url.slice(origin.length);
|
||||
if (rest.length <= 10) return url;
|
||||
return `${origin}${rest.slice(0, max - origin.length - 1)}…`;
|
||||
} catch {
|
||||
return `${url.slice(0, max - 1)}…`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LinkPreviewCard — pure UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LinkPreviewCard({
|
||||
href,
|
||||
onMouseDown,
|
||||
}: {
|
||||
href: string;
|
||||
onMouseDown?: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const handleCopy = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(href);
|
||||
toast.success("Link copied");
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
},
|
||||
[href],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openLink(href);
|
||||
},
|
||||
[href],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="link-preview-card" onMouseDown={onMouseDown}>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs text-muted-foreground px-1"
|
||||
title={href}
|
||||
>
|
||||
{truncateUrl(href)}
|
||||
</span>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleCopy}
|
||||
title="Copy link"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleOpen}
|
||||
title="Open link"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useCloseOnOutsideClick(active: boolean, close: () => void) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest(".link-preview-card")) return;
|
||||
close();
|
||||
};
|
||||
const t = setTimeout(() => document.addEventListener("mousedown", handle), 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
document.removeEventListener("mousedown", handle);
|
||||
};
|
||||
}, [active, close]);
|
||||
}
|
||||
|
||||
function useCloseOnEscape(active: boolean, close: () => void) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handle = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
document.addEventListener("keydown", handle);
|
||||
return () => document.removeEventListener("keydown", handle);
|
||||
}, [active, close]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EditorLinkPreview — for editable ContentEditor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditorLinkPreview({ editor }: { editor: Editor }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [href, setHref] = useState("");
|
||||
|
||||
const close = useCallback(() => setVisible(false), []);
|
||||
|
||||
const virtualRef = useRef({
|
||||
getBoundingClientRect: () => new DOMRect(),
|
||||
contextElement: editor.view.dom,
|
||||
});
|
||||
|
||||
const { refs, floatingStyles, isPositioned, update } = useFloating({
|
||||
strategy: "fixed",
|
||||
placement: "bottom",
|
||||
open: visible,
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
elements: { reference: virtualRef.current },
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
if (!editor.isEditable) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
if (!editor.state.selection.empty || !editor.isActive("link")) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const linkHref = (editor.getAttributes("link").href as string) || "";
|
||||
if (!linkHref) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const coords = editor.view.coordsAtPos(editor.state.selection.from);
|
||||
virtualRef.current = {
|
||||
getBoundingClientRect: () =>
|
||||
new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top),
|
||||
contextElement: editor.view.dom,
|
||||
};
|
||||
|
||||
setHref(linkHref);
|
||||
setVisible(true);
|
||||
update();
|
||||
};
|
||||
|
||||
editor.on("selectionUpdate", check);
|
||||
return () => { editor.off("selectionUpdate", check); };
|
||||
}, [editor, update]);
|
||||
|
||||
// Close on any ancestor scroll or window resize
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const close = () => {
|
||||
setVisible(false);
|
||||
};
|
||||
const ancestors = getOverflowAncestors(editor.view.dom);
|
||||
ancestors.forEach((el) => el.addEventListener("scroll", close, { passive: true }));
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
ancestors.forEach((el) => el.removeEventListener("scroll", close));
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [visible, editor]);
|
||||
|
||||
useCloseOnOutsideClick(visible, close);
|
||||
useCloseOnEscape(visible, close);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: 50,
|
||||
display: visible && isPositioned ? undefined : "none",
|
||||
}}
|
||||
>
|
||||
<LinkPreviewCard href={href} onMouseDown={(e) => e.preventDefault()} />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReadonlyLinkWrapper — for ReadonlyContent (react-markdown)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ReadonlyLinkWrapper({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
strategy: "fixed",
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
elements: { reference: anchorRef.current },
|
||||
open,
|
||||
});
|
||||
|
||||
const toggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Close on any ancestor scroll
|
||||
useEffect(() => {
|
||||
if (!open || !anchorRef.current) return;
|
||||
const hide = () => setOpen(false);
|
||||
const ancestors = getOverflowAncestors(anchorRef.current);
|
||||
ancestors.forEach((el) => el.addEventListener("scroll", hide, { passive: true }));
|
||||
return () => {
|
||||
ancestors.forEach((el) => el.removeEventListener("scroll", hide));
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useCloseOnOutsideClick(open, close);
|
||||
useCloseOnEscape(open, close);
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
ref={anchorRef}
|
||||
href={href}
|
||||
onClick={toggle}
|
||||
role="button"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div ref={refs.setFloating} style={{ ...floatingStyles, zIndex: 50 }}>
|
||||
<LinkPreviewCard href={href} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { EditorLinkPreview, ReadonlyLinkWrapper, openLink };
|
||||
@@ -33,7 +33,7 @@ import { cn } from "@multica/ui/lib/utils";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { IssueMentionCard } from "../issues/components/issue-mention-card";
|
||||
import { ImageLightbox } from "./extensions/image-view";
|
||||
import { ReadonlyLinkWrapper } from "./link-preview";
|
||||
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -129,9 +129,25 @@ const components: Partial<Components> = {
|
||||
return <span className="mention">{children}</span>;
|
||||
}
|
||||
|
||||
// Regular links — show preview card on click
|
||||
if (!href) return <a>{children}</a>;
|
||||
return <ReadonlyLinkWrapper href={href}>{children}</ReadonlyLinkWrapper>;
|
||||
// Regular links — open directly on click
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!href) return;
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// Images — centered with toolbar + lightbox (matches Tiptap ImageView NodeView)
|
||||
|
||||
@@ -40,11 +40,11 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@floating-ui/react-dom": "^2.1.8",
|
||||
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@tiptap/core": "^3.22.1",
|
||||
|
||||
"@tiptap/extension-bubble-menu": "^3.22.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.22.1",
|
||||
"@tiptap/extension-document": "^3.22.1",
|
||||
"@tiptap/extension-image": "^3.22.1",
|
||||
|
||||
7
pnpm-lock.yaml
generated
7
pnpm-lock.yaml
generated
@@ -589,9 +589,6 @@ importers:
|
||||
'@floating-ui/dom':
|
||||
specifier: ^1.7.6
|
||||
version: 1.7.6
|
||||
'@floating-ui/react-dom':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@multica/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
@@ -604,6 +601,9 @@ importers:
|
||||
'@tiptap/core':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-bubble-menu':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-code-block-lowlight':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
@@ -8500,7 +8500,6 @@ snapshots:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-bullet-list@3.22.1(@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user