feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
|
||||
import { Icon, type IconName } from '../icons';
|
||||
|
||||
export interface MenuItem {
|
||||
type?: 'item';
|
||||
label: string;
|
||||
icon?: IconName;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
export interface MenuSeparator {
|
||||
type: 'separator';
|
||||
}
|
||||
export interface MenuLabel {
|
||||
type: 'label';
|
||||
label: string;
|
||||
}
|
||||
export type MenuEntry = MenuItem | MenuSeparator | MenuLabel;
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
entries: MenuEntry[];
|
||||
}
|
||||
|
||||
// Module-level emitter — typically one gallery is interactive at a time.
|
||||
let current: MenuState | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
export function openContextMenu(x: number, y: number, entries: MenuEntry[]) {
|
||||
current = { x, y, entries };
|
||||
emit();
|
||||
}
|
||||
export function closeContextMenu() {
|
||||
if (current) {
|
||||
current = null;
|
||||
emit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a context menu is open (it owns Escape while it is). */
|
||||
export function isContextMenuOpen(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
function getSnapshot() {
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Renders the active context menu (mounted once near the gallery root). */
|
||||
export function ContextMenuHost() {
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, () => null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!state || !ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
const left = Math.min(state.x, window.innerWidth - rect.width - pad);
|
||||
const top = Math.min(state.y, window.innerHeight - rect.height - pad);
|
||||
setPos({ left: Math.max(pad, left), top: Math.max(pad, top) });
|
||||
// Move keyboard focus to the first actionable item when the menu opens.
|
||||
ref.current.querySelector<HTMLButtonElement>('button.apg-menu__item:not([disabled])')?.focus();
|
||||
}, [state]);
|
||||
|
||||
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
||||
const items = Array.from(
|
||||
ref.current?.querySelectorAll<HTMLButtonElement>('button.apg-menu__item:not([disabled])') ?? [],
|
||||
);
|
||||
if (items.length === 0) return;
|
||||
const idx = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
items[(idx + 1) % items.length]!.focus();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
items[(idx - 1 + items.length) % items.length]!.focus();
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
items[0]!.focus();
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
items[items.length - 1]!.focus();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) closeContextMenu();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeContextMenu();
|
||||
};
|
||||
const onScroll = () => closeContextMenu();
|
||||
window.addEventListener('mousedown', onDown, true);
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', onDown, true);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="apg-menu"
|
||||
role="menu"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
>
|
||||
{state.entries.map((entry, i) => {
|
||||
if (entry.type === 'separator') return <div key={i} className="apg-menu__sep" />;
|
||||
if (entry.type === 'label')
|
||||
return (
|
||||
<div key={i} className="apg-menu__label">
|
||||
{entry.label}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={['apg-menu__item', entry.danger ? 'apg-menu__item--danger' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
disabled={entry.disabled}
|
||||
onClick={() => {
|
||||
closeContextMenu();
|
||||
entry.onClick();
|
||||
}}
|
||||
>
|
||||
{entry.icon ? (
|
||||
<span className="apg-menu__icon">
|
||||
<Icon name={entry.icon} size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="apg-menu__icon" />
|
||||
)}
|
||||
<span>{entry.label}</span>
|
||||
{entry.checked ? (
|
||||
<span className="apg-menu__check">
|
||||
<Icon name="check" size={15} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user