mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 08:40:01 +02:00
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. --> ## Thinking Path > - Paperclip helps operators manage AI-agent companies. > - Operators create tasks and comments through shared rich-text editors. > - These editors show slash-command and mention matches in a floating menu. > - Modal dialogs treat that body-level menu as outside content and cancel its wheel and touch movement. > - This pull request keeps scroll events inside the floating menu and preserves native scrolling. > - The benefit is that operators can reach every match with a mouse wheel, a trackpad, or a touch screen. ## Linked Issues or Issue Description No public GitHub issue exists for this bug. **What happened?** Slash-command and mention menus could contain more matches than their visible height. When an editor was inside a modal dialog, the modal scroll lock canceled wheel and touch movement on the body-level menu portal. Operators could not scroll to later matches. **Expected behavior** The autocomplete menu must scroll with a mouse wheel, a two-finger trackpad gesture, and a vertical touch gesture. Keyboard selection and normal editor behavior must stay unchanged. **Steps to reproduce** 1. Open a task or comment editor inside a modal dialog. 2. Enter a slash command or mention query that has more matches than the menu can show. 3. Try to scroll the menu with a wheel, trackpad, or touch gesture. **Paperclip version or commit** `7ea2068ef8` on `master`. **Deployment mode** Local development UI built from source. ## What Changed - Keep wheel and touch movement inside the shared autocomplete menu portal. - Add vertical overscroll containment while preserving native momentum scrolling. - Add a regression test that mounts the real dialog and verifies that wheel and touch movement stay uncanceled. ## Verification - `pnpm --dir ui exec vitest run src/components/MarkdownEditor.test.tsx` - `pnpm check:token-gates` - `pnpm -r typecheck` - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm test:run` - `pnpm build` The two AWS variables are omitted from the full test command because this agent runtime injects static AWS credentials. One unrelated CLI doctor test correctly warns when those credentials are present. The CI environment does not inject them. ## Risks - Low risk. Event propagation stops only on the open autocomplete menu portal. - Ancestor listeners no longer receive wheel or touch movement from that menu. Native menu scrolling and option-level touch handling still receive the events. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex with model ID `gpt-5`. The deployment suffix and context-window size are not exposed to the agent. The model used agentic reasoning, repository tools, GitHub tools, and local code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
1179 lines
35 KiB
TypeScript
1179 lines
35 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
import { buildIssueReferenceHref, buildProjectMentionHref, buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
|
import {
|
|
computeMentionMenuPosition,
|
|
findClosestAutocompleteAnchor,
|
|
findMentionMatch,
|
|
isSameAutocompleteSession,
|
|
issueMentionTitle,
|
|
MarkdownEditor,
|
|
type MentionOption,
|
|
placeCaretAfterMentionAnchor,
|
|
shouldAcceptAutocompleteKey,
|
|
} from "./MarkdownEditor";
|
|
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
|
|
|
|
const mdxEditorMockState = vi.hoisted(() => ({
|
|
emitMountEmptyReset: false,
|
|
emitMountParseError: false,
|
|
emitMountSilentEmptyState: false,
|
|
throwOnRender: false,
|
|
markdownValues: [] as string[],
|
|
suppressHtmlProcessingValues: [] as boolean[],
|
|
}));
|
|
|
|
function containsHtmlLikeTag(markdown: string) {
|
|
return /<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^>]*)?\/?>/.test(markdown);
|
|
}
|
|
|
|
vi.mock("@mdxeditor/editor", async () => {
|
|
const React = await import("react");
|
|
|
|
function setForwardedRef<T>(ref: React.ForwardedRef<T | null>, value: T | null) {
|
|
if (typeof ref === "function") {
|
|
ref(value);
|
|
return;
|
|
}
|
|
if (ref) {
|
|
(ref as React.MutableRefObject<T | null>).current = value;
|
|
}
|
|
}
|
|
|
|
const MDXEditor = React.forwardRef(function MockMDXEditor(
|
|
{
|
|
markdown,
|
|
placeholder,
|
|
onChange,
|
|
onError,
|
|
className,
|
|
suppressHtmlProcessing,
|
|
}: {
|
|
markdown: string;
|
|
placeholder?: string;
|
|
onChange?: (value: string) => void;
|
|
onError?: (error: unknown) => void;
|
|
suppressHtmlProcessing?: boolean;
|
|
className?: string;
|
|
},
|
|
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
|
|
) {
|
|
if (mdxEditorMockState.throwOnRender) {
|
|
throw new Error("Rich editor render crashed");
|
|
}
|
|
mdxEditorMockState.markdownValues.push(markdown);
|
|
mdxEditorMockState.suppressHtmlProcessingValues.push(Boolean(suppressHtmlProcessing));
|
|
const [content, setContent] = React.useState(markdown);
|
|
const editableRef = React.useRef<HTMLDivElement>(null);
|
|
const handle = React.useMemo(() => ({
|
|
setMarkdown: (value: string) => setContent(value),
|
|
focus: () => editableRef.current?.focus(),
|
|
}), []);
|
|
|
|
React.useEffect(() => {
|
|
if (!suppressHtmlProcessing && containsHtmlLikeTag(markdown)) {
|
|
setContent("");
|
|
onError?.({
|
|
error: "Error parsing markdown: HTML-like formatting requires suppressHtmlProcessing",
|
|
source: markdown,
|
|
});
|
|
return;
|
|
}
|
|
setContent(markdown);
|
|
}, [markdown, onError, suppressHtmlProcessing]);
|
|
|
|
React.useEffect(() => {
|
|
setForwardedRef(forwardedRef, null);
|
|
const timer = window.setTimeout(() => {
|
|
setForwardedRef(forwardedRef, handle);
|
|
if (mdxEditorMockState.emitMountEmptyReset) {
|
|
setContent("");
|
|
onChange?.("");
|
|
}
|
|
if (mdxEditorMockState.emitMountSilentEmptyState) {
|
|
setContent("");
|
|
}
|
|
if (mdxEditorMockState.emitMountParseError) {
|
|
setContent("");
|
|
onError?.({
|
|
error: "Unsupported markdown syntax",
|
|
source: markdown,
|
|
});
|
|
}
|
|
}, 0);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
setForwardedRef(forwardedRef, null);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={editableRef}
|
|
data-testid="mdx-editor"
|
|
className={className}
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
>
|
|
{content || placeholder || ""}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
return {
|
|
CodeMirrorEditor: () => null,
|
|
MDXEditor,
|
|
codeBlockPlugin: () => ({}),
|
|
codeMirrorPlugin: () => ({}),
|
|
createRootEditorSubscription$: Symbol("createRootEditorSubscription$"),
|
|
headingsPlugin: () => ({}),
|
|
imagePlugin: () => ({}),
|
|
linkDialogPlugin: () => ({}),
|
|
linkPlugin: () => ({}),
|
|
listsPlugin: () => ({}),
|
|
markdownShortcutPlugin: () => ({}),
|
|
quotePlugin: () => ({}),
|
|
realmPlugin: (plugin: unknown) => plugin,
|
|
tablePlugin: () => ({}),
|
|
thematicBreakPlugin: () => ({}),
|
|
};
|
|
});
|
|
|
|
vi.mock("../lib/mention-deletion", () => ({
|
|
mentionDeletionPlugin: () => ({}),
|
|
}));
|
|
|
|
vi.mock("../lib/paste-normalization", () => ({
|
|
pasteNormalizationPlugin: () => ({}),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
async function flush() {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
function createFileDragEvent(type: string) {
|
|
const event = (
|
|
typeof DragEvent === "function"
|
|
? new DragEvent(type, { bubbles: true, cancelable: true })
|
|
: new Event(type, { bubbles: true, cancelable: true })
|
|
) as Event & {
|
|
dataTransfer: { types: string[]; files: File[]; dropEffect?: string };
|
|
};
|
|
Object.defineProperty(event, "dataTransfer", {
|
|
configurable: true,
|
|
value: {
|
|
types: ["Files"],
|
|
files: [],
|
|
},
|
|
});
|
|
return event;
|
|
}
|
|
|
|
describe("issueMentionTitle", () => {
|
|
it("strips the leading identifier from the mention name", () => {
|
|
expect(
|
|
issueMentionTitle({
|
|
id: "issue:1",
|
|
kind: "issue",
|
|
name: "PAP-102 @task references",
|
|
issueIdentifier: "PAP-102",
|
|
}),
|
|
).toBe("@task references");
|
|
});
|
|
|
|
it("returns the full name when there is no separate title", () => {
|
|
expect(
|
|
issueMentionTitle({
|
|
id: "issue:1",
|
|
kind: "issue",
|
|
name: "PAP-7",
|
|
issueIdentifier: "PAP-7",
|
|
}),
|
|
).toBe("");
|
|
});
|
|
|
|
it("falls back to the name when the identifier is missing", () => {
|
|
expect(
|
|
issueMentionTitle({ id: "issue:1", kind: "issue", name: "Some task" }),
|
|
).toBe("Some task");
|
|
});
|
|
});
|
|
|
|
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;
|
|
mdxEditorMockState.emitMountParseError = false;
|
|
mdxEditorMockState.emitMountSilentEmptyState = false;
|
|
mdxEditorMockState.throwOnRender = false;
|
|
mdxEditorMockState.markdownValues = [];
|
|
mdxEditorMockState.suppressHtmlProcessingValues = [];
|
|
});
|
|
|
|
it("applies async external value updates once the editor ref becomes ready", async () => {
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value=""
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value="Loaded plan body"
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
expect(container.textContent).toContain("Loaded plan body");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the external value when the unfocused editor emits an empty mount reset", async () => {
|
|
mdxEditorMockState.emitMountEmptyReset = true;
|
|
const handleChange = vi.fn();
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value="Loaded plan body"
|
|
onChange={handleChange}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
expect(container.textContent).toContain("Loaded plan body");
|
|
expect(handleChange).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("does not recreate the mention decoration observer when the external value changes", async () => {
|
|
const originalMutationObserver = globalThis.MutationObserver;
|
|
|
|
class MockMutationObserver implements MutationObserver {
|
|
static instances: MockMutationObserver[] = [];
|
|
|
|
readonly observe = vi.fn();
|
|
readonly disconnect = vi.fn();
|
|
readonly takeRecords = vi.fn<() => MutationRecord[]>(() => []);
|
|
|
|
constructor(readonly callback: MutationCallback) {
|
|
MockMutationObserver.instances.push(this);
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal("MutationObserver", MockMutationObserver);
|
|
const root = createRoot(container);
|
|
|
|
try {
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value="First value"
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
const editable = container.querySelector('[contenteditable="true"]');
|
|
expect(editable).not.toBeNull();
|
|
const mentionObserverCountAfterInitialRender = MockMutationObserver.instances.filter(
|
|
(observer) => observer.observe.mock.calls.some(([target]) => target === editable),
|
|
).length;
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value="Updated value"
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
|
|
// A separate rich-editor health observer is expected to recreate when the
|
|
// controlled value changes. This assertion only covers the mention
|
|
// decoration observer that attaches to the editable element itself.
|
|
expect(
|
|
MockMutationObserver.instances.filter(
|
|
(observer) => observer.observe.mock.calls.some(([target]) => target === editable),
|
|
),
|
|
).toHaveLength(mentionObserverCountAfterInitialRender);
|
|
} finally {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
vi.stubGlobal("MutationObserver", originalMutationObserver);
|
|
}
|
|
});
|
|
|
|
it("converts advisory-style html image tags to markdown image syntax before mounting the editor", async () => {
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value={`Before\n\n<img width="10" height="10" alt="image" src="https://example.com/test.png" />\n\nAfter`}
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("");
|
|
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
|
|
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
|
|
expect(container.textContent).toContain("Before");
|
|
expect(container.textContent).toContain("After");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps arbitrary HTML-like tags in the rich editor instead of falling back to raw source", async () => {
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value={'<section data-source="paste">\n## My take\n\n<p>Benchmark notes</p>\n</section>'}
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
|
|
expect(container.querySelector("textarea")).toBeNull();
|
|
expect(container.textContent).toContain("Benchmark notes");
|
|
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps scriptable pasted HTML inert in the rich editor", async () => {
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value={'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>'}
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
|
|
expect(container.querySelector("textarea")).toBeNull();
|
|
expect(container.querySelector("script, iframe, p[onclick]")).toBeNull();
|
|
expect(container.textContent).toContain('fetch("/api/secrets")');
|
|
expect(container.textContent).toContain("Plain text");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
|
|
mdxEditorMockState.emitMountParseError = true;
|
|
const handleChange = vi.fn();
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value="Affected versions: <= v0.3.1"
|
|
onChange={handleChange}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
await vi.waitFor(() => {
|
|
expect(container.querySelector("textarea")).not.toBeNull();
|
|
});
|
|
const textarea = container.querySelector("textarea");
|
|
expect(textarea).not.toBeNull();
|
|
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
|
|
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
|
|
expect(handleChange).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("falls back to a raw textarea when the rich editor crashes during render", async () => {
|
|
mdxEditorMockState.throwOnRender = true;
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const handleChange = vi.fn();
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value="5. python3 circleback/sync_insights.py --input <tmp> -- writes insights/<group>/*.md"
|
|
onChange={handleChange}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(container.querySelector("textarea")).not.toBeNull();
|
|
});
|
|
const textarea = container.querySelector("textarea");
|
|
expect(textarea).not.toBeNull();
|
|
expect(textarea?.value).toBe("5. python3 circleback/sync_insights.py --input <tmp> -- writes insights/<group>/*.md");
|
|
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
"Markdown rich editor failed; falling back to raw textarea",
|
|
expect.objectContaining({
|
|
error: expect.any(Error),
|
|
componentStack: expect.any(String),
|
|
}),
|
|
);
|
|
consoleError.mockRestore();
|
|
expect(handleChange).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("falls back to a raw textarea when the rich editor mounts into the placeholder without callbacks", async () => {
|
|
mdxEditorMockState.emitMountSilentEmptyState = true;
|
|
const handleChange = vi.fn();
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value="Affected versions: <= v0.3.1"
|
|
onChange={handleChange}
|
|
placeholder="Add a description..."
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
await vi.waitFor(() => {
|
|
expect(container.querySelector("textarea")).not.toBeNull();
|
|
});
|
|
const textarea = container.querySelector("textarea");
|
|
expect(textarea).not.toBeNull();
|
|
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
|
|
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
|
|
expect(handleChange).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows the editor-scoped dropzone by default when files are dragged over it", async () => {
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value=""
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
imageUploadHandler={async () => "https://example.com/image.png"}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
|
|
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
|
|
expect(scope).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
scope?.dispatchEvent(createFileDragEvent("dragenter"));
|
|
});
|
|
await flush();
|
|
|
|
expect(scope?.className).toContain("ring-1");
|
|
expect(container.textContent).toContain("Drop image to upload");
|
|
|
|
await act(async () => {
|
|
scope?.dispatchEvent(createFileDragEvent("dragleave"));
|
|
});
|
|
await flush();
|
|
|
|
expect(scope?.className).not.toContain("ring-1");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("defers file-drop visuals to a parent container when requested", async () => {
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value=""
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
imageUploadHandler={async () => "https://example.com/image.png"}
|
|
fileDropTarget="parent"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
|
|
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
|
|
expect(scope).not.toBeNull();
|
|
|
|
act(() => {
|
|
scope?.dispatchEvent(createFileDragEvent("dragenter"));
|
|
});
|
|
|
|
expect(scope?.className).not.toContain("ring-1");
|
|
expect(container.textContent).not.toContain("Drop image to upload");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("does not show the raw fallback while image-only markdown is settling", async () => {
|
|
mdxEditorMockState.emitMountSilentEmptyState = true;
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value=""
|
|
onChange={() => {}}
|
|
placeholder="Markdown body"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await flush();
|
|
await flush();
|
|
|
|
expect(container.querySelector("textarea")).toBeNull();
|
|
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("places the menu top on the caret line and offsets the left a space-width past the caret", () => {
|
|
expect(
|
|
computeMentionMenuPosition(
|
|
{ viewportTop: 100, viewportBottom: 118, viewportLeft: 240 },
|
|
{ offsetLeft: 0, offsetTop: 0, width: 800, height: 600 },
|
|
),
|
|
).toEqual({
|
|
top: 100,
|
|
left: 250,
|
|
});
|
|
});
|
|
|
|
it("applies visual viewport offsets when present", () => {
|
|
expect(
|
|
computeMentionMenuPosition(
|
|
{ viewportTop: 20, viewportBottom: 38, viewportLeft: 120 },
|
|
{ offsetLeft: 24, offsetTop: 320, width: 320, height: 260 },
|
|
),
|
|
).toEqual({
|
|
top: 340,
|
|
left: 154,
|
|
});
|
|
});
|
|
|
|
it("clamps the mention menu back into view near the viewport edges", () => {
|
|
expect(
|
|
computeMentionMenuPosition(
|
|
{ viewportTop: 260, viewportBottom: 278, viewportLeft: 240 },
|
|
{ offsetLeft: 0, offsetTop: 0, width: 280, height: 220 },
|
|
),
|
|
).toEqual({
|
|
top: 12,
|
|
left: 92,
|
|
});
|
|
});
|
|
|
|
it("flips the menu above the caret line when it would overflow below", () => {
|
|
expect(
|
|
computeMentionMenuPosition(
|
|
{ viewportTop: 560, viewportBottom: 580, viewportLeft: 200 },
|
|
{ offsetLeft: 0, offsetTop: 0, width: 800, height: 600 },
|
|
),
|
|
).toEqual({
|
|
top: 372,
|
|
left: 210,
|
|
});
|
|
});
|
|
|
|
it("keeps a short mention menu on the same line when it fits below the caret", () => {
|
|
expect(
|
|
computeMentionMenuPosition(
|
|
{ viewportTop: 160, viewportBottom: 178, viewportLeft: 120 },
|
|
{ offsetLeft: 0, offsetTop: 0, width: 320, height: 220 },
|
|
{ width: 188, height: 42 },
|
|
),
|
|
).toEqual({
|
|
top: 160,
|
|
left: 130,
|
|
});
|
|
});
|
|
|
|
it("keeps mention queries active across spaces", () => {
|
|
expect(findMentionMatch("Ping @Paperclip App", "Ping @Paperclip App".length)).toEqual({
|
|
trigger: "mention",
|
|
marker: "@",
|
|
query: "Paperclip App",
|
|
atPos: 5,
|
|
endPos: "Ping @Paperclip App".length,
|
|
});
|
|
});
|
|
|
|
it("still rejects slash commands once spaces are typed", () => {
|
|
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
|
|
});
|
|
|
|
it("keeps routine slash queries active across spaces", () => {
|
|
expect(findMentionMatch("/routine:Weekly release review", "/routine:Weekly release review".length)).toEqual({
|
|
trigger: "skill",
|
|
marker: "/",
|
|
query: "routine:Weekly release review",
|
|
atPos: 0,
|
|
endPos: "/routine:Weekly release review".length,
|
|
});
|
|
});
|
|
|
|
it("does not treat Enter as skill autocomplete accept", () => {
|
|
expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false);
|
|
expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true);
|
|
expect(shouldAcceptAutocompleteKey("Enter", "mention")).toBe(true);
|
|
expect(shouldAcceptAutocompleteKey("Tab", "skill")).toBe(true);
|
|
});
|
|
|
|
it("keeps the same autocomplete session active while the slash query is unchanged", () => {
|
|
const textNode = document.createTextNode("/agent");
|
|
expect(isSameAutocompleteSession(
|
|
{
|
|
trigger: "skill",
|
|
marker: "/",
|
|
query: "agent",
|
|
textNode,
|
|
atPos: 0,
|
|
endPos: 6,
|
|
},
|
|
{
|
|
trigger: "skill",
|
|
marker: "/",
|
|
query: "agent",
|
|
textNode,
|
|
atPos: 0,
|
|
endPos: 6,
|
|
},
|
|
)).toBe(true);
|
|
|
|
expect(isSameAutocompleteSession(
|
|
{
|
|
trigger: "skill",
|
|
marker: "/",
|
|
query: "agent",
|
|
textNode,
|
|
atPos: 0,
|
|
endPos: 6,
|
|
},
|
|
{
|
|
trigger: "skill",
|
|
marker: "/",
|
|
query: "agent-browser",
|
|
textNode,
|
|
atPos: 0,
|
|
endPos: 14,
|
|
},
|
|
)).toBe(false);
|
|
});
|
|
|
|
it("finds skill anchors by mention metadata instead of visible text", () => {
|
|
const editable = document.createElement("div");
|
|
const skillLink = document.createElement("a");
|
|
skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser"));
|
|
skillLink.textContent = "/agent-browser ";
|
|
editable.appendChild(skillLink);
|
|
|
|
const found = findClosestAutocompleteAnchor(editable, {
|
|
id: "skill:skill-123",
|
|
kind: "skill",
|
|
skillId: "skill-123",
|
|
key: "agent-browser",
|
|
name: "Agent Browser",
|
|
slug: "agent-browser",
|
|
description: null,
|
|
href: buildSkillMentionHref("skill-123", "agent-browser"),
|
|
aliases: ["agent-browser", "Agent Browser"],
|
|
});
|
|
|
|
expect(found).toBe(skillLink);
|
|
});
|
|
|
|
it("finds routine anchors by mention metadata instead of visible text", () => {
|
|
const editable = document.createElement("div");
|
|
const routineLink = document.createElement("a");
|
|
routineLink.setAttribute("href", buildRoutineMentionHref("routine-123"));
|
|
routineLink.textContent = "/routine:Weekly release review ";
|
|
editable.appendChild(routineLink);
|
|
|
|
const found = findClosestAutocompleteAnchor(editable, {
|
|
id: "routine:routine-123",
|
|
kind: "routine",
|
|
routineId: "routine-123",
|
|
name: "Weekly release review",
|
|
status: "active",
|
|
href: buildRoutineMentionHref("routine-123"),
|
|
aliases: ["routine:Weekly release review", "Weekly release review"],
|
|
});
|
|
|
|
expect(found).toBe(routineLink);
|
|
});
|
|
|
|
it("places the caret after the mention's trailing space when present", () => {
|
|
const editable = document.createElement("div");
|
|
editable.contentEditable = "true";
|
|
document.body.appendChild(editable);
|
|
|
|
const skillLink = document.createElement("a");
|
|
skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser"));
|
|
skillLink.textContent = "/agent-browser";
|
|
const trailingSpace = document.createTextNode(" ");
|
|
editable.append(skillLink, trailingSpace);
|
|
|
|
expect(placeCaretAfterMentionAnchor(skillLink)).toBe(true);
|
|
|
|
const selection = window.getSelection();
|
|
expect(selection?.anchorNode).toBe(trailingSpace);
|
|
expect(selection?.anchorOffset).toBe(1);
|
|
|
|
editable.remove();
|
|
});
|
|
|
|
function createTouchEvent(
|
|
type: "touchstart" | "touchmove" | "touchend",
|
|
touches: Array<{ clientX: number; clientY: number }>,
|
|
) {
|
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
const list = touches as unknown as TouchList;
|
|
Object.defineProperty(event, "touches", { value: type === "touchend" ? [] : list });
|
|
Object.defineProperty(event, "changedTouches", { value: list });
|
|
return event;
|
|
}
|
|
|
|
async function openMentionMenuFor(
|
|
handleChange: Mock<(value: string) => void>,
|
|
mentions: MentionOption[] = [
|
|
{
|
|
id: "project:project-123",
|
|
kind: "project" as const,
|
|
name: "Paperclip App",
|
|
projectId: "project-123",
|
|
projectColor: "#336699",
|
|
},
|
|
],
|
|
matchText = "Paperclip App",
|
|
): Promise<{ option: HTMLButtonElement; root: ReturnType<typeof createRoot>; menu: HTMLElement }> {
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MarkdownEditor
|
|
value="@Pap"
|
|
onChange={handleChange}
|
|
mentions={mentions}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
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(matchText)) as HTMLButtonElement | undefined;
|
|
expect(option).toBeTruthy();
|
|
const menu = document.body.querySelector('[data-testid="mention-autocomplete-menu"]') as HTMLElement | null;
|
|
expect(menu).toBeTruthy();
|
|
return { option: option!, root, menu: menu! };
|
|
}
|
|
|
|
it("accepts mention selection from a touch tap", async () => {
|
|
const handleChange = vi.fn();
|
|
const { option, root } = await openMentionMenuFor(handleChange);
|
|
const point = { clientX: 100, clientY: 50 };
|
|
|
|
act(() => {
|
|
option.dispatchEvent(createTouchEvent("touchstart", [point]));
|
|
});
|
|
act(() => {
|
|
option.dispatchEvent(createTouchEvent("touchend", [point]));
|
|
});
|
|
|
|
expect(handleChange).toHaveBeenCalledWith(
|
|
`[@Paperclip App](${buildProjectMentionHref("project-123", "#336699")}) `,
|
|
);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("inserts a compact issue link when an @task reference is selected", async () => {
|
|
const handleChange = vi.fn();
|
|
const { option, root } = await openMentionMenuFor(
|
|
handleChange,
|
|
[
|
|
{
|
|
id: "issue:issue-1",
|
|
kind: "issue" as const,
|
|
name: "PAP-102 @task references",
|
|
issueId: "issue-1",
|
|
issueIdentifier: "PAP-102",
|
|
},
|
|
],
|
|
"PAP-102",
|
|
);
|
|
const point = { clientX: 100, clientY: 50 };
|
|
|
|
act(() => {
|
|
option.dispatchEvent(createTouchEvent("touchstart", [point]));
|
|
});
|
|
act(() => {
|
|
option.dispatchEvent(createTouchEvent("touchend", [point]));
|
|
});
|
|
|
|
expect(handleChange).toHaveBeenCalledWith(
|
|
`[PAP-102](${buildIssueReferenceHref("PAP-102")}) `,
|
|
);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("renders the task tag and identifier for issue mention options", async () => {
|
|
const handleChange = vi.fn();
|
|
const { option, root } = await openMentionMenuFor(
|
|
handleChange,
|
|
[
|
|
{
|
|
id: "issue:issue-1",
|
|
kind: "issue" as const,
|
|
name: "PAP-102 @task references",
|
|
issueId: "issue-1",
|
|
issueIdentifier: "PAP-102",
|
|
},
|
|
],
|
|
"PAP-102",
|
|
);
|
|
|
|
expect(option.textContent).toContain("PAP-102");
|
|
expect(option.textContent).toContain("@task references");
|
|
expect(option.textContent).toContain("Task");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("marks the autocomplete portal as floating UI for modal pointer handling", async () => {
|
|
const handleChange = vi.fn();
|
|
const { option, root } = await openMentionMenuFor(handleChange);
|
|
|
|
const menu = option.closest("[data-paperclip-floating-ui]");
|
|
expect(menu).toBeTruthy();
|
|
expect(menu?.className).toContain("pointer-events-auto");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("does not preventDefault on touchstart so the mention menu can scroll on mobile", async () => {
|
|
const handleChange = vi.fn();
|
|
const { option, root } = await openMentionMenuFor(handleChange);
|
|
|
|
const touchstart = createTouchEvent("touchstart", [{ clientX: 100, clientY: 50 }]);
|
|
act(() => {
|
|
option.dispatchEvent(touchstart);
|
|
});
|
|
|
|
expect(touchstart.defaultPrevented).toBe(false);
|
|
expect(handleChange).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("renders all mention matches inside a bounded scroll container", async () => {
|
|
const handleChange = vi.fn();
|
|
const mentions = Array.from({ length: 12 }, (_, index) => ({
|
|
id: `project:project-${index}`,
|
|
kind: "project" as const,
|
|
name: `Paperclip App ${index}`,
|
|
projectId: `project-${index}`,
|
|
projectColor: "#336699",
|
|
}));
|
|
const { menu, root } = await openMentionMenuFor(handleChange, mentions);
|
|
|
|
const options = Array.from(menu.querySelectorAll('button[type="button"]'));
|
|
expect(options).toHaveLength(12);
|
|
expect(menu.className).toContain("max-h-(--sz-208px)");
|
|
expect(menu.className).toContain("overflow-y-auto");
|
|
expect(menu.style.touchAction).toBe("pan-y");
|
|
|
|
const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 80 });
|
|
act(() => {
|
|
menu.dispatchEvent(wheel);
|
|
});
|
|
expect(wheel.defaultPrevented).toBe(false);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("lets wheel and touch scrolling reach the autocomplete menu inside a modal", async () => {
|
|
const root = createRoot(container);
|
|
const mentions = Array.from({ length: 12 }, (_, index) => ({
|
|
id: `project:project-${index}`,
|
|
kind: "project" as const,
|
|
name: `Paperclip App ${index}`,
|
|
projectId: `project-${index}`,
|
|
projectColor: "#336699",
|
|
}));
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<Dialog open>
|
|
<DialogContent>
|
|
<DialogTitle>Create task</DialogTitle>
|
|
<MarkdownEditor value="@Pap" onChange={() => {}} mentions={mentions} />
|
|
</DialogContent>
|
|
</Dialog>,
|
|
);
|
|
});
|
|
await flush();
|
|
|
|
const editable = document.body.querySelector('[data-testid="mdx-editor"]');
|
|
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 menu = document.body.querySelector('[data-testid="mention-autocomplete-menu"]');
|
|
expect(menu).toBeTruthy();
|
|
|
|
const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 80 });
|
|
act(() => {
|
|
menu?.dispatchEvent(wheel);
|
|
});
|
|
expect(wheel.defaultPrevented).toBe(false);
|
|
|
|
const touchMove = createTouchEvent("touchmove", [{ clientX: 100, clientY: 90 }]);
|
|
act(() => {
|
|
menu?.firstElementChild?.dispatchEvent(touchMove);
|
|
});
|
|
expect(touchMove.defaultPrevented).toBe(false);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("caps rendered mention matches while keeping the menu scrollable", async () => {
|
|
const handleChange = vi.fn();
|
|
const mentions = Array.from({ length: 60 }, (_, index) => ({
|
|
id: `project:project-${index}`,
|
|
kind: "project" as const,
|
|
name: `Paperclip App ${index}`,
|
|
projectId: `project-${index}`,
|
|
projectColor: "#336699",
|
|
}));
|
|
const { menu, root } = await openMentionMenuFor(handleChange, mentions);
|
|
|
|
const options = Array.from(menu.querySelectorAll('button[type="button"]'));
|
|
expect(options).toHaveLength(50);
|
|
expect(menu.className).toContain("overflow-y-auto");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("scrolls the active mention option into view during keyboard navigation", async () => {
|
|
const handleChange = vi.fn();
|
|
const scrollIntoView = vi.fn();
|
|
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
|
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
|
configurable: true,
|
|
value: scrollIntoView,
|
|
});
|
|
const mentions = Array.from({ length: 12 }, (_, index) => ({
|
|
id: `project:project-${index}`,
|
|
kind: "project" as const,
|
|
name: `Paperclip App ${index}`,
|
|
projectId: `project-${index}`,
|
|
projectColor: "#336699",
|
|
}));
|
|
const { root } = await openMentionMenuFor(handleChange, mentions);
|
|
scrollIntoView.mockClear();
|
|
|
|
const editorScope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement;
|
|
expect(editorScope).toBeTruthy();
|
|
|
|
act(() => {
|
|
editorScope?.dispatchEvent(new KeyboardEvent("keydown", {
|
|
key: "ArrowDown",
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
});
|
|
await flush();
|
|
|
|
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
if (originalScrollIntoView) {
|
|
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
|
configurable: true,
|
|
value: originalScrollIntoView,
|
|
});
|
|
} else {
|
|
delete (HTMLElement.prototype as unknown as { scrollIntoView?: unknown }).scrollIntoView;
|
|
}
|
|
});
|
|
|
|
it("does not select when the touch moves like a scroll", async () => {
|
|
const handleChange = vi.fn();
|
|
const { option, root } = await openMentionMenuFor(handleChange);
|
|
const start = { clientX: 100, clientY: 50 };
|
|
const moved = { clientX: 100, clientY: 90 };
|
|
|
|
act(() => {
|
|
option.dispatchEvent(createTouchEvent("touchstart", [start]));
|
|
});
|
|
act(() => {
|
|
option.dispatchEvent(createTouchEvent("touchmove", [moved]));
|
|
});
|
|
act(() => {
|
|
option.dispatchEvent(createTouchEvent("touchend", [moved]));
|
|
});
|
|
|
|
expect(handleChange).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|