mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
Add issue-detail g i inbox shortcut
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
ede3206423
commit
69ff793c6a
3 changed files with 163 additions and 1 deletions
|
|
@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
hasBlockingShortcutDialog,
|
hasBlockingShortcutDialog,
|
||||||
isKeyboardShortcutTextInputTarget,
|
isKeyboardShortcutTextInputTarget,
|
||||||
|
resolveGoToInboxKeyAction,
|
||||||
resolveInboxQuickArchiveKeyAction,
|
resolveInboxQuickArchiveKeyAction,
|
||||||
} from "./keyboardShortcuts";
|
} from "./keyboardShortcuts";
|
||||||
|
|
||||||
|
|
@ -109,4 +110,49 @@ describe("keyboardShortcuts helpers", () => {
|
||||||
hasOpenDialog: false,
|
hasOpenDialog: false,
|
||||||
})).toBe("disarm");
|
})).toBe("disarm");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("arms go-to-inbox on a clean g press", () => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
|
||||||
|
expect(resolveGoToInboxKeyAction({
|
||||||
|
armed: false,
|
||||||
|
defaultPrevented: false,
|
||||||
|
key: "g",
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
target: button,
|
||||||
|
hasOpenDialog: false,
|
||||||
|
})).toBe("arm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to inbox on i after g", () => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
|
||||||
|
expect(resolveGoToInboxKeyAction({
|
||||||
|
armed: true,
|
||||||
|
defaultPrevented: false,
|
||||||
|
key: "i",
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
target: button,
|
||||||
|
hasOpenDialog: false,
|
||||||
|
})).toBe("navigate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disarms go-to-inbox instead of firing from an editor", () => {
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
|
||||||
|
expect(resolveGoToInboxKeyAction({
|
||||||
|
armed: true,
|
||||||
|
defaultPrevented: false,
|
||||||
|
key: "i",
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
target: input,
|
||||||
|
hasOpenDialog: false,
|
||||||
|
})).toBe("disarm");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
|
||||||
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
||||||
|
|
||||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||||
|
export type GoToInboxKeyAction = "ignore" | "arm" | "navigate" | "disarm";
|
||||||
|
|
||||||
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
|
@ -52,3 +53,35 @@ export function resolveInboxQuickArchiveKeyAction({
|
||||||
if (key === "y") return "archive";
|
if (key === "y") return "archive";
|
||||||
return "disarm";
|
return "disarm";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveGoToInboxKeyAction({
|
||||||
|
armed,
|
||||||
|
defaultPrevented,
|
||||||
|
key,
|
||||||
|
metaKey,
|
||||||
|
ctrlKey,
|
||||||
|
altKey,
|
||||||
|
target,
|
||||||
|
hasOpenDialog,
|
||||||
|
}: {
|
||||||
|
armed: boolean;
|
||||||
|
defaultPrevented: boolean;
|
||||||
|
key: string;
|
||||||
|
metaKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
target: EventTarget | null;
|
||||||
|
hasOpenDialog: boolean;
|
||||||
|
}): GoToInboxKeyAction {
|
||||||
|
if (defaultPrevented) return armed ? "disarm" : "ignore";
|
||||||
|
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
||||||
|
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) {
|
||||||
|
return armed ? "disarm" : "ignore";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedKey = key.toLowerCase();
|
||||||
|
if (!armed) return normalizedKey === "g" ? "arm" : "ignore";
|
||||||
|
if (normalizedKey === "i") return "navigate";
|
||||||
|
if (normalizedKey === "g") return "arm";
|
||||||
|
return "disarm";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,11 @@ import {
|
||||||
rememberIssueDetailLocationState,
|
rememberIssueDetailLocationState,
|
||||||
shouldArmIssueDetailInboxQuickArchive,
|
shouldArmIssueDetailInboxQuickArchive,
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
|
import {
|
||||||
|
hasBlockingShortcutDialog,
|
||||||
|
resolveGoToInboxKeyAction,
|
||||||
|
resolveInboxQuickArchiveKeyAction,
|
||||||
|
} from "../lib/keyboardShortcuts";
|
||||||
import {
|
import {
|
||||||
applyOptimisticIssueFieldUpdate,
|
applyOptimisticIssueFieldUpdate,
|
||||||
applyOptimisticIssueFieldUpdateToCollection,
|
applyOptimisticIssueFieldUpdateToCollection,
|
||||||
|
|
@ -1232,6 +1236,8 @@ export function IssueDetail() {
|
||||||
}, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]);
|
}, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]);
|
||||||
|
|
||||||
const inboxQuickArchiveArmedRef = useRef(false);
|
const inboxQuickArchiveArmedRef = useRef(false);
|
||||||
|
const goToInboxShortcutArmedRef = useRef(false);
|
||||||
|
const goToInboxShortcutTimeoutRef = useRef<number | null>(null);
|
||||||
const canQuickArchiveFromInbox =
|
const canQuickArchiveFromInbox =
|
||||||
keyboardShortcutsEnabled &&
|
keyboardShortcutsEnabled &&
|
||||||
!issue?.hiddenAt &&
|
!issue?.hiddenAt &&
|
||||||
|
|
@ -1301,6 +1307,83 @@ export function IssueDetail() {
|
||||||
};
|
};
|
||||||
}, [archiveFromInbox, canQuickArchiveFromInbox, issue?.id]);
|
}, [archiveFromInbox, canQuickArchiveFromInbox, issue?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyboardShortcutsEnabled) {
|
||||||
|
goToInboxShortcutArmedRef.current = false;
|
||||||
|
if (goToInboxShortcutTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(goToInboxShortcutTimeoutRef.current);
|
||||||
|
goToInboxShortcutTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearArmTimeout = () => {
|
||||||
|
if (goToInboxShortcutTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(goToInboxShortcutTimeoutRef.current);
|
||||||
|
goToInboxShortcutTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disarm = () => {
|
||||||
|
goToInboxShortcutArmedRef.current = false;
|
||||||
|
clearArmTimeout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const arm = () => {
|
||||||
|
goToInboxShortcutArmedRef.current = true;
|
||||||
|
clearArmTimeout();
|
||||||
|
goToInboxShortcutTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
goToInboxShortcutArmedRef.current = false;
|
||||||
|
goToInboxShortcutTimeoutRef.current = null;
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = () => {
|
||||||
|
disarm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocusIn = (event: FocusEvent) => {
|
||||||
|
if (event.target instanceof HTMLElement && event.target !== document.body) {
|
||||||
|
disarm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const action = resolveGoToInboxKeyAction({
|
||||||
|
armed: goToInboxShortcutArmedRef.current,
|
||||||
|
defaultPrevented: event.defaultPrevented,
|
||||||
|
key: event.key,
|
||||||
|
metaKey: event.metaKey,
|
||||||
|
ctrlKey: event.ctrlKey,
|
||||||
|
altKey: event.altKey,
|
||||||
|
target: event.target,
|
||||||
|
hasOpenDialog: hasBlockingShortcutDialog(document),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action === "ignore") return;
|
||||||
|
if (action === "arm") {
|
||||||
|
arm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disarm();
|
||||||
|
if (action !== "navigate") return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox");
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
document.addEventListener("focusin", handleFocusIn, true);
|
||||||
|
document.addEventListener("keydown", handleKeyDown, true);
|
||||||
|
return () => {
|
||||||
|
disarm();
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
document.removeEventListener("focusin", handleFocusIn, true);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown, true);
|
||||||
|
};
|
||||||
|
}, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]);
|
||||||
|
|
||||||
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
||||||
const attachmentList = attachments ?? [];
|
const attachmentList = attachments ?? [];
|
||||||
const imageAttachments = attachmentList.filter(isImageAttachment);
|
const imageAttachments = attachmentList.filter(isImageAttachment);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue