Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
8aab7d9b80 fix(editor): use native BubbleMenu and simplify link click
Revert to Tiptap's native <BubbleMenu> component which has
battle-tested focus management (preventHide, relatedTarget checks,
mousedown capture). The custom useFloating + createPortal approach
had unfixable focus/blur issues — portal DOM updates caused editor
blur cycles, and reimplementing the plugin's focus logic was
incomplete.

Also simplify link clicking: clicks on links now always open them
(both edit and readonly mode) instead of showing a preview card.
Remove EditorLinkPreview component entirely.

Changes:
- bubble-menu.tsx: back to <BubbleMenu> from @tiptap/react/menus
- content-editor.tsx: link click always opens, remove EditorLinkPreview
- link-preview.tsx: remove EditorLinkPreview, keep ReadonlyLinkWrapper
- shouldShowBubbleMenu: add .trim() to filter whitespace-only selections
- package.json: restore @tiptap/extension-bubble-menu dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:25:54 +08:00
7 changed files with 145 additions and 555 deletions

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
},

View File

@@ -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 };

View File

@@ -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)

View File

@@ -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
View File

@@ -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: