feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { type RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Accessible modal focus management:
|
||||
* - moves focus into the container when it opens,
|
||||
* - traps Tab / Shift+Tab within it,
|
||||
* - restores focus to the previously-focused element on close.
|
||||
* Optionally calls `onEscape` when Escape is pressed inside the container.
|
||||
*/
|
||||
export function useFocusTrap(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
active: boolean,
|
||||
onEscape?: () => void,
|
||||
) {
|
||||
const onEscapeRef = useRef(onEscape);
|
||||
onEscapeRef.current = onEscape;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || typeof document === 'undefined') return;
|
||||
const container = ref.current;
|
||||
if (!container) return;
|
||||
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
const focusables = () =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement,
|
||||
);
|
||||
|
||||
// Move focus inside on open.
|
||||
const first = focusables()[0];
|
||||
if (first) first.focus();
|
||||
else {
|
||||
container.setAttribute('tabindex', '-1');
|
||||
container.focus();
|
||||
}
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && onEscapeRef.current) {
|
||||
e.stopPropagation();
|
||||
onEscapeRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
const list = focusables();
|
||||
if (list.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const firstEl = list[0]!;
|
||||
const lastEl = list[list.length - 1]!;
|
||||
const activeEl = document.activeElement;
|
||||
if (e.shiftKey) {
|
||||
if (activeEl === firstEl || activeEl === container) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
}
|
||||
} else if (activeEl === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
container.removeEventListener('keydown', onKey);
|
||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
};
|
||||
// ref / onEscapeRef are stable; only re-run when open state flips.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { isContextMenuOpen } from '../components/ContextMenu';
|
||||
import { isModalOpen } from '../components/Modal';
|
||||
import { useGalleryStoreApi } from '../store/context';
|
||||
import { mediaForView } from '../store/selectors';
|
||||
|
||||
/**
|
||||
* Wire Apple-Photos-style global keyboard shortcuts.
|
||||
*
|
||||
* `enabled` is a parameter (not a call-site condition) so hook order stays
|
||||
* stable: an embedding host that owns its own key handling passes `false`.
|
||||
*/
|
||||
export function useKeyboardShortcuts(enabled: boolean = true) {
|
||||
const api = useGalleryStoreApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
// Never hijack typing in inputs / editable fields.
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = api.getState();
|
||||
const selection = [...state.selection];
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
|
||||
// Cmd/Ctrl+A — select all in the current view.
|
||||
if (meta && (e.key === 'a' || e.key === 'A')) {
|
||||
if (state.lightboxId || state.editorId) return;
|
||||
e.preventDefault();
|
||||
const ids = mediaForView(state).map((m) => m.id);
|
||||
state.selectMany(ids);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete / Backspace — trash (or permanently delete in the bin).
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
|
||||
e.preventDefault();
|
||||
if (state.view === 'recently-deleted') state.deletePermanently(selection);
|
||||
else state.trash(selection);
|
||||
return;
|
||||
}
|
||||
|
||||
// F — toggle favourite.
|
||||
if ((e.key === 'f' || e.key === 'F') && !meta && selection.length) {
|
||||
e.preventDefault();
|
||||
state.toggleFavorite(selection);
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — clear selection / focus, then leave full screen.
|
||||
if (e.key === 'Escape') {
|
||||
// Anything layered above the shell owns Escape first: the lightbox,
|
||||
// editors, the camera, modals and context menus each close themselves.
|
||||
const overlayOpen =
|
||||
Boolean(state.lightboxId) ||
|
||||
Boolean(state.editorId) ||
|
||||
state.cameraOpen ||
|
||||
isModalOpen() ||
|
||||
isContextMenuOpen();
|
||||
if (overlayOpen) return;
|
||||
if (state.objectFocus) state.setObjectFocus(null);
|
||||
else if (state.selection.size) state.clearSelection();
|
||||
else if (state.fullscreen) state.setFullscreen(false);
|
||||
}
|
||||
|
||||
// Enter / Space — open the (single) selection in the lightbox.
|
||||
if ((e.key === 'Enter' || e.key === ' ') && selection.length === 1 && !state.lightboxId) {
|
||||
e.preventDefault();
|
||||
state.openLightbox(selection[0]!);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [api, enabled]);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** SSR-safe media-query hook. */
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const mq = window.matchMedia(query);
|
||||
const update = () => setMatches(mq.matches);
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** True on phone-sized viewports (the sidebar becomes an overlay drawer). */
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 760px)');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useGallery } from '../store/context';
|
||||
import { mediaForView } from '../store/selectors';
|
||||
import type { GalleryState } from '../store/store';
|
||||
import type { MediaItem } from '../types';
|
||||
|
||||
/** The media items for the active view (filters + search + object-focus applied). */
|
||||
export function useViewMedia(): MediaItem[] {
|
||||
const media = useGallery((s) => s.media);
|
||||
const albums = useGallery((s) => s.albums);
|
||||
const view = useGallery((s) => s.view);
|
||||
const gridFilter = useGallery((s) => s.gridFilter);
|
||||
const searchQuery = useGallery((s) => s.searchQuery);
|
||||
const objectFocus = useGallery((s) => s.objectFocus);
|
||||
const tagFocus = useGallery((s) => s.tagFocus);
|
||||
const personFocus = useGallery((s) => s.personFocus);
|
||||
const searchPreset = useGallery((s) => s.searchPreset);
|
||||
const semanticResults = useGallery((s) => s.semanticResults);
|
||||
const recentlyViewed = useGallery((s) => s.recentlyViewed);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
mediaForView({
|
||||
media,
|
||||
albums,
|
||||
view,
|
||||
gridFilter,
|
||||
searchQuery,
|
||||
objectFocus,
|
||||
tagFocus,
|
||||
personFocus,
|
||||
searchPreset,
|
||||
semanticResults,
|
||||
recentlyViewed,
|
||||
} as GalleryState),
|
||||
[media, albums, view, gridFilter, searchQuery, objectFocus, tagFocus, personFocus, searchPreset, semanticResults, recentlyViewed],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user