Merge pull request #41 from HappyOnigiri/refactor/unify-key-handling-and-escape-policy

refactor: unify key handling and escape policy
This commit is contained in:
HappyOnigiri
2026-03-02 07:52:41 +09:00
committed by GitHub
12 changed files with 3247 additions and 3152 deletions
+3 -6
View File
@@ -1,5 +1,6 @@
import type React from "react";
import { useEffect, useRef } from "react";
import { useEscapeToClose } from "../../hooks/useEscapeToClose";
// Use the same CSS as the light-table's ConfirmModal for consistency
import "../../features/light-table/components/ConfirmModal.css";
@@ -25,6 +26,7 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
}) => {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<Element | null>(null);
useEscapeToClose({ onEscape: onCancel });
// Focus management: Trap Tab, ESC handler, and focus restore
useEffect(() => {
@@ -44,11 +46,6 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onCancel();
return;
}
if (e.key === "Tab") {
if (!dialogRef.current) return;
@@ -88,7 +85,7 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
(previousFocusRef.current as HTMLElement).focus();
}
};
}, [onCancel]);
}, []);
return (
<div
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useKeydownCapture } from "../../../hooks/useKeydownCapture";
import { logger } from "../../../logger";
import { useGallery } from "../../../store/GalleryContext";
import { stopKeyboardEvent } from "../../../utils/keyboard";
import { useLightTableStore } from "../../light-table/store";
export type SidebarView = "gallery" | "search" | "tags";
@@ -81,24 +83,29 @@ export const useGalleryLogic = () => {
loadMoreImages,
]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (state.activeModal.type !== "none") {
dispatch({ type: "CLOSE_MODAL" });
e.preventDefault();
e.stopPropagation();
} else if (state.selectedIds.size > 0) {
dispatch({ type: "CLEAR_SELECTION" });
e.preventDefault();
e.stopPropagation();
}
useKeydownCapture({
onKeyDown: (e: KeyboardEvent) => {
if (e.key !== "Escape") {
return;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [state.activeModal.type, state.selectedIds.size, dispatch]);
const hasOpenDialog = Boolean(
document.querySelector(
'[role="dialog"][aria-modal="true"], [role="alertdialog"][aria-modal="true"], .meld-modal-overlay',
),
);
if (state.activeModal.type !== "none" || hasOpenDialog) {
return;
}
if (state.selectedIds.size === 0) {
return;
}
dispatch({ type: "CLEAR_SELECTION" });
stopKeyboardEvent(e);
},
});
useEffect(() => {
const observer = new IntersectionObserver(
@@ -1,5 +1,5 @@
import type React from "react";
import { useEffect } from "react";
import { useEscapeToClose } from "../../../hooks/useEscapeToClose";
import "./ConfirmModal.css";
@@ -21,16 +21,7 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
onConfirm,
onCancel,
}) => {
// Cancel with ESC key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onCancel]);
useEscapeToClose({ onEscape: onCancel });
return (
<div
@@ -1,48 +1,49 @@
import { useEffect } from "react";
import { useCallback } from "react";
import { useKeydownCapture } from "../../../hooks/useKeydownCapture";
import { useGallery } from "../../../store/GalleryContext";
import {
isEditableActiveElement,
stopKeyboardEvent,
} from "../../../utils/keyboard";
import { useLightTableStore } from "../store";
export const useLightTableKeys = () => {
const { state: galleryState } = useGallery();
const slots = useLightTableStore((s) => s.slots);
const addToBucket = useLightTableStore((s) => s.addToBucket);
const isActionable = slots.length > 0 && galleryState.selectedIds.size > 0;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Ignore if input elements (input, textarea) have focus
if (
document.activeElement?.tagName === "INPUT" ||
document.activeElement?.tagName === "TEXTAREA" ||
(document.activeElement as HTMLElement)?.isContentEditable
) {
if (isEditableActiveElement()) {
return;
}
// Ignore if nothing is selected
const selectedIds = galleryState.selectedIds;
if (!selectedIds || selectedIds.size === 0) return;
if (!selectedIds || selectedIds.size === 0) {
return;
}
// Look for a slot with a matching shortcut key
const slot = slots.find(
(s) => s.shortcutKey.toLowerCase() === e.key.toLowerCase(),
);
if (slot) {
e.preventDefault();
// Add all selected images to the bucket
selectedIds.forEach((id: number) => {
addToBucket(slot.id, String(id));
});
// Notification to toast etc. can be implemented separately
console.log(`Added ${selectedIds.size} images to slot ${slot.label}`);
if (!slot) {
return;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [slots, addToBucket, galleryState.selectedIds]);
stopKeyboardEvent(e);
// Add all selected images to the bucket
selectedIds.forEach((id: number) => {
addToBucket(slot.id, String(id));
});
},
[slots, addToBucket, galleryState.selectedIds],
);
useKeydownCapture({ onKeyDown: handleKeyDown, enabled: isActionable });
};
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useEscapeToClose } from "../../../hooks/useEscapeToClose";
import { useOnPointerDownOutside } from "../../../hooks/useOnPointerDownOutside";
import { useGallery } from "../../../store/GalleryContext";
import { stopReactKeyboardEvent } from "../../../utils/keyboard";
import { useFavoritesLogic } from "../hooks/useFavoritesLogic";
import { FavoriteItem } from "./FavoriteItem";
@@ -252,8 +253,14 @@ export const FavoritesContextMenu: React.FC<FavoritesContextMenuProps> = ({
outline: "none",
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveEditFavorite();
if (e.key === "Escape") setEditingFavorite(null);
if (e.key === "Enter") {
stopReactKeyboardEvent(e);
handleSaveEditFavorite();
}
if (e.key === "Escape") {
stopReactKeyboardEvent(e);
setEditingFavorite(null);
}
}}
/>
</div>
@@ -293,10 +300,13 @@ export const FavoritesContextMenu: React.FC<FavoritesContextMenuProps> = ({
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
stopReactKeyboardEvent(e);
handleSaveEditFavorite();
}
if (e.key === "Escape") setEditingFavorite(null);
if (e.key === "Escape") {
stopReactKeyboardEvent(e);
setEditingFavorite(null);
}
}}
/>
</div>
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useEscapeToClose } from "../../../hooks/useEscapeToClose";
import { useGallery } from "../../../store/GalleryContext";
import { stopReactKeyboardEvent } from "../../../utils/keyboard";
import { useFavoritesLogic } from "../hooks/useFavoritesLogic";
import { useSearchLogic } from "../hooks/useSearchLogic";
import { SearchSuggestions } from "./SearchSuggestions";
@@ -390,8 +391,14 @@ export const SearchBar: React.FC = () => {
outline: "none",
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveEditFavorite();
if (e.key === "Escape") setEditingFavorite(null);
if (e.key === "Enter") {
stopReactKeyboardEvent(e);
handleSaveEditFavorite();
}
if (e.key === "Escape") {
stopReactKeyboardEvent(e);
setEditingFavorite(null);
}
}}
/>
</div>
@@ -431,10 +438,13 @@ export const SearchBar: React.FC = () => {
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
stopReactKeyboardEvent(e);
handleSaveEditFavorite();
}
if (e.key === "Escape") setEditingFavorite(null);
if (e.key === "Escape") {
stopReactKeyboardEvent(e);
setEditingFavorite(null);
}
}}
/>
</div>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { logger } from "../../../logger";
import { useGallery } from "../../../store/GalleryContext";
import { stopReactKeyboardEvent } from "../../../utils/keyboard";
import * as searchApi from "../api/searchApi";
export interface Suggestion {
@@ -234,6 +235,7 @@ export const useSearchLogic = () => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
stopReactKeyboardEvent(e);
if (showSuggestions && selectedIndex >= 0) {
applySuggestion(suggestions[selectedIndex]);
} else {
@@ -241,21 +243,22 @@ export const useSearchLogic = () => {
}
} else if (e.key === "Tab") {
if (showSuggestions && suggestions.length > 0) {
stopReactKeyboardEvent(e);
const indexToApply = selectedIndex >= 0 ? selectedIndex : 0;
applySuggestion(suggestions[indexToApply]);
e.preventDefault();
}
} else if (e.key === "ArrowDown") {
if (showSuggestions) {
stopReactKeyboardEvent(e);
setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
e.preventDefault();
}
} else if (e.key === "ArrowUp") {
if (showSuggestions) {
stopReactKeyboardEvent(e);
setSelectedIndex((prev) => Math.max(prev - 1, -1));
e.preventDefault();
}
} else if (e.key === "Escape") {
stopReactKeyboardEvent(e);
setShowSuggestions(false);
}
};
@@ -6,6 +6,7 @@ import { RESERVED_TAG_KEYWORD } from "../../../constants";
import { useEscapeToClose } from "../../../hooks/useEscapeToClose";
import { useGallery } from "../../../store/GalleryContext";
import type { Tag as TagType } from "../../../types";
import { stopReactKeyboardEvent } from "../../../utils/keyboard";
import * as imagesApi from "../../images/api/imagesApi";
import * as tagsApi from "../api/tagsApi";
@@ -126,8 +127,7 @@ export const TagEditModal: React.FC<TagEditModalProps> = ({
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && searchQuery.trim()) {
e.preventDefault();
e.stopPropagation();
stopReactKeyboardEvent(e);
handleAddTag(searchQuery.trim());
}
};
@@ -1,8 +1,10 @@
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useKeydownCapture } from "../../../hooks/useKeydownCapture";
import { useOnPointerDownOutside } from "../../../hooks/useOnPointerDownOutside";
import { useGallery } from "../../../store/GalleryContext";
import type { MeldImage } from "../../../types";
import { stopKeyboardEvent } from "../../../utils/keyboard";
import { getThumbnailViewUrl } from "../../../utils/url";
import { useImageActions } from "../../images/hooks/useImageActions";
import { useImageLineage } from "../../images/hooks/useImageLineage";
@@ -62,22 +64,30 @@ export const useImageCardLogic = (image: MeldImage) => {
}
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (popupContent) {
setPopupContent(null);
} else {
setIsMenuOpen(false);
}
const handleEscapeKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key !== "Escape") {
return;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [popupContent]);
if (popupContent) {
stopKeyboardEvent(e);
setPopupContent(null);
return;
}
if (isMenuOpen) {
stopKeyboardEvent(e);
setIsMenuOpen(false);
}
},
[popupContent, isMenuOpen],
);
useKeydownCapture({
enabled: isMenuOpen || popupContent !== null,
onKeyDown: handleEscapeKeyDown,
});
const parentChain = getParentChain(image);
const showFilename = state.settings["sidebar.show_filename"];
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from "react";
interface UseKeydownCaptureOptions {
onKeyDown: (e: KeyboardEvent) => void;
enabled?: boolean;
}
export const useKeydownCapture = ({
onKeyDown,
enabled = true,
}: UseKeydownCaptureOptions): void => {
useEffect(() => {
if (!enabled) {
return;
}
window.addEventListener("keydown", onKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", onKeyDown, { capture: true });
};
}, [onKeyDown, enabled]);
};
+23
View File
@@ -0,0 +1,23 @@
import type React from "react";
export const stopKeyboardEvent = (e: KeyboardEvent): void => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
export const stopReactKeyboardEvent = (e: React.KeyboardEvent): void => {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
};
export const isEditableActiveElement = (): boolean => {
const el = document.activeElement;
if (!(el instanceof HTMLElement)) {
return false;
}
const tagName = el.tagName;
return tagName === "INPUT" || tagName === "TEXTAREA" || el.isContentEditable;
};
+3087 -3066
View File
File diff suppressed because it is too large Load Diff