diff --git a/apps/mobile/src/lib/mobile-image-upload-placeholder.ts b/apps/mobile/src/lib/mobile-image-upload-placeholder.ts index f8f81aac..c212e0c0 100644 --- a/apps/mobile/src/lib/mobile-image-upload-placeholder.ts +++ b/apps/mobile/src/lib/mobile-image-upload-placeholder.ts @@ -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) => { diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index 2a5b5ebd..ab75518f 100644 --- a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -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() diff --git a/apps/web/src/lib/resource-insertion.test.mjs b/apps/web/src/lib/resource-insertion.test.mjs index 6f1a7e5f..54c6dd80 100644 --- a/apps/web/src/lib/resource-insertion.test.mjs +++ b/apps/web/src/lib/resource-insertion.test.mjs @@ -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(); }); diff --git a/apps/web/src/lib/resource-insertion.ts b/apps/web/src/lib/resource-insertion.ts index 4d3dbae1..4a20c0b1 100644 --- a/apps/web/src/lib/resource-insertion.ts +++ b/apps/web/src/lib/resource-insertion.ts @@ -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[], diff --git a/packages/shared/src/image-gallery-editing.ts b/packages/shared/src/image-gallery-editing.ts index dd547736..353abfbf 100644 --- a/packages/shared/src/image-gallery-editing.ts +++ b/packages/shared/src/image-gallery-editing.ts @@ -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, +): 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, +) => { + 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); + } +}; diff --git a/packages/shared/src/image-gallery.ts b/packages/shared/src/image-gallery.ts index ac0ad238..dd484760 100644 --- a/packages/shared/src/image-gallery.ts +++ b/packages/shared/src/image-gallery.ts @@ -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 => diff --git a/packages/shared/src/native-image-gallery.test.mjs b/packages/shared/src/native-image-gallery.test.mjs index 3f8e83d4..7d5a8250 100644 --- a/packages/shared/src/native-image-gallery.test.mjs +++ b/packages/shared/src/native-image-gallery.test.mjs @@ -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)); diff --git a/packages/shared/src/native-image-gallery.ts b/packages/shared/src/native-image-gallery.ts index 8115c2c0..0fbc346e 100644 --- a/packages/shared/src/native-image-gallery.ts +++ b/packages/shared/src/native-image-gallery.ts @@ -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];