From f3034fbf0eef083657ef42ea3f53875f18e91099 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 3 Sep 2026 00:35:25 +0800 Subject: [PATCH] fix: repair image galleries and prevent overlapping editor menus (#342) --- apps/web/package.json | 1 + apps/web/src/components/EditorPane.tsx | 11 ++-- .../components/editor/EditorPaneChrome.tsx | 25 ++++++++- .../src/components/editor/ResizableImage.tsx | 51 +++++++++++------ .../editor/editor-draft-state.test.mjs | 30 ++++++++++ .../components/editor/editor-draft-state.ts | 11 +++- .../editor/image-editing-context.test.mjs | 48 ++++++++++++++++ .../editor/image-editing-context.ts | 11 ++++ .../image-resource-menu-position.test.mjs | 51 +++++++++++++++++ .../editor/image-resource-menu-position.ts | 12 ++++ .../editor/useAiBubbleMenu.test.mjs | 36 +++++++++++- .../src/components/editor/useAiBubbleMenu.ts | 14 ++++- .../editor/useEditorResourceActions.ts | 1 + apps/web/src/styles/globals.css | 20 ++----- .../src/styles/image-gallery-layout.test.mjs | 10 ++++ bun.lock | 1 + packages/shared/src/content.ts | 6 +- packages/shared/src/image-gallery.test.mjs | 34 ++++++++++++ packages/shared/src/image-gallery.ts | 55 ++++++++++++++++++- 19 files changed, 382 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/components/editor/image-editing-context.test.mjs create mode 100644 apps/web/src/components/editor/image-editing-context.ts create mode 100644 apps/web/src/components/editor/image-resource-menu-position.test.mjs create mode 100644 apps/web/src/components/editor/image-resource-menu-position.ts diff --git a/apps/web/package.json b/apps/web/package.json index a6a9187d..d92ad6f9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index d40d3fcb..11427d4c 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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(() => { diff --git a/apps/web/src/components/editor/EditorPaneChrome.tsx b/apps/web/src/components/editor/EditorPaneChrome.tsx index b592809d..abbfa916 100644 --- a/apps/web/src/components/editor/EditorPaneChrome.tsx +++ b/apps/web/src/components/editor/EditorPaneChrome.tsx @@ -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 }) => ( @@ -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(
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) onMouseLeave(); + }} >
, document.body, -); + ); +}; type NotebookMoveOption = ReturnType[number]; diff --git a/apps/web/src/components/editor/ResizableImage.tsx b/apps/web/src/components/editor/ResizableImage.tsx index 3bb85e1c..55c7c274 100644 --- a/apps/web/src/components/editor/ResizableImage.tsx +++ b/apps/web/src/components/editor/ResizableImage.tsx @@ -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(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) => { - 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 = ( @@ -219,7 +231,7 @@ const ResizableImageNodeView = ({ requestImagePreview(); }} /> - {editable && selected && ( + {showSizeControls && (
{previewButton}
@@ -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)} > {t(preset.labelKey)} @@ -247,16 +260,22 @@ const ResizableImageNodeView = ({ {t("editor.imageGallery.group")} ) : null} -
)} - {(!editable || !selected) && ( + {!showSizeControls && (
{previewButton}
diff --git a/apps/web/src/components/editor/editor-draft-state.test.mjs b/apps/web/src/components/editor/editor-draft-state.test.mjs index 8568b843..e40cf173 100644 --- a/apps/web/src/components/editor/editor-draft-state.test.mjs +++ b/apps/web/src/components/editor/editor-draft-state.test.mjs @@ -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, diff --git a/apps/web/src/components/editor/editor-draft-state.ts b/apps/web/src/components/editor/editor-draft-state.ts index 46c1be7a..3b6667bf 100644 --- a/apps/web/src/components/editor/editor-draft-state.ts +++ b/apps/web/src/components/editor/editor-draft-state.ts @@ -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), }; }; diff --git a/apps/web/src/components/editor/image-editing-context.test.mjs b/apps/web/src/components/editor/image-editing-context.test.mjs new file mode 100644 index 00000000..35ba56d7 --- /dev/null +++ b/apps/web/src/components/editor/image-editing-context.test.mjs @@ -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); + } + }); +}); diff --git a/apps/web/src/components/editor/image-editing-context.ts b/apps/web/src/components/editor/image-editing-context.ts new file mode 100644 index 00000000..bb097a88 --- /dev/null +++ b/apps/web/src/components/editor/image-editing-context.ts @@ -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; +}; diff --git a/apps/web/src/components/editor/image-resource-menu-position.test.mjs b/apps/web/src/components/editor/image-resource-menu-position.test.mjs new file mode 100644 index 00000000..82a0040c --- /dev/null +++ b/apps/web/src/components/editor/image-resource-menu-position.test.mjs @@ -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); + } + }); +}); diff --git a/apps/web/src/components/editor/image-resource-menu-position.ts b/apps/web/src/components/editor/image-resource-menu-position.ts new file mode 100644 index 00000000..62dae638 --- /dev/null +++ b/apps/web/src/components/editor/image-resource-menu-position.ts @@ -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 = { + placement: "bottom-end", + strategy: "fixed", + middleware: [ + offset(8), + flip({ padding: 12 }), + shift({ padding: 12 }), + ], +}; diff --git a/apps/web/src/components/editor/useAiBubbleMenu.test.mjs b/apps/web/src/components/editor/useAiBubbleMenu.test.mjs index d19e1954..8f67f6f7 100644 --- a/apps/web/src/components/editor/useAiBubbleMenu.test.mjs +++ b/apps/web/src/components/editor/useAiBubbleMenu.test.mjs @@ -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); }); }); diff --git a/apps/web/src/components/editor/useAiBubbleMenu.ts b/apps/web/src/components/editor/useAiBubbleMenu.ts index 97d9be2a..95723263 100644 --- a/apps/web/src/components/editor/useAiBubbleMenu.ts +++ b/apps/web/src/components/editor/useAiBubbleMenu.ts @@ -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; 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): 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], ); diff --git a/apps/web/src/components/editor/useEditorResourceActions.ts b/apps/web/src/components/editor/useEditorResourceActions.ts index 93129916..78807154 100644 --- a/apps/web/src/components/editor/useEditorResourceActions.ts +++ b/apps/web/src/components/editor/useEditorResourceActions.ts @@ -16,6 +16,7 @@ export type AttachmentMenuTarget = { export type ImageMenuTarget = { kind: "image"; + element?: HTMLElement; url: string; filename: string; resourceId: string | null; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index f1f5923e..1777212d 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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 { diff --git a/apps/web/src/styles/image-gallery-layout.test.mjs b/apps/web/src/styles/image-gallery-layout.test.mjs index cd0ac463..81578d74 100644 --- a/apps/web/src/styles/image-gallery-layout.test.mjs +++ b/apps/web/src/styles/image-gallery-layout.test.mjs @@ -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 {", diff --git a/bun.lock b/bun.lock index b3b3a14c..753222d8 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 9b1a2ce4..75c15206 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -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() || diff --git a/packages/shared/src/image-gallery.test.mjs b/packages/shared/src/image-gallery.test.mjs index d7fbd79e..8564cabe 100644 --- a/packages/shared/src/image-gallery.test.mjs +++ b/packages/shared/src/image-gallery.test.mjs @@ -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); + }); }); diff --git a/packages/shared/src/image-gallery.ts b/packages/shared/src/image-gallery.ts index 4387c2b5..ac0ad238 100644 --- a/packages/shared/src/image-gallery.ts +++ b/packages/shared/src/image-gallery.ts @@ -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; +}; + +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" }], + }; +};