mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
fix(ui): preserve desktop annotation focus
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4e6d81f711
commit
718e90557c
4 files changed
+91
-6
No files matched your search
@@ -26,7 +26,7 @@ import { cn, relativeTime } from "@/lib/utils";
|
||||
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { hasActiveNativeSelection, isCoarsePointerDevice } from "@/lib/document-annotation-selection";
|
||||
import { isCoarsePointerDevice } from "@/lib/document-annotation-selection";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { deriveInitials } from "./Identity";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
@@ -312,7 +312,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.pendingAnchor || !props.open || props.isMobile) return;
|
||||
if (isCoarsePointerDevice() || hasActiveNativeSelection()) return;
|
||||
if (isCoarsePointerDevice()) return;
|
||||
composerRef.current?.focus();
|
||||
}, [props.isMobile, props.open, props.pendingAnchor]);
|
||||
|
||||
|
||||
@@ -1058,4 +1058,59 @@ describe("IssueDocumentAnnotations", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-focuses the composer for desktop selection requests", async () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => {});
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const body = container.querySelector("p");
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(body!);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
window.getSelection()?.addRange(range);
|
||||
focusSpy.mockClear();
|
||||
|
||||
const selectButton = container.querySelector<HTMLButtonElement>('[data-testid="mock-annotation-selection"]');
|
||||
await act(async () => selectButton?.click());
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('[data-testid="document-annotation-composer"]')).not.toBeNull();
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
focusSpy.mockRestore();
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { verifyDocumentAnchorSelector } from "@paperclipai/shared";
|
||||
import {
|
||||
buildAnchorFromContainerSelection,
|
||||
getContainerTextOffset,
|
||||
isCoarsePointerDevice,
|
||||
rangesForNormalizedSpan,
|
||||
} from "./document-annotation-selection";
|
||||
|
||||
@@ -55,6 +56,34 @@ function selectText(container: HTMLElement, needle: string): Range {
|
||||
throw new Error(`Could not find "${needle}" in container`);
|
||||
}
|
||||
|
||||
describe("isCoarsePointerDevice", () => {
|
||||
it("uses the primary pointer media query on hybrid touch laptops", () => {
|
||||
const targetWindow = {
|
||||
navigator: { maxTouchPoints: 5 },
|
||||
matchMedia: (query: string) => ({ matches: query === "(pointer: fine)" }),
|
||||
} as unknown as Window;
|
||||
|
||||
expect(isCoarsePointerDevice(targetWindow)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects coarse primary pointers when media queries are available", () => {
|
||||
const targetWindow = {
|
||||
navigator: { maxTouchPoints: 0 },
|
||||
matchMedia: (query: string) => ({ matches: query === "(pointer: coarse)" }),
|
||||
} as unknown as Window;
|
||||
|
||||
expect(isCoarsePointerDevice(targetWindow)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to legacy touch points without matchMedia", () => {
|
||||
const targetWindow = {
|
||||
navigator: { maxTouchPoints: 1 },
|
||||
} as unknown as Window;
|
||||
|
||||
expect(isCoarsePointerDevice(targetWindow)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAnchorFromContainerSelection", () => {
|
||||
it("produces a selector that verifies against the same markdown", () => {
|
||||
const container = makeContainer();
|
||||
|
||||
@@ -23,10 +23,11 @@ export function isCoarsePointerDevice(targetWindow: Window = window): boolean {
|
||||
const navigatorWithLegacyTouchPoints = targetWindow.navigator as Navigator & {
|
||||
msMaxTouchPoints?: number;
|
||||
};
|
||||
if ((navigatorWithLegacyTouchPoints.maxTouchPoints ?? 0) > 0) return true;
|
||||
if ((navigatorWithLegacyTouchPoints.msMaxTouchPoints ?? 0) > 0) return true;
|
||||
return typeof targetWindow.matchMedia === "function"
|
||||
&& targetWindow.matchMedia("(pointer: coarse)").matches;
|
||||
if (typeof targetWindow.matchMedia === "function") {
|
||||
return targetWindow.matchMedia("(pointer: coarse)").matches;
|
||||
}
|
||||
return (navigatorWithLegacyTouchPoints.maxTouchPoints ?? 0) > 0
|
||||
|| (navigatorWithLegacyTouchPoints.msMaxTouchPoints ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function hasActiveNativeSelection(targetWindow: Window = window): boolean {
|
||||
|
||||
Reference in new issue
Block a user