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
98 changes: 97 additions & 1 deletion apps/desktop/src/preview/AnnotationKeyboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts";
import { handleAnnotationKeyEvent, resolveAnnotationSubmission } from "./AnnotationKeyboard.ts";

const keyboardEvent = (
overrides: Partial<Parameters<typeof resolveAnnotationSubmission>[0]> = {},
Expand Down Expand Up @@ -35,3 +35,99 @@ describe("resolveAnnotationSubmission", () => {
expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), false)).toBeNull();
});
});

// The annotation listener is registered first, as the preload's is, so a later
// page listener stands in for the inspected page's capture-phase shortcuts.
const dispatchKey = (
input: {
inOverlay?: boolean;
commentFocused?: boolean;
canSend?: boolean;
isTrusted?: boolean;
} = {},
overrides: Partial<Parameters<typeof resolveAnnotationSubmission>[0]> = {},
type = "keydown",
) => {
const window = new EventTarget();
const event = Object.assign(new Event(type, { cancelable: true }), keyboardEvent(overrides));
Object.defineProperty(event, "isTrusted", { value: input.isTrusted ?? true });
let action: ReturnType<typeof handleAnnotationKeyEvent> | undefined;
let pageEvents = 0;
window.addEventListener(type, () => {
action = handleAnnotationKeyEvent(event, {
overlayTarget: input.inOverlay === false ? new EventTarget() : window,
commentFocused: input.commentFocused ?? true,
canSend: input.canSend ?? true,
});
});
window.addEventListener(type, (pageEvent) => {
pageEvents += 1;
pageEvent.preventDefault();
});
window.dispatchEvent(event);
return { action, pageEvents, defaultPrevented: event.defaultPrevented };
};

describe("handleAnnotationKeyEvent", () => {
it("hides overlay typing from page shortcuts without cancelling the edit", () => {
const edits = [
["keydown", " "],
["keypress", " "],
["keyup", " "],
["keydown", "ArrowLeft"],
["keydown", "d"],
] as const;
for (const [type, key] of edits) {
expect(dispatchKey({}, { key }, type)).toEqual({
action: null,
pageEvents: 0,
defaultPrevented: false,
});
}
});

it("submits from the focused comment and leaves multiline and IME input editable", () => {
expect(dispatchKey()).toMatchObject({ action: "attach", defaultPrevented: true });
expect(dispatchKey({}, { ctrlKey: true }).action).toBe("send");
expect(dispatchKey({ canSend: false }, { metaKey: true })).toMatchObject({
action: null,
defaultPrevented: false,
});
expect(dispatchKey({ commentFocused: false }).defaultPrevented).toBe(false);
expect(dispatchKey({}, { shiftKey: true }).defaultPrevented).toBe(false);
expect(dispatchKey({}, { isComposing: true }).defaultPrevented).toBe(false);
});

it("ignores submission and cancellation events dispatched by the page", () => {
for (const overrides of [{}, { ctrlKey: true }, { key: "Escape" }]) {
expect(dispatchKey({ isTrusted: false }, overrides)).toEqual({
action: null,
pageEvents: 0,
defaultPrevented: false,
});
}
});

it("keeps Escape and tool shortcuts while the page keeps its own keys", () => {
expect(dispatchKey({}, { key: "Escape" })).toEqual({
action: "cancel",
pageEvents: 0,
defaultPrevented: true,
});
expect(dispatchKey({ inOverlay: false }, { key: " " }).pageEvents).toBe(1);
expect(dispatchKey({ inOverlay: false }, { key: "r" }).action).toBe("marquee");
expect(dispatchKey({ inOverlay: false }, { key: "Escape" }).action).toBe("cancel");
expect(dispatchKey({ inOverlay: false }, { key: "r" }, "keyup").action).toBeNull();
// The preload shares the page's realm, so the page owns Object.prototype.
/* eslint-disable no-extend-native -- the inspected page can do this */
const inherited = Object.getOwnPropertyDescriptor(Object.prototype, "x");
Object.defineProperty(Object.prototype, "x", { value: "send", configurable: true });
try {
expect(dispatchKey({ inOverlay: false }, { key: "x" }).action).toBeNull();
} finally {
if (inherited) Object.defineProperty(Object.prototype, "x", inherited);
else Reflect.deleteProperty(Object.prototype, "x");
}
/* eslint-enable no-extend-native */
});
});
56 changes: 56 additions & 0 deletions apps/desktop/src/preview/AnnotationKeyboard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { PreviewAnnotationSubmission } from "@t3tools/contracts";

export type AnnotationTool = "select" | "marquee" | "draw" | "erase";

