mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
* feat(editor): support markdown checkbox task lists (#3593) Render `- [ ]` / `- [x]` as interactive checkboxes in the issue content editor, matching GitHub / Notion. - Register TaskList + a patched TaskItem in the shared extension factory. Both ship their own markdown tokenizer / renderMarkdown, input rules, and a checkbox NodeView; the taskList tokenizer is consulted before marked's built-in list tokenizer, so `- [ ]` becomes a task while a plain `- ` still falls through to the bullet list. - Patch TaskItem's keymap to share PatchedListItem's split -> lift Enter chain (double-Enter on an empty item exits the list); nested: true enables sub-tasks and nested round-trips. - Add a "Task list" entry to the bubble-menu list dropdown (+ i18n for en / zh-Hans / ja / ko). - Style task lists in prose.css for both the editor ([data-type="taskList"]) and the readonly remark-gfm output (.contains-task-list); completed items render muted. Readonly already rendered task lists via remark-gfm; this brings the editable view to parity. Adds markdown round-trip and readonly checked-state tests. MUL-2926 Co-authored-by: multica-agent <github@multica.ai> * fix(editor): keep readonly nested task lists block-laid-out (#3593) The shared `display: flex` rule on task-list items broke nested task lists in the readonly view. remark-gfm renders a task item as `<li><input> text <ul>…</ul></li>` — no body wrapper — so a nested list is a direct sibling of the checkbox and text, and flex pulled it onto the same row. The editor's Tiptap NodeView wraps the body in a `<div>`, so it was unaffected. Split the task-list CSS into separate editor and readonly blocks: the editor keeps the flex row; readonly stays a block list item with an inline checkbox so a nested `<ul>` drops below and indents under its parent. Adds a readonly test that pins the nested DOM shape (nested `<ul>` inside the parent `<li>`), so a future remark-gfm change that wraps the body fails loudly. MUL-2926 Co-authored-by: multica-agent <github@multica.ai> * feat(editor): convert `- [ ] ` typing into a task list (#3593) TaskItem's built-in input rule only converts `[ ] ` / `[x] ` typed at the start of a plain paragraph. When the user types the GitHub-style `- [ ] ` the leading `- ` first turns the line into a bullet, and the built-in rule no longer fires — so `[ ]` stayed as literal text and nothing became a checkbox. Add an input rule on PatchedTaskItem that catches the checkbox token when it is the entire content of a freshly-typed list item (bullet or ordered) and converts just that item into a task item (deleteRange → liftListItem → toggleList). The anchored regex means it only fires on an item whose whole content is `[ ] ` / `[x] `, so sibling items in the same list are left untouched. Adds typing-level tests (real input-rule simulation) covering `[ ] `, `[x] `, `- [ ] `, `- [x] `, the mixed-list split case, and the plain-bullet no-op. MUL-2926 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
646 lines
23 KiB
TypeScript
646 lines
23 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* EditorBubbleMenu — floating formatting toolbar for text selection.
|
|
*
|
|
* Positioned with @floating-ui/dom (computePosition + 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.
|
|
*
|
|
* Key design decisions:
|
|
* - contextElement on the virtual reference tells Floating UI where to
|
|
* find scroll ancestors, enabling the hide middleware to detect
|
|
* nested scroll container clipping.
|
|
* - visibility:hidden (not display:none) keeps the element measurable
|
|
* so computePosition can size it correctly on first show.
|
|
* - onMouseDown preventDefault on the portal root prevents all clicks
|
|
* inside the menu from stealing focus from the editor.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
import {
|
|
computePosition,
|
|
offset,
|
|
flip,
|
|
shift,
|
|
hide,
|
|
autoUpdate,
|
|
} from "@floating-ui/dom";
|
|
import { useEditorState } from "@tiptap/react";
|
|
import type { Editor } from "@tiptap/core";
|
|
import { posToDOMRect } from "@tiptap/core";
|
|
import { NodeSelection } from "@tiptap/pm/state";
|
|
import { toast } from "sonner";
|
|
import { useCreateIssue } from "@multica/core/issues/mutations";
|
|
import { useT } from "../i18n";
|
|
import { modKey } from "@multica/core/platform";
|
|
import { Toggle } from "@multica/ui/components/ui/toggle";
|
|
import { Separator } from "@multica/ui/components/ui/separator";
|
|
import {
|
|
Tooltip,
|
|
TooltipTrigger,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
} from "@multica/ui/components/ui/tooltip";
|
|
import {
|
|
Popover,
|
|
PopoverTrigger,
|
|
PopoverContent,
|
|
} from "@multica/ui/components/ui/popover";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import {
|
|
Bold,
|
|
Italic,
|
|
Strikethrough,
|
|
Code,
|
|
Highlighter,
|
|
Link2,
|
|
List,
|
|
ListOrdered,
|
|
ListTodo,
|
|
Quote,
|
|
ChevronDown,
|
|
Check,
|
|
X,
|
|
Unlink,
|
|
Type,
|
|
Heading1,
|
|
Heading2,
|
|
Heading3,
|
|
FilePlus,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function shouldShowBubbleMenu(editor: Editor): boolean {
|
|
if (!editor.isEditable) return false;
|
|
const { selection } = editor.state;
|
|
if (selection.empty) return false;
|
|
const { from, to } = selection;
|
|
if (!editor.state.doc.textBetween(from, to).trim().length) return false;
|
|
if (selection instanceof NodeSelection) return false;
|
|
const $from = editor.state.doc.resolve(from);
|
|
if ($from.parent.type.name === "codeBlock") return false;
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mark Toggle Button
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type InlineMark = "bold" | "italic" | "strike" | "code" | "highlight";
|
|
|
|
const toggleMarkActions: Record<InlineMark, (editor: Editor) => void> = {
|
|
bold: (e) => e.chain().focus().toggleBold().run(),
|
|
italic: (e) => e.chain().focus().toggleItalic().run(),
|
|
strike: (e) => e.chain().focus().toggleStrike().run(),
|
|
code: (e) => e.chain().focus().toggleCode().run(),
|
|
highlight: (e) => e.chain().focus().toggleHighlight().run(),
|
|
};
|
|
|
|
function MarkButton({
|
|
editor,
|
|
mark,
|
|
icon: Icon,
|
|
label,
|
|
shortcut,
|
|
isActive,
|
|
}: {
|
|
editor: Editor;
|
|
mark: InlineMark;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
label: string;
|
|
shortcut: string;
|
|
isActive: boolean;
|
|
}) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Toggle
|
|
size="sm"
|
|
pressed={isActive}
|
|
onPressedChange={() => toggleMarkActions[mark](editor)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
/>
|
|
}
|
|
>
|
|
<Icon className="size-3.5" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={8}>
|
|
{label}
|
|
<span className="ml-1.5 text-muted-foreground">{shortcut}</span>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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@]+$/;
|
|
|
|
/**
|
|
* Normalise a user-entered URL: add protocol, detect mailto, block XSS.
|
|
*
|
|
* Uses a blocklist (not allowlist) for protocols — only `javascript:`,
|
|
* `data:`, and `vbscript:` are blocked. All other protocols pass through
|
|
* because they can't execute code in the browser and are legitimate
|
|
* deep-link targets in a team tool (slack://, vscode://, figma://).
|
|
* Tiptap's `isAllowedUri` in the `setLink` command provides a second
|
|
* safety layer.
|
|
*/
|
|
function normalizeUrl(input: string): string {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return "";
|
|
if (trimmed.startsWith("/")) return trimmed;
|
|
if (DANGEROUS_PROTOCOL_RE.test(trimmed)) return "";
|
|
if (HAS_PROTOCOL_RE.test(trimmed)) return trimmed;
|
|
if (EMAIL_RE.test(trimmed)) return `mailto:${trimmed}`;
|
|
if (trimmed.startsWith("//")) return `https:${trimmed}`;
|
|
return `https://${trimmed}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Link Edit Bar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function LinkEditBar({
|
|
editor,
|
|
onClose,
|
|
}: {
|
|
editor: Editor;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useT("editor");
|
|
const existingHref = editor.getAttributes("link").href as string | undefined;
|
|
const [url, setUrl] = useState(existingHref ?? "");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
|
return () => clearTimeout(t);
|
|
}, []);
|
|
|
|
const apply = useCallback(() => {
|
|
const href = normalizeUrl(url);
|
|
if (!href) {
|
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
} else {
|
|
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
|
}
|
|
onClose();
|
|
}, [editor, url, onClose]);
|
|
|
|
const remove = useCallback(() => {
|
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
onClose();
|
|
}, [editor, onClose]);
|
|
|
|
return (
|
|
<div className="bubble-menu-link-edit" onMouseDown={(e) => e.preventDefault()}>
|
|
<Input
|
|
ref={inputRef}
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder="https://..."
|
|
aria-label={t(($) => $.bubble_menu.url_aria_label)}
|
|
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(); }
|
|
}}
|
|
/>
|
|
<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()}>
|
|
<Unlink className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button size="icon-xs" variant="ghost" onClick={() => { onClose(); editor.commands.focus(); }} onMouseDown={(e) => e.preventDefault()}>
|
|
<X className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Heading Dropdown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function HeadingDropdown({ editor, onOpenChange, activeLevel }: { editor: Editor; onOpenChange: (open: boolean) => void; activeLevel: number | undefined }) {
|
|
const { t } = useT("editor");
|
|
const [open, setOpen] = useState(false);
|
|
const label = activeLevel ? `H${activeLevel}` : t(($) => $.bubble_menu.heading_dropdown.text);
|
|
const items = [
|
|
{ label: t(($) => $.bubble_menu.heading_dropdown.normal_text), icon: Type, active: !activeLevel, action: () => editor.chain().focus().setParagraph().run() },
|
|
{ label: t(($) => $.bubble_menu.heading_dropdown.heading_1), icon: Heading1, active: activeLevel === 1, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run() },
|
|
{ label: t(($) => $.bubble_menu.heading_dropdown.heading_2), icon: Heading2, active: activeLevel === 2, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
|
|
{ label: t(($) => $.bubble_menu.heading_dropdown.heading_3), icon: Heading3, active: activeLevel === 3, action: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
|
|
];
|
|
|
|
const handleOpenChange = useCallback((next: boolean) => {
|
|
setOpen(next);
|
|
onOpenChange(next);
|
|
}, [onOpenChange]);
|
|
|
|
return (
|
|
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
|
|
<PopoverTrigger
|
|
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" />
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
side="bottom"
|
|
sideOffset={8}
|
|
align="start"
|
|
className="w-auto min-w-32 p-1"
|
|
initialFocus={false}
|
|
finalFocus={false}
|
|
>
|
|
{items.map((item) => (
|
|
<button
|
|
type="button"
|
|
key={item.label}
|
|
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
item.action();
|
|
handleOpenChange(false);
|
|
}}
|
|
>
|
|
<item.icon className="size-3.5" />
|
|
{item.label}
|
|
{item.active && <Check className="ml-auto size-3.5" />}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List Dropdown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ListDropdown({ editor, onOpenChange, isBullet, isOrdered, isTask }: { editor: Editor; onOpenChange: (open: boolean) => void; isBullet: boolean; isOrdered: boolean; isTask: boolean }) {
|
|
const { t } = useT("editor");
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const handleOpenChange = useCallback((next: boolean) => {
|
|
setOpen(next);
|
|
onOpenChange(next);
|
|
}, [onOpenChange]);
|
|
|
|
return (
|
|
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
|
|
<Tooltip>
|
|
<TooltipTrigger render={
|
|
<PopoverTrigger 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 || isTask} onMouseDown={(e) => e.preventDefault()} />
|
|
}>
|
|
<List className="size-3.5" />
|
|
<ChevronDown className="size-3" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={8}>{t(($) => $.bubble_menu.list)}</TooltipContent>
|
|
</Tooltip>
|
|
<PopoverContent
|
|
side="bottom"
|
|
sideOffset={8}
|
|
align="start"
|
|
className="w-auto min-w-32 p-1"
|
|
initialFocus={false}
|
|
finalFocus={false}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
editor.chain().focus().toggleBulletList().run();
|
|
handleOpenChange(false);
|
|
}}
|
|
>
|
|
<List className="size-3.5" /> {t(($) => $.bubble_menu.list_dropdown.bullet_list)}
|
|
{isBullet && <Check className="ml-auto size-3.5" />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
editor.chain().focus().toggleOrderedList().run();
|
|
handleOpenChange(false);
|
|
}}
|
|
>
|
|
<ListOrdered className="size-3.5" /> {t(($) => $.bubble_menu.list_dropdown.ordered_list)}
|
|
{isOrdered && <Check className="ml-auto size-3.5" />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
editor.chain().focus().toggleTaskList().run();
|
|
handleOpenChange(false);
|
|
}}
|
|
>
|
|
<ListTodo className="size-3.5" /> {t(($) => $.bubble_menu.list_dropdown.task_list)}
|
|
{isTask && <Check className="ml-auto size-3.5" />}
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Create Sub-Issue Button
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Turns the current selection into a sub-issue of `parentIssueId` and replaces
|
|
* the selection with a mention link to the new issue. Title is the selected
|
|
* text (trimmed, collapsed whitespace, capped). Only rendered when a parent
|
|
* issue is in scope; otherwise there's no meaningful "sub-issue of" target.
|
|
*/
|
|
function CreateSubIssueButton({
|
|
editor,
|
|
parentIssueId,
|
|
}: {
|
|
editor: Editor;
|
|
parentIssueId: string;
|
|
}) {
|
|
const { t } = useT("editor");
|
|
const createIssue = useCreateIssue();
|
|
const [pending, setPending] = useState(false);
|
|
|
|
const handleClick = useCallback(async () => {
|
|
if (pending) return;
|
|
const { from, to } = editor.state.selection;
|
|
if (from === to) return;
|
|
|
|
// Title from selection: collapse whitespace, cap length. The full selection
|
|
// still becomes the link text — only the issue title is capped.
|
|
const rawTitle = editor.state.doc.textBetween(from, to, " ", " ").trim();
|
|
const title = rawTitle.replace(/\s+/g, " ").slice(0, 200);
|
|
if (!title) return;
|
|
|
|
setPending(true);
|
|
try {
|
|
const newIssue = await createIssue.mutateAsync({
|
|
title,
|
|
parent_issue_id: parentIssueId,
|
|
});
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.insertContentAt(
|
|
{ from, to },
|
|
[
|
|
{
|
|
type: "mention",
|
|
attrs: {
|
|
id: newIssue.id,
|
|
label: newIssue.identifier,
|
|
type: "issue",
|
|
},
|
|
},
|
|
{ type: "text", text: " " },
|
|
],
|
|
)
|
|
.run();
|
|
toast.success(t(($) => $.bubble_menu.sub_issue.created, { identifier: newIssue.identifier }));
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: t(($) => $.bubble_menu.sub_issue.create_failed),
|
|
);
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}, [editor, parentIssueId, createIssue, pending, t]);
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Toggle
|
|
size="sm"
|
|
pressed={false}
|
|
disabled={pending}
|
|
onPressedChange={handleClick}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
/>
|
|
}
|
|
>
|
|
{pending ? (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
) : (
|
|
<FilePlus className="size-3.5" />
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={8}>
|
|
{t(($) => $.bubble_menu.sub_issue.tooltip)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main Bubble Menu — @floating-ui/dom + portal to body
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function EditorBubbleMenu({
|
|
editor,
|
|
currentIssueId,
|
|
}: {
|
|
editor: Editor;
|
|
currentIssueId?: string;
|
|
}) {
|
|
const { t } = useT("editor");
|
|
const [visible, setVisible] = useState(false);
|
|
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
|
|
const floatingRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Precise subscription to formatting state — only re-renders when these
|
|
// values actually change, not on every transaction.
|
|
const fmt = useEditorState({
|
|
editor,
|
|
selector: ({ editor: e }) => ({
|
|
bold: e.isActive("bold"),
|
|
italic: e.isActive("italic"),
|
|
strike: e.isActive("strike"),
|
|
code: e.isActive("code"),
|
|
highlight: e.isActive("highlight"),
|
|
link: e.isActive("link"),
|
|
blockquote: e.isActive("blockquote"),
|
|
bulletList: e.isActive("bulletList"),
|
|
orderedList: e.isActive("orderedList"),
|
|
taskList: e.isActive("taskList"),
|
|
heading1: e.isActive("heading", { level: 1 }),
|
|
heading2: e.isActive("heading", { level: 2 }),
|
|
heading3: e.isActive("heading", { level: 3 }),
|
|
}),
|
|
});
|
|
|
|
// Virtual reference that tracks the text selection.
|
|
// contextElement tells autoUpdate/hide where to find scroll ancestors.
|
|
const virtualRef = useMemo(
|
|
() => ({
|
|
getBoundingClientRect: () => {
|
|
if (editor.isDestroyed) return new DOMRect();
|
|
const { from, to } = editor.state.selection;
|
|
return posToDOMRect(editor.view, from, to);
|
|
},
|
|
contextElement: editor.view.dom,
|
|
}),
|
|
[editor],
|
|
);
|
|
|
|
// Show/hide based on selection state
|
|
useEffect(() => {
|
|
const onTransaction = () => {
|
|
if (!editor.isInitialized) return;
|
|
setVisible(shouldShowBubbleMenu(editor));
|
|
};
|
|
editor.on("transaction", onTransaction);
|
|
return () => { editor.off("transaction", onTransaction); };
|
|
}, [editor]);
|
|
|
|
// Hide on blur — debounced to allow focus to settle (e.g. clicking menu)
|
|
useEffect(() => {
|
|
const onBlur = () => {
|
|
setTimeout(() => {
|
|
if (editor.isDestroyed) return;
|
|
const el = floatingRef.current;
|
|
if (el && el.contains(document.activeElement)) return;
|
|
if (editor.view.hasFocus()) return;
|
|
setVisible(false);
|
|
}, 0);
|
|
};
|
|
editor.on("blur", onBlur);
|
|
return () => { editor.off("blur", onBlur); };
|
|
}, [editor]);
|
|
|
|
// Position the floating element with autoUpdate when visible
|
|
useEffect(() => {
|
|
const el = floatingRef.current;
|
|
if (!visible || !el || !editor.isInitialized) return;
|
|
|
|
const updatePosition = () => {
|
|
computePosition(virtualRef, el, {
|
|
strategy: "fixed",
|
|
placement: "top",
|
|
middleware: [offset(8), flip(), shift({ padding: 8 }), hide()],
|
|
}).then(({ x, y, middlewareData }) => {
|
|
if (!el.isConnected) return;
|
|
const hidden = middlewareData.hide?.referenceHidden;
|
|
el.style.visibility = hidden ? "hidden" : "visible";
|
|
el.style.left = `${x}px`;
|
|
el.style.top = `${y}px`;
|
|
});
|
|
};
|
|
|
|
// autoUpdate monitors all scroll ancestors (via contextElement),
|
|
// resize, and animation frames — no manual scroll listener needed.
|
|
const cleanup = autoUpdate(virtualRef, el, updatePosition);
|
|
return cleanup;
|
|
}, [visible, editor, virtualRef]);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
const handle = (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (editor.view.dom.contains(target)) return;
|
|
if (floatingRef.current?.contains(target)) return;
|
|
setVisible(false);
|
|
};
|
|
document.addEventListener("mousedown", handle);
|
|
return () => document.removeEventListener("mousedown", handle);
|
|
}, [visible, editor]);
|
|
|
|
// Reset mode on selection change
|
|
useEffect(() => {
|
|
const handler = () => setMode("toolbar");
|
|
editor.on("selectionUpdate", handler);
|
|
return () => { editor.off("selectionUpdate", handler); };
|
|
}, [editor]);
|
|
|
|
// Refocus editor when Popover closes
|
|
const handleMenuOpenChange = useCallback(
|
|
(open: boolean) => { if (!open) editor.commands.focus(); },
|
|
[editor],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={floatingRef}
|
|
style={{
|
|
position: "fixed",
|
|
zIndex: 50,
|
|
width: "max-content",
|
|
visibility: visible ? "visible" : "hidden",
|
|
}}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
>
|
|
{mode === "link-edit" ? (
|
|
<LinkEditBar editor={editor} onClose={() => { setMode("toolbar"); editor.commands.focus(); }} />
|
|
) : (
|
|
<TooltipProvider delay={300}>
|
|
<div className="bubble-menu">
|
|
<MarkButton editor={editor} mark="bold" icon={Bold} label={t(($) => $.bubble_menu.bold)} shortcut={`${modKey}+B`} isActive={fmt.bold} />
|
|
<MarkButton editor={editor} mark="italic" icon={Italic} label={t(($) => $.bubble_menu.italic)} shortcut={`${modKey}+I`} isActive={fmt.italic} />
|
|
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label={t(($) => $.bubble_menu.strikethrough)} shortcut={`${modKey}+Shift+S`} isActive={fmt.strike} />
|
|
<MarkButton editor={editor} mark="code" icon={Code} label={t(($) => $.bubble_menu.code)} shortcut={`${modKey}+E`} isActive={fmt.code} />
|
|
<MarkButton editor={editor} mark="highlight" icon={Highlighter} label={t(($) => $.bubble_menu.highlight)} shortcut={`${modKey}+Shift+H`} isActive={fmt.highlight} />
|
|
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
|
<Tooltip>
|
|
<TooltipTrigger render={
|
|
<Toggle size="sm" pressed={fmt.link} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
|
|
}>
|
|
<Link2 className="size-3.5" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={8}>{t(($) => $.bubble_menu.link)}</TooltipContent>
|
|
</Tooltip>
|
|
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
|
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} activeLevel={fmt.heading1 ? 1 : fmt.heading2 ? 2 : fmt.heading3 ? 3 : undefined} />
|
|
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} isBullet={fmt.bulletList} isOrdered={fmt.orderedList} isTask={fmt.taskList} />
|
|
<Tooltip>
|
|
<TooltipTrigger render={
|
|
<Toggle size="sm" pressed={fmt.blockquote} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
|
|
}>
|
|
<Quote className="size-3.5" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={8}>{t(($) => $.bubble_menu.quote)}</TooltipContent>
|
|
</Tooltip>
|
|
{currentIssueId && (
|
|
<>
|
|
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
|
<CreateSubIssueButton editor={editor} parentIssueId={currentIssueId} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export { EditorBubbleMenu };
|