mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* Reapply "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)
This reverts commit c10bfa8f56.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): seed virtualized lists so route-return doesn't flash blank (MUL-4750)
The relanded MUL-4474 virtualization flashed an empty card area (group /
column headers present, rows blank) when returning to /issues or crossing
inbox<->issues. Two stacked blank windows caused it:
1. The scroll element reaches Virtuoso via a callback ref that lands in
state, so the first render after a remount has customScrollParent === null
and the code rendered nothing.
2. Even once mounted, Virtuoso renders 0 rows until its post-paint
ResizeObserver measures the viewport.
Fix both, four surfaces (list / board / swimlane / inbox):
- New shared <VirtuosoSeed> renders a bounded slice of the real rows while the
scroll parent is still null, reusing each caller's own itemContent /
computeItemKey so a seeded row is identical to its virtualized counterpart.
- Pass initialItemCount={Math.min(len, SEED)} so the measurement frame keeps
those rows instead of collapsing to empty.
SEED is capped at 30 and floored by Math.min, so small workspaces
(hasMore=false) and short columns never over-mount — the path that crashed on
real Desktop before. restoreStateFrom (tab-switch Activity restore) is
intentionally out of scope for this round.
Verified: @multica/views tsc --noEmit, eslint, and full vitest (1936 tests)
pass. Real-Desktop route-return / DnD / keyboard / scroll-position regression
pass still owed on device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(issues): defer per-card popup mounting, share one context menu per surface
Tab-switching to a board froze the main thread for seconds: every card
eagerly mounted ~6 popup roots (context menu, pickers, hover cards) plus
per-card query subscriptions, multiplied by seed x columns x remount.
- DeferredPopup: pickers render a pixel-identical static trigger and mount
the real popover on first pointerenter/keydown (Base UI opens on click,
so the warm mount always wins the race)
- AssigneePicker/PriorityPicker/DateOnlyPicker defer when uncontrolled;
AssigneePicker's members/agents/squads/frequency subscriptions now only
start on interaction
- IssueActionsContextMenu: one controlled ContextMenu per surface anchored
at the cursor via a virtual anchor; items delegate (issue, position) up.
Known debt: iOS Safari long-press no longer opens it
- ActorAvatar hover cards warm-mount on pointerenter with a manual
first-dwell timer matching Base UI's OPEN_DELAY
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): stabilize board column scrollbar across mounts
Column scrollbars redrew visibly on every surface mount (route switches,
first open): the seed frame's scroll height covered only the seeded cards,
then Virtuoso spaced out the full count.
- VirtuosoSeed: optional estimatedItemHeight renders a trailing spacer so
the seed frame's scroll height already approximates the full list
- Board columns: seed capped at 10 (one column viewport of ~110px cards,
not the 36px-row-sized generic 30) and the same estimate feeds Virtuoso's
defaultItemHeight so both phases agree until real measurements land
- Columns at <=30 cards skip virtualization entirely and render plainly
(same itemContent), making their scroll height browser-measured truth in
every scenario -- the per-column split Linear ships
(data-virtual-cluster=false for small clusters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): reduce issue detail mount cost
Parse long Markdown in smaller chunks without the duplicate initial sync, defer title and empty composer editors until intent, and keep the description editor eager to avoid layout shifts.
* chore(desktop): add navigation boundary lint rule (MUL-4741 Phase 2 prereq)
The tab Coordinator protocol requires that application code never
navigates directly (invariant 1: a Router location change without a
Coordinator token is a protocol error). Enforce it statically:
- renderer app code may not import useNavigate/Navigate from
react-router-dom nor call router.navigate; src/platform is exempt
- the five known legacy sites (the RFC §8.1 migration checklist,
cross-validated: the rule fires on exactly those and nothing else)
carry inline eslint-disable directives tagged MUL-4741 — the Phase 2
migration removes them one by one, and this rule holding with zero
disables is the machine check that the migration is complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(views): open deferred pickers on click, align triggerRender types
Pointerenter warm-mounting swapped the trigger element mid-gesture: real
browsers re-hit-test so the click lands on the new trigger, but synthetic
pointer sequences (tests, assistive tech) keep dispatching on the detached
node and the first click dies. Upgrade now happens on click/Enter/Space
only — the same timing as Base UI's own trigger — with the in-flight click
stopped so the popup's just-mounted outside-press dismissal doesn't close
it in the same breath.
Also widen triggerRender to ReactElement<Record<string, unknown>> (React
19 defaults ReactElement props to unknown) and mount the
IssueContextMenuProvider in the swimlane test harness like IssueSurface
does in production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(desktop): single-router tab sessions with Coordinator protocol (MUL-4741 Phase 2)
Replace the per-tab-router + <Activity> keep-alive model with the approved
single-router session architecture:
- TabSession: tabs are pure serializable state (url, resourceKey, virtual
history stack, scroll memento). Persist v4 does the one-time legacy
view-state import from v3; mountGeneration is deliberately unpersisted.
- Coordinator (platform/tab-coordinator.ts) is the only router writer: it
reconciles THE app router to the active session URL with navigation
tokens; a location change without a token is a protocol error handled by
bounded recovery (invariant 1). The router history is never used — every
reconcile is a replace; back/forward are session-stack operations.
- ActiveTabHost mounts exactly one tab, keyed on tabId:mountGeneration.
reload() = generation bump + active-scope query invalidation (never
router.revalidate, never a global cache invalidation). Warm switches
restore scroll pre-paint; cold restores pre-size containers from the
memento's saved scrollHeight and settle when data lands.
- resourceKey dedup (pathname only) replaces exact-path dedup: opening
/slug/issues?filter=b focuses the existing issues tab (RFC §8.2,
deliberate semantic change).
- All five §8.1 legacy navigation sites migrated (index <Navigate>, error
page recovery, workspace-layout login bounce, overlay parking, shell
back/forward); their MUL-4741 ratchet eslint-disables are removed, so the
navigation boundary rule now holds with zero exemptions outside
src/platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(issues): register board columns and list scroller for scroll mementos
Per-container scroll registration for the MUL-4741 tab session memento:
board columns key by group id (each column's offset restores
independently, the per-column split Linear ships), the list view keys as
"list". Chat and issue-detail already carry the marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tabs): pull-based scroll restoration fed into virtualized lists (MUL-4741)
Rebuild the restore side of the memento protocol on first principles (the
model Linear ships): a saved offset is an INPUT to the mounting view, not a
post-hoc DOM mutation from outside.
- ScrollRestorationProvider (views/platform): views pull their saved offset
while mounting. Virtualized lists feed it into Virtuoso's initialScrollTop
so the first render already materializes the rows around it — this
replaces the pushed spacer+scrollTop hook, whose foreign spacer deadlocked
against Virtuoso's own height model (restore landed the viewport in
phantom space, Virtuoso rendered nothing, and the spacer's removal
condition could never be met → blank issue detail). Plain containers
assign the offset at ref-attach, pre-paint. Web has no provider and
behaves as before.
- Memento keys gain a route dimension (`${pathname}::${containerKey}`) and
capture now also fires before in-tab navigation, so back/forward restores
each route's own offsets and same-named containers on different routes
no longer collide.
- commitScrollMemento uses REPLACE-per-route semantics: a container
scrolled back to 0 clears its stale offset instead of resurrecting the
old position on the next visit.
- List view gets the same estimate alignment as the board (36px rows into
the seed spacer and defaultItemHeight), which keeps the shared scroller's
height truthful from the first frame so the restored offset sticks.
Known gap: chat's bottom-anchored list captures offsets but has no restore
consumer — intentional, it re-anchors to bottom on mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): make unlabelled code fences plaintext instead of auto-detected
Follow-up to the issue-detail mount work: lowlight's highlightAuto runs
every registered grammar over the full block for code fences without a
language, which dominated mount cost on code-heavy comments. Extract a
shared syntax-highlight module whose auto fallback deterministically
renders plaintext; explicitly labelled languages highlight as before.
Also ignore .gstack/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): lazy-mount column-header popups, right-size swimlane seed
Trace analysis of tab/view switching (30s session): a swimlane mount spent
its largest slice on eagerly-mounted header machinery — ~170 tooltip roots
(one per lane x status cell add-button), 13 column dropdown-menus, and up
to 30 fully-materialized lanes from the generic seed count.
- DeferredTooltip (views/common): renders only the trigger until first
hover, then mounts a controlled Tooltip anchored to the SAME element
(no trigger swap, so mid-gesture events never land on a detached node);
ui TooltipContent grows an `anchor` passthrough for it.
- Board/list/swimlane header add-buttons and hide-column dropdowns now
defer via DeferredTooltip / DeferredPopup (which gains an ariaHasPopup
option for menu triggers).
- Swimlane lane seed drops 30 -> 6 (a lane row is ~300px+; a viewport fits
~3) on both the pre-scroll seed and Virtuoso's initialItemCount.
- openTab gains an `activate` option so "open and focus" paths (pinned-tab
redirect, explicit open-in-new-tab) are ONE store write instead of
openTab + setActiveTab back-to-back — one full subscriber pass per user
action instead of two.
- Dev-only breadcrumb logs IssueSurfaceContent's remount key: the trace
showed the surface mounting twice inside one task, and the my-issues
relation toggle is one confirmed key-flip source; the log ties the next
trace's mounts to exact key transitions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): stop double full-tree render passes on surface interactions
Trace forensics on view switching showed every interaction paying TWO full
surface render passes (React's own Cascading Update marker sits between
them): entering swimlane flips controller-level loading state (loadProjects
enables the projects query), and any such flag flip re-rendered the entire
unmemoized view tree (~600-1000ms dev per pass).
- Memoize BoardView / ListView / SwimLaneView: controller/data outputs are
already useMemo/useCallback-stable, so a controller flag flip now
re-renders the header, not the whole board. The one unstable prop —
BoardView's inline assigneeGroups.flatMap — moves into a useMemo.
- Selection reset on mount swapped the initial empty Set for a NEW empty
Set, buying a guaranteed extra full pass per surface mount; functional
bail keeps the reference when nothing was selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(ui): drive sidebar resize by direct DOM writes, drop motion/react
Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): wire swimlane's outer scroller into tab scroll restoration
Review blocker on #5403: board/list/issue-detail register their scroll
containers with the tab session memento protocol, but the swimlane outer
scroller did not — under the single-router architecture an inactive tab
unmounts, so a deep-scrolled swimlane returned at top after a tab switch
or reload.
Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for
capture, useRestoredScrollRef in the scroller's attach callback for the
pre-paint assignment, and the saved offset into the lane Virtuoso's
initialScrollTop. Regression test asserts both the capture marker and the
restored offset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
685 lines
30 KiB
TypeScript
685 lines
30 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* ContentEditor — the rich-text editor used wherever the user TYPES content.
|
|
*
|
|
* Architecture decisions (April 2026 refactor):
|
|
*
|
|
* 1. EDITING ONLY. Read-only display is handled by `ReadonlyContent` (a
|
|
* react-markdown renderer), not this component. There used to be an
|
|
* `editable` prop here that toggled between modes, but every readonly
|
|
* callsite migrated to ReadonlyContent and the prop only invited
|
|
* misuse — Tiptap's `useEditor` reads `editable` at mount, so toggling
|
|
* the prop later silently failed (mounted-as-readonly editors stayed
|
|
* unfocusable forever). To express "currently disabled", wrap this
|
|
* component in a layout that sets `pointer-events-none` / `aria-disabled`
|
|
* — don't reach into the editor.
|
|
*
|
|
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
|
|
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
|
|
* Previously we had a custom `markdownToHtml()` pipeline (Marked library)
|
|
* for loading and regex post-processing for saving — two asymmetric paths
|
|
* that caused roundtrip inconsistencies. The @tiptap/markdown extension
|
|
* (v3.21.0+) handles table cell <p> wrapping and custom mention tokenizers
|
|
* natively, eliminating the need for the HTML detour.
|
|
*
|
|
* 3. PREPROCESSING is minimal: only legacy mention shortcode migration and
|
|
* URL linkification (preprocessMarkdown). No HTML conversion.
|
|
*
|
|
* Tech: Tiptap v3 (ProseMirror wrapper), @tiptap/markdown for
|
|
* bidirectional Markdown ↔ ProseMirror JSON conversion.
|
|
*/
|
|
|
|
import {
|
|
forwardRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type MouseEvent as ReactMouseEvent,
|
|
} from "react";
|
|
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
|
import { useWorkspaceSlug } from "@multica/core/paths";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { issueIdentifierOptions } from "@multica/core/issues/queries";
|
|
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
|
import { isIssueIdentifier } from "@multica/ui/markdown";
|
|
import type { Attachment } from "@multica/core/types";
|
|
import {
|
|
parseMarkdownChunked,
|
|
MARKDOWN_CHUNK_THRESHOLD,
|
|
type MarkdownManagerLike,
|
|
} from "./utils/parse-markdown-chunked";
|
|
import type { MentionItem } from "./extensions/mention-suggestion";
|
|
import type { IssueIdentifierResolver } from "./extensions/issue-identifier-autolink";
|
|
import { createEditorExtensions } from "./extensions";
|
|
import { uploadAndInsertFile } from "./extensions/file-upload";
|
|
import { preprocessMarkdown } from "./utils/preprocess";
|
|
import { repairEmptyListItems } from "./utils/repair-list-items";
|
|
import { openLink, isMentionHref } from "./utils/link-handler";
|
|
import { EditorBubbleMenu } from "./bubble-menu";
|
|
import { posFromAnchor, type TextAnchor } from "./text-anchor";
|
|
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
|
import { AttachmentDownloadProvider } from "./attachment-download-context";
|
|
import "katex/dist/katex.min.css";
|
|
import "./styles/index.css";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Blob URLs (blob:http://…) are process-local and expire on reload. Strip them
|
|
* from serialised markdown so they never reach the database. */
|
|
const BLOB_IMAGE_RE = /!\[[^\]]*\]\(blob:[^)]*\)\n?/g;
|
|
|
|
function stripBlobUrls(md: string): string {
|
|
return md.replace(BLOB_IMAGE_RE, "");
|
|
}
|
|
|
|
/** Canonical comparison form for a markdown string: drop process-local blob
|
|
* URLs and trailing blank lines so both sides of a dirty check compare
|
|
* like-for-like. One definition for the normalization rule — a future tweak
|
|
* (e.g. stripping another ephemeral token) lands here instead of in the
|
|
* several call sites it used to be copy-pasted across. */
|
|
function normalizeMarkdown(md: string): string {
|
|
return stripBlobUrls(md).trimEnd();
|
|
}
|
|
|
|
/** `normalizeMarkdown` applied to the live editor's serialized content. */
|
|
function normalizeEditorMarkdown(editor: Editor): string {
|
|
return normalizeMarkdown(editor.getMarkdown());
|
|
}
|
|
|
|
/** True when any node in the document is mid-upload (`attrs.uploading`). The
|
|
* `return !found` early-out matches the original inline scans verbatim: in
|
|
* ProseMirror it only stops descending into the matched node's subtree (not
|
|
* the whole walk), but once `found` flips true the boolean result is fixed. */
|
|
function hasUploadingNode(editor: Editor): boolean {
|
|
let found = false;
|
|
editor.state.doc.descendants((node) => {
|
|
if (node.attrs.uploading) found = true;
|
|
return !found;
|
|
});
|
|
return found;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ContentEditorProps {
|
|
defaultValue?: string;
|
|
onUpdate?: (markdown: string) => void;
|
|
placeholder?: string;
|
|
className?: string;
|
|
debounceMs?: number;
|
|
onSubmit?: () => void;
|
|
onBlur?: () => void;
|
|
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
|
/** Show the floating formatting toolbar on text selection. Defaults true. */
|
|
showBubbleMenu?: boolean;
|
|
/**
|
|
* ID of the issue this editor belongs to. When set, the bubble menu exposes
|
|
* a "Create sub-issue from selection" action that parents the new issue
|
|
* under this ID and replaces the selection with a mention link.
|
|
*/
|
|
currentIssueId?: string;
|
|
/**
|
|
* When true, the `@` suggestion picker is disabled but the mention node
|
|
* type remains in the schema, so existing mentions pasted in from other
|
|
* Multica editors still render as the normal pill. Use for editors where
|
|
* *creating* a new mention has no business meaning (e.g. agent system
|
|
* prompts) but *preserving* an existing one still matters.
|
|
*/
|
|
disableMentions?: boolean;
|
|
/** Chat can surface current/recent issue/project suggestions. Other editors use default mention behavior. */
|
|
mentionMode?: "default" | "context";
|
|
mentionContextItems?: MentionItem[];
|
|
/** Enable the `/` command picker. Defaults false. */
|
|
enableSlashCommands?: boolean;
|
|
/**
|
|
* Which `/` menu to show when enableSlashCommands is true: "skill" (default)
|
|
* lists the active agent's skills (chat); "command" shows the fixed built-in
|
|
* command menu (issue comments), e.g. /note.
|
|
*/
|
|
slashCommandMode?: "skill" | "command";
|
|
/**
|
|
* Attachments referenced by this content. The download buttons on file
|
|
* cards and images inside the editor look up an attachment by `url` and
|
|
* fetch a fresh CloudFront signature at click time, so a stale URL
|
|
* persisted in markdown never opens. Pass `issue.attachments` /
|
|
* `comment.attachments` etc.; omit when no attachment context is
|
|
* available (NodeView buttons fall back to opening the raw URL).
|
|
*/
|
|
attachments?: Attachment[];
|
|
/**
|
|
* Flush a pending debounced `onUpdate` when the editor unmounts instead of
|
|
* dropping it. Default false ON PURPOSE: most composers clear their draft
|
|
* and then unmount (comment edit cancel, create-issue / feedback submit),
|
|
* and a flush there would hand the discarded content right back to
|
|
* `onUpdate`, resurrecting the cleared draft. Opt in only where closing
|
|
* means "keep what the user last saw" — e.g. the issue-detail description
|
|
* editor, whose 1500ms debounce would otherwise drop a paste made just
|
|
* before the modal closes.
|
|
*/
|
|
flushPendingOnUnmount?: boolean;
|
|
/**
|
|
* Called once when the Tiptap instance exists and its initial content is
|
|
* set (creation is deferred past first paint by `immediatelyRender: false`).
|
|
* Readonly-first hosts such as comment and reply composers use this as the
|
|
* signal to swap their static shell for the live editor.
|
|
*/
|
|
onReady?: () => void;
|
|
}
|
|
|
|
interface ContentEditorRef {
|
|
getMarkdown: () => string;
|
|
clearContent: () => void;
|
|
focus: () => void;
|
|
/**
|
|
* Focus and place the caret at the document position under the given
|
|
* viewport coordinates. Used by readonly-first hosts so the click that
|
|
* summoned the editor lands the caret where the user clicked, matching
|
|
* the always-mounted editor's behavior. Falls back to focusing the end
|
|
* when no position resolves (click below the last line). Must be called
|
|
* while the editor element is laid out (not display: none).
|
|
*/
|
|
focusAtCoords: (coords: { x: number; y: number }) => void;
|
|
/**
|
|
* Focus and place the caret at the document position a text anchor
|
|
* resolves to. Preferred over `focusAtCoords` for readonly-first hosts:
|
|
* the anchor is a logical position ("block N, character M"), so it is
|
|
* immune to layout differences between the readonly render and the
|
|
* editor render — which is exactly where pixel coordinates drift on
|
|
* long documents.
|
|
*/
|
|
focusAtAnchor: (anchor: TextAnchor) => void;
|
|
/** Drop focus from the editor — used by chat after send so the caret
|
|
* stops competing with the StatusPill / streaming reply for the user's
|
|
* attention. */
|
|
blur: () => void;
|
|
uploadFile: (file: File) => void;
|
|
/** True when file uploads are still in progress. */
|
|
hasActiveUploads: () => boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
|
function ContentEditor(
|
|
{
|
|
defaultValue = "",
|
|
onUpdate,
|
|
placeholder: placeholderText = "",
|
|
className,
|
|
debounceMs = 300,
|
|
onSubmit,
|
|
onBlur,
|
|
onUploadFile,
|
|
showBubbleMenu = true,
|
|
currentIssueId,
|
|
disableMentions = false,
|
|
mentionMode = "default",
|
|
mentionContextItems,
|
|
enableSlashCommands = false,
|
|
slashCommandMode = "skill",
|
|
attachments,
|
|
flushPendingOnUnmount = false,
|
|
onReady,
|
|
},
|
|
ref,
|
|
) {
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
const flushPendingOnUnmountRef = useRef(flushPendingOnUnmount);
|
|
// Markdown serialized at `onUpdate` time, awaiting its debounce fire. The
|
|
// unmount flush emits this cached copy — it runs mid-teardown and can't
|
|
// assume the editor instance is still readable.
|
|
const pendingFlushRef = useRef<string | null>(null);
|
|
const onUpdateRef = useRef(onUpdate);
|
|
const onSubmitRef = useRef(onSubmit);
|
|
const onBlurRef = useRef(onBlur);
|
|
const onReadyRef = useRef(onReady);
|
|
const onUploadFileRef = useRef<
|
|
((file: File) => Promise<UploadResult | null>) | undefined
|
|
>(undefined);
|
|
const mentionContextItemsRef = useRef<MentionItem[]>(mentionContextItems ?? []);
|
|
const lastEmittedRef = useRef<string | null>(null);
|
|
// `content` already consumes the initial defaultValue when Tiptap mounts.
|
|
// Track the prop separately so the external-sync effect only handles real
|
|
// changes after mount, not Markdown serializer canonicalization from that
|
|
// first parse.
|
|
const lastDefaultValueRef = useRef(defaultValue);
|
|
// Live placeholder text. Passed into the Placeholder extension as a getter
|
|
// (not a static string) so the plugin re-reads it on every decoration pass —
|
|
// the sync effect below updates this ref and nudges a repaint. Tiptap
|
|
// snapshots a *string* placeholder at mount, so a getter is what lets it
|
|
// change without remounting the editor.
|
|
const placeholderRef = useRef(placeholderText);
|
|
|
|
// In-session record of attachments freshly uploaded through this editor.
|
|
// Surfaces (like the quick-create modal) that don't have a server-supplied
|
|
// `attachments` prop still need the AttachmentDownloadProvider to know
|
|
// about images the user just pasted/dropped — without a record in scope,
|
|
// Attachment.normalize() can't swap the persisted /api/attachments/<id>/
|
|
// download URL to a freshly-loadable one, and the <img> renders broken in
|
|
// any environment where the renderer's origin doesn't proxy /api to the
|
|
// API host (MUL-3192, Desktop/Electron).
|
|
const [sessionUploads, setSessionUploads] = useState<Attachment[]>([]);
|
|
// Wrap the caller-supplied uploader so we can stash each successful result
|
|
// in `sessionUploads`. The wrapper is rebuilt only when the underlying
|
|
// `onUploadFile` identity changes, so the inner ref handed to Tiptap stays
|
|
// stable across renders the way the original passthrough did.
|
|
const wrappedOnUploadFile = useMemo(() => {
|
|
if (!onUploadFile) return undefined;
|
|
return async (file: File): Promise<UploadResult | null> => {
|
|
const result = await onUploadFile(file);
|
|
// Only track attachments that carry a persisted id — the no-workspace
|
|
// avatar branch returns an id-less record that the resolver can't key
|
|
// off of, and tracking it would just bloat memory without helping
|
|
// anyone. See useFileUpload's `markdownLink` docstring for why.
|
|
if (result?.id) {
|
|
setSessionUploads((prev) =>
|
|
// Deduplicate on id so a re-upload (or a paste-then-drop of the
|
|
// same blob) doesn't create a parallel record.
|
|
prev.some((a) => a.id === result.id) ? prev : [...prev, result],
|
|
);
|
|
}
|
|
return result;
|
|
};
|
|
}, [onUploadFile]);
|
|
|
|
// Merged list fed to AttachmentDownloadProvider. Caller-supplied attachments
|
|
// (issue / comment editors that pre-load the full attachments[] from the
|
|
// server) take precedence — we only append session uploads the caller
|
|
// doesn't already have, so a parent re-render that includes the same record
|
|
// doesn't end up with two copies.
|
|
//
|
|
// One exception on id collision: when the caller's copy has an EMPTY
|
|
// `download_url` (the create-issue draft strips the short-lived signed URL
|
|
// before persisting), backfill it from the session upload. The session copy
|
|
// holds the this-response signed URL, so the just-pasted image first-paints
|
|
// from it instead of taking an extra redirect hop through `markdown_url`.
|
|
const providerAttachments = useMemo(() => {
|
|
if (sessionUploads.length === 0) return attachments;
|
|
const sessionById = new Map(sessionUploads.map((a) => [a.id, a]));
|
|
const merged: Attachment[] = [];
|
|
for (const a of attachments ?? []) {
|
|
const session = a.id ? sessionById.get(a.id) : undefined;
|
|
if (session) sessionById.delete(a.id);
|
|
merged.push(
|
|
session && !a.download_url
|
|
? { ...a, download_url: session.download_url }
|
|
: a,
|
|
);
|
|
}
|
|
merged.push(...sessionById.values());
|
|
return merged;
|
|
}, [attachments, sessionUploads]);
|
|
|
|
// Current workspace slug kept in a ref so the click handler always sees the
|
|
// latest value without recreating the editor. Used by openLink to prefix
|
|
// legacy /issues/... style paths that lack a workspace slug.
|
|
const workspaceSlug = useWorkspaceSlug();
|
|
const workspaceSlugRef = useRef(workspaceSlug);
|
|
workspaceSlugRef.current = workspaceSlug;
|
|
|
|
// Keep refs in sync without recreating editor
|
|
onUpdateRef.current = onUpdate;
|
|
onSubmitRef.current = onSubmit;
|
|
onBlurRef.current = onBlur;
|
|
onReadyRef.current = onReady;
|
|
onUploadFileRef.current = wrappedOnUploadFile;
|
|
mentionContextItemsRef.current = mentionContextItems ?? [];
|
|
flushPendingOnUnmountRef.current = flushPendingOnUnmount;
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
// Linear-style bare identifier autolink resolver. Fully lazy — it runs only
|
|
// on user input, never on render, so it adds no query hook to this widely
|
|
// used component. It reads the current workspace from the query cache (via
|
|
// the slug ref) and returns null outside a workspace, for non-identifier
|
|
// tokens, or when the prefix can't match this workspace, so no network call
|
|
// happens for those; the exact-match filter enforces correctness.
|
|
const resolveIssueIdentifierRef = useRef<IssueIdentifierResolver | undefined>(
|
|
undefined,
|
|
);
|
|
resolveIssueIdentifierRef.current = async (identifier) => {
|
|
if (!isIssueIdentifier(identifier)) return null;
|
|
const slug = workspaceSlugRef.current;
|
|
if (!slug) return null;
|
|
const workspaces = await queryClient.fetchQuery(workspaceListOptions());
|
|
const ws = workspaces.find((w) => w.slug === slug);
|
|
if (!ws) return null;
|
|
const prefix = ws.issue_prefix;
|
|
if (
|
|
prefix &&
|
|
!identifier.toUpperCase().startsWith(`${prefix.toUpperCase()}-`)
|
|
) {
|
|
return null;
|
|
}
|
|
const issue = await queryClient.fetchQuery(
|
|
issueIdentifierOptions(ws.id, identifier),
|
|
);
|
|
return issue ? { id: issue.id, identifier: issue.identifier } : null;
|
|
};
|
|
|
|
const initialContent = defaultValue ? preprocessMarkdown(defaultValue) : "";
|
|
// With `immediatelyRender: false` the Tiptap instance is created after
|
|
// mount, so an imperative `focus()` fired on the same tick (e.g. chat
|
|
// auto-focusing a brand-new conversation) would hit a null editor and no-op.
|
|
// Latch the intent here and honor it in `onCreate` once the editor exists.
|
|
const focusOnReadyRef = useRef(false);
|
|
// Large markdown is parsed in chunks to dodge marked's O(n²) tokenizer (see
|
|
// parseMarkdownChunked). Small docs stay on the single-parse fast path.
|
|
const mountChunked = initialContent.length > MARKDOWN_CHUNK_THRESHOLD;
|
|
|
|
const editor = useEditor({
|
|
immediatelyRender: false,
|
|
// Explicit for clarity — the real perf win is useEditorState in BubbleMenu.
|
|
shouldRerenderOnTransaction: false,
|
|
onCreate: ({ editor: ed }) => {
|
|
// For large docs we mount empty (below) and parse in chunks here, so the
|
|
// O(n²) marked tokenizer never sees the whole document at once.
|
|
if (mountChunked) {
|
|
const manager = (
|
|
ed.storage as { markdown?: { manager?: MarkdownManagerLike } }
|
|
).markdown?.manager;
|
|
if (manager) {
|
|
ed.commands.setContent(
|
|
parseMarkdownChunked(manager, initialContent),
|
|
{ emitUpdate: false },
|
|
);
|
|
} else {
|
|
ed.commands.setContent(initialContent, {
|
|
emitUpdate: false,
|
|
contentType: "markdown",
|
|
});
|
|
}
|
|
}
|
|
// A markdown draft ending in an empty list item (e.g. `"1. \n\n"` left
|
|
// after typing `1.`) parses into a caretless, schema-invalid item;
|
|
// repair it so the mounted editor has a real cursor in the list.
|
|
repairEmptyListItems(ed);
|
|
lastEmittedRef.current = normalizeEditorMarkdown(ed);
|
|
if (focusOnReadyRef.current) {
|
|
focusOnReadyRef.current = false;
|
|
ed.commands.focus("end");
|
|
}
|
|
},
|
|
content: mountChunked ? "" : initialContent,
|
|
contentType: mountChunked
|
|
? undefined
|
|
: defaultValue
|
|
? "markdown"
|
|
: undefined,
|
|
extensions: createEditorExtensions({
|
|
placeholder: () => placeholderRef.current,
|
|
queryClient,
|
|
onSubmitRef,
|
|
onUploadFileRef,
|
|
disableMentions,
|
|
mentionMode,
|
|
getMentionContextItems: () => mentionContextItemsRef.current,
|
|
enableSlashCommands,
|
|
slashCommandMode,
|
|
resolveIssueIdentifierRef,
|
|
}),
|
|
onUpdate: ({ editor: ed }) => {
|
|
if (!onUpdateRef.current) return;
|
|
if (flushPendingOnUnmountRef.current) {
|
|
pendingFlushRef.current = normalizeEditorMarkdown(ed);
|
|
}
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
debounceRef.current = undefined;
|
|
pendingFlushRef.current = null;
|
|
const md = normalizeEditorMarkdown(ed);
|
|
if (md === lastEmittedRef.current) return;
|
|
lastEmittedRef.current = md;
|
|
onUpdateRef.current?.(md);
|
|
}, debounceMs);
|
|
},
|
|
onBlur: () => {
|
|
onBlurRef.current?.();
|
|
},
|
|
editorProps: {
|
|
handleDOMEvents: {
|
|
click(_view, event) {
|
|
const target = event.target as HTMLElement;
|
|
// Skip links inside NodeView wrappers — they handle their own clicks
|
|
if (target.closest("[data-node-view-wrapper]")) return false;
|
|
|
|
const link = target.closest("a");
|
|
const href = link?.getAttribute("href");
|
|
if (!href || isMentionHref(href)) return false;
|
|
|
|
event.preventDefault();
|
|
openLink(href, workspaceSlugRef.current);
|
|
return true;
|
|
},
|
|
},
|
|
attributes: {
|
|
class: cn("flex-1 rich-text-editor text-sm outline-none", className),
|
|
},
|
|
},
|
|
});
|
|
|
|
// Signal hosts that the deferred editor instance now exists. Fired from a
|
|
// passive effect (not `onCreate`) so it runs after the commit in which
|
|
// <EditorContent> attached the editor DOM — callers can measure/focus it.
|
|
const readyFiredRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!editor || readyFiredRef.current) return;
|
|
readyFiredRef.current = true;
|
|
onReadyRef.current?.();
|
|
}, [editor]);
|
|
|
|
// Cleanup on unmount. A pending debounced update is DROPPED by default,
|
|
// not flushed — see the `flushPendingOnUnmount` prop doc for why. When the
|
|
// owner opted in, emit the markdown cached at `onUpdate` time so a long
|
|
// debounce can't swallow the last edit when the surrounding modal closes.
|
|
useEffect(() => {
|
|
return () => {
|
|
if (!debounceRef.current) return;
|
|
clearTimeout(debounceRef.current);
|
|
debounceRef.current = undefined;
|
|
if (!flushPendingOnUnmountRef.current) return;
|
|
const pending = pendingFlushRef.current;
|
|
pendingFlushRef.current = null;
|
|
if (pending === null || pending === lastEmittedRef.current) return;
|
|
lastEmittedRef.current = pending;
|
|
onUpdateRef.current?.(pending);
|
|
};
|
|
}, []);
|
|
|
|
// Sync external `defaultValue` changes into the editor.
|
|
// Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831);
|
|
// without this effect, a WS-driven description update keeps the editor
|
|
// showing stale content until the issue is closed and reopened.
|
|
useEffect(() => {
|
|
if (!editor || editor.isDestroyed) return;
|
|
|
|
const previousDefaultValue = lastDefaultValueRef.current;
|
|
lastDefaultValueRef.current = defaultValue;
|
|
|
|
// The initial defaultValue was already parsed through useEditor's
|
|
// `content` option (or in onCreate for the chunked path). Comparing that
|
|
// source Markdown to Tiptap's canonical serialization can differ even
|
|
// when they represent the same document, and used to cause an immediate
|
|
// second full parse. Only later prop changes belong to this sync effect.
|
|
if (defaultValue === previousDefaultValue) return;
|
|
|
|
// Guard 0: never clobber an in-flight upload. An external `defaultValue`
|
|
// change can arrive mid-upload — e.g. chat lazy-creates a session on the
|
|
// first file upload, which flips `activeSessionId` → the draft key →
|
|
// `defaultValue`. If we `setContent` over a document that still holds an
|
|
// `uploading` image/fileCard node, that node is wiped and the upload's
|
|
// finalize can no longer find it (the file vanishes, leaving an empty
|
|
// `!file[name]()`). Like the dirty guards below, an uploading node is
|
|
// local state that an external sync must not overwrite.
|
|
if (hasUploadingNode(editor)) return;
|
|
|
|
const current = normalizeEditorMarkdown(editor);
|
|
// "Dirty" = user has local edits not yet flushed through the debounced
|
|
// `onUpdate`. `lastEmittedRef` is advanced only after a debounce fire,
|
|
// so a divergence means the editor holds unsaved bytes.
|
|
const isDirty =
|
|
lastEmittedRef.current !== null && current !== lastEmittedRef.current;
|
|
|
|
// Guard 1: focused AND dirty — protect bytes the user is actively
|
|
// typing. Focused-but-clean falls through: applying setContent is safe
|
|
// (no user input to lose) and necessary, because onBlur has no replay
|
|
// mechanism and a focused clean editor would otherwise drop this sync
|
|
// permanently.
|
|
if (editor.isFocused && isDirty) return;
|
|
|
|
// Guard 2: unfocused-but-dirty — blur happened but the debounce window
|
|
// (debounceMs, 1500ms for description) hasn't flushed yet. The pending
|
|
// onUpdate will reach the server and the cache will reconcile; skipping
|
|
// here avoids overwriting unsaved local edits.
|
|
if (isDirty) return;
|
|
|
|
const incoming = defaultValue ? preprocessMarkdown(defaultValue) : "";
|
|
const incomingNormalized = normalizeMarkdown(incoming);
|
|
// Guard 3: normalized-equal short-circuit. Avoids a no-op transaction
|
|
// when the cache reflects a write this same editor just emitted.
|
|
if (incomingNormalized === current) return;
|
|
|
|
// Guard 4: `emitUpdate: false`. Tiptap v3's setContent defaults to
|
|
// `emitUpdate: true`; without this we would re-trigger onUpdate →
|
|
// server save → self-write loop.
|
|
const { from, to } = editor.state.selection;
|
|
// Same chunked path on WS-driven re-parse of a large description.
|
|
const manager =
|
|
incoming.length > MARKDOWN_CHUNK_THRESHOLD
|
|
? (editor.storage as { markdown?: { manager?: MarkdownManagerLike } })
|
|
.markdown?.manager
|
|
: undefined;
|
|
if (manager) {
|
|
editor.commands.setContent(parseMarkdownChunked(manager, incoming), {
|
|
emitUpdate: false,
|
|
});
|
|
} else {
|
|
editor.commands.setContent(incoming, {
|
|
emitUpdate: false,
|
|
contentType: "markdown",
|
|
});
|
|
}
|
|
|
|
// An empty list item in the incoming markdown parses into a caretless,
|
|
// schema-invalid node; repair it and let it own the caret. Otherwise clamp
|
|
// the prior selection to the new doc size so the caret doesn't snap to
|
|
// position 0 after ProseMirror replaces the document.
|
|
if (!repairEmptyListItems(editor, { from, to })) {
|
|
const docSize = editor.state.doc.content.size;
|
|
editor.commands.setTextSelection({
|
|
from: Math.min(from, docSize),
|
|
to: Math.min(to, docSize),
|
|
});
|
|
}
|
|
|
|
lastEmittedRef.current = normalizeEditorMarkdown(editor);
|
|
}, [defaultValue, editor]);
|
|
|
|
// Sync external `placeholder` changes into the mounted editor.
|
|
// The Placeholder extension is configured with a getter over `placeholderRef`
|
|
// (see createEditorExtensions above), which the plugin re-invokes every time
|
|
// it recomputes its decorations. Update the ref, then dispatch an empty
|
|
// transaction to force that recompute — the placeholder refreshes without a
|
|
// remount. Without this, it stays frozen at its mount value: switching
|
|
// between an archived and an active chat session under the same agent (no
|
|
// editor remount) leaves the input stuck on "This session is archived" even
|
|
// though it is usable.
|
|
useEffect(() => {
|
|
if (placeholderRef.current === placeholderText) return;
|
|
placeholderRef.current = placeholderText;
|
|
if (!editor || editor.isDestroyed) return;
|
|
// `docChanged` is false on an empty transaction, so onUpdate never fires
|
|
// and no self-write loop is triggered.
|
|
editor.view.dispatch(editor.state.tr);
|
|
}, [editor, placeholderText]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
// Intentionally NOT routed through `normalizeMarkdown` — this refactor
|
|
// must preserve the exact current return value (no `trimEnd`).
|
|
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
|
|
clearContent: () => {
|
|
editor?.commands.clearContent();
|
|
},
|
|
focus: () => {
|
|
if (editor) editor.commands.focus();
|
|
// Editor not mounted yet — defer the focus to `onCreate`.
|
|
else focusOnReadyRef.current = true;
|
|
},
|
|
focusAtCoords: (coords: { x: number; y: number }) => {
|
|
if (!editor) {
|
|
// Editor not mounted yet — degrade to the latched plain focus.
|
|
focusOnReadyRef.current = true;
|
|
return;
|
|
}
|
|
const pos = editor.view.posAtCoords({ left: coords.x, top: coords.y });
|
|
if (pos) editor.commands.focus(pos.pos);
|
|
else editor.commands.focus("end");
|
|
},
|
|
focusAtAnchor: (anchor: TextAnchor) => {
|
|
if (!editor) {
|
|
// Editor not mounted yet — degrade to the latched plain focus.
|
|
focusOnReadyRef.current = true;
|
|
return;
|
|
}
|
|
editor.commands.focus(posFromAnchor(editor.state.doc, anchor));
|
|
},
|
|
blur: () => {
|
|
editor?.commands.blur();
|
|
},
|
|
uploadFile: (file: File) => {
|
|
if (!editor || !onUploadFileRef.current) return;
|
|
const endPos = editor.state.doc.content.size;
|
|
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
|
|
},
|
|
hasActiveUploads: () => (editor ? hasUploadingNode(editor) : false),
|
|
}));
|
|
|
|
// Link hover card — disabled when BubbleMenu is active (has selection)
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const hoverDisabled = !editor?.state.selection.empty;
|
|
const hover = useLinkHover(wrapperRef, hoverDisabled);
|
|
|
|
const handleContainerMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
if (!editor) return;
|
|
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest(".ProseMirror")) return;
|
|
if (target.closest("a, button, input, textarea, [role='button'], [data-node-view-wrapper]")) return;
|
|
|
|
event.preventDefault();
|
|
editor.commands.focus("end");
|
|
};
|
|
|
|
if (!editor) return null;
|
|
|
|
return (
|
|
<AttachmentDownloadProvider attachments={providerAttachments}>
|
|
<div
|
|
ref={wrapperRef}
|
|
className="relative flex flex-1 min-h-full flex-col"
|
|
onMouseDown={handleContainerMouseDown}
|
|
>
|
|
<EditorContent className="flex flex-1 flex-col" editor={editor} />
|
|
{showBubbleMenu && (
|
|
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
|
|
)}
|
|
<LinkHoverCard {...hover} />
|
|
</div>
|
|
</AttachmentDownloadProvider>
|
|
);
|
|
},
|
|
);
|
|
|
|
export { ContentEditor, type ContentEditorProps, type ContentEditorRef };
|