fix: repair image galleries and prevent overlapping editor menus (#342)

This commit is contained in:
tianma-if committed 2026-09-03 00:35:25 +08:00
1 parent 24158a1b52
commit f3034fbf0e
19 files changed
+382 -46

No files matched your search

+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"dependencies": {
"@edgeever/client": "workspace:*",
"@floating-ui/react-dom": "^2.1.8",
"@codemirror/lang-markdown": "^6.5.2",
"@fontsource-variable/noto-sans-sc": "^5.2.8",
"@tiptap/extension-code-block-lowlight": "3.31.0",
+6 -5
View File
@@ -115,6 +115,7 @@ import {
MEMO_CONTENT_STYLE,
markdownToDoc,
MergeDivider,
normalizeImageGalleries,
PLUGIN_EMBED_NODE_TYPE,
pluginEmbedToMarkdown,
isPdfAttachment,
@@ -1537,9 +1538,9 @@ const RichEditorPane = ({
...detail,
kind: "image",
position: {
left: Math.min(Math.max(rect.right - 8, 12), window.innerWidth - 12),
top: Math.min(Math.max(rect.bottom - 8, 12), window.innerHeight - 12),
placement: "inside-bottom-right",
left: rect.left + rect.width / 2,
top: rect.bottom + 8,
placement: "below",
},
});
};
@@ -1833,7 +1834,7 @@ const RichEditorPane = ({
currentMemo.id,
markdownSource,
)
: (currentEditor?.getJSON() as TiptapDoc),
: normalizeImageGalleries(currentEditor?.getJSON() as TiptapDoc),
updatedAt: new Date().toISOString(),
});
},
@@ -2069,7 +2070,7 @@ const RichEditorPane = ({
return null;
}
return currentEditor.getJSON() as TiptapDoc;
return normalizeImageGalleries(currentEditor.getJSON() as TiptapDoc);
}, [getMobilePlainTextValue, markdownSource, useMarkdownSourceEditor, useMobilePlainTextEditor]);
const characterCount = useMemo(() => {
@@ -1,5 +1,6 @@
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { autoUpdate, useFloating } from "@floating-ui/react-dom";
import { FileDown, Pencil, Save, Trash2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -17,6 +18,7 @@ import { ThemeToggle } from "@/components/ThemeToggle";
import { cn } from "@/lib/utils";
import type { getNotebookMoveOptions } from "@/lib/app-helpers";
import type { ResourceMenuTarget } from "./useEditorResourceActions";
import { imageResourceMenuPosition } from "./image-resource-menu-position";
export const IconTooltip = ({ label, children }: { label: string; children: ReactNode }) => (
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
@@ -90,14 +92,26 @@ export const ResourceActionMenu = ({
onDelete: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}) => createPortal(
}) => {
const imageElement = target.kind === "image" ? target.element : undefined;
const { refs, floatingStyles, isPositioned } = useFloating({
...imageResourceMenuPosition,
elements: { reference: imageElement ?? null },
whileElementsMounted: autoUpdate,
});
return createPortal(
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<div
ref={refs.setFloating}
data-edgeever-resource-menu
role="toolbar"
aria-label={labels.download}
className="fixed z-[110] flex items-center gap-1 rounded-lg border border-slate-200 bg-white p-1 shadow-lg"
style={{
style={imageElement ? {
...floatingStyles,
visibility: isPositioned ? "visible" : "hidden",
} : {
left: target.position.left,
top: target.position.top,
transform: target.position.placement === "inside-bottom-right"
@@ -108,6 +122,10 @@ export const ResourceActionMenu = ({
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocusCapture={onMouseEnter}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) onMouseLeave();
}}
>
<IconTooltip label={labels.download}>
<Button type="button" size="sm" variant="ghost" aria-label={labels.download} onClick={onDownload}>
@@ -144,7 +162,8 @@ export const ResourceActionMenu = ({
</div>
</TooltipProvider>,
document.body,
);
);
};
type NotebookMoveOption = ReturnType<typeof getNotebookMoveOptions>[number];
@@ -1,7 +1,7 @@
import { useCallback, useRef, useState, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
import { mergeAttributes } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper, ReactNodeViewRenderer, useEditorState, type NodeViewProps } from "@tiptap/react";
import { Maximize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getImageReferrerPolicy, IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared";
@@ -15,6 +15,7 @@ import { getAttachmentResourceId } from "@/lib/attachment-links";
import { createMarkdownImagePasteRule } from "@/lib/markdown-image-paste";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { isImageInGallery } from "./image-editing-context";
export type ImageMenuRequestDetail = {
element: HTMLElement;
@@ -47,7 +48,14 @@ const ResizableImageNodeView = ({
const [previewWidth, setPreviewWidth] = useState<number | null>(null);
const nodeWidth = parseImageWidth(node.attrs.width) ?? DEFAULT_IMAGE_WIDTH_PERCENT;
const width = previewWidth ?? nodeWidth;
const editable = editor.isEditable;
const { editable, inGallery } = useEditorState({
editor,
selector: ({ editor: activeEditor }) => ({
editable: activeEditor.isEditable,
inGallery: isImageInGallery(activeEditor.state.doc, getPos()),
}),
});
const showSizeControls = editable && selected && !inGallery;
const alt = typeof node.attrs.alt === "string" ? node.attrs.alt : "";
const title = typeof node.attrs.title === "string" ? node.attrs.title : "";
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
@@ -59,7 +67,10 @@ const ResizableImageNodeView = ({
const resolved = editor.state.doc.resolve(position);
const parent = resolved.parent;
const index = resolved.index();
if (parent.child(index)?.type.name !== "image") return null;
// Only standalone top-level images can be grouped. Offering this action to
// images already inside a gallery can empty the parent gallery, which the
// schema then fills with a default image whose src is null.
if (parent.type.name !== "doc" || parent.child(index)?.type.name !== "image") return null;
let startIndex = index;
let endIndex = index;
@@ -133,11 +144,12 @@ const ResizableImageNodeView = ({
}, [alt, hideImageMenu, src, title]);
const updateWidth = useCallback((nextWidth: number) => {
if (!editor.isEditable || isImageInGallery(editor.state.doc, getPos())) return;
updateAttributes({ width: clampImageWidth(nextWidth) });
}, [updateAttributes]);
}, [editor, getPos, updateAttributes]);
const startResize = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
if (!editable) return;
if (!editable || isImageInGallery(editor.state.doc, getPos())) return;
const wrapper = wrapperRef.current;
const parent = wrapper?.parentElement;
@@ -169,7 +181,7 @@ const ResizableImageNodeView = ({
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerCancel);
previewFromPointer(event.clientX);
}, [editable, nodeWidth, updateWidth]);
}, [editable, editor, getPos, nodeWidth, updateWidth]);
const previewButton = (
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
@@ -219,7 +231,7 @@ const ResizableImageNodeView = ({
requestImagePreview();
}}
/>
{editable && selected && (
{showSizeControls && (
<div className="edgeever-image-controls" contentEditable={false}>
{previewButton}
<div className="edgeever-image-presets" aria-label={t("editor.imageScale")}>
@@ -228,8 +240,9 @@ const ResizableImageNodeView = ({
key={preset.width}
type="button"
className={cn("edgeever-image-preset", width === preset.width && "is-active")}
title={t(preset.labelKey)}
aria-label={t(preset.labelKey)}
aria-pressed={width === preset.width}
onMouseDown={(event) => event.preventDefault()}
onClick={() => updateWidth(preset.width)}
>
<span>{t(preset.labelKey)}</span>
@@ -247,16 +260,22 @@ const ResizableImageNodeView = ({
<span>{t("editor.imageGallery.group")}</span>
</button>
) : null}
<button
type="button"
className="edgeever-image-resize-handle"
title={t("editor.resizeImage")}
aria-label={t("editor.resizeImage")}
onPointerDown={startResize}
/>
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-image-resize-handle"
aria-label={t("editor.resizeImage")}
onPointerDown={startResize}
/>
</TooltipTrigger>
<TooltipContent side="top">{t("editor.resizeImage")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{(!editable || !selected) && (
{!showSizeControls && (
<div className="edgeever-image-preview-control" contentEditable={false}>
{previewButton}
</div>
@@ -53,6 +53,36 @@ describe("editor draft source resolution", () => {
expect(state.hasUnsavedChanges).toBe(false);
});
test("repairs invalid saved gallery images and marks the memo for autosave", () => {
const state = resolveEditorDraftState({
memo: {
...memo,
contentJson: {
type: "doc",
content: [
{
type: "edgeeverImageGallery",
attrs: { layout: "3" },
content: [{ type: "image", attrs: { src: null } }],
},
{
type: "edgeeverImageGallery",
attrs: { layout: "1" },
content: [
{ type: "image", attrs: { src: "/one.png" } },
{ type: "image", attrs: { src: "/two.png" } },
],
},
],
},
},
});
expect(state.contentJson.content).toHaveLength(1);
expect(state.contentJson.content[0].content).toHaveLength(2);
expect(state.hasUnsavedChanges).toBe(true);
});
test("uses a newer draft and marks it dirty without a queue entry", () => {
const state = resolveEditorDraftState({
memo,
@@ -1,5 +1,6 @@
import {
docToMarkdown,
normalizeImageGalleries,
resolveMemoContentDoc,
type MemoDetail,
type TiptapDoc,
@@ -48,7 +49,7 @@ export const isLocalDraftEquivalentToMemo = (memo: MemoDetail, draft: LocalDraft
const memoContent = resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown);
return draft.title === getEditableMemoTitle(memo.title) &&
stringArraysEqual(parseTagsText(draft.tagsText), memo.tags) &&
docToMarkdown(draft.contentJson) === docToMarkdown(memoContent);
docToMarkdown(normalizeImageGalleries(draft.contentJson)) === docToMarkdown(memoContent);
};
/**
@@ -84,11 +85,15 @@ export const resolveEditorDraftState = ({
: useQueuedPayload && queuedPayload
? queuedPayload.tags.join(", ")
: memo.tags.join(", ");
const contentJson = useDraft && draft
const sourceContentJson = useDraft && draft
? draft.contentJson
: useQueuedPayload && queuedPayload
? queuedPayload.contentJson
: resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown);
const contentJson = normalizeImageGalleries(sourceContentJson);
const repairedSavedContent = source === "memo" && Boolean(
memo.contentJson && normalizeImageGalleries(memo.contentJson) !== memo.contentJson,
);
const contentMarkdown = docToMarkdown(contentJson);
const sourceVersion = source === "draft" && draft
? draft.updatedAt
@@ -103,6 +108,6 @@ export const resolveEditorDraftState = ({
tagsText,
contentJson,
contentMarkdown,
hasUnsavedChanges: Boolean(useDraft && !queuedUpdate),
hasUnsavedChanges: Boolean((useDraft && !queuedUpdate) || repairedSavedContent),
};
};
@@ -0,0 +1,48 @@
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 } from "@tiptap/pm/state";
import { history, undo, redo } from "@tiptap/pm/history";
import { ImageGallery, IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared";
import { isImageInGallery } from "./image-editing-context";
const schema = getSchema([StarterKit, Image, ImageGallery]);
const image = () => schema.nodes.image.create({ src: "image.png" });
const gallery = (layout = "auto") => schema.nodes[IMAGE_GALLERY_NODE_TYPE].create({ layout }, [image(), image()]);
describe("image editing context", () => {
test("only gallery children use gallery controls, including single-column layout", () => {
for (const layout of ["auto", "1", "2", "3"]) {
const doc = schema.nodes.doc.create(null, [gallery(layout), image()]);
expect(isImageInGallery(doc, 1)).toBe(true);
expect(isImageInGallery(doc, 2)).toBe(true);
expect(isImageInGallery(doc, 4)).toBe(false);
}
});
test("tracks grouping, undo, redo and ungrouping without relying on DOM nesting", () => {
let state = EditorState.create({
schema,
doc: schema.nodes.doc.create(null, [image(), image()]),
plugins: [history()],
});
const dispatch = (transaction) => { state = state.apply(transaction); };
expect(isImageInGallery(state.doc, 0)).toBe(false);
dispatch(state.tr.replaceWith(0, 2, gallery()));
expect(isImageInGallery(state.doc, 1)).toBe(true);
undo(state, dispatch);
expect(isImageInGallery(state.doc, 0)).toBe(false);
redo(state, dispatch);
expect(isImageInGallery(state.doc, 1)).toBe(true);
dispatch(state.tr.replaceWith(0, 4, state.doc.firstChild.content));
expect(isImageInGallery(state.doc, 0)).toBe(false);
});
test("ignores detached image positions", () => {
const doc = schema.nodes.doc.create(null, [image()]);
for (const position of [undefined, -1, 20]) {
expect(isImageInGallery(doc, position)).toBe(false);
}
});
});
@@ -0,0 +1,11 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared";
export const isImageInGallery = (doc: ProseMirrorNode, position: number | undefined): boolean => {
if (typeof position !== "number" || position < 0 || position > doc.content.size) return false;
const resolved = doc.resolve(position);
for (let depth = resolved.depth; depth > 0; depth -= 1) {
if (resolved.node(depth).type.name === IMAGE_GALLERY_NODE_TYPE) return true;
}
return false;
};
@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";
import { computePosition } from "@floating-ui/react-dom";
import { imageResourceMenuPosition } from "./image-resource-menu-position";
const menuSize = { width: 300, height: 42 };
const viewport = { x: 0, y: 0, width: 1024, height: 768 };
const positionMenu = (reference) => computePosition({}, {}, {
...imageResourceMenuPosition,
platform: {
getElementRects: () => ({ reference, floating: { x: 0, y: 0, ...menuSize } }),
getClippingRect: () => viewport,
getDimensions: () => menuSize,
getOffsetParent: () => null,
isElement: () => false,
getDocumentElement: () => ({}),
isRTL: () => false,
},
});
const expectOutsideImage = (position, image) => {
expect(position.y >= image.y + image.height + 8 ||
position.y + menuSize.height <= image.y - 8).toBe(true);
expect(position.x).toBeGreaterThanOrEqual(12);
expect(position.x + menuSize.width).toBeLessThanOrEqual(viewport.width - 12);
};
describe("image resource menu positioning", () => {
test("keeps actions below a short image instead of covering its adjustment controls", async () => {
const image = { x: 300, y: 200, width: 500, height: 60 };
const position = await positionMenu(image);
expect(position.placement).toBe("bottom-end");
expectOutsideImage(position, image);
});
test("flips above an image near the bottom of the viewport", async () => {
const image = { x: 300, y: 700, width: 500, height: 60 };
const position = await positionMenu(image);
expect(position.placement.startsWith("top")).toBe(true);
expectOutsideImage(position, image);
});
test("keeps the menu within horizontal bounds when the image is resized", async () => {
for (const image of [
{ x: 0, y: 200, width: 160, height: 30 },
{ x: 900, y: 200, width: 124, height: 30 },
]) {
expectOutsideImage(await positionMenu(image), image);
}
});
});
@@ -0,0 +1,12 @@
import { flip, offset, shift, type ComputePositionConfig } from "@floating-ui/react-dom";
/** Keep resource actions outside the image, separate from its in-image controls. */
export const imageResourceMenuPosition: Partial<ComputePositionConfig> = {
placement: "bottom-end",
strategy: "fixed",
middleware: [
offset(8),
flip({ padding: 12 }),
shift({ padding: 12 }),
],
};
@@ -1,11 +1,17 @@
import { describe, expect, test } from "bun:test";
import { shouldShowAiBubbleMenu } from "./useAiBubbleMenu.ts";
import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { AllSelection, NodeSelection, TextSelection } from "@tiptap/pm/state";
import { ImageGallery, IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared";
import { hasAiTextSelection, shouldShowAiBubbleMenu } from "./useAiBubbleMenu.ts";
const visibleState = {
assistantOpen: false,
editable: true,
enabled: true,
selectionEmpty: false,
selectionHasText: true,
};
describe("AI bubble menu visibility", () => {
@@ -18,5 +24,33 @@ describe("AI bubble menu visibility", () => {
expect(shouldShowAiBubbleMenu({ ...visibleState, editable: false })).toBe(false);
expect(shouldShowAiBubbleMenu({ ...visibleState, selectionEmpty: true })).toBe(false);
expect(shouldShowAiBubbleMenu({ ...visibleState, assistantOpen: true })).toBe(false);
expect(shouldShowAiBubbleMenu({ ...visibleState, selectionHasText: false })).toBe(false);
});
const schema = getSchema([StarterKit, Image, ImageGallery]);
const image = () => schema.nodes.image.create({ src: "image.png", alt: "not selected text" });
const paragraph = (text) => schema.nodes.paragraph.create(null, schema.text(text));
const isVisible = (doc, selection) => shouldShowAiBubbleMenu({
...visibleState,
selectionEmpty: selection.empty,
selectionHasText: hasAiTextSelection({ doc, selection }),
});
test("hides for standalone images, gallery children, and entire galleries", () => {
const gallery = schema.nodes[IMAGE_GALLERY_NODE_TYPE].create(null, [image(), image()]);
const doc = schema.nodes.doc.create(null, [image(), gallery]);
for (const position of [0, 1, 2, 3]) {
expect(isVisible(doc, NodeSelection.create(doc, position))).toBe(false);
}
expect(isVisible(doc, new AllSelection(doc))).toBe(false);
});
test("keeps text and mixed text/image selections available, but not whitespace", () => {
const doc = schema.nodes.doc.create(null, [paragraph("hello"), image()]);
expect(isVisible(doc, TextSelection.create(doc, 1, 6))).toBe(true);
expect(isVisible(doc, new AllSelection(doc))).toBe(true);
expect(isVisible(doc, TextSelection.create(doc, 1))).toBe(false);
const blank = schema.nodes.doc.create(null, [paragraph(" "), image()]);
expect(isVisible(blank, new AllSelection(blank))).toBe(false);
});
});
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import type { BubbleMenuProps } from "@tiptap/react/menus";
import type { EditorState } from "@tiptap/pm/state";
import {
AI_SELECTION_MENU_CHANGED_EVENT,
readAiSelectionMenuPreference,
@@ -10,17 +11,27 @@ type BubbleMenuOptions = NonNullable<BubbleMenuProps["options"]>;
const AI_BUBBLE_MENU_OPTIONS: BubbleMenuOptions = { placement: "top" };
// Image/gallery node selections are non-empty too, but belong to the image
// controls. DOM text would also include their toolbar labels; read the doc.
export const hasAiTextSelection = ({ doc, selection }: Pick<EditorState, "doc" | "selection">): boolean => (
!selection.empty && selection.ranges.some(({ $from, $to }) => (
doc.textBetween($from.pos, $to.pos, " ", "").trim().length > 0
))
);
export const shouldShowAiBubbleMenu = ({
assistantOpen,
editable,
enabled,
selectionEmpty,
selectionHasText,
}: {
assistantOpen: boolean;
editable: boolean;
enabled: boolean;
selectionEmpty: boolean;
}): boolean => enabled && editable && !selectionEmpty && !assistantOpen;
selectionHasText: boolean;
}): boolean => enabled && editable && !selectionEmpty && selectionHasText && !assistantOpen;
export const useAiBubbleMenu = (assistantOpen: boolean) => {
const [enabled, setEnabled] = useState(readAiSelectionMenuPreference);
@@ -52,6 +63,7 @@ export const useAiBubbleMenu = (assistantOpen: boolean) => {
editable: editor.isEditable,
enabled,
selectionEmpty: editor.state.selection.empty,
selectionHasText: hasAiTextSelection(editor.state),
}),
[assistantOpen, enabled],
);
@@ -16,6 +16,7 @@ export type AttachmentMenuTarget = {
export type ImageMenuTarget = {
kind: "image";
element?: HTMLElement;
url: string;
filename: string;
resourceId: string | null;
+6 -14
View File
@@ -1233,6 +1233,7 @@ textarea {
}
.ProseMirror .edgeever-image-node:hover .edgeever-image-preview-control,
.ProseMirror .edgeever-image-node.is-selected .edgeever-image-preview-control,
.ProseMirror .edgeever-image-preview-control:focus-within {
opacity: 1;
pointer-events: auto;
@@ -1308,11 +1309,12 @@ textarea {
}
.ProseMirror .edgeever-image-gallery__toolbar {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 6;
position: relative;
display: flex;
flex-wrap: wrap;
width: fit-content;
max-width: 100%;
margin-bottom: 0.5rem;
align-items: center;
gap: 0.25rem;
border: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
@@ -1320,16 +1322,6 @@ textarea {
background: rgba(255, 255, 255, 0.96);
padding: 0.3rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16);
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease;
}
.ProseMirror .edgeever-image-gallery:hover .edgeever-image-gallery__toolbar,
.ProseMirror .edgeever-image-gallery.is-selected .edgeever-image-gallery__toolbar,
.ProseMirror .edgeever-image-gallery__toolbar:focus-within {
opacity: 1;
pointer-events: auto;
}
.ProseMirror .edgeever-image-gallery__button {
@@ -4,6 +4,16 @@ import { readFileSync } from "node:fs";
const globals = readFileSync(new URL("./globals.css", import.meta.url), "utf8");
describe("image gallery layout", () => {
test("reserves a wrapping toolbar row instead of overlaying image controls", () => {
const toolbar = globals.match(/\.ProseMirror \.edgeever-image-gallery__toolbar \{([^}]+)\}/)?.[1];
expect(toolbar).toContain("position: relative;");
expect(toolbar).toContain("flex-wrap: wrap;");
expect(toolbar).toContain("max-width: 100%;");
expect(toolbar).toContain("margin-bottom: 0.5rem;");
expect(toolbar).not.toContain("position: absolute");
expect(toolbar).not.toContain("opacity: 0");
});
test("fills React image node grid cells without exposing a persistent background", () => {
expect(globals).toContain(
".edgeever-image-gallery__content > div > div > .edgeever-image-node {",
+1
View File
@@ -209,6 +209,7 @@
"dependencies": {
"@codemirror/lang-markdown": "^6.5.2",
"@edgeever/client": "workspace:*",
"@floating-ui/react-dom": "^2.1.8",
"@fontsource-variable/noto-sans-sc": "^5.2.8",
"@tiptap/extension-code-block-lowlight": "3.31.0",
"@tiptap/pm": "3.31.0",
+4 -2
View File
@@ -13,7 +13,7 @@ import {
} from "./mathematics-markdown";
import { projectNativeUnknownContentForMarkdown } from "./mobile-content-compatibility";
import { PluginEmbed, PLUGIN_EMBED_NODE_TYPE } from "./plugin-embed";
import { ImageGallery, IMAGE_GALLERY_NODE_TYPE } from "./image-gallery";
import { ImageGallery, IMAGE_GALLERY_NODE_TYPE, normalizeImageGalleries } from "./image-gallery";
export { PluginEmbed, PLUGIN_EMBED_NODE_TYPE, pluginEmbedToMarkdown, normalizePluginEmbedAttributes } from "./plugin-embed";
export type { PluginEmbedAttributes } from "./plugin-embed";
@@ -139,7 +139,9 @@ export const resolveMemoContentDoc = (
contentMarkdown: string | null | undefined
): TiptapDoc => {
const currentDoc = contentJson && Array.isArray(contentJson.content)
? upgradeStandaloneFileLinks(upgradeStandalonePdfLinks(upgradeLegacyAttachmentLinks(contentJson)))
? normalizeImageGalleries(
upgradeStandaloneFileLinks(upgradeStandalonePdfLinks(upgradeLegacyAttachmentLinks(contentJson))),
)
: emptyDoc();
if (
!contentMarkdown?.trim() ||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import {
groupConsecutiveImagesIntoGalleries,
IMAGE_GALLERY_NODE_TYPE,
normalizeImageGalleries,
resolveImageGalleryLayout,
} from "./image-gallery.ts";
@@ -33,4 +34,37 @@ describe("image gallery", () => {
image("three"),
]);
});
test("removes empty gallery images and unwraps a remaining singleton", () => {
expect(normalizeImageGalleries({
type: "doc",
content: [
{
type: IMAGE_GALLERY_NODE_TYPE,
attrs: { layout: "3" },
content: [image(""), image("kept")],
},
{
type: IMAGE_GALLERY_NODE_TYPE,
attrs: { layout: "auto" },
content: [image(null)],
},
],
})).toEqual({
type: "doc",
content: [image("kept")],
});
});
test("preserves valid galleries without cloning the document", () => {
const doc = {
type: "doc",
content: [{
type: IMAGE_GALLERY_NODE_TYPE,
attrs: { layout: "2" },
content: [image("one"), image("two")],
}],
};
expect(normalizeImageGalleries(doc)).toBe(doc);
});
});
+54 -1
View File
@@ -1,5 +1,5 @@
import { mergeAttributes, Node } from "@tiptap/core";
import type { TiptapNode } from "./content";
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;
@@ -87,3 +87,56 @@ export const groupConsecutiveImagesIntoGalleries = (nodes: TiptapNode[]): Tiptap
return grouped;
};
const hasUsableImageSource = (node: TiptapNode | TiptapTextNode) =>
node.type === "image" &&
"attrs" in node &&
typeof node.attrs?.src === "string" &&
node.attrs.src.trim().length > 0;
type NormalizedNodes = {
changed: boolean;
nodes: Array<TiptapNode | TiptapTextNode>;
};
const normalizeNode = (node: TiptapNode | TiptapTextNode): NormalizedNodes => {
if (node.type === IMAGE_GALLERY_NODE_TYPE) {
const images = (node.content ?? []).filter(hasUsableImageSource) as TiptapNode[];
if (images.length === 0) return { changed: true, nodes: [] };
if (images.length === 1) return { changed: true, nodes: images };
if (images.length !== node.content?.length) {
return { changed: true, nodes: [{ ...node, content: images }] };
}
return { changed: false, nodes: [node] };
}
if (!("content" in node) || !node.content) {
return { changed: false, nodes: [node] };
}
let changed = false;
const content = node.content.flatMap((child) => {
const normalized = normalizeNode(child);
changed ||= normalized.changed;
return normalized.nodes;
});
return changed
? { changed: true, nodes: [{ ...node, content }] }
: { changed: false, nodes: [node] };
};
/** Removes invalid gallery images and unwraps galleries that no longer contain a group. */
export const normalizeImageGalleries = (doc: TiptapDoc): TiptapDoc => {
let changed = false;
const content = doc.content.flatMap((node) => {
const normalized = normalizeNode(node);
changed ||= normalized.changed;
return normalized.nodes as TiptapNode[];
});
if (!changed) return doc;
return {
...doc,
content: content.length > 0 ? content : [{ type: "paragraph" }],
};
};