Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
} from 'react';
import type { GestureLayerDefinition, PageGestureDefinition, PageGestureState } from '../types';
type GestureTrackingState = {
gestureId: string;
startX: number;
startY: number;
readyToTrigger: boolean;
};
type GestureContextValue = {
pageState: PageGestureState;
setPageState: (nextState: PageGestureState) => void;
registerLayer: (layer: GestureLayerDefinition) => void;
unregisterLayer: (layerId: string) => void;
};
const GestureContext = createContext<GestureContextValue | null>(null);
function isInteractiveElement(target: EventTarget | null) {
if (!(target instanceof Element)) {
return false;
}
return Boolean(
target.closest(
'button, a, input, select, textarea, summary, label, [role="button"], [role="link"], [data-no-gesture]',
),
);
}
function isGestureAvailable(pageState: PageGestureState, gesture: PageGestureDefinition) {
if (gesture.enabled === false) {
return false;
}
if (!gesture.activeStates || gesture.activeStates.length === 0) {
return true;
}
return gesture.activeStates.includes(pageState);
}
export function GestureProvider({ children }: PropsWithChildren) {
const [pageState, setPageState] = useState<PageGestureState>('anyway');
const [mobileViewport, setMobileViewport] = useState(false);
const [layerVersion, setLayerVersion] = useState(0);
const layersRef = useRef<GestureLayerDefinition[]>([]);
const trackingRef = useRef<GestureTrackingState | null>(null);
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 768px)');
const updateViewport = () => {
setMobileViewport(mediaQuery.matches);
};
updateViewport();
mediaQuery.addEventListener('change', updateViewport);
return () => {
mediaQuery.removeEventListener('change', updateViewport);
};
}, []);
const resolveGesture = (gestureId: string) => {
for (let layerIndex = layersRef.current.length - 1; layerIndex >= 0; layerIndex -= 1) {
const layer = layersRef.current[layerIndex];
const gesture = layer.gestures.find((candidate) => candidate.id === gestureId);
if (gesture) {
return gesture;
}
}
return null;
};
const topRightGesture = useMemo(() => {
const resolveGestureByTrigger = (trigger: PageGestureDefinition['trigger']) => {
for (let layerIndex = layersRef.current.length - 1; layerIndex >= 0; layerIndex -= 1) {
const layer = layersRef.current[layerIndex];
if (layer.enabled === false) {
continue;
}
for (let gestureIndex = layer.gestures.length - 1; gestureIndex >= 0; gestureIndex -= 1) {
const gesture = layer.gestures[gestureIndex];
if (!isGestureAvailable(pageState, gesture)) {
continue;
}
if (gesture.mobileOnly && !mobileViewport) {
continue;
}
if (gesture.trigger === trigger) {
return gesture;
}
}
}
return null;
};
return resolveGestureByTrigger('pull-down-top-right');
}, [layerVersion, mobileViewport, pageState]);
const middleRightGesture = useMemo(() => {
for (let layerIndex = layersRef.current.length - 1; layerIndex >= 0; layerIndex -= 1) {
const layer = layersRef.current[layerIndex];
if (layer.enabled === false) {
continue;
}
for (let gestureIndex = layer.gestures.length - 1; gestureIndex >= 0; gestureIndex -= 1) {
const gesture = layer.gestures[gestureIndex];
if (!isGestureAvailable(pageState, gesture)) {
continue;
}
if (gesture.mobileOnly && !mobileViewport) {
continue;
}
if (gesture.trigger === 'pull-left-middle-right') {
return gesture;
}
}
}
return null;
}, [layerVersion, mobileViewport, pageState]);
const resetTracking = () => {
trackingRef.current = null;
};
useEffect(() => {
if ((!topRightGesture && !middleRightGesture) || !mobileViewport) {
trackingRef.current = null;
return;
}
const handleWindowTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) {
trackingRef.current = null;
return;
}
const inTopRightZone = Boolean(
topRightGesture &&
touch.clientX >= window.innerWidth - (topRightGesture.hotZoneSize ?? 120) &&
touch.clientY <= (topRightGesture.hotZoneSize ?? 120),
);
const middleHotZoneSize = middleRightGesture?.hotZoneSize ?? 120;
const middleZoneTop = window.innerHeight * 0.25;
const middleZoneBottom = window.innerHeight * 0.75;
const inMiddleRightZone = Boolean(
middleRightGesture &&
touch.clientX >= window.innerWidth - middleHotZoneSize &&
touch.clientY >= middleZoneTop &&
touch.clientY <= middleZoneBottom,
);
if ((!inTopRightZone && !inMiddleRightZone) || isInteractiveElement(event.target)) {
trackingRef.current = null;
return;
}
trackingRef.current = {
gestureId: inTopRightZone && topRightGesture ? topRightGesture.id : middleRightGesture!.id,
startX: touch.clientX,
startY: touch.clientY,
readyToTrigger: false,
};
};
const handleWindowTouchMove = (event: TouchEvent) => {
const tracking = trackingRef.current;
if (!tracking) {
return;
}
const touch = event.touches[0];
const gesture = resolveGesture(tracking.gestureId);
if (!touch || !gesture || !isGestureAvailable(pageState, gesture)) {
return;
}
const deltaX = touch.clientX - tracking.startX;
const deltaY = touch.clientY - tracking.startY;
const minDistance = gesture.minDistance ?? 72;
const maxHorizontalDrift = gesture.maxHorizontalDrift ?? 96;
const isReady =
gesture.trigger === 'pull-left-middle-right'
? deltaX <= minDistance * -1 && Math.abs(deltaY) <= maxHorizontalDrift
: deltaY >= minDistance && Math.abs(deltaX) <= maxHorizontalDrift;
if (tracking.readyToTrigger !== isReady) {
trackingRef.current = {
...tracking,
readyToTrigger: isReady,
};
}
};
const handleWindowTouchEnd = () => {
const tracking = trackingRef.current;
trackingRef.current = null;
if (!tracking?.readyToTrigger) {
return;
}
const gesture = resolveGesture(tracking.gestureId);
if (!gesture || !isGestureAvailable(pageState, gesture)) {
return;
}
gesture.onTrigger();
};
window.addEventListener('touchstart', handleWindowTouchStart, { passive: true });
window.addEventListener('touchmove', handleWindowTouchMove, { passive: true });
window.addEventListener('touchend', handleWindowTouchEnd, { passive: true });
window.addEventListener('touchcancel', resetTracking, { passive: true });
return () => {
window.removeEventListener('touchstart', handleWindowTouchStart);
window.removeEventListener('touchmove', handleWindowTouchMove);
window.removeEventListener('touchend', handleWindowTouchEnd);
window.removeEventListener('touchcancel', resetTracking);
};
}, [middleRightGesture, mobileViewport, pageState, topRightGesture]);
const value = useMemo<GestureContextValue>(
() => ({
pageState,
setPageState,
registerLayer: (layer) => {
const nextLayers = layersRef.current.filter((candidate) => candidate.id !== layer.id);
nextLayers.push(layer);
layersRef.current = nextLayers;
setLayerVersion((previous) => previous + 1);
},
unregisterLayer: (layerId) => {
layersRef.current = layersRef.current.filter((candidate) => candidate.id !== layerId);
setLayerVersion((previous) => previous + 1);
},
}),
[pageState],
);
return (
<GestureContext.Provider value={value}>
{children}
</GestureContext.Provider>
);
}
export function useGestureContext() {
const context = useContext(GestureContext);
if (!context) {
throw new Error('useGestureContext must be used within a GestureProvider.');
}
return context;
}

View File

@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useGestureContext } from '../context/GestureContext';
import type { GestureLayerDefinition, PageGestureState } from '../types';
export function useGesturePageState(pageState: PageGestureState) {
const { setPageState } = useGestureContext();
useEffect(() => {
setPageState(pageState);
}, [pageState, setPageState]);
}
export function useGestureLayer(layer: GestureLayerDefinition) {
const { registerLayer, unregisterLayer } = useGestureContext();
useEffect(() => {
registerLayer(layer);
return () => {
unregisterLayer(layer.id);
};
}, [layer, registerLayer, unregisterLayer]);
}

3
src/layer/gesture/index.ts Executable file
View File

@@ -0,0 +1,3 @@
export * from './context/GestureContext';
export * from './hooks/useGestureLayer';
export * from './types';

View File

@@ -0,0 +1,19 @@
export type PageGestureState = 'anyway';
export type PageGestureDefinition = {
id: string;
activeStates?: PageGestureState[];
enabled?: boolean;
mobileOnly?: boolean;
trigger: 'pull-down-top-right' | 'pull-left-middle-right';
hotZoneSize?: number;
minDistance?: number;
maxHorizontalDrift?: number;
onTrigger: () => void;
};
export type GestureLayerDefinition = {
id: string;
enabled?: boolean;
gestures: PageGestureDefinition[];
};

