Files
edgeever/packages/shared/src/native-image-gallery.test.mjs
T
tianma-if 7846cd07e8 fix(mobile): merge later uploads into adjacent image galleries
Share gallery merging across web, Android and iOS, and handle single-image uploads. Refs #342.
2026-09-03 11:35:31 +08:00

148 lines
8.6 KiB
JavaScript

import { describe, expect, test } from "bun:test";
import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { EditorState, NodeSelection } from "@tiptap/pm/state";
import { closeHistory, history, redo, undo } from "@tiptap/pm/history";
import { ImageGallery, IMAGE_GALLERY_NODE_TYPE } from "./image-gallery";
import { createImageInsertTransaction, getImageInsertionRange, groupUploadedImages, NATIVE_IMAGE_GALLERY_CSS } from "./native-image-gallery";
const schema = getSchema([StarterKit, Image, ImageGallery]);
const image = (src) => schema.nodes.image.create({ src, alt: src });
const paragraph = (text) => schema.nodes.paragraph.create(null, text ? schema.text(text) : null);
const gallery = () => schema.nodes[IMAGE_GALLERY_NODE_TYPE].create({ layout: "3" }, [image("old-a"), image("old-b")]);
const makeEditor = (nodes) => {
const editor = {
schema, isEditable: true, isDestroyed: false,
state: EditorState.create({ schema, doc: schema.nodes.doc.create(null, nodes), plugins: [history()] }),
};
editor.view = { dispatch: (tr) => { editor.state = editor.state.apply(tr); } };
return editor;
};
describe("native gallery editing", () => {
test("advances the insertion target through a three-image upload batch", () => {
const editor = makeEditor([paragraph()]);
for (const src of ["one", "two", "three"]) {
editor.view.dispatch(createImageInsertTransaction(editor.state, { src }));
}
expect(editor.state.doc.content.content.map((node) => node.attrs.src)).toEqual(["one", "two", "three"]);
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", () => {
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(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"]);
undo(editor.state, editor.view.dispatch);
expect(editor.state.doc.childCount).toBe(4);
expect(editor.state.doc.child(1).attrs.src).toBe("one");
});
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;
expect(groupUploadedImages(editor, ["old-a", "old-b", "one", "two"])).toBe(false);
expect(groupUploadedImages(editor, ["one"])).toBe(false);
expect(editor.state.doc).toBe(original);
editor.isEditable = false;
expect(groupUploadedImages(editor, ["one", "two"])).toBe(false);
});
test("a later single upload extends the saved gallery and preserves layout, selection and undo", () => {
const editor = makeEditor([gallery()]);
// Rehydrate the JSON as when returning to the note after autosave.
editor.state = EditorState.create({ schema, doc: schema.nodeFromJSON(editor.state.doc.toJSON()), plugins: [history()] });
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)));
editor.view.dispatch(createImageInsertTransaction(editor.state, { src: "third", alt: "third" }));
const beforeGrouping = editor.state.doc.toJSON();
editor.view.dispatch(closeHistory(editor.state.tr));
expect(groupUploadedImages(editor, ["third"])).toBe(true);
expect(editor.state.doc.childCount).toBe(1);
expect(editor.state.doc.firstChild.attrs.layout).toBe("3");
expect(editor.state.doc.firstChild.content.content.map((node) => node.attrs.src)).toEqual(["old-a", "old-b", "third"]);
expect(editor.state.selection.node.attrs.src).toBe("third");
editor.state.doc.check();
expect(undo(editor.state, editor.view.dispatch)).toBe(true);
expect(editor.state.doc.toJSON()).toEqual(beforeGrouping);
expect(redo(editor.state, editor.view.dispatch)).toBe(true);
expect(editor.state.doc.firstChild.childCount).toBe(3);
expect(groupUploadedImages(editor, ["third"])).toBe(false);
});
for (const layout of ["auto", "1", "2", "3"]) {
for (const side of ["before", "after"]) {
test(`merges a later batch ${side} the existing ${layout} gallery in upload order`, () => {
const existing = schema.nodes[IMAGE_GALLERY_NODE_TYPE].create({ layout }, [image("old-a"), image("old-b")]);
const editor = makeEditor([existing]);
for (const [index, src] of ["new-a", "new-b", "new-c"].entries()) {
const selection = index === 0 ? { from: side === "before" ? 0 : 4, to: side === "before" ? 0 : 4 } : editor.state.selection;
editor.view.dispatch(createImageInsertTransaction(editor.state, { src }, selection));
}
expect(groupUploadedImages(editor, ["new-a", "new-b", "new-c"])).toBe(true);
expect(editor.state.doc.childCount).toBe(1);
expect(editor.state.doc.firstChild.attrs.layout).toBe(layout);
const expected = side === "before" ? ["new-a", "new-b", "new-c", "old-a", "old-b"] : ["old-a", "old-b", "new-a", "new-b", "new-c"];
expect(editor.state.doc.firstChild.content.content.map((node) => node.attrs.src)).toEqual(expected);
editor.state.doc.check();
});
}
}
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(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");
// 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));
expect(editor.state.doc.childCount).toBe(1);
expect(editor.state.doc.firstChild.content.content.map((node) => node.attrs.src)).toEqual(["old-a", "old-b", "third"]);
});
test("does not merge existing galleries or cross an attachment/text boundary", () => {
const editor = makeEditor([gallery(), image("third"), gallery(), paragraph("attachment"), image("fourth")]);
expect(groupUploadedImages(editor, ["third", "fourth"])).toBe(true);
expect(editor.state.doc.childCount).toBe(4);
expect(editor.state.doc.child(0).childCount).toBe(3);
expect(editor.state.doc.child(1).childCount).toBe(2);
expect(editor.state.doc.child(2).textContent).toBe("attachment");
expect(editor.state.doc.child(3).attrs.src).toBe("fourth");
});
test("empty, read-only and destroyed editors do not change", () => {
const editor = makeEditor([gallery(), image("third")]);
const original = editor.state.doc;
expect(groupUploadedImages(editor, [])).toBe(false);
editor.isEditable = false;
expect(groupUploadedImages(editor, ["third"])).toBe(false);
editor.isEditable = true;
editor.isDestroyed = true;
expect(groupUploadedImages(editor, ["third"])).toBe(false);
expect(editor.state.doc).toBe(original);
});
test("inserts after selected media instead of replacing it or nesting a gallery", () => {
const doc = schema.nodes.doc.create(null, [gallery(), image("last"), paragraph("text")]);
expect(getImageInsertionRange(doc, NodeSelection.create(doc, 0))).toEqual({ from: 4, to: 4 });
expect(getImageInsertionRange(doc, NodeSelection.create(doc, 1))).toEqual({ from: 4, to: 4 });
expect(getImageInsertionRange(doc, NodeSelection.create(doc, 4))).toEqual({ from: 5, to: 5 });
expect(getImageInsertionRange(doc, { from: 6, to: 8 })).toEqual({ from: 6, to: 8 });
});
test("layout and source metadata survive JSON round trips", () => {
const doc = schema.nodes.doc.create(null, [gallery()]);
expect(schema.nodeFromJSON(doc.toJSON()).toJSON()).toEqual(doc.toJSON());
expect(NATIVE_IMAGE_GALLERY_CSS).toContain("min-height: 44px");
expect(NATIVE_IMAGE_GALLERY_CSS).toContain(".edgeever-native-gallery-content .edgeever-image-size-controls { display: none !important; }");
});
});