mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(ui): restore touch mention selection
This commit is contained in:
1 parent
31a73c8c9c
commit
093691b683
2 files changed
+117
-8
No files matched your search
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import {
|
||||
computeMentionMenuPosition,
|
||||
findClosestAutocompleteAnchor,
|
||||
@@ -36,17 +36,20 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||
markdown,
|
||||
placeholder,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
markdown: string;
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => void;
|
||||
className?: string;
|
||||
},
|
||||
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
|
||||
) {
|
||||
const [content, setContent] = React.useState(markdown);
|
||||
const editableRef = React.useRef<HTMLDivElement>(null);
|
||||
const handle = React.useMemo(() => ({
|
||||
setMarkdown: (value: string) => setContent(value),
|
||||
focus: () => {},
|
||||
focus: () => editableRef.current?.focus(),
|
||||
}), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -64,7 +67,17 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div data-testid="mdx-editor">{content || placeholder || ""}</div>;
|
||||
return (
|
||||
<div
|
||||
ref={editableRef}
|
||||
data-testid="mdx-editor"
|
||||
className={className}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
>
|
||||
{content || placeholder || ""}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -105,14 +118,28 @@ async function flush() {
|
||||
|
||||
describe("MarkdownEditor", () => {
|
||||
let container: HTMLDivElement;
|
||||
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
originalRangeRect = Range.prototype.getBoundingClientRect;
|
||||
Range.prototype.getBoundingClientRect = () => ({
|
||||
x: 32,
|
||||
y: 24,
|
||||
width: 12,
|
||||
height: 18,
|
||||
top: 24,
|
||||
right: 44,
|
||||
bottom: 42,
|
||||
left: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
Range.prototype.getBoundingClientRect = originalRangeRect;
|
||||
vi.clearAllMocks();
|
||||
mdxEditorMockState.emitMountEmptyReset = false;
|
||||
});
|
||||
@@ -312,4 +339,64 @@ describe("MarkdownEditor", () => {
|
||||
|
||||
editable.remove();
|
||||
});
|
||||
|
||||
it("accepts mention selection from touchstart taps", async () => {
|
||||
const handleChange = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value="@Pap"
|
||||
onChange={handleChange}
|
||||
mentions={[
|
||||
{
|
||||
id: "project:project-123",
|
||||
kind: "project",
|
||||
name: "Paperclip App",
|
||||
projectId: "project-123",
|
||||
projectColor: "#336699",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const editable = container.querySelector('[contenteditable="true"]');
|
||||
expect(editable).not.toBeNull();
|
||||
|
||||
const textNode = editable?.firstChild;
|
||||
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode!, "@Pap".length);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
|
||||
.find((node) => node.textContent?.includes("Paperclip App"));
|
||||
expect(option).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
option?.dispatchEvent(new Event("touchstart", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
`[@Paperclip App](${buildProjectMentionHref("project-123", "#336699")}) `,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
@@ -443,6 +446,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const skillEnterArmedRef = useRef(false);
|
||||
const autocompleteSelectionHandledRef = useRef(false);
|
||||
const mentionActive = mentionState !== null && (
|
||||
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|
||||
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
|
||||
@@ -676,6 +680,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
};
|
||||
}, [checkMention, mentionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mentionActive) return;
|
||||
autocompleteSelectionHandledRef.current = false;
|
||||
}, [mentionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!editable) return;
|
||||
@@ -696,7 +705,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
// Read from ref to avoid stale-closure issues (selectionchange can
|
||||
// update state between the last render and this callback firing).
|
||||
const state = mentionStateRef.current;
|
||||
if (!state) return;
|
||||
if (!state) return false;
|
||||
const current = latestValueRef.current;
|
||||
const next = applyMention(current, state, option);
|
||||
if (next !== current) {
|
||||
@@ -729,10 +738,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return true;
|
||||
},
|
||||
[decorateProjectMentions, onChange],
|
||||
);
|
||||
|
||||
const handleAutocompletePress = useCallback((
|
||||
event: ReactMouseEvent<HTMLButtonElement> | ReactPointerEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>,
|
||||
option: AutocompleteOption,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (autocompleteSelectionHandledRef.current) return;
|
||||
const handled = selectMention(option);
|
||||
if (handled) {
|
||||
autocompleteSelectionHandledRef.current = true;
|
||||
}
|
||||
}, [selectMention]);
|
||||
|
||||
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
|
||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||
}
|
||||
@@ -917,10 +940,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
||||
i === mentionIndex && "bg-accent",
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
selectMention(option);
|
||||
}}
|
||||
onPointerDown={(e) => handleAutocompletePress(e, option)}
|
||||
onMouseDown={(e) => handleAutocompletePress(e, option)}
|
||||
onTouchStart={(e) => handleAutocompletePress(e, option)}
|
||||
onMouseEnter={() => {
|
||||
if (mentionStateRef.current?.trigger === "skill") {
|
||||
skillEnterArmedRef.current = true;
|
||||
|
||||
Reference in new issue
Block a user