View File

@@ -0,0 +1,419 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
} from 'react';
import { sendClientNotification } from '../../../app/main/notificationApi';
import type {
GpsAnchor,
GpsConfig,
GpsCoordinates,
GpsGeofenceEvent,
GpsLayerSnapshot,
GpsPermissionState,
} from '../types';
type GpsLayerContextValue = GpsLayerSnapshot & {
enableGps: () => void;
disableGps: () => void;
toggleGps: () => void;
saveCurrentAnchor: (name: string) => { ok: boolean; message?: string; anchor?: GpsAnchor };
removeAnchor: (anchorId: string) => void;
selectAnchor: (anchorId: string | null) => void;
updateConfig: (patch: Partial<GpsConfig>) => void;
clearEvents: () => void;
};
const GPS_STORAGE_KEY = 'gps-layer:anchors';
const GPS_CONFIG_STORAGE_KEY = 'gps-layer:config';
const GPS_SELECTED_STORAGE_KEY = 'gps-layer:selected-anchor';
const GPS_EVENTS_LIMIT = 24;
const DEFAULT_CONFIG: GpsConfig = {
notifyOnEnter: true,
notifyOnExit: true,
radiusMeters: 120,
};
const GpsLayerContext = createContext<GpsLayerContextValue | null>(null);
function clampRadiusMeters(value: number) {
if (!Number.isFinite(value)) {
return DEFAULT_CONFIG.radiusMeters;
}
return Math.min(5000, Math.max(30, Math.round(value)));
}
function readJsonStorage<T>(key: string, fallback: T, parse: (value: unknown) => T) {
if (typeof window === 'undefined') {
return fallback;
}
try {
const raw = window.localStorage.getItem(key);
if (!raw) {
return fallback;
}
return parse(JSON.parse(raw));
} catch {
return fallback;
}
}
function createAnchorId() {
return `gps-anchor-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function toCoordinates(position: GeolocationPosition): GpsCoordinates {
return {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: Number.isFinite(position.coords.accuracy) ? position.coords.accuracy : null,
altitude: Number.isFinite(position.coords.altitude ?? NaN) ? position.coords.altitude ?? null : null,
heading: Number.isFinite(position.coords.heading ?? NaN) ? position.coords.heading ?? null : null,
speed: Number.isFinite(position.coords.speed ?? NaN) ? position.coords.speed ?? null : null,
timestamp: position.timestamp,
};
}
function toRadians(value: number) {
return (value * Math.PI) / 180;
}
function calculateDistanceMeters(
latitudeA: number,
longitudeA: number,
latitudeB: number,
longitudeB: number,
) {
const earthRadiusMeters = 6371000;
const deltaLatitude = toRadians(latitudeB - latitudeA);
const deltaLongitude = toRadians(longitudeB - longitudeA);
const sinLatitude = Math.sin(deltaLatitude / 2);
const sinLongitude = Math.sin(deltaLongitude / 2);
const root =
sinLatitude * sinLatitude +
Math.cos(toRadians(latitudeA)) *
Math.cos(toRadians(latitudeB)) *
sinLongitude *
sinLongitude;
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(root), Math.sqrt(1 - root));
}
function parseAnchors(value: unknown): GpsAnchor[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
if (!item || typeof item !== 'object') {
return [];
}
const candidate = item as Partial<GpsAnchor>;
if (
typeof candidate.id !== 'string' ||
typeof candidate.name !== 'string' ||
typeof candidate.createdAt !== 'string' ||
!Number.isFinite(candidate.latitude) ||
!Number.isFinite(candidate.longitude)
) {
return [];
}
return [
{
id: candidate.id,
name: candidate.name,
latitude: Number(candidate.latitude),
longitude: Number(candidate.longitude),
createdAt: candidate.createdAt,
},
];
});
}
function parseConfig(value: unknown): GpsConfig {
if (!value || typeof value !== 'object') {
return DEFAULT_CONFIG;
}
const candidate = value as Partial<GpsConfig>;
return {
notifyOnEnter: candidate.notifyOnEnter ?? DEFAULT_CONFIG.notifyOnEnter,
notifyOnExit: candidate.notifyOnExit ?? DEFAULT_CONFIG.notifyOnExit,
radiusMeters: clampRadiusMeters(candidate.radiusMeters ?? DEFAULT_CONFIG.radiusMeters),
};
}
function sendGpsPushNotification(event: GpsGeofenceEvent) {
return sendClientNotification({
title: `GPS 거점 ${event.type === 'enter' ? 'In' : 'Out'} 알림`,
body: `${event.anchorName} ${event.type === 'enter' ? '진입' : '이탈'} 감지 (${event.distanceMeters}m)`,
threadId: 'gps-geofence',
data: {
category: 'gps-geofence',
eventType: event.type,
anchorId: event.anchorId,
anchorName: event.anchorName,
distanceMeters: String(event.distanceMeters),
createdAt: event.createdAt,
},
}).catch(() => undefined);
}
export function GpsLayerProvider({ children }: PropsWithChildren) {
const [enabled, setEnabled] = useState(false);
const [permissionState, setPermissionState] = useState<GpsPermissionState>('idle');
const [currentPosition, setCurrentPosition] = useState<GpsCoordinates | null>(null);
const [anchors, setAnchors] = useState<GpsAnchor[]>(() => readJsonStorage(GPS_STORAGE_KEY, [], parseAnchors));
const [config, setConfig] = useState<GpsConfig>(() =>
readJsonStorage(GPS_CONFIG_STORAGE_KEY, DEFAULT_CONFIG, parseConfig),
);
const [selectedAnchorId, setSelectedAnchorId] = useState<string | null>(() =>
readJsonStorage(GPS_SELECTED_STORAGE_KEY, null, (value) => (typeof value === 'string' ? value : null)),
);
const [anchorPresence, setAnchorPresence] = useState<Record<string, boolean>>({});
const [events, setEvents] = useState<GpsGeofenceEvent[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const watchIdRef = useRef<number | null>(null);
const anchorPresenceRef = useRef<Record<string, boolean>>({});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(GPS_STORAGE_KEY, JSON.stringify(anchors));
}, [anchors]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(GPS_CONFIG_STORAGE_KEY, JSON.stringify(config));
}, [config]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
if (selectedAnchorId) {
window.localStorage.setItem(GPS_SELECTED_STORAGE_KEY, selectedAnchorId);
return;
}
window.localStorage.removeItem(GPS_SELECTED_STORAGE_KEY);
}, [selectedAnchorId]);
useEffect(() => {
if (!selectedAnchorId) {
return;
}
const selectedExists = anchors.some((anchor) => anchor.id === selectedAnchorId);
if (!selectedExists) {
setSelectedAnchorId(anchors[0]?.id ?? null);
}
}, [anchors, selectedAnchorId]);
useEffect(() => {
if (typeof navigator === 'undefined' || !('geolocation' in navigator)) {
setPermissionState('unsupported');
return;
}
if (!enabled) {
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
}
return;
}
setPermissionState((previous) => (previous === 'granted' ? previous : 'requesting'));
setErrorMessage(null);
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
setCurrentPosition(toCoordinates(position));
setPermissionState('granted');
setErrorMessage(null);
},
(error) => {
setPermissionState(error.code === error.PERMISSION_DENIED ? 'denied' : 'error');
setErrorMessage(error.message || 'GPS 위치를 읽지 못했습니다.');
},
{
enableHighAccuracy: true,
maximumAge: 5000,
timeout: 10000,
},
);
return () => {
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
}
};
}, [enabled]);
useEffect(() => {
if (!currentPosition || anchors.length === 0) {
anchorPresenceRef.current = {};
setAnchorPresence({});
return;
}
const nextPresence: Record<string, boolean> = {};
const nextEvents: GpsGeofenceEvent[] = [];
for (const anchor of anchors) {
const distanceMeters = calculateDistanceMeters(
currentPosition.latitude,
currentPosition.longitude,
anchor.latitude,
anchor.longitude,
);
const isInside = distanceMeters <= config.radiusMeters;
const previousPresence = anchorPresenceRef.current[anchor.id];
nextPresence[anchor.id] = isInside;
if (typeof previousPresence === 'boolean' && previousPresence !== isInside) {
const type = isInside ? 'enter' : 'exit';
const shouldNotify =
(type === 'enter' && config.notifyOnEnter) || (type === 'exit' && config.notifyOnExit);
const event: GpsGeofenceEvent = {
id: `gps-event-${Date.now()}-${anchor.id}-${type}`,
anchorId: anchor.id,
anchorName: anchor.name,
type,
distanceMeters: Math.round(distanceMeters),
createdAt: new Date().toISOString(),
};
nextEvents.push(event);
if (shouldNotify) {
void sendGpsPushNotification(event);
}
}
}
anchorPresenceRef.current = nextPresence;
setAnchorPresence(nextPresence);
if (nextEvents.length > 0) {
setEvents((previous) => [...nextEvents.reverse(), ...previous].slice(0, GPS_EVENTS_LIMIT));
}
}, [anchors, config.notifyOnEnter, config.notifyOnExit, config.radiusMeters, currentPosition]);
const value = useMemo<GpsLayerContextValue>(
() => ({
enabled,
supported: typeof navigator !== 'undefined' && 'geolocation' in navigator,
permissionState,
currentPosition,
anchors,
config,
selectedAnchorId,
anchorPresence,
events,
errorMessage,
enableGps: () => {
setEnabled(true);
},
disableGps: () => {
setEnabled(false);
},
toggleGps: () => {
setEnabled((previous) => !previous);
},
saveCurrentAnchor: (name: string) => {
const trimmedName = name.trim();
if (!currentPosition) {
return {
ok: false,
message: '현재 GPS 좌표가 없어 거점을 저장할 수 없습니다.',
};
}
if (!trimmedName) {
return {
ok: false,
message: '거점명을 입력해 주세요.',
};
}
const nextAnchor: GpsAnchor = {
id: createAnchorId(),
name: trimmedName,
latitude: currentPosition.latitude,
longitude: currentPosition.longitude,
createdAt: new Date().toISOString(),
};
setAnchors((previous) => [nextAnchor, ...previous]);
setSelectedAnchorId(nextAnchor.id);
return {
ok: true,
anchor: nextAnchor,
};
},
removeAnchor: (anchorId) => {
setAnchors((previous) => previous.filter((anchor) => anchor.id !== anchorId));
setAnchorPresence((previous) => {
const nextPresence = { ...previous };
delete nextPresence[anchorId];
anchorPresenceRef.current = nextPresence;
return nextPresence;
});
},
selectAnchor: (anchorId) => {
setSelectedAnchorId(anchorId);
},
updateConfig: (patch) => {
setConfig((previous) => ({
notifyOnEnter: patch.notifyOnEnter ?? previous.notifyOnEnter,
notifyOnExit: patch.notifyOnExit ?? previous.notifyOnExit,
radiusMeters:
patch.radiusMeters === undefined ? previous.radiusMeters : clampRadiusMeters(patch.radiusMeters),
}));
},
clearEvents: () => {
setEvents([]);
},
}),
[anchorPresence, anchors, config, currentPosition, enabled, errorMessage, events, permissionState, selectedAnchorId],
);
return <GpsLayerContext.Provider value={value}>{children}</GpsLayerContext.Provider>;
}
export function useGpsLayerContext() {
const context = useContext(GpsLayerContext);
if (!context) {
throw new Error('useGpsLayerContext must be used within a GpsLayerProvider.');
}
return context;
}

View File

@@ -0,0 +1,5 @@
import { useGpsLayerContext } from '../context/GpsLayerContext';
export function useGpsLayer() {
return useGpsLayerContext();
}

3
src/layer/gps/index.ts Executable file
View File

@@ -0,0 +1,3 @@
export * from './context/GpsLayerContext';
export * from './hooks/useGpsLayer';
export * from './types';

55
src/layer/gps/types/index.ts Executable file
View File

@@ -0,0 +1,55 @@
export type GpsPermissionState =
| 'idle'
| 'requesting'
| 'granted'
| 'denied'
| 'unsupported'
| 'error';
export type GpsCoordinates = {
latitude: number;
longitude: number;
accuracy: number | null;
altitude: number | null;
heading: number | null;
speed: number | null;
timestamp: number;
};
export type GpsAnchor = {
id: string;
name: string;
latitude: number;
longitude: number;
createdAt: string;
};
export type GpsConfig = {
notifyOnEnter: boolean;
notifyOnExit: boolean;
radiusMeters: number;
};
export type GpsGeofenceEventType = 'enter' | 'exit';
export type GpsGeofenceEvent = {
id: string;
anchorId: string;
anchorName: string;
type: GpsGeofenceEventType;
distanceMeters: number;
createdAt: string;
};
export type GpsLayerSnapshot = {
enabled: boolean;
supported: boolean;
permissionState: GpsPermissionState;
currentPosition: GpsCoordinates | null;
anchors: GpsAnchor[];
config: GpsConfig;
selectedAnchorId: string | null;
anchorPresence: Record<string, boolean>;
events: GpsGeofenceEvent[];
errorMessage: string | null;
};

3
src/layer/index.ts Executable file
View File

@@ -0,0 +1,3 @@
export * from './gesture';
export * from './gps';
export * from './search';

View File

@@ -0,0 +1,122 @@
import {
createContext,
useContext,
useMemo,
useRef,
useState,
type PropsWithChildren,
} from 'react';
import { SearchCommandModal, type SearchKeywordOption } from '../../../components/search';
import type { SearchLayerSnapshot, SearchOpenMode, SearchWindowSelection, SearchWindowSelectionDraft } from '../types';
type SearchLayerContextValue = SearchLayerSnapshot & {
setOptions: (options: SearchKeywordOption[]) => void;
openSearch: (mode?: SearchOpenMode) => void;
closeSearch: () => void;
clearWindowSelection: (instanceId: string) => void;
addWindowSelections: (selections: SearchWindowSelectionDraft[]) => void;
};
const SearchLayerContext = createContext<SearchLayerContextValue | null>(null);
const WINDOW_SELECTION_DEDUP_MS = 500;
export function SearchLayerProvider({ children }: PropsWithChildren) {
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<SearchKeywordOption[]>([]);
const [mode, setMode] = useState<SearchOpenMode>('navigate');
const [windowSelections, setWindowSelections] = useState<SearchWindowSelection[]>([]);
const lastWindowSelectionRef = useRef<{ id: string; at: number } | null>(null);
const value = useMemo<SearchLayerContextValue>(
() => ({
open,
options,
mode,
windowSelections,
setOptions,
openSearch: (nextMode = 'navigate') => {
lastWindowSelectionRef.current = null;
setMode(nextMode);
setOpen(true);
},
closeSearch: () => {
setOpen(false);
},
clearWindowSelection: (instanceId: string) => {
setWindowSelections((previous) => previous.filter((selection) => selection.instanceId !== instanceId));
},
addWindowSelections: (selections) => {
if (selections.length === 0) {
return;
}
setWindowSelections((previous) => [
...previous,
...selections.map((selection) => ({
...selection,
instanceId: `window-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
})),
]);
},
}),
[mode, open, options, windowSelections],
);
return (
<SearchLayerContext.Provider value={value}>
{children}
<SearchCommandModal
open={open}
options={options}
mode={mode}
onClose={value.closeSearch}
onSelectOption={(option) => {
if (mode === 'window') {
const now = Date.now();
const previousSelection = lastWindowSelectionRef.current;
if (
previousSelection &&
previousSelection.id === option.id &&
now - previousSelection.at <= WINDOW_SELECTION_DEDUP_MS
) {
return;
}
lastWindowSelectionRef.current = {
id: option.id,
at: now,
};
setOpen(false);
setWindowSelections((previous) => [
...previous,
{
instanceId: `window-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
id: option.id,
label: option.label,
group: option.group,
description: option.description,
keywords: option.keywords ?? [],
},
]);
(option.onSelectWindow ?? option.onSelect)();
return;
}
option.onSelect();
}}
/>
</SearchLayerContext.Provider>
);
}
export function useSearchLayerContext() {
const context = useContext(SearchLayerContext);
if (!context) {
throw new Error('useSearchLayerContext must be used within a SearchLayerProvider.');
}
return context;
}

View File

@@ -0,0 +1,5 @@
import { useSearchLayerContext } from '../context/SearchLayerContext';
export function useSearchLayer() {
return useSearchLayerContext();
}

3
src/layer/search/index.ts Executable file
View File

@@ -0,0 +1,3 @@
export * from './context/SearchLayerContext';
export * from './hooks/useSearchLayer';
export * from './types';

21
src/layer/search/types/index.ts Executable file
View File

@@ -0,0 +1,21 @@
import type { SearchKeywordOption } from '../../../components/search';
export type SearchOpenMode = 'navigate' | 'window';
export type SearchWindowSelection = {
instanceId: string;
id: string;
label: string;
group: string;
description?: string;
keywords: string[];
};
export type SearchWindowSelectionDraft = Omit<SearchWindowSelection, 'instanceId'>;
export type SearchLayerSnapshot = {
open: boolean;
options: SearchKeywordOption[];
mode: SearchOpenMode;
windowSelections: SearchWindowSelection[];
};