Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-images-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

Fixes image action controls intermittently failing to appear when selecting an image in the editor.
25 changes: 19 additions & 6 deletions packages/admin/src/components/editor/ImageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ declare module "@tiptap/react" {
}

// React component for the image node view
function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }: NodeViewProps) {
function ImageNodeView({
node,
updateAttributes,
selected,
deleteNode,
editor,
getPos,
}: NodeViewProps) {
const { t } = useLingui();
const [isEditingAlt, setIsEditingAlt] = React.useState(false);
const [altText, setAltText] = React.useState(node.attrs.alt || "");
Expand Down Expand Up @@ -75,6 +82,14 @@ function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }:
setAltText(node.attrs.alt || "");
}, [node.attrs.alt]);

const handlePointerDown = (event: React.PointerEvent) => {
if (!editor.isEditable || !event.isPrimary || event.button !== 0) return;
const position = getPos();
if (typeof position === "number") {
editor.commands.setNodeSelection(position);
}
};

const getImageAttrs = (): ImageAttributes => ({
src: node.attrs.src,
alt: node.attrs.alt,
Expand Down Expand Up @@ -165,10 +180,8 @@ function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }:
return (
<NodeViewWrapper
style={alignmentStyle}
className={cn(
"relative my-4 group",
selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg",
)}
onPointerDown={handlePointerDown}
className={cn("relative my-4", selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg")}
>
<figure className="relative">
<img
Expand All @@ -185,7 +198,7 @@ function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }:

{/* Selection overlay with actions */}
{selected && (
<div className="absolute top-2 end-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute top-2 end-2 flex gap-1">
<Button
type="button"
variant="secondary"
Expand Down
62 changes: 62 additions & 0 deletions packages/admin/tests/editor/image-selection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { screen } from "@testing-library/react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import * as React from "react";
import { describe, it, expect } from "vitest";

import { ImageExtension } from "../../src/components/editor/ImageNode.js";
import { render } from "../utils/render.js";

function TestEditor() {
const editor = useEditor({
extensions: [StarterKit, ImageExtension],
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Before" }] },
{ type: "image", attrs: { src: "/img.jpg", alt: "Example" } },
],
},
immediatelyRender: true,
});

if (!editor) return null;
return <EditorContent editor={editor} />;
}

function pressWithPointerDrift(target: HTMLElement) {
const rect = target.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = rect.top + rect.height / 2;
const pointer = {
bubbles: true,
button: 0,
buttons: 1,
clientX,
clientY,
isPrimary: true,
pointerId: 1,
pointerType: "mouse",
};

target.dispatchEvent(new PointerEvent("pointerdown", pointer));
target.dispatchEvent(new MouseEvent("mousedown", pointer));
document.dispatchEvent(new MouseEvent("mousemove", { ...pointer, clientX: clientX + 6 }));
target.dispatchEvent(
new PointerEvent("pointerup", { ...pointer, buttons: 0, clientX: clientX + 6 }),
);
target.dispatchEvent(new MouseEvent("mouseup", { ...pointer, buttons: 0, clientX: clientX + 6 }));
}

describe("Editor image selection", () => {
it("shows image actions after a primary press with slight pointer drift", async () => {
void render(<TestEditor />);
const image = await screen.findByRole("img", { name: "Example" });

expect(screen.queryByRole("button", { name: "Image settings" })).toBeNull();
pressWithPointerDrift(image);

const settings = await screen.findByRole("button", { name: "Image settings" });
expect(getComputedStyle(settings.parentElement!).opacity).toBe("1");
});
});
Loading