fix: repair image galleries and prevent overlapping editor menus (#342)
This commit is contained in:
1 parent
24158a1b52
commit
f3034fbf0e
19 files changed
+382
-46
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user