interface AnnotationKeyboardEvent {
readonly key: string;
readonly metaKey: boolean;
Expand All @@ -8,6 +10,60 @@ interface AnnotationKeyboardEvent {
readonly isComposing: boolean;
}

interface AnnotationKeyEvent extends AnnotationKeyboardEvent {
readonly type: string;
readonly target: EventTarget | null;
readonly isTrusted: boolean;
preventDefault(): void;
stopPropagation(): void;
stopImmediatePropagation(): void;
}

export type AnnotationKeyAction = "cancel" | AnnotationTool | PreviewAnnotationSubmission;

// Runs from the preload's window capture listeners, ahead of any listener the
// inspected page installs. Overlay events are hidden from the page but never
// cancelled, so the editor still receives Space, caret keys and composition.
export function handleAnnotationKeyEvent(
event: AnnotationKeyEvent,
input: {
overlayTarget: EventTarget;
commentFocused: boolean;
canSend: boolean;
},
): AnnotationKeyAction | null {
const inOverlay = event.target === input.overlayTarget;
if (inOverlay) event.stopImmediatePropagation();
// The page can dispatch synthetic key events on the visible shadow host.
if (event.type !== "keydown" || !event.isTrusted) return null;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
return "cancel";
}
if (inOverlay) {
const submission = input.commentFocused
? resolveAnnotationSubmission(event, input.canSend)
: null;
if (submission) event.preventDefault();
return submission;
}
// Fixed cases, not a table: the preload shares the page's realm, so an
// object lookup would also read keys the page planted on Object.prototype.
switch (event.key) {
case "v":
return "select";
case "r":
return "marquee";
case "d":
return "draw";
case "e":
return "erase";
default:
return null;
}
}

export function resolveAnnotationSubmission(
event: AnnotationKeyboardEvent,
canSend: boolean,
Expand Down
48 changes: 23 additions & 25 deletions apps/desktop/src/preview/PickPreload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type {
} from "@t3tools/contracts";

import { createAnnotationIdSource } from "./AnnotationIds.ts";
import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts";
import { handleAnnotationKeyEvent, type AnnotationTool } from "./AnnotationKeyboard.ts";
import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts";
import { installRecordingCursor } from "./RecordingCursor.ts";
import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts";
Expand Down Expand Up @@ -117,8 +117,6 @@ ipcRenderer.on(RECORDING_POINTER_CHANNEL, (_event, point: unknown) => {
);
});

type AnnotationTool = "select" | "marquee" | "draw" | "erase";

interface SelectedElement {
id: string;
element: Element;
Expand All @@ -131,6 +129,7 @@ interface AnnotationSession {
teardown: (notifyMain: boolean) => void;
applyTheme: (theme: DesktopPreviewAnnotationTheme) => void;
setSendEnabled: (enabled: boolean) => void;
onKeyEvent: (event: KeyboardEvent) => void;
}

let activeSession: AnnotationSession | null = null;
Expand Down Expand Up @@ -187,6 +186,13 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => {
window.addEventListener("pointerdown", reportHumanPointerInput, true);
window.addEventListener("keydown", reportHumanKeyInput, true);

// Preload listeners run before page scripts. A listener installed when picking
// starts cannot shield the closed shadow root from the page's capture listeners.
const onAnnotationKeyEvent = (event: KeyboardEvent): void => activeSession?.onKeyEvent(event);
window.addEventListener("keydown", onAnnotationKeyEvent, true);
window.addEventListener("keyup", onAnnotationKeyEvent, true);
window.addEventListener("keypress", onAnnotationKeyEvent, true);

const MOUSE_BUTTON_BACK = 3;
const MOUSE_BUTTON_FORWARD = 4;

Expand Down Expand Up @@ -1344,7 +1350,6 @@ function startAnnotation(sendEnabled: boolean): void {
window.removeEventListener("pointerout", onPointerOut, true);
window.removeEventListener("click", onClick, true);
window.removeEventListener("blur", onWindowBlur);
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("scroll", repaint, true);
window.removeEventListener("resize", repaint);
dragHandle.removeEventListener("pointerdown", onEditorPointerDown);
Expand All @@ -1363,19 +1368,22 @@ function startAnnotation(sendEnabled: boolean): void {

const onCancel = (): void => teardown(false);
const onCaptured = (): void => teardown(false);
const onKeyDown = (event: KeyboardEvent): void => {
if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
const onKeyEvent = (event: KeyboardEvent): void => {
const action = handleAnnotationKeyEvent(event, {
overlayTarget: host,
commentFocused: shadowRoot.activeElement === comment,
canSend: sendEnabled,
});
if (action === null) return;
if (action === "cancel") {
teardown(true);
return;
}
if (event.key === "v") tool = "select";
else if (event.key === "r") tool = "marquee";
else if (event.key === "d") tool = "draw";
else if (event.key === "e") tool = "erase";
else return;
if (action === "attach" || action === "send") {
submitAnnotation(action);
return;
}
tool = action;
refreshToolButtons();
};

Expand Down Expand Up @@ -1445,24 +1453,13 @@ function startAnnotation(sendEnabled: boolean): void {
});
};
submit.addEventListener("click", () => submitAnnotation("attach"));
root.addEventListener("keydown", (event) => {
const submission =
event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null;
// Keep this in the bubble phase so editor inputs receive the event before
// it is isolated from listeners installed by the inspected page.
event.stopImmediatePropagation();
if (!submission) return;
event.preventDefault();
submitAnnotation(submission);
});

window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false });
window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false });
window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false });
window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true });
window.addEventListener("click", onClick, { capture: true, passive: false });
window.addEventListener("blur", onWindowBlur);
window.addEventListener("keydown", onKeyDown, { capture: true });
window.addEventListener("scroll", repaint, { capture: true, passive: true });
window.addEventListener("resize", repaint, { passive: true });
ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel);
Expand All @@ -1473,6 +1470,7 @@ function startAnnotation(sendEnabled: boolean): void {
updateStatus();
activeSession = {
teardown,
onKeyEvent,
applyTheme: (theme) => applyAnnotationTheme(host, theme),
setSendEnabled: (enabled) => {
sendEnabled = enabled;
Expand Down
Loading