fix(editor): group consecutive image uploads into a gallery

Uploading images one at a time beside each other now joins them into one gallery on web, mobile web, and the native editors.
This commit is contained in:
tianma-if committed 2026-09-27 11:45:42 +08:00
1 parent 5fe220704e
commit 401246be47
8 files changed
+235 -49

No files matched your search

@@ -1,12 +1,15 @@
import { normalizeImageGalleries, type TiptapDoc, type TiptapNode } from "@edgeever/shared";
const IMAGE_UPLOAD_PLACEHOLDER_PREFIX = "edgeever-image-upload://";
import {
isTransientImageUploadSource,
normalizeImageGalleries,
TRANSIENT_IMAGE_UPLOAD_PREFIX,
type TiptapDoc,
type TiptapNode,
} from "@edgeever/shared";
export const createMobileImageUploadPlaceholderSource = (id: string) =>
`${IMAGE_UPLOAD_PLACEHOLDER_PREFIX}${id}`;
`${TRANSIENT_IMAGE_UPLOAD_PREFIX}${id}`;
export const isMobileImageUploadPlaceholderSource = (source: unknown) =>
typeof source === "string" && source.startsWith(IMAGE_UPLOAD_PLACEHOLDER_PREFIX);
export const isMobileImageUploadPlaceholderSource = isTransientImageUploadSource;
export const stripMobileImageUploadPlaceholders = (doc: TiptapDoc): TiptapDoc => {
const stripNodes = (nodes: TiptapNode[]): TiptapNode[] => nodes.flatMap((node) => {
@@ -10,6 +10,8 @@ import Placeholder from "@tiptap/extension-placeholder";
import { createExcerpt, createEdgeEverDocumentExtensions, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, isPdfAttachment, wrapDetailsContentHtml, type MemoDetail, type MemoEditSession, type Notebook, type TagSummary, type TiptapDoc } from "@edgeever/shared";
import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
import { NEW_IMAGE_WIDTH_PERCENT } from "@edgeever/shared/image-display";
import { insertUploadedResources } from "@/lib/resource-insertion";
import { getResourceInsertionTarget } from "@/lib/resource-insertion-target";
import { getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor";
import { EdgeEverLink } from "@edgeever/shared/editor-link";
import { createInlineFieldExtension } from "@/components/editor/InlineField";
@@ -627,16 +629,16 @@ export const MobileStandaloneTiptapEditor = ({
setSaveStateStable("uploading");
const { resource } = await uploadMobileEditorResource(currentMemo.id, uploadFile);
if (resource.kind === "image") {
editor
.chain()
.focus()
.setImage({
const target = getResourceInsertionTarget(editor.state.selection);
editor.chain().focus().command(insertUploadedResources(target, [{
type: "image",
attrs: {
src: resource.url,
alt: file.name,
title: file.name,
width: NEW_IMAGE_WIDTH_PERCENT,
})
.run();
},
}], true)).run();
} else if (isPdfAttachment(file.type, resource.filename || file.name)) {
editor
.chain()
+55 -3
View File
@@ -34,10 +34,62 @@ describe("uploaded resources beside an existing gallery", () => {
editor.destroy();
});
test("keeps a single upload standalone when there is no neighboring gallery", () => {
test("a single upload beside a standalone image becomes one gallery", () => {
const editor = makeEditor([image("old"), paragraph()]);
insert(editor, 2, [image("new")]);
expect(editor.getJSON().content.map((node) => node.type)).toEqual(["image", "image"]);
editor.commands.setNodeSelection(0);
insert(editor, getResourceInsertionTarget(editor.state.selection), [image("new")]);
expect(editor.getJSON().content.map((node) => node.type)).toEqual([IMAGE_GALLERY_NODE_TYPE, "paragraph"]);
expect(editor.getJSON().content[0].attrs.layout).toBe("auto");
expect(editor.getJSON().content[0].content.map((node) => node.attrs.src)).toEqual(["old", "new"]);
expect(editor.state.selection.node.attrs.src).toBe("new");
insert(editor, getResourceInsertionTarget(editor.state.selection), [image("third")]);
expect(editor.getJSON().content).toHaveLength(2);
expect(editor.getJSON().content[0].content.map((node) => node.attrs.src)).toEqual(["old", "new", "third"]);
editor.destroy();
});
test("undo restores the standalone image after an adjacent upload is grouped", () => {
const editor = makeEditor([image("old"), paragraph()]);
editor.view.updateState(editor.state.reconfigure({ plugins: [history()] }));
const before = editor.getJSON();
editor.view.dispatch(closeHistory(editor.state.tr));
editor.commands.setNodeSelection(0);
insert(editor, getResourceInsertionTarget(editor.state.selection), [image("new")]);
expect(undo(editor.state, editor.view.dispatch)).toBe(true);
expect(editor.getJSON()).toEqual(before);
expect(redo(editor.state, editor.view.dispatch)).toBe(true);
expect(editor.getJSON().content[0].content.map((node) => node.attrs.src)).toEqual(["old", "new"]);
editor.destroy();
});
test("a single upload before a standalone image keeps upload order", () => {
const editor = makeEditor([image("old"), paragraph("keep")]);
insert(editor, 0, [image("new")]);
expect(editor.getJSON().content[0].content.map((node) => node.attrs.src)).toEqual(["new", "old"]);
expect(editor.getJSON().content[1].content[0].text).toBe("keep");
editor.destroy();
});
test("a multi-image upload absorbs the adjacent standalone run and preserves its metadata", () => {
const editor = makeEditor([image("old"), paragraph()]);
editor.commands.setNodeSelection(0);
insert(editor, getResourceInsertionTarget(editor.state.selection), [image("new-a"), image("new-b")]);
const gallery = editor.getJSON().content[0];
expect(gallery.attrs.layout).toBe("auto");
expect(gallery.content.map((node) => node.attrs.src)).toEqual(["old", "new-a", "new-b"]);
expect(gallery.content[1].attrs.title).toBe("new-a");
editor.destroy();
});
test("uploading beside loose images joins the earlier gallery and keeps its layout", () => {
const editor = makeEditor([gallery("3"), image("loose"), gallery("2", ["other-a", "other-b"])]);
insert(editor, 5, [image("new")]);
const content = editor.getJSON().content;
expect(content.map((node) => node.type)).toEqual([IMAGE_GALLERY_NODE_TYPE, IMAGE_GALLERY_NODE_TYPE]);
expect(content[0].attrs.layout).toBe("3");
expect(content[0].content.map((node) => node.attrs.src)).toEqual(["one", "two", "loose", "new"]);
expect(content[1].attrs.layout).toBe("2");
expect(content[1].content.map((node) => node.attrs.src)).toEqual(["other-a", "other-b"]);
editor.destroy();
});
+1 -1
View File
@@ -7,7 +7,7 @@ import {
import { mergeUploadedImagesIntoAdjacentGalleries } from "@edgeever/shared/image-gallery-editing";
import type { ResourceInsertionTarget } from "./resource-insertion-target";
/** Insert a batch and extend only galleries immediately beside the new media. */
/** Insert a batch and fold it into the adjacent images or gallery. */
export const insertUploadedResources = (
target: ResourceInsertionTarget,
content: TiptapNode[],
+128 -26
View File
@@ -1,43 +1,145 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { Transaction } from "@tiptap/pm/state";
import { canJoin } from "@tiptap/pm/transform";
import { IMAGE_GALLERY_NODE_TYPE } from "./image-gallery";
import { IMAGE_GALLERY_NODE_TYPE, isTransientImageUploadSource } from "./image-gallery";
/** Extend adjacent galleries using only the nodes from the completed upload. */
const isCompletedImage = (node: ProseMirrorNode | null | undefined): node is ProseMirrorNode =>
node?.type.name === "image" && !isTransientImageUploadSource(node.attrs.src);
const isSavedGallery = (
node: ProseMirrorNode | null | undefined,
inserted: ReadonlySet<ProseMirrorNode>,
): node is ProseMirrorNode =>
!!node && node.type.name === IMAGE_GALLERY_NODE_TYPE && !inserted.has(node);
const locateTopLevel = (
tr: Transaction,
target: ProseMirrorNode,
): { node: ProseMirrorNode; pos: number } | null => {
let found: { node: ProseMirrorNode; pos: number } | null = null;
tr.doc.forEach((node, pos) => {
if (node === target) found = { node, pos };
});
return found;
};
const expandCompletedImageRun = (tr: Transaction, pos: number, node: ProseMirrorNode) => {
let start = pos;
let end = pos + node.nodeSize;
while (isCompletedImage(tr.doc.resolve(start).nodeBefore)) {
start -= tr.doc.resolve(start).nodeBefore!.nodeSize;
}
while (isCompletedImage(tr.doc.resolve(end).nodeAfter)) {
end += tr.doc.resolve(end).nodeAfter!.nodeSize;
}
return { start, end };
};
const wrapImageRun = (
tr: Transaction,
start: number,
end: number,
type: ProseMirrorNode["type"],
attrs: ProseMirrorNode["attrs"],
) => {
const range = tr.doc.resolve(start).blockRange(tr.doc.resolve(end));
if (!range) return false;
tr.wrap(range, [{ type, attrs }]);
return true;
};
/**
* Fold a completed upload into the images or gallery it touches.
* Joining, rather than replacing a saved gallery, keeps selections inside it.
* Unfinished upload placeholders stay put. Text, attachments, and a second
* saved gallery still end the run.
*/
export const mergeUploadedImagesIntoAdjacentGalleries = (
tr: Transaction,
nodes: readonly ProseMirrorNode[],
) => {
// Match node identities, not URLs, so other occurrences are left alone.
const inserted = new Set(nodes);
const candidates: Array<{ node: ProseMirrorNode; pos: number }> = [];
tr.doc.forEach((node, pos) => {
const pending: ProseMirrorNode[] = [];
tr.doc.forEach((node) => {
if (inserted.has(node) && (node.type.name === "image" || node.type.name === IMAGE_GALLERY_NODE_TYPE)) {
candidates.push({ node, pos });
pending.push(node);
}
});
// Work backwards so later joins do not invalidate earlier positions. Never
// cross paragraphs/attachments or combine two pre-existing galleries.
for (const { node, pos } of candidates.reverse()) {
const before = tr.doc.resolve(pos).nodeBefore;
const after = tr.doc.resolve(pos + node.nodeSize).nodeAfter;
const previousGallery = before?.type.name === IMAGE_GALLERY_NODE_TYPE && !inserted.has(before) ? before : null;
const nextGallery = after?.type.name === IMAGE_GALLERY_NODE_TYPE && !inserted.has(after) ? after : null;
const neighbor = previousGallery ?? nextGallery;
if (!neighbor) continue;
// Wrap only the new singleton, then join containers. Unlike replacing the
// entire old gallery, joining maps existing selections within it correctly.
if (node.type.name === "image") {
const range = tr.doc.resolve(pos).blockRange(tr.doc.resolve(pos + node.nodeSize));
if (!range) continue;
tr.wrap(range, [{ type: neighbor.type, attrs: neighbor.attrs }]);
} else if (!previousGallery) {
tr.setNodeMarkup(pos, undefined, neighbor.attrs);
}
const joinAt = previousGallery ? pos : pos + tr.doc.nodeAt(pos)!.nodeSize;
if (canJoin(tr.doc, joinAt)) tr.join(joinAt);
// Work backwards so a join does not invalidate a later upload's position.
for (const candidate of pending.reverse()) {
const current = locateTopLevel(tr, candidate);
if (!current) continue;
absorbUploadedNode(tr, current.node, current.pos, inserted);
}
return tr;
};
const absorbUploadedNode = (
tr: Transaction,
node: ProseMirrorNode,
pos: number,
inserted: ReadonlySet<ProseMirrorNode>,
) => {
const galleryType = tr.doc.type.schema.nodes[IMAGE_GALLERY_NODE_TYPE];
if (!galleryType) return;
const { start, end } = expandCompletedImageRun(tr, pos, node);
const previousGallery = isSavedGallery(tr.doc.resolve(start).nodeBefore, inserted)
? tr.doc.resolve(start).nodeBefore
: null;
// A new image between two saved galleries joins the earlier one only.
const nextGallery = previousGallery
? null
: isSavedGallery(tr.doc.resolve(end).nodeAfter, inserted)
? tr.doc.resolve(end).nodeAfter
: null;
const neighbor = previousGallery ?? nextGallery;
const hasLooseImages = start < pos || end > pos + node.nodeSize;
if (!hasLooseImages && !neighbor) return;
if (node.type.name === "image") {
const attrs = neighbor?.attrs ?? { layout: "auto" };
if (!wrapImageRun(tr, start, end, neighbor?.type ?? galleryType, attrs)) return;
if (previousGallery) {
if (canJoin(tr.doc, start)) tr.join(start);
} else if (nextGallery) {
const wrapped = tr.doc.nodeAt(start);
const joinAt = wrapped ? start + wrapped.nodeSize : null;
if (joinAt !== null && canJoin(tr.doc, joinAt)) tr.join(joinAt);
}
return;
}
const attrs = neighbor?.attrs ?? node.attrs;
let galleryPos = pos;
if (end > pos + node.nodeSize) {
const rightFrom = pos + node.nodeSize;
if (wrapImageRun(tr, rightFrom, end, galleryType, attrs) && canJoin(tr.doc, rightFrom)) {
tr.join(rightFrom);
}
}
if (start < galleryPos) {
if (wrapImageRun(tr, start, galleryPos, galleryType, attrs)) {
const wrapped = tr.doc.nodeAt(start);
const joinAt = wrapped ? start + wrapped.nodeSize : null;
if (joinAt !== null && canJoin(tr.doc, joinAt)) {
tr.join(joinAt);
galleryPos = start;
}
}
}
const current = tr.doc.nodeAt(galleryPos);
if (!current || current.type.name !== IMAGE_GALLERY_NODE_TYPE) return;
const before = tr.doc.resolve(galleryPos).nodeBefore;
const after = tr.doc.resolve(galleryPos + current.nodeSize).nodeAfter;
if (isSavedGallery(before, inserted)) {
if (canJoin(tr.doc, galleryPos)) tr.join(galleryPos);
} else if (isSavedGallery(after, inserted)) {
tr.setNodeMarkup(galleryPos, undefined, after.attrs);
const joinAt = galleryPos + tr.doc.nodeAt(galleryPos)!.nodeSize;
if (canJoin(tr.doc, joinAt)) tr.join(joinAt);
}
};
+5
View File
@@ -3,6 +3,11 @@ import type { TiptapDoc, TiptapNode, TiptapTextNode } from "./content";
export const IMAGE_GALLERY_NODE_TYPE = "edgeeverImageGallery" as const;
export const IMAGE_GALLERY_LAYOUTS = ["auto", "2", "3", "1"] as const;
/** In-document src used while a mobile upload has not finished. */
export const TRANSIENT_IMAGE_UPLOAD_PREFIX = "edgeever-image-upload://";
export const isTransientImageUploadSource = (source: unknown) =>
typeof source === "string" && source.startsWith(TRANSIENT_IMAGE_UPLOAD_PREFIX);
export type ImageGalleryLayout = (typeof IMAGE_GALLERY_LAYOUTS)[number];
export const resolveImageGalleryLayout = (value: unknown): ImageGalleryLayout =>
@@ -30,19 +30,41 @@ describe("native gallery editing", () => {
expect(groupUploadedImages(editor, ["one", "two", "three"])).toBe(true);
expect(editor.state.doc.firstChild.content.content.map((node) => node.attrs.src)).toEqual(["one", "two", "three"]);
});
test("groups only this batch, preserves order and is undoable", () => {
test("a new batch absorbs the adjacent standalone image and stays undoable", () => {
const editor = makeEditor([image("old"), image("one"), image("two"), paragraph("text")]);
expect(groupUploadedImages(editor, ["one", "two"])).toBe(true);
expect(editor.state.doc.child(0).attrs.src).toBe("old");
const group = editor.state.doc.child(1);
expect(editor.state.doc.childCount).toBe(2);
const group = editor.state.doc.child(0);
expect(group.type.name).toBe(IMAGE_GALLERY_NODE_TYPE);
expect(group.attrs.layout).toBe("auto");
expect(group.content.content.map((node) => node.attrs.src)).toEqual(["one", "two"]);
expect(group.content.content.map((node) => node.attrs.src)).toEqual(["old", "one", "two"]);
expect(editor.state.doc.child(1).textContent).toBe("text");
undo(editor.state, editor.view.dispatch);
expect(editor.state.doc.childCount).toBe(4);
expect(editor.state.doc.child(1).attrs.src).toBe("one");
});
test("an unfinished upload between images stays outside the gallery", () => {
const editor = makeEditor([
image("old"),
image("edgeever-image-upload://pending"),
image("new"),
]);
const original = editor.state.doc;
expect(groupUploadedImages(editor, ["new"])).toBe(false);
expect(editor.state.doc).toBe(original);
});
test("a later single upload beside a standalone image becomes one gallery", () => {
const editor = makeEditor([image("old"), paragraph("keep"), image("apart")]);
editor.view.dispatch(createImageInsertTransaction(editor.state, { src: "new" }, { from: 1, to: 1 }));
expect(groupUploadedImages(editor, ["new"])).toBe(true);
expect(editor.state.doc.child(0).content.content.map((node) => node.attrs.src)).toEqual(["old", "new"]);
expect(editor.state.doc.child(1).textContent).toBe("keep");
expect(editor.state.doc.child(2).attrs.src).toBe("apart");
editor.state.doc.check();
});
test("never crosses text or nests a gallery, and leaves isolated singletons alone", () => {
const editor = makeEditor([gallery(), paragraph("keep"), image("one"), paragraph("keep"), image("two")]);
const original = editor.state.doc;
@@ -96,11 +118,11 @@ describe("native gallery editing", () => {
test("groups only successful Android placeholder replacements and leaves pending uploads alone", () => {
const editor = makeEditor([gallery()]);
editor.view.dispatch(createImageInsertTransaction(editor.state, { src: "upload:third" }, { from: 4, to: 4 }));
editor.view.dispatch(createImageInsertTransaction(editor.state, { src: "upload:pending" }));
editor.view.dispatch(createImageInsertTransaction(editor.state, { src: "edgeever-image-upload://pending" }));
editor.view.dispatch(editor.state.tr.setNodeMarkup(4, undefined, { src: "third", alt: "third" }));
expect(groupUploadedImages(editor, ["third"])).toBe(true);
expect(editor.state.doc.firstChild.childCount).toBe(3);
expect(editor.state.doc.lastChild.attrs.src).toBe("upload:pending");
expect(editor.state.doc.lastChild.attrs.src).toBe("edgeever-image-upload://pending");
// Cancelling the pending upload must leave the existing gallery intact.
const pendingPos = editor.state.doc.firstChild.nodeSize;
editor.view.dispatch(editor.state.tr.delete(pendingPos, pendingPos + 1));
+1 -1
View File
@@ -39,7 +39,7 @@ export const createImageInsertTransaction = (
return tr;
};
/** Group completed uploads, then extend adjacent galleries without moving user text. */
/** Group a completed upload with the adjacent images or gallery, without crossing text. */
export const groupUploadedImages = (editor: Editor, sources: readonly string[]): boolean => {
if (!editor.isEditable || editor.isDestroyed || sources.length === 0) return false;
const type = editor.schema.nodes[IMAGE_GALLERY_NODE_TYPE];