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:
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()
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user