@imjp/writenex-astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +539 -0
  2. package/dist/chunk-5PM6EQE5.js +151 -0
  3. package/dist/chunk-5PM6EQE5.js.map +1 -0
  4. package/dist/chunk-7XU5X6CW.js +1331 -0
  5. package/dist/chunk-7XU5X6CW.js.map +1 -0
  6. package/dist/chunk-AAOQHQPU.js +574 -0
  7. package/dist/chunk-AAOQHQPU.js.map +1 -0
  8. package/dist/chunk-CF2XXJFF.js +1410 -0
  9. package/dist/chunk-CF2XXJFF.js.map +1 -0
  10. package/dist/chunk-CRPZUUDU.js +52 -0
  11. package/dist/chunk-CRPZUUDU.js.map +1 -0
  12. package/dist/chunk-CYLDJ3HZ.js +310 -0
  13. package/dist/chunk-CYLDJ3HZ.js.map +1 -0
  14. package/dist/chunk-KIKIPIFA.js +1 -0
  15. package/dist/chunk-KIKIPIFA.js.map +1 -0
  16. package/dist/chunk-XNTQTTJU.js +145 -0
  17. package/dist/chunk-XNTQTTJU.js.map +1 -0
  18. package/dist/client/index.css +2 -0
  19. package/dist/client/index.css.map +1 -0
  20. package/dist/client/index.js +375 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/styles.css +584 -0
  23. package/dist/client/variables.css +304 -0
  24. package/dist/config/index.d.ts +54 -0
  25. package/dist/config/index.js +38 -0
  26. package/dist/config/index.js.map +1 -0
  27. package/dist/config-BmEdBDo_.d.ts +220 -0
  28. package/dist/content-BWR52vD-.d.ts +64 -0
  29. package/dist/discovery/index.d.ts +310 -0
  30. package/dist/discovery/index.js +38 -0
  31. package/dist/discovery/index.js.map +1 -0
  32. package/dist/errors-C0iYiDTv.d.ts +107 -0
  33. package/dist/filesystem/index.d.ts +1292 -0
  34. package/dist/filesystem/index.js +203 -0
  35. package/dist/filesystem/index.js.map +1 -0
  36. package/dist/image-FP7w5ZIs.d.ts +47 -0
  37. package/dist/index.d.ts +64 -0
  38. package/dist/index.js +151 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/loader-55LWCXHA.js +12 -0
  41. package/dist/loader-55LWCXHA.js.map +1 -0
  42. package/dist/loader-CrdnaAWR.d.ts +327 -0
  43. package/dist/server/index.d.ts +357 -0
  44. package/dist/server/index.js +37 -0
  45. package/dist/server/index.js.map +1 -0
  46. package/package.json +94 -0
  47. package/src/client/App.tsx +900 -0
  48. package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
  49. package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
  50. package/src/client/components/ConfigPanel/index.ts +6 -0
  51. package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
  52. package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
  53. package/src/client/components/CreateContentModal/index.ts +7 -0
  54. package/src/client/components/Editor/Editor.css +885 -0
  55. package/src/client/components/Editor/Editor.tsx +484 -0
  56. package/src/client/components/Editor/ImageDialog.css +344 -0
  57. package/src/client/components/Editor/ImageDialog.tsx +367 -0
  58. package/src/client/components/Editor/LinkDialog.css +326 -0
  59. package/src/client/components/Editor/LinkDialog.tsx +332 -0
  60. package/src/client/components/Editor/index.ts +6 -0
  61. package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
  62. package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
  63. package/src/client/components/FrontmatterForm/index.ts +7 -0
  64. package/src/client/components/Header/Header.css +300 -0
  65. package/src/client/components/Header/Header.tsx +300 -0
  66. package/src/client/components/Header/index.ts +7 -0
  67. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
  68. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
  69. package/src/client/components/KeyboardShortcuts/index.ts +6 -0
  70. package/src/client/components/LazyEditor.tsx +75 -0
  71. package/src/client/components/LiveRegion/LiveRegion.css +19 -0
  72. package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
  73. package/src/client/components/LiveRegion/index.ts +7 -0
  74. package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
  75. package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
  76. package/src/client/components/SearchReplace/index.ts +7 -0
  77. package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
  78. package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
  79. package/src/client/components/SelectCollectionModal/index.ts +7 -0
  80. package/src/client/components/Sidebar/Sidebar.css +570 -0
  81. package/src/client/components/Sidebar/Sidebar.tsx +617 -0
  82. package/src/client/components/Sidebar/index.ts +7 -0
  83. package/src/client/components/SkipLink/SkipLink.css +51 -0
  84. package/src/client/components/SkipLink/SkipLink.tsx +67 -0
  85. package/src/client/components/SkipLink/index.ts +7 -0
  86. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
  87. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
  88. package/src/client/components/UnsavedChangesModal/index.ts +1 -0
  89. package/src/client/components/VersionHistory/DiffViewer.css +430 -0
  90. package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
  91. package/src/client/components/VersionHistory/VersionActions.css +318 -0
  92. package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
  93. package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
  94. package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
  95. package/src/client/components/VersionHistory/index.ts +9 -0
  96. package/src/client/context/ApiContext.tsx +154 -0
  97. package/src/client/context/ThemeContext.tsx +172 -0
  98. package/src/client/hooks/useAnnounce.ts +201 -0
  99. package/src/client/hooks/useApi.ts +374 -0
  100. package/src/client/hooks/useArrowNavigation.ts +286 -0
  101. package/src/client/hooks/useAutosave.ts +241 -0
  102. package/src/client/hooks/useFocusTrap.ts +178 -0
  103. package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
  104. package/src/client/hooks/useSearch.ts +206 -0
  105. package/src/client/hooks/useVersionHistory.ts +451 -0
  106. package/src/client/index.tsx +70 -0
  107. package/src/client/styles.css +584 -0
  108. package/src/client/utils/focus.ts +57 -0
  109. package/src/client/utils/openInEditor.ts +130 -0
  110. package/src/client/variables.css +304 -0
  111. package/src/config/defaults.ts +109 -0
  112. package/src/config/index.ts +32 -0
  113. package/src/config/loader.ts +174 -0
  114. package/src/config/schema.ts +161 -0
  115. package/src/core/constants.ts +39 -0
  116. package/src/core/errors.ts +739 -0
  117. package/src/core/index.ts +11 -0
  118. package/src/discovery/collections.ts +216 -0
  119. package/src/discovery/index.ts +33 -0
  120. package/src/discovery/patterns.ts +702 -0
  121. package/src/discovery/schema.ts +453 -0
  122. package/src/filesystem/images.ts +798 -0
  123. package/src/filesystem/index.ts +107 -0
  124. package/src/filesystem/reader.ts +452 -0
  125. package/src/filesystem/version-config.ts +390 -0
  126. package/src/filesystem/versions.ts +1339 -0
  127. package/src/filesystem/watcher.ts +226 -0
  128. package/src/filesystem/writer.ts +540 -0
  129. package/src/index.ts +61 -0
  130. package/src/integration.ts +228 -0
  131. package/src/server/assets.ts +254 -0
  132. package/src/server/cache.ts +355 -0
  133. package/src/server/index.ts +33 -0
  134. package/src/server/middleware.ts +209 -0
  135. package/src/server/routes.ts +1428 -0
  136. package/src/types/api.ts +61 -0
  137. package/src/types/config.ts +134 -0
  138. package/src/types/content.ts +64 -0
  139. package/src/types/image.ts +48 -0
  140. package/src/types/index.ts +58 -0
  141. package/src/types/version.ts +117 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @fileoverview Autosave hook for automatic content saving
3
+ *
4
+ * This hook provides automatic saving functionality with debouncing
5
+ * to prevent excessive API calls while ensuring content is saved
6
+ * regularly.
7
+ *
8
+ * ## Features:
9
+ * - Debounced saving (default 3 seconds)
10
+ * - Save status indicator
11
+ * - Error handling with retry
12
+ * - Pause/resume capability
13
+ * - Save on unmount option
14
+ *
15
+ * @module @writenex/astro/client/hooks/useAutosave
16
+ */
17
+
18
+ import { useCallback, useEffect, useRef, useState } from "react";
19
+
20
+ /**
21
+ * Autosave status
22
+ */
23
+ export type AutosaveStatus = "idle" | "pending" | "saving" | "saved" | "error";
24
+
25
+ /**
26
+ * Options for useAutosave hook
27
+ */
28
+ export interface UseAutosaveOptions {
29
+ /** Debounce delay in milliseconds (default: 3000) */
30
+ delay?: number;
31
+ /** Whether autosave is enabled (default: true) */
32
+ enabled?: boolean;
33
+ /** Save function to call */
34
+ onSave: () => Promise<boolean>;
35
+ /** Callback when save succeeds */
36
+ onSuccess?: () => void;
37
+ /** Callback when save fails */
38
+ onError?: (error: Error) => void;
39
+ /** Whether to save on component unmount (default: true) */
40
+ saveOnUnmount?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Return value from useAutosave hook
45
+ */
46
+ export interface UseAutosaveReturn {
47
+ /** Current autosave status */
48
+ status: AutosaveStatus;
49
+ /** Trigger a change that will schedule autosave */
50
+ triggerChange: () => void;
51
+ /** Force immediate save */
52
+ saveNow: () => Promise<void>;
53
+ /** Cancel pending autosave */
54
+ cancel: () => void;
55
+ /** Whether there are pending changes */
56
+ hasPendingChanges: boolean;
57
+ /** Last saved timestamp */
58
+ lastSaved: Date | null;
59
+ }
60
+
61
+ /**
62
+ * Hook for automatic content saving with debounce
63
+ *
64
+ * @param options - Autosave configuration options
65
+ * @returns Autosave controls and status
66
+ *
67
+ * @example
68
+ * ```tsx
69
+ * const { status, triggerChange, saveNow } = useAutosave({
70
+ * delay: 3000,
71
+ * enabled: true,
72
+ * onSave: async () => {
73
+ * const result = await api.save(content);
74
+ * return result.success;
75
+ * },
76
+ * });
77
+ *
78
+ * // Call triggerChange when content changes
79
+ * const handleChange = (newContent) => {
80
+ * setContent(newContent);
81
+ * triggerChange();
82
+ * };
83
+ * ```
84
+ */
85
+ export function useAutosave(options: UseAutosaveOptions): UseAutosaveReturn {
86
+ const {
87
+ delay = 3000,
88
+ enabled = true,
89
+ onSave,
90
+ onSuccess,
91
+ onError,
92
+ saveOnUnmount = true,
93
+ } = options;
94
+
95
+ const [status, setStatus] = useState<AutosaveStatus>("idle");
96
+ const [lastSaved, setLastSaved] = useState<Date | null>(null);
97
+ const [hasPendingChanges, setHasPendingChanges] = useState(false);
98
+
99
+ // Refs for cleanup and tracking
100
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
101
+ const isMountedRef = useRef(true);
102
+ const isSavingRef = useRef(false);
103
+
104
+ /**
105
+ * Clear the pending timeout
106
+ */
107
+ const clearPendingTimeout = useCallback(() => {
108
+ if (timeoutRef.current) {
109
+ clearTimeout(timeoutRef.current);
110
+ timeoutRef.current = null;
111
+ }
112
+ }, []);
113
+
114
+ /**
115
+ * Perform the actual save operation
116
+ */
117
+ const performSave = useCallback(async () => {
118
+ if (isSavingRef.current) return;
119
+
120
+ isSavingRef.current = true;
121
+ setStatus("saving");
122
+
123
+ try {
124
+ const success = await onSave();
125
+
126
+ if (!isMountedRef.current) return;
127
+
128
+ if (success) {
129
+ setStatus("saved");
130
+ setHasPendingChanges(false);
131
+ setLastSaved(new Date());
132
+ onSuccess?.();
133
+
134
+ // Reset to idle after showing "saved" briefly
135
+ setTimeout(() => {
136
+ if (isMountedRef.current) {
137
+ setStatus("idle");
138
+ }
139
+ }, 2000);
140
+ } else {
141
+ setStatus("error");
142
+ onError?.(new Error("Save failed"));
143
+ }
144
+ } catch (err) {
145
+ if (!isMountedRef.current) return;
146
+
147
+ setStatus("error");
148
+ onError?.(err instanceof Error ? err : new Error("Save failed"));
149
+ } finally {
150
+ isSavingRef.current = false;
151
+ }
152
+ }, [onSave, onSuccess, onError]);
153
+
154
+ /**
155
+ * Schedule a save after the debounce delay
156
+ */
157
+ const scheduleSave = useCallback(() => {
158
+ clearPendingTimeout();
159
+
160
+ if (!enabled) return;
161
+
162
+ setStatus("pending");
163
+ setHasPendingChanges(true);
164
+
165
+ timeoutRef.current = setTimeout(() => {
166
+ performSave();
167
+ }, delay);
168
+ }, [enabled, delay, clearPendingTimeout, performSave]);
169
+
170
+ /**
171
+ * Trigger a change that will schedule autosave
172
+ */
173
+ const triggerChange = useCallback(() => {
174
+ if (!enabled) return;
175
+ scheduleSave();
176
+ }, [enabled, scheduleSave]);
177
+
178
+ /**
179
+ * Force immediate save
180
+ */
181
+ const saveNow = useCallback(async () => {
182
+ clearPendingTimeout();
183
+ await performSave();
184
+ }, [clearPendingTimeout, performSave]);
185
+
186
+ /**
187
+ * Cancel pending autosave
188
+ */
189
+ const cancel = useCallback(() => {
190
+ clearPendingTimeout();
191
+ setStatus("idle");
192
+ }, [clearPendingTimeout]);
193
+
194
+ // Cleanup on unmount
195
+ useEffect(() => {
196
+ isMountedRef.current = true;
197
+
198
+ return () => {
199
+ isMountedRef.current = false;
200
+ clearPendingTimeout();
201
+
202
+ // Save on unmount if enabled and there are pending changes
203
+ if (saveOnUnmount && hasPendingChanges && !isSavingRef.current) {
204
+ // Fire and forget - component is unmounting
205
+ onSave().catch(() => {
206
+ // Ignore errors on unmount
207
+ });
208
+ }
209
+ };
210
+ }, [clearPendingTimeout, saveOnUnmount, hasPendingChanges, onSave]);
211
+
212
+ return {
213
+ status,
214
+ triggerChange,
215
+ saveNow,
216
+ cancel,
217
+ hasPendingChanges,
218
+ lastSaved,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Format last saved time for display
224
+ *
225
+ * @param date - Last saved date
226
+ * @returns Formatted string like "Saved just now" or "Saved 2 min ago"
227
+ */
228
+ export function formatLastSaved(date: Date | null): string {
229
+ if (!date) return "";
230
+
231
+ const now = new Date();
232
+ const diffMs = now.getTime() - date.getTime();
233
+ const diffSec = Math.floor(diffMs / 1000);
234
+ const diffMin = Math.floor(diffSec / 60);
235
+
236
+ if (diffSec < 10) return "Saved just now";
237
+ if (diffSec < 60) return `Saved ${diffSec}s ago`;
238
+ if (diffMin < 60) return `Saved ${diffMin}m ago`;
239
+
240
+ return `Saved at ${date.toLocaleTimeString()}`;
241
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @fileoverview Focus trap hook for modal accessibility
3
+ *
4
+ * This hook provides focus containment within a container element,
5
+ * ensuring keyboard users cannot tab outside of modal dialogs.
6
+ * It handles Tab/Shift+Tab cycling at boundaries and supports
7
+ * focus restoration when the trap is deactivated.
8
+ *
9
+ * ## Features:
10
+ * - Focus containment within container
11
+ * - Tab and Shift+Tab cycling at boundaries
12
+ * - Escape key callback support
13
+ * - Automatic focus on first focusable element
14
+ * - Focus restoration to trigger element on close
15
+ *
16
+ * @module @writenex/astro/client/hooks/useFocusTrap
17
+ * @see {@link UseFocusTrapOptions} - Configuration options
18
+ */
19
+
20
+ import { useCallback, useEffect, useRef } from "react";
21
+ import {
22
+ getFocusableElements,
23
+ getFirstFocusable,
24
+ getLastFocusable,
25
+ } from "../utils/focus";
26
+
27
+ /**
28
+ * Options for useFocusTrap hook
29
+ */
30
+ export interface UseFocusTrapOptions {
31
+ /** Whether the trap is active */
32
+ enabled: boolean;
33
+ /** Callback when escape is pressed */
34
+ onEscape?: () => void;
35
+ /** Element to restore focus to on close */
36
+ returnFocusTo?: HTMLElement | null;
37
+ }
38
+
39
+ /**
40
+ * Return value from useFocusTrap hook
41
+ */
42
+ export interface UseFocusTrapReturn {
43
+ /** Ref to attach to the container element */
44
+ containerRef: React.RefObject<HTMLDivElement | null>;
45
+ }
46
+
47
+ /**
48
+ * Hook for trapping focus within a container element
49
+ *
50
+ * This hook is essential for modal accessibility, ensuring that keyboard
51
+ * users cannot accidentally tab outside of a modal dialog. It automatically
52
+ * moves focus to the first focusable element when enabled and restores
53
+ * focus to the trigger element when disabled.
54
+ *
55
+ * @param options - Focus trap configuration options
56
+ * @returns Object containing the container ref to attach to the trap element
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * function Modal({ isOpen, onClose }) {
61
+ * const triggerRef = useRef<HTMLButtonElement>(null);
62
+ * const { containerRef } = useFocusTrap({
63
+ * enabled: isOpen,
64
+ * onEscape: onClose,
65
+ * returnFocusTo: triggerRef.current,
66
+ * });
67
+ *
68
+ * return (
69
+ * <div ref={containerRef} role="dialog" aria-modal="true">
70
+ * <button onClick={onClose}>Close</button>
71
+ * <input type="text" />
72
+ * </div>
73
+ * );
74
+ * }
75
+ * ```
76
+ */
77
+ export function useFocusTrap(options: UseFocusTrapOptions): UseFocusTrapReturn {
78
+ const { enabled, onEscape, returnFocusTo } = options;
79
+
80
+ const containerRef = useRef<HTMLDivElement | null>(null);
81
+ const previousActiveElementRef = useRef<HTMLElement | null>(null);
82
+
83
+ /**
84
+ * Handle keydown events for focus trapping
85
+ */
86
+ const handleKeyDown = useCallback(
87
+ (event: KeyboardEvent) => {
88
+ if (!enabled || !containerRef.current) return;
89
+
90
+ // Handle Escape key
91
+ if (event.key === "Escape" && onEscape) {
92
+ event.preventDefault();
93
+ onEscape();
94
+ return;
95
+ }
96
+
97
+ // Handle Tab key for focus cycling
98
+ if (event.key === "Tab") {
99
+ const focusableElements = getFocusableElements(containerRef.current);
100
+
101
+ if (focusableElements.length === 0) {
102
+ // No focusable elements, prevent tab from leaving
103
+ event.preventDefault();
104
+ return;
105
+ }
106
+
107
+ const firstFocusable = getFirstFocusable(containerRef.current);
108
+ const lastFocusable = getLastFocusable(containerRef.current);
109
+ const activeElement = document.activeElement as HTMLElement;
110
+
111
+ if (event.shiftKey) {
112
+ // Shift+Tab: cycle from first to last
113
+ if (activeElement === firstFocusable) {
114
+ event.preventDefault();
115
+ lastFocusable?.focus();
116
+ }
117
+ } else {
118
+ // Tab: cycle from last to first
119
+ if (activeElement === lastFocusable) {
120
+ event.preventDefault();
121
+ firstFocusable?.focus();
122
+ }
123
+ }
124
+ }
125
+ },
126
+ [enabled, onEscape]
127
+ );
128
+
129
+ // Set up focus trap when enabled
130
+ useEffect(() => {
131
+ if (!enabled) return;
132
+
133
+ // Store the currently focused element for restoration
134
+ previousActiveElementRef.current =
135
+ returnFocusTo || (document.activeElement as HTMLElement);
136
+
137
+ // Move focus to first focusable element in container
138
+ const container = containerRef.current;
139
+ if (container) {
140
+ // Use requestAnimationFrame to ensure DOM is ready
141
+ requestAnimationFrame(() => {
142
+ const firstFocusable = getFirstFocusable(container);
143
+ if (firstFocusable) {
144
+ firstFocusable.focus();
145
+ } else {
146
+ // If no focusable elements, focus the container itself
147
+ container.setAttribute("tabindex", "-1");
148
+ container.focus();
149
+ }
150
+ });
151
+ }
152
+
153
+ // Add keydown listener
154
+ document.addEventListener("keydown", handleKeyDown);
155
+
156
+ return () => {
157
+ document.removeEventListener("keydown", handleKeyDown);
158
+ };
159
+ }, [enabled, handleKeyDown, returnFocusTo]);
160
+
161
+ // Restore focus when trap is disabled
162
+ useEffect(() => {
163
+ if (enabled) return;
164
+
165
+ // Restore focus to the previous element
166
+ const elementToFocus = returnFocusTo || previousActiveElementRef.current;
167
+ if (elementToFocus && typeof elementToFocus.focus === "function") {
168
+ // Use requestAnimationFrame to ensure the modal is fully closed
169
+ requestAnimationFrame(() => {
170
+ elementToFocus.focus();
171
+ });
172
+ }
173
+ }, [enabled, returnFocusTo]);
174
+
175
+ return {
176
+ containerRef,
177
+ };
178
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @fileoverview Keyboard shortcuts hook for Writenex editor
3
+ *
4
+ * This hook provides centralized keyboard shortcut handling with
5
+ * support for common editor operations.
6
+ *
7
+ * ## Shortcuts:
8
+ * - Alt + N: New content
9
+ * - Ctrl/Cmd + S: Save
10
+ * - Ctrl/Cmd + P: Preview
11
+ * - Ctrl/Cmd + /: Toggle shortcuts help
12
+ * - Escape: Close modals/panels
13
+ *
14
+ * @module @writenex/astro/client/hooks/useKeyboardShortcuts
15
+ */
16
+
17
+ import { useCallback, useEffect, useState } from "react";
18
+
19
+ /**
20
+ * Shortcut definition
21
+ */
22
+ export interface ShortcutDefinition {
23
+ /** Unique key for the shortcut */
24
+ key: string;
25
+ /** Display label */
26
+ label: string;
27
+ /** Keyboard key to press */
28
+ keys: string;
29
+ /** Whether Ctrl/Cmd is required */
30
+ ctrl?: boolean;
31
+ /** Whether Shift is required */
32
+ shift?: boolean;
33
+ /** Whether Alt is required */
34
+ alt?: boolean;
35
+ /** Handler function */
36
+ handler: () => void;
37
+ /** Whether the shortcut is enabled */
38
+ enabled?: boolean;
39
+ }
40
+
41
+ /**
42
+ * Options for useKeyboardShortcuts hook
43
+ */
44
+ export interface UseKeyboardShortcutsOptions {
45
+ /** Shortcuts to register */
46
+ shortcuts: ShortcutDefinition[];
47
+ /** Whether shortcuts are globally enabled */
48
+ enabled?: boolean;
49
+ }
50
+
51
+ /**
52
+ * Return value from useKeyboardShortcuts hook
53
+ */
54
+ export interface UseKeyboardShortcutsReturn {
55
+ /** Whether shortcuts help modal is open */
56
+ showHelp: boolean;
57
+ /** Toggle shortcuts help modal */
58
+ toggleHelp: () => void;
59
+ /** Close shortcuts help modal */
60
+ closeHelp: () => void;
61
+ /** List of registered shortcuts for display */
62
+ shortcuts: ShortcutDefinition[];
63
+ }
64
+
65
+ /**
66
+ * Check if event target is an input element
67
+ */
68
+ function isInputElement(target: EventTarget | null): boolean {
69
+ if (!target || !(target instanceof HTMLElement)) return false;
70
+
71
+ const tagName = target.tagName.toLowerCase();
72
+ if (tagName === "input" || tagName === "textarea" || tagName === "select") {
73
+ return true;
74
+ }
75
+
76
+ // Check for contenteditable
77
+ if (target.isContentEditable) {
78
+ return true;
79
+ }
80
+
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Format shortcut keys for display
86
+ */
87
+ export function formatShortcut(shortcut: ShortcutDefinition): string {
88
+ const parts: string[] = [];
89
+
90
+ // Use Cmd on Mac, Ctrl on others
91
+ const isMac =
92
+ typeof navigator !== "undefined" &&
93
+ /Mac|iPod|iPhone|iPad/.test(navigator.platform);
94
+
95
+ if (shortcut.ctrl) {
96
+ parts.push(isMac ? "⌘" : "Ctrl");
97
+ }
98
+ if (shortcut.shift) {
99
+ parts.push(isMac ? "⇧" : "Shift");
100
+ }
101
+ if (shortcut.alt) {
102
+ parts.push(isMac ? "⌥" : "Alt");
103
+ }
104
+
105
+ // Format the key
106
+ let keyDisplay = shortcut.keys.toUpperCase();
107
+ if (keyDisplay === "ESCAPE") keyDisplay = "Esc";
108
+ if (keyDisplay === "ARROWUP") keyDisplay = "↑";
109
+ if (keyDisplay === "ARROWDOWN") keyDisplay = "↓";
110
+ if (keyDisplay === "ARROWLEFT") keyDisplay = "←";
111
+ if (keyDisplay === "ARROWRIGHT") keyDisplay = "→";
112
+
113
+ parts.push(keyDisplay);
114
+
115
+ return parts.join(isMac ? "" : "+");
116
+ }
117
+
118
+ /**
119
+ * Hook for managing keyboard shortcuts
120
+ *
121
+ * @param options - Shortcut configuration
122
+ * @returns Shortcut controls and help modal state
123
+ *
124
+ * @example
125
+ * ```tsx
126
+ * const { showHelp, toggleHelp, shortcuts } = useKeyboardShortcuts({
127
+ * shortcuts: [
128
+ * { key: 'save', label: 'Save', keys: 's', ctrl: true, handler: handleSave },
129
+ * { key: 'new', label: 'New', keys: 'n', alt: true, handler: handleNew },
130
+ * ],
131
+ * });
132
+ * ```
133
+ */
134
+ export function useKeyboardShortcuts(
135
+ options: UseKeyboardShortcutsOptions
136
+ ): UseKeyboardShortcutsReturn {
137
+ const { shortcuts, enabled = true } = options;
138
+ const [showHelp, setShowHelp] = useState(false);
139
+
140
+ const toggleHelp = useCallback(() => {
141
+ setShowHelp((prev) => !prev);
142
+ }, []);
143
+
144
+ const closeHelp = useCallback(() => {
145
+ setShowHelp(false);
146
+ }, []);
147
+
148
+ useEffect(() => {
149
+ if (!enabled) return;
150
+
151
+ const handleKeyDown = (e: KeyboardEvent) => {
152
+ // Check for help shortcut first (Ctrl + /)
153
+ if ((e.ctrlKey || e.metaKey) && e.key === "/") {
154
+ e.preventDefault();
155
+ toggleHelp();
156
+ return;
157
+ }
158
+
159
+ // Close help on Escape
160
+ if (e.key === "Escape" && showHelp) {
161
+ e.preventDefault();
162
+ closeHelp();
163
+ return;
164
+ }
165
+
166
+ // Find matching shortcut
167
+ for (const shortcut of shortcuts) {
168
+ if (shortcut.enabled === false) continue;
169
+
170
+ const keyMatch = e.key.toLowerCase() === shortcut.keys.toLowerCase();
171
+ const ctrlMatch = shortcut.ctrl
172
+ ? e.ctrlKey || e.metaKey
173
+ : !(e.ctrlKey || e.metaKey);
174
+ const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
175
+ const altMatch = shortcut.alt ? e.altKey : !e.altKey;
176
+
177
+ if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
178
+ // Allow some shortcuts even in input fields
179
+ const allowInInput =
180
+ shortcut.key === "save" || shortcut.key === "escape";
181
+
182
+ if (!allowInInput && isInputElement(e.target)) {
183
+ continue;
184
+ }
185
+
186
+ e.preventDefault();
187
+ shortcut.handler();
188
+ return;
189
+ }
190
+ }
191
+ };
192
+
193
+ window.addEventListener("keydown", handleKeyDown);
194
+ return () => window.removeEventListener("keydown", handleKeyDown);
195
+ }, [enabled, shortcuts, showHelp, toggleHelp, closeHelp]);
196
+
197
+ return {
198
+ showHelp,
199
+ toggleHelp,
200
+ closeHelp,
201
+ shortcuts,
202
+ };
203
+ }