mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* 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>
272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
/**
|
|
* Shared extension factory for ContentEditor.
|
|
*
|
|
* One function builds the extension array for BOTH edit and readonly modes.
|
|
* This ensures visual consistency — the same extensions parse and render
|
|
* content identically regardless of mode.
|
|
*
|
|
* Split:
|
|
* - Both modes: StarterKit, CodeBlock, Link, Image, Table, Markdown, Mention
|
|
* - Edit only: Typography, Placeholder, markdownPaste, submitShortcut,
|
|
* fileUpload, Mention suggestion popup
|
|
*
|
|
* Link config differs: edit mode has autolink (detects URLs while typing),
|
|
* readonly does not (prevents false positives on display).
|
|
*
|
|
* Mention suggestion is only attached in edit mode — readonly doesn't need
|
|
* the autocomplete popup.
|
|
*
|
|
* All link styling is controlled by styles/prose.css (var(--brand) color),
|
|
* not Tailwind HTMLAttributes, to keep a single source of truth.
|
|
*/
|
|
import type { RefObject } from "react";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
import Link from "@tiptap/extension-link";
|
|
import Typography from "@tiptap/extension-typography";
|
|
import Image from "@tiptap/extension-image";
|
|
import TableRow from "@tiptap/extension-table-row";
|
|
import TableHeader from "@tiptap/extension-table-header";
|
|
import TableCell from "@tiptap/extension-table-cell";
|
|
import { Table } from "@tiptap/extension-table";
|
|
import { TaskList } from "@tiptap/extension-list";
|
|
import { Markdown } from "@tiptap/markdown";
|
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
import type { AnyExtension } from "@tiptap/core";
|
|
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
|
import { shouldAutoLink } from "@multica/ui/markdown";
|
|
import { escapeMarkdownLabel } from "../utils/escape-markdown-label";
|
|
import { BaseMentionExtension } from "./mention-extension";
|
|
import { createMentionSuggestion, type MentionItem } from "./mention-suggestion";
|
|
import {
|
|
createIssueIdentifierAutolinkExtension,
|
|
type IssueIdentifierResolver,
|
|
} from "./issue-identifier-autolink";
|
|
import { SlashCommandExtension } from "./slash-command-extension";
|
|
import { createSlashCommandSuggestion, createBuiltinCommandSuggestion } from "./slash-command-suggestion";
|
|
import { CodeBlockView } from "./code-block-view";
|
|
import { PatchedListItem, PatchedTaskItem } from "./list-item";
|
|
import { createMarkdownPasteExtension } from "./markdown-paste";
|
|
import { createMarkdownCopyExtension } from "./markdown-copy";
|
|
import { createBlurShortcutExtension } from "./blur-shortcut";
|
|
import { createSubmitShortcutExtension } from "./submit-shortcut";
|
|
import { createFileUploadExtension } from "./file-upload";
|
|
import { FileCardExtension } from "./file-card";
|
|
import { ImageView } from "./image-view";
|
|
import { BlockMathExtension, InlineMathExtension } from "./math";
|
|
import { HighlightExtension } from "./highlight";
|
|
import { codeLowlight } from "../syntax-highlight";
|
|
|
|
const LinkExtension = Link.extend({ inclusive: false }).configure({
|
|
openOnClick: false,
|
|
autolink: true,
|
|
linkOnPaste: true,
|
|
defaultProtocol: "https",
|
|
shouldAutoLink,
|
|
});
|
|
|
|
export const ImageExtension = Image.extend({
|
|
addAttributes() {
|
|
return {
|
|
...this.parent?.(),
|
|
uploading: {
|
|
default: false,
|
|
renderHTML: (attrs: Record<string, unknown>) =>
|
|
attrs.uploading ? { "data-uploading": "" } : {},
|
|
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
|
|
},
|
|
// Intrinsic pixel dimensions, captured on upload (file-upload.ts). The
|
|
// browser uses width/height on <img> to compute aspect-ratio and reserve
|
|
// the box before the image decodes, so inserting an image causes no
|
|
// layout shift (and the post-insert scrollIntoView stays correct). Not
|
|
// serialized to markdown — `renderMarkdown` only emits src/alt/title — so
|
|
// round-trips stay clean.
|
|
width: {
|
|
default: null,
|
|
renderHTML: (attrs: Record<string, unknown>) =>
|
|
attrs.width ? { width: attrs.width as number } : {},
|
|
parseHTML: (el: HTMLElement) => {
|
|
const w = parseInt(el.getAttribute("width") || "", 10);
|
|
return Number.isFinite(w) ? w : null;
|
|
},
|
|
},
|
|
height: {
|
|
default: null,
|
|
renderHTML: (attrs: Record<string, unknown>) =>
|
|
attrs.height ? { height: attrs.height as number } : {},
|
|
parseHTML: (el: HTMLElement) => {
|
|
const h = parseInt(el.getAttribute("height") || "", 10);
|
|
return Number.isFinite(h) ? h : null;
|
|
},
|
|
},
|
|
};
|
|
},
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(ImageView);
|
|
},
|
|
renderMarkdown: (node: any) => {
|
|
const src = node.attrs?.src || "";
|
|
const alt = escapeMarkdownLabel(node.attrs?.alt || "");
|
|
const title = node.attrs?.title;
|
|
if (title) {
|
|
return ``;
|
|
}
|
|
return ``;
|
|
},
|
|
}).configure({
|
|
inline: false,
|
|
allowBase64: false,
|
|
});
|
|
|
|
export interface EditorExtensionsOptions {
|
|
/**
|
|
* Placeholder text, or a getter for it. Prefer a getter when the value can
|
|
* change over the editor's lifetime: Tiptap's Placeholder snapshots a string
|
|
* option at mount, but re-invokes a function every time it recomputes its
|
|
* decorations — so a getter (paired with an empty-transaction nudge) lets the
|
|
* placeholder update live without remounting the editor. See ContentEditor.
|
|
*/
|
|
placeholder?: string | (() => string);
|
|
queryClient?: import("@tanstack/react-query").QueryClient;
|
|
onSubmitRef?: RefObject<(() => void) | undefined>;
|
|
onUploadFileRef?: RefObject<
|
|
((file: File) => Promise<UploadResult | null>) | undefined
|
|
>;
|
|
/**
|
|
* Character count above which a plain-text paste becomes a .txt attachment
|
|
* instead of document text. A ref so the value can change without
|
|
* recreating the editor. Omitted (the default) keeps every paste as text.
|
|
*/
|
|
pasteAsFileThresholdRef?: RefObject<number | undefined>;
|
|
/**
|
|
* When true, the `@` suggestion picker is not attached. The mention node
|
|
* type is still registered in the schema so any mention pasted in from
|
|
* another Multica editor renders as the normal mention pill instead of
|
|
* being silently dropped by ProseMirror's schema check. 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;
|
|
/** Override @ behavior for chat context suggestions. */
|
|
mentionMode?: "default" | "context";
|
|
getMentionContextItems?: () => MentionItem[];
|
|
/** When true, attach the `/` picker. Default false. */
|
|
enableSlashCommands?: boolean;
|
|
/**
|
|
* Which `/` menu to attach when enableSlashCommands is true:
|
|
* - "skill" (default) — the chat picker listing the active agent's skills.
|
|
* - "command" — the fixed built-in command menu (issue comments), e.g. /note.
|
|
*/
|
|
slashCommandMode?: "skill" | "command";
|
|
/**
|
|
* Resolver for Linear-style bare issue-identifier autolinking. When present
|
|
* (and mentions are enabled), typing a boundary after `MUL-123` or pasting
|
|
* text with identifiers resolves them and swaps in real issue mentions. A
|
|
* ref so the editor is created once while the resolver reads live workspace
|
|
* context; the setup layer owns React Query + workspace access.
|
|
*/
|
|
resolveIssueIdentifierRef?: RefObject<IssueIdentifierResolver | undefined>;
|
|
}
|
|
|
|
export function createEditorExtensions(
|
|
options: EditorExtensionsOptions,
|
|
): AnyExtension[] {
|
|
const { placeholder: placeholderText } = options;
|
|
|
|
return [
|
|
StarterKit.configure({
|
|
heading: { levels: [1, 2, 3] },
|
|
link: false,
|
|
codeBlock: false,
|
|
// Underline has no Markdown representation. Tiptap's extension serializes
|
|
// the mark as `++text++`, which is not CommonMark or GFM, so ReadonlyContent
|
|
// (react-markdown + remark-gfm) renders the delimiters literally. Disabling
|
|
// the extension drops the mark at parse time instead: pasted `<u>` /
|
|
// `text-decoration: underline` keep their text, and Cmd+U becomes a no-op
|
|
// rather than a way to produce content the display layer cannot render.
|
|
underline: false,
|
|
// Disable StarterKit's stock ListItem — its Enter keybind binds only
|
|
// `splitListItem`, which leaves the user stuck inside an empty top-level
|
|
// list item (see list-item.ts). PatchedListItem below restores the
|
|
// standard split → lift fallback chain.
|
|
listItem: false,
|
|
}),
|
|
PatchedListItem,
|
|
// Checkbox task lists: `- [ ]` / `- [x]`. TaskList + TaskItem ship their own
|
|
// markdown tokenizer / renderMarkdown, an input rule (typing `[] ` / `[x] `),
|
|
// 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 PatchedListItem's bullet list.
|
|
TaskList,
|
|
PatchedTaskItem,
|
|
CodeBlockLowlight.extend({
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(CodeBlockView);
|
|
},
|
|
}).configure({ lowlight: codeLowlight }),
|
|
// ⚠️ Link MUST appear before markdownPaste in this array.
|
|
// linkOnPaste relies on Link's handlePaste plugin firing first;
|
|
// markdownPaste's handlePaste is a catch-all that returns true.
|
|
LinkExtension,
|
|
ImageExtension,
|
|
// renderWrapper wraps the table in `<div class="tableWrapper">` (the same
|
|
// wrapper the resizable NodeView emits), which prose.css styles with
|
|
// `overflow-x: auto`. Without it a wide table is a bare <table> that can't
|
|
// shrink below min-content, so the horizontal scrollbar lands on the
|
|
// page-level scroll container instead of the table itself.
|
|
Table.configure({ resizable: false, renderWrapper: true }),
|
|
TableRow,
|
|
TableHeader,
|
|
TableCell,
|
|
BlockMathExtension,
|
|
InlineMathExtension,
|
|
HighlightExtension,
|
|
// 3-space indent so nested ordered lists survive CommonMark in ReadonlyContent.
|
|
Markdown.configure({ indentation: { style: "space", size: 3 } }),
|
|
// Make Cmd+C / Cmd+X / drag write Markdown source to clipboard text/plain
|
|
// so users can copy rich content out as the original Markdown.
|
|
createMarkdownCopyExtension(),
|
|
FileCardExtension,
|
|
BaseMentionExtension.configure({
|
|
HTMLAttributes: { class: "mention" },
|
|
...(options.disableMentions
|
|
? { suggestion: { allow: () => false } }
|
|
: options.queryClient
|
|
? { suggestion: createMentionSuggestion(options.queryClient, { mode: options.mentionMode, getContextItems: options.getMentionContextItems }) }
|
|
: {}),
|
|
}),
|
|
// Linear-style bare identifier → issue mention. Attached only when a
|
|
// resolver is provided AND mention creation is enabled (an editor that
|
|
// suppresses new mentions should not synthesise them from identifiers).
|
|
...(!options.disableMentions && options.resolveIssueIdentifierRef
|
|
? [
|
|
createIssueIdentifierAutolinkExtension({
|
|
resolveRef: options.resolveIssueIdentifierRef,
|
|
}),
|
|
]
|
|
: []),
|
|
SlashCommandExtension.configure({
|
|
HTMLAttributes: { class: "slash-command" },
|
|
suggestion: !options.enableSlashCommands
|
|
? { char: "/", allow: () => false }
|
|
: options.slashCommandMode === "command"
|
|
? createBuiltinCommandSuggestion()
|
|
: options.queryClient
|
|
? createSlashCommandSuggestion(options.queryClient)
|
|
: { char: "/", allow: () => false },
|
|
}),
|
|
Typography,
|
|
Placeholder.configure({ placeholder: placeholderText }),
|
|
createMarkdownPasteExtension(),
|
|
createSubmitShortcutExtension(() => {
|
|
const fn = options.onSubmitRef?.current;
|
|
if (!fn) return false;
|
|
fn();
|
|
return true;
|
|
}),
|
|
createBlurShortcutExtension(),
|
|
createFileUploadExtension(options.onUploadFileRef!, options.pasteAsFileThresholdRef),
|
|
];
|
|
}
|