mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 10:59:06 +02:00
* fix(editor): an in-flight upload placeholder is never content, and is drawn once
Two defects with one cause: a placeholder for an upload in progress was both
serialised into the draft body and drawn a second time as a chip.
The document IS the persisted draft (getMarkdown -> setDraft), so serialising
an in-flight node turns it into text that outlives the upload:
- fileCard emitted `!file[x.pdf]()`. Its own tokenizer cannot parse an empty
href back, so the line survived reopen as dead literal text, sat next to
the real link the write-back appended, and shipped with the comment.
- image emitted its process-local `blob:` URL, which ContentEditor then
scrubbed back out with a regex on every serialise.
Both renderMarkdown implementations now emit nothing while `attrs.uploading`
is set (or no URL exists). A node becomes content the moment it holds a real
URL and never before, which is strictly stronger than scrubbing after the
fact — so BLOB_IMAGE_RE / stripBlobUrls are deleted rather than extended.
Separately, ComposerUploadChips rendered every non-`uploaded` entry, including
ones whose placeholder node is right there in the editor. Every upload started
from a live mount inserts a node first (uploadAndInsertFile is the uploader's
only caller), so those chips were the same upload drawn twice, in two visual
languages, shifting layout as they appeared and vanished. useCoordinatedUploads
now exposes `orphanUploads` — the entries inherited from the persisted draft,
whose originating mount is gone and whose node died with it. That is the case
the chip strip was introduced for, and now the only one it covers.
`getMarkdown()` deliberately stays untrimmed (see its safety-net test); only
its stripBlobUrls wrapper is gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(editor): keep a failed upload visible after the chip/node split
Self-review catch on the previous commit: suppressing the chip for every
upload this mount started also suppressed it for FAILED ones. The document
cannot stand in for those — uploadAndInsertFile removes the placeholder node
on failure — so the outcome was left to a toast that has already gone.
The rule is not "started here" but "the document is showing it", and the
document only ever shows a live placeholder: still `uploading` AND started by
this mount. `failed` / `interrupted` always get a chip, `uploaded` never does
(the editor and AttachmentList render those), which also makes an
`orphanUploads.length` gate mean what the call sites assume.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(editor): keep the chip when the user deletes a running upload's placeholder
Code-review catch: "started by this mount" is necessary but not sufficient for
"the document is showing it". Cmd+Z right after a paste removes the placeholder
node while the upload keeps running — and gate.isBlocked keeps blocking send on
the store entry regardless of the node — so the previous filter left a dead send
button with nothing on screen explaining it.
The filter now also consults editorGate.uploading, which is the document's own
answer to "am I showing a placeholder right now" (sourced from the uploading-node
scan via onUploadingChange). Started-here AND still shown is what suppresses a
chip; either half failing brings it back.
Also drops a stale stripBlobUrls reference from the use-upload-gate docstring —
that helper no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(editor): a failed upload leaves nothing behind
The failure chip carried no information the toast had not already given at the
moment it happened, and it could not act on it: the bytes were never persisted,
so there is nothing to retry, and the file is still on disk to re-attach. Its
only affordance was a dismiss ✕.
It cost more than that. The entry lives in the persisted draft, so it survived
reload and reopen until dismissed by hand — and `isMeaningful` counts uploads,
so a single flaky request kept an otherwise-empty draft alive for the full
30-day TTL. Uploading again did not clear it either: a new upload is a new
clientUploadId.
Failures now remove their placeholder outright instead of marking it. Both
failure paths (size check, coordinator settle) collapse into that one rule,
which also folds the paste-as-file recovery into the shared branch rather than
duplicating it.
`interrupted` keeps its chip: it is discovered a session later, when the user
no longer remembers attaching anything. `orphanUploads` still handles `failed`
because an older client may have persisted one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(editor): one upload, one node, from start to finish
The chip strip existed because the document could not answer for an upload it
was not showing. Give it that ability and the strip has no reason to exist.
Three changes make one model:
- ONE IDENTITY. The node's `uploadId` and the draft's `clientUploadId` were
two independently minted random values, because the node is inserted before
the handler that created the draft record runs. `uploadAndInsertFile` now
mints the id up front and hands it to the uploader, which adopts it. Asking
"is this upload in the document" becomes a lookup instead of an inference.
- REBUILD ON MOUNT. A placeholder is never serialised (it is not content), so
it dies with the document that drew it and a reopened composer showed no
trace of an upload still running. The draft record is enough to draw it
again. Once per id per mount: a placeholder the user deleted mid-upload
stays deleted (MUL-5181), and the guard is what stops the next store write
from undoing that. Skipped entirely while chat pins its document to another
draft — `uploads` follows the selected key, the document does not.
- SETTLE IN PLACE. The write-back replaces the placeholder where the user last
saw it instead of appending the link at the end. A card promotes to an image
when that is what arrived; the rebuild path only ever has a filename, so it
cannot know in advance.
With that, the chips are deleted outright, along with `orphanUploads` and the
three-condition rule that approximated all of the above. `interrupted` goes
too: nothing could act on it, no surface rendered it after this change, and
`isMeaningful` counted it — one dead record kept an empty draft alive for the
full TTL. The attachment's absence from the body is the signal to re-attach.
SubmitButton's `busy` now spins rather than only greying out, so an upload
with no other on-screen trace (a composer still rebuilding, a placeholder the
user deleted) does not read as a dead control.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(editor): whoever draws a placeholder registers it, not whoever finds it
Review catch on the rebuild effect. An upload started by the current mount had
its node drawn synchronously by uploadAndInsertFile, but its id only entered
`rebuiltUploadIdsRef` once the effect ran and happened to find that node. In
between, a delete (Cmd+Z right after a paste) left the effect looking at an
unmarked `uploading` record with no node — so it drew a second one, undoing a
removal MUL-5181 says must stick, and letting the settle land an attachment
the user had taken out.
The id is now registered where it is minted: an id handed into handleUpload
means the editor already drew the node. The window is sub-frame and needs a
keystroke inside one render pass, but "whoever draws it registers it" is a
rule, where "the effect will notice in time" was a race.
The composer mocks called `onUploadFile(file)` with no id, so they were not
exercising the one-id contract at all — every mount-started upload looked
inherited to the hook. They now mint and pass one like the real handle does,
which is what lets the new regression test see the difference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
import { Extension } from "@tiptap/core";
|
|
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
|
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
|
import { createSafeId } from "@multica/core/utils";
|
|
|
|
/**
|
|
* Locate this upload's placeholder node, whichever shape it took.
|
|
*
|
|
* `uploadId` is the SAME value the draft store knows as `clientUploadId` —
|
|
* minted once in {@link uploadAndInsertFile} (or by the rebuild path) and
|
|
* carried by both records. One identity is what lets a settle find its own
|
|
* node in a document that a different mount rebuilt.
|
|
*/
|
|
|
|
function findUploadNode(editor: any, uploadId: string): { pos: number; node: any } | null {
|
|
let found: { pos: number; node: any } | null = null;
|
|
editor.state.doc.descendants((node: any, pos: number) => {
|
|
if (found) return false;
|
|
if (
|
|
(node.type.name === "fileCard" || node.type.name === "image") &&
|
|
node.attrs.uploadId === uploadId
|
|
) {
|
|
found = { pos, node };
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
return found;
|
|
}
|
|
|
|
/** Drop this upload's placeholder node, whichever shape it took. */
|
|
|
|
export function removeUploadNode(editor: any, uploadId: string): boolean {
|
|
const hit = findUploadNode(editor, uploadId);
|
|
if (!hit) return false;
|
|
editor.view.dispatch(editor.state.tr.delete(hit.pos, hit.pos + hit.node.nodeSize));
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Turn this upload's placeholder into the finished attachment, in place.
|
|
*
|
|
* Returns false when no node carries the id — the caller then knows the
|
|
* document is not showing this upload and can fall back to appending.
|
|
*
|
|
* A fileCard settling into an image swaps node TYPE, not just attrs: the
|
|
* rebuild path cannot know an upload will turn out to be an image (it has no
|
|
* bytes, only a filename), so it always writes a card and this is where the
|
|
* document catches up with what actually arrived.
|
|
*/
|
|
|
|
export function settleUploadNode(editor: any, uploadId: string, result: UploadResult): boolean {
|
|
const hit = findUploadNode(editor, uploadId);
|
|
if (!hit) return false;
|
|
// Persist the stable per-attachment URL, never the short-lived signed one
|
|
// (MUL-3130). `link` is the fallback for the no-workspace avatar branch.
|
|
const href = result.markdownLink || result.link;
|
|
const isImage = (result.content_type ?? "").startsWith("image/");
|
|
const tr = editor.state.tr;
|
|
|
|
if (hit.node.type.name === "image") {
|
|
tr.setNodeMarkup(hit.pos, undefined, {
|
|
...hit.node.attrs,
|
|
src: href,
|
|
alt: result.filename,
|
|
uploading: false,
|
|
uploadId: null,
|
|
});
|
|
} else if (isImage) {
|
|
tr.replaceWith(
|
|
hit.pos,
|
|
hit.pos + hit.node.nodeSize,
|
|
editor.schema.nodes.image.create({ src: href, alt: result.filename, uploading: false }),
|
|
);
|
|
} else {
|
|
tr.setNodeMarkup(hit.pos, undefined, {
|
|
...hit.node.attrs,
|
|
href,
|
|
uploading: false,
|
|
uploadId: null,
|
|
});
|
|
}
|
|
editor.view.dispatch(tr);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Write a placeholder for an upload this document is not showing yet.
|
|
*
|
|
* Used when a composer reopens over an upload a previous mount started: the
|
|
* bytes are gone with that mount, so there is no preview to render and the
|
|
* card is all we can honestly draw — {@link settleUploadNode} promotes it to
|
|
* an image if that is what arrives.
|
|
*/
|
|
|
|
export function insertUploadPlaceholder(
|
|
editor: any,
|
|
upload: { uploadId: string; filename: string; size?: number },
|
|
): boolean {
|
|
// Idempotent: already drawn counts as success, so a caller retrying until
|
|
// it lands cannot be fooled into retrying forever by its own first insert.
|
|
if (findUploadNode(editor, upload.uploadId)) return true;
|
|
const endPos = editor.state.doc.content.size;
|
|
editor
|
|
.chain()
|
|
.insertContentAt(endPos, {
|
|
type: "fileCard",
|
|
attrs: {
|
|
filename: upload.filename,
|
|
href: "",
|
|
fileSize: upload.size ?? 0,
|
|
uploading: true,
|
|
uploadId: upload.uploadId,
|
|
},
|
|
})
|
|
.run();
|
|
return true;
|
|
}
|
|
|
|
export function findImagePosBySrc(editor: any, src: string): number | null {
|
|
if (!editor) return null;
|
|
let imagePos: number | null = null;
|
|
editor.state.doc.descendants((node: any, pos: number) => {
|
|
if (imagePos !== null) return false;
|
|
if (node.type.name === "image" && node.attrs.src === src) {
|
|
imagePos = pos;
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
return imagePos;
|
|
}
|
|
|
|
/**
|
|
* Read an image's intrinsic pixel dimensions off-thread. Returns null when the
|
|
* decode fails or the API is unavailable (e.g. jsdom in tests, where
|
|
* `createImageBitmap` is undefined) — callers degrade to no reserved box.
|
|
*/
|
|
async function readImageDimensions(
|
|
file: File,
|
|
): Promise<{ width: number; height: number } | null> {
|
|
if (typeof createImageBitmap !== "function") return null;
|
|
try {
|
|
const bitmap = await createImageBitmap(file);
|
|
const dims = { width: bitmap.width, height: bitmap.height };
|
|
bitmap.close();
|
|
return dims.width > 0 && dims.height > 0 ? dims : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Measure the file's intrinsic size and write it onto the freshly-inserted
|
|
* image node so the browser reserves the box before decode (no layout shift).
|
|
* Fire-and-forget after insert: keyed on the blob `src`, so if the upload swap
|
|
* already replaced it we simply skip — the swap preserves any width/height we
|
|
* managed to set via `...imageNode.attrs`.
|
|
*/
|
|
async function applyImageDimensions(editor: any, file: File, src: string) {
|
|
const dims = await readImageDimensions(file);
|
|
if (!dims) return;
|
|
|
|
const imagePos = findImagePosBySrc(editor, src);
|
|
if (imagePos === null) return;
|
|
|
|
const imageNode = editor.state.doc.nodeAt(imagePos);
|
|
if (!imageNode || imageNode.attrs.width) return;
|
|
|
|
const tr = editor.state.tr.setNodeMarkup(imagePos, undefined, {
|
|
...imageNode.attrs,
|
|
width: dims.width,
|
|
height: dims.height,
|
|
});
|
|
editor.view.dispatch(tr);
|
|
}
|
|
|
|
function moveSelectionToParagraphAfterImage(editor: any, src: string) {
|
|
const imagePos = findImagePosBySrc(editor, src);
|
|
if (imagePos === null) return;
|
|
|
|
const imageNode = editor.state.doc.nodeAt(imagePos);
|
|
if (!imageNode) return;
|
|
|
|
const afterImagePos = imagePos + imageNode.nodeSize;
|
|
const $afterImage = editor.state.doc.resolve(afterImagePos);
|
|
if ($afterImage.nodeAfter?.type.name !== "paragraph") return;
|
|
|
|
const paragraphStart = afterImagePos + 1;
|
|
const tr = editor.state.tr
|
|
.setSelection(TextSelection.create(editor.state.doc, paragraphStart))
|
|
.scrollIntoView();
|
|
editor.view.dispatch(tr);
|
|
}
|
|
|
|
/**
|
|
* Shared upload flow: insert blob preview → upload → replace with real URL.
|
|
* Used by both paste/drop (at cursor) and button upload (at end of doc).
|
|
*/
|
|
export async function uploadAndInsertFile(
|
|
|
|
editor: any,
|
|
file: File,
|
|
handler: (file: File, uploadId: string) => Promise<UploadResult | null>,
|
|
pos?: number,
|
|
) {
|
|
const isImage = file.type.startsWith("image/");
|
|
// One id for both records. The handler adopts it as the draft's
|
|
// `clientUploadId`, so a settle arriving at a different mount can still find
|
|
// the node this one drew — see findUploadNode.
|
|
const uploadId = createSafeId();
|
|
|
|
if (isImage) {
|
|
const blobUrl = URL.createObjectURL(file);
|
|
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true, uploadId };
|
|
if (pos !== undefined) {
|
|
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
|
|
} else {
|
|
editor.chain().focus().setImage(imgAttrs).run();
|
|
moveSelectionToParagraphAfterImage(editor, blobUrl);
|
|
}
|
|
|
|
// Reserve the image box ASAP so the async decode doesn't shift layout.
|
|
// Fire-and-forget: must not delay the handler() call below, which the
|
|
// synchronous-insert contract (instant preview) depends on.
|
|
void applyImageDimensions(editor, file, blobUrl);
|
|
|
|
try {
|
|
const result = await handler(file, uploadId);
|
|
// The upload outlives the mount (coordinator-owned, MUL-5181): by the
|
|
// time it settles this editor may be destroyed. Dispatching against a
|
|
// destroyed EditorView throws, and the catch would dispatch again —
|
|
// the write-back path owns delivery for dead editors, not this swap.
|
|
if (editor.isDestroyed) return;
|
|
if (result) settleUploadNode(editor, uploadId, result);
|
|
else removeUploadNode(editor, uploadId);
|
|
} catch {
|
|
if (!editor.isDestroyed) removeUploadNode(editor, uploadId);
|
|
} finally {
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
} else {
|
|
// Non-image: insert skeleton fileCard → upload → finalize with real URL
|
|
const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId };
|
|
const insertContent = { type: "fileCard", attrs: cardAttrs };
|
|
if (pos !== undefined) {
|
|
editor.chain().focus().insertContentAt(pos, insertContent).run();
|
|
} else {
|
|
editor.chain().focus().insertContent(insertContent).run();
|
|
}
|
|
|
|
try {
|
|
const result = await handler(file, uploadId);
|
|
// See the image branch: a settle after this editor's destroy must not
|
|
// dispatch against the dead EditorView.
|
|
if (editor.isDestroyed) return;
|
|
if (result) settleUploadNode(editor, uploadId, result);
|
|
else removeUploadNode(editor, uploadId);
|
|
} catch {
|
|
if (!editor.isDestroyed) removeUploadNode(editor, uploadId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Deduplicate files from the same paste/drop event.
|
|
* macOS/Chrome can put the same file in the FileList twice. */
|
|
function dedupFiles(files: FileList): File[] {
|
|
const seen = new Set<string>();
|
|
return Array.from(files).filter((file) => {
|
|
const key = `${file.name}\0${file.size}\0${file.type}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/** Filename given to the .txt synthesised from an over-threshold paste. */
|
|
export const PASTED_TEXT_FILENAME = "pasted-text.txt";
|
|
|
|
/**
|
|
* Source text of every file synthesised by the paste-as-file path, keyed by
|
|
* the File instance that carries it downstream.
|
|
*
|
|
* Unlike a dropped file, this one has no copy anywhere else: the text was
|
|
* never written into the document and its source may be a tab the user has
|
|
* already closed. So whoever owns the draft must be able to put it back when
|
|
* the upload fails. A WeakMap rather than a property on the File keeps the
|
|
* File exactly as the upload layer expects it, and lets the entry die with the
|
|
* upload. Read it with {@link pastedTextSource}; a File that came from disk
|
|
* returns undefined and needs no recovery.
|
|
*/
|
|
const pastedTextSources = new WeakMap<File, string>();
|
|
|
|
/** Record the text a synthesised paste file was built from. */
|
|
export function markPastedTextFile(file: File, text: string): File {
|
|
pastedTextSources.set(file, text);
|
|
return file;
|
|
}
|
|
|
|
/** The text an over-threshold paste was made from, or undefined for real files. */
|
|
export function pastedTextSource(file: File): string | undefined {
|
|
return pastedTextSources.get(file);
|
|
}
|
|
|
|
export function createFileUploadExtension(
|
|
onUploadFileRef: React.RefObject<
|
|
((file: File, uploadId: string) => Promise<UploadResult | null>) | undefined
|
|
>,
|
|
/**
|
|
* Character count above which a plain-text paste is uploaded as a .txt
|
|
* attachment instead of being inserted into the document. A ref because the
|
|
* extension array is built once at mount while the prop that feeds it can
|
|
* change. Undefined / 0 keeps every paste as text — the default, so an
|
|
* editor that never opts in behaves exactly as before.
|
|
*/
|
|
pasteAsFileThresholdRef?: React.RefObject<number | undefined>,
|
|
) {
|
|
return Extension.create({
|
|
name: "fileUpload",
|
|
addProseMirrorPlugins() {
|
|
const { editor } = this;
|
|
|
|
const handleFiles = async (files: File[]) => {
|
|
const handler = onUploadFileRef.current;
|
|
if (!handler) return false;
|
|
for (const file of files) {
|
|
await uploadAndInsertFile(editor, file, handler);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
return [
|
|
new Plugin({
|
|
key: new PluginKey("fileUpload"),
|
|
props: {
|
|
handlePaste(_view, event) {
|
|
const files = event.clipboardData?.files;
|
|
if (!files?.length) {
|
|
// No file on the clipboard: this may still be a paste large
|
|
// enough that the host wants it as an attachment rather than
|
|
// thousands of characters of body text (turn-based composers
|
|
// only — document editors never pass a threshold).
|
|
const threshold = pasteAsFileThresholdRef?.current;
|
|
if (!threshold || threshold <= 0) return false;
|
|
const text = event.clipboardData?.getData("text/plain") ?? "";
|
|
if (text.length <= threshold) return false;
|
|
if (!onUploadFileRef.current) return false;
|
|
// A paste INTO a code block is the one long paste that is
|
|
// deliberately inline — the user opened a fence to show the
|
|
// thing. Converting it to an attachment would take away what
|
|
// they just asked for.
|
|
if (editor.isActive("codeBlock")) return false;
|
|
const file = new File([text], PASTED_TEXT_FILENAME, { type: "text/plain" });
|
|
handleFiles([markPastedTextFile(file, text)]);
|
|
return true;
|
|
}
|
|
if (!onUploadFileRef.current) return false;
|
|
handleFiles(dedupFiles(files));
|
|
return true;
|
|
},
|
|
handleDrop(view, event) {
|
|
const dragEvent = event as DragEvent;
|
|
const files = dragEvent.dataTransfer?.files;
|
|
if (!files?.length) return false;
|
|
const handler = onUploadFileRef.current;
|
|
if (!handler) return false;
|
|
// Resolve drop position from mouse coordinates.
|
|
// Only the first file uses the drop position; subsequent files
|
|
// append to the end to avoid stale position issues.
|
|
const dropPos = view.posAtCoords({ left: dragEvent.clientX, top: dragEvent.clientY });
|
|
const unique = dedupFiles(files);
|
|
for (let i = 0; i < unique.length; i++) {
|
|
const insertPos = i === 0 ? dropPos?.pos : undefined;
|
|
uploadAndInsertFile(editor, unique[i]!, handler, insertPos);
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
});
|
|
}
|