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:
@@ -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"];
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Generated
+3087
-3066
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user