Files
multica/packages/views/issues/components/reply-input.tsx
Naiyuan Qing 995ec8fcf2 feat(editor): 粘贴超长文本自动转为文本附件 (#6019)
* feat(editor): convert over-threshold pastes into a text attachment

Pasting more than PASTE_AS_FILE_THRESHOLD (4000) characters into a
turn-based composer now uploads a `pasted-text.txt` attachment instead of
writing thousands of characters into the body. Opt-in per editor: chat,
new comment, reply and comment edit pass the threshold; issue and project
descriptions deliberately do not, because there a long paste IS the
content.

The synthesised File goes through the existing upload pipeline unchanged
(nothing in it inspects a File's origin), so draft persistence, status
chips and .txt preview all come for free.

Recovery is the part that needed real design. A dropped file survives a
failed upload on disk; this text exists nowhere else — it was never
written into the document and its source tab may be closed. Since uploads
outlive their mount (MUL-5181), the editor cannot own that recovery, so
useCoordinatedUploads does: deliverPastedTextBack mirrors
deliverFinishedUpload (live editor, else the persisted body) and is the
single responder, so the live and dead cases can neither both fire nor
both be skipped. The text is restored as markdown because markdown-paste
is what would have handled it had it never been converted.

A paste inside a code block stays inline — opening a fence is the request
to show the thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(editor): pin the paste handler order against the real extension array

Both markdownPaste and fileUpload register catch-all handlePaste handlers,
so which one ProseMirror consults first decides whether paste-as-file
works at all. That ordering is not visible in either file: Tiptap reverses
the extension array before collecting plugins (@tiptap/core `get
plugins()`), and neither extension sets a priority, so the array position
in createEditorExtensions is the whole contract.

The existing tests mounted the fileUpload extension alone and could not
see it. This one builds the production array via createEditorExtensions;
swapping the two entries makes it fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 14:31:44 +08:00

317 lines
13 KiB
TypeScript

"use client";
import { useRef, useState, useCallback, useEffect } from "react";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay, useLazyEditor, useUploadGate, useComposerSubmit } from "../../editor";
import { PASTE_AS_FILE_THRESHOLD } from "../../editor/paste-as-file";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { ActorAvatar } from "../../common/actor-avatar";
import { contentReferencesAttachment } from "@multica/core/types";
import { formatShortcut, useShortcut } from "@multica/core/shortcuts";
import { useCommentDraftStore, type CommentDraftKey } from "@multica/core/issues/stores";
import { cn } from "@multica/ui/lib/utils";
import type { AvatarSize } from "@multica/ui/lib/avatar-size";
import { useT } from "../../i18n";
import { CommentTriggerChips } from "./comment-trigger-chips";
import { useCommentTriggerPreview } from "../hooks/use-comment-trigger-preview";
import { useCommentUploads } from "./use-comment-uploads";
import { ComposerUploadChips } from "./composer-upload-chips";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ReplyInputProps {
issueId: string;
parentId: string;
placeholder?: string;
avatarType: string;
avatarId: string;
/** Resolves true on success, false on failure — the reply box keeps its text
* (locked + spinning) until then, clearing only on success. */
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
size?: "sm" | "default";
/** When set, hydrates/persists the in-progress reply via the draft store.
* Required for replies inside virtualized timeline threads, where the
* enclosing CommentCard may unmount on scroll-out. */
draftKey?: CommentDraftKey;
}
// ---------------------------------------------------------------------------
// ReplyInput
// ---------------------------------------------------------------------------
function ReplyInput({
issueId,
parentId,
placeholder,
avatarType,
avatarId,
onSubmit,
size = "default",
draftKey,
}: ReplyInputProps) {
const { t } = useT("issues");
const { t: tEditor } = useT("editor");
const sendShortcut = useShortcut("send");
const placeholderText = placeholder ?? t(($) => $.reply.placeholder);
const editorRef = useRef<ContentEditorRef>(null);
const composerRef = useRef<HTMLDivElement>(null);
// See CommentInput — replying mid-upload posts without the file.
const uploadGate = useUploadGate(editorRef);
// If a draft key is provided, hydrate from store on mount (defaultValue is
// the only injection point on ContentEditorRef) and flush on every onUpdate.
const [initialDraft] = useState(() =>
draftKey ? useCommentDraftStore.getState().getDraft(draftKey) : undefined,
);
const [content, setContent] = useState(initialDraft ?? "");
const setDraft = useCommentDraftStore((s) => s.setDraft);
const [isEmpty, setIsEmpty] = useState(!initialDraft?.trim());
const [suppressedAgentIds, setSuppressedAgentIds] = useState<Set<string>>(() => new Set());
const triggerPreview = useCommentTriggerPreview({ issueId, parentId, content });
// Uploads for this reply session (MUL-5181) — owned by the coordinator. With
// a draftKey they persist in the draft store so scroll-out/close no longer
// drops an in-flight upload; without one (no persistence context) they fall
// back to session-local state inside the hook.
// `gate` widens the editor gate with coordinator-owned placeholders — see
// CommentInput.
const { uploads, attachments: pendingAttachments, handleUpload, removeUpload, gate } =
useCommentUploads(draftKey, { issueId }, uploadGate, editorRef);
// Readonly-first: static shell until intent; an unsent draft mounts the
// real editor immediately (see CommentInput). This is also what keeps the
// reply box working across Virtuoso scroll-out — a typed draft rehydrates
// into a live editor when the card remounts, an untouched box folds back
// to the shell.
const lazy = useLazyEditor({
initialActive:
!!initialDraft?.trim() ||
(draftKey ? useCommentDraftStore.getState().getUploads(draftKey).length > 0 : false),
editorRef,
});
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: lazy.uploadOrQueue,
});
// Flush on tab close / mobile background — same rationale as CommentInput.
useEffect(() => {
if (!draftKey) return;
const flush = () => {
const md = editorRef.current?.getMarkdown();
if (md && md.trim().length > 0) setDraft(draftKey, md);
};
const onVis = () => { if (document.visibilityState === "hidden") flush(); };
document.addEventListener("visibilitychange", onVis);
window.addEventListener("pagehide", flush);
return () => {
document.removeEventListener("visibilitychange", onVis);
window.removeEventListener("pagehide", flush);
};
}, [draftKey, setDraft]);
useEffect(() => {
setSuppressedAgentIds(new Set());
}, [issueId, parentId]);
useEffect(() => {
const visible = new Set(triggerPreview.agents.map((agent) => agent.id));
setSuppressedAgentIds((prev) => {
const next = new Set([...prev].filter((id) => visible.has(id)));
return next.size === prev.size ? prev : next;
});
}, [triggerPreview.agents]);
const toggleSuppressedAgent = useCallback((agentId: string) => {
setSuppressedAgentIds((prev) => {
const next = new Set(prev);
if (next.has(agentId)) next.delete(agentId);
else next.add(agentId);
return next;
});
}, []);
// Await-then-render send (see CommentInput): the shared hook keeps the text,
// locks + spins, and clears only once the server accepts it.
// Stale-submit guard — see CommentInput.
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const submittedEntryRef = useRef<unknown>(null);
// See CommentInput: bound to the branch that actually wiped the editor, so a
// draft the stale-submit guard kept is never disturbed.
const editorScrubbedRef = useRef(false);
const { submitting, submit } = useComposerSubmit({
editorRef,
uploadGate: gate,
containerRef: composerRef,
// A thread reply is rarely the last thing the user has to say, so the caret
// stays in the box for the next one. Unlike a top-level comment, the posted
// reply lands directly above the box that is still focused — nothing needs
// to pull the eye elsewhere. `containerRef` keeps this from stealing focus
// if the user moved to another composer while the reply was in flight.
afterAccepted: () => (editorScrubbedRef.current ? "refocus" : "none"),
onSubmit: (content) => {
editorScrubbedRef.current = false;
if (draftKey) {
// Flush pending debounce before snapshotting — see CommentInput.
const pending = editorRef.current?.flushPendingUpdate?.();
if (pending != null) setDraft(draftKey, pending);
submittedEntryRef.current = useCommentDraftStore.getState().drafts[draftKey];
}
// Bind only uploads the body still references (see CommentInput):
// deleting an inline image really unbinds it; close-surviving uploads
// are written back into the body by the settle handler.
const activeIds = pendingAttachments
.filter((a) => contentReferencesAttachment(content, a))
.map((a) => a.id);
const suppressAgentIds = triggerPreview.agents
.filter((agent) => suppressedAgentIds.has(agent.id))
.map((agent) => agent.id);
return onSubmit(
content,
activeIds.length > 0 ? activeIds : undefined,
suppressAgentIds.length > 0 ? suppressAgentIds : undefined,
);
},
onAccepted: () => {
// Success may only consume the entry it submitted — see CommentInput.
if (draftKey) {
const lateMd = editorRef.current?.flushPendingUpdate?.();
if (lateMd != null) setDraft(draftKey, lateMd);
const store = useCommentDraftStore.getState();
const live = store.drafts[draftKey];
const untouched = live === undefined || live === submittedEntryRef.current;
if (untouched) store.clearDraft(draftKey);
if (!mountedRef.current || !untouched) return;
} else {
if (!mountedRef.current) return;
uploads.forEach((u) => removeUpload(u.clientUploadId));
}
editorRef.current?.clearContent();
setContent("");
setIsEmpty(true);
setSuppressedAgentIds(new Set());
editorScrubbedRef.current = true;
},
});
const avatarSize: AvatarSize = size === "sm" ? "sm" : "md";
return (
<div className="group/editor flex items-start gap-2.5">
<ActorAvatar
actorType={avatarType}
actorId={avatarId}
size={avatarSize}
className="mt-0.5 shrink-0"
/>
<div
{...dropZoneProps}
ref={composerRef}
className={cn(
"relative min-w-0 flex-1 flex flex-col",
!isEmpty && "pb-9",
)}
>
{/* Lock the editor while the reply is in flight — see CommentInput. */}
{lazy.active && (
<div
className={cn(
"flex-1 min-h-0 overflow-y-auto",
submitting && "pointer-events-none opacity-60",
!lazy.ready && "hidden",
)}
aria-busy={submitting || undefined}
>
<ContentEditor
ref={editorRef}
defaultValue={initialDraft}
onReady={lazy.onReady}
placeholder={placeholderText}
onUpdate={(md) => {
setContent(md);
setIsEmpty(!md.trim());
// setDraft keeps any pending attachments and drops the entry only
// when text AND attachments are both empty.
if (draftKey) setDraft(draftKey, md);
}}
onSubmit={submit}
onUploadFile={handleUpload}
pasteAsFileThreshold={PASTE_AS_FILE_THRESHOLD}
onUploadingChange={uploadGate.onUploadingChange}
debounceMs={100}
currentIssueId={issueId}
attachments={pendingAttachments}
enableSlashCommands
slashCommandMode="command"
/>
</div>
)}
{uploads.some((u) => u.status !== "uploaded") && (
<ComposerUploadChips uploads={uploads} onRemove={removeUpload} className="mt-1" />
)}
{/* Static shell — clones the empty single-line reply box (see
CommentInput for the pattern). */}
{!lazy.ready && (
<div
data-testid="reply-composer-shell"
role="button"
tabIndex={0}
aria-label={placeholderText}
className="flex-1 min-h-0 cursor-text rich-text-editor text-sm"
onClick={() => lazy.activate()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
lazy.activate();
}
}}
>
{/* <p> under rich-text-editor: same type metrics as the real
editor's empty paragraph — no height jump on swap. */}
<p className="text-muted-foreground">{placeholderText}</p>
</div>
)}
<div className="absolute bottom-0 left-0 right-24 min-w-0">
<CommentTriggerChips
agents={triggerPreview.agents}
blocked={triggerPreview.blocked}
draftContent={content}
suppressedAgentIds={suppressedAgentIds}
onToggle={toggleSuppressedAgent}
/>
</div>
<div className="absolute bottom-0 right-0 flex items-center gap-1">
<FileUploadButton
size="sm"
multiple
onSelect={(file) => lazy.uploadOrQueue([file])}
/>
<SubmitButton
onClick={submit}
disabled={isEmpty}
loading={submitting}
busy={gate.uploading}
tooltip={gate.uploading
? tEditor(($) => $.upload.in_progress)
: sendShortcut
? `${t(($) => $.comment.send_tooltip)} · ${formatShortcut(sendShortcut)}`
: t(($) => $.comment.send_tooltip)}
ariaLabel={gate.uploading
? tEditor(($) => $.upload.in_progress)
: t(($) => $.comment.send_tooltip)}
/>
</div>
{isDragOver && <FileDropOverlay />}
</div>
</div>
);
}
export { ReplyInput, type ReplyInputProps };