@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.
- package/README.md +539 -0
- package/dist/chunk-5PM6EQE5.js +151 -0
- package/dist/chunk-5PM6EQE5.js.map +1 -0
- package/dist/chunk-7XU5X6CW.js +1331 -0
- package/dist/chunk-7XU5X6CW.js.map +1 -0
- package/dist/chunk-AAOQHQPU.js +574 -0
- package/dist/chunk-AAOQHQPU.js.map +1 -0
- package/dist/chunk-CF2XXJFF.js +1410 -0
- package/dist/chunk-CF2XXJFF.js.map +1 -0
- package/dist/chunk-CRPZUUDU.js +52 -0
- package/dist/chunk-CRPZUUDU.js.map +1 -0
- package/dist/chunk-CYLDJ3HZ.js +310 -0
- package/dist/chunk-CYLDJ3HZ.js.map +1 -0
- package/dist/chunk-KIKIPIFA.js +1 -0
- package/dist/chunk-KIKIPIFA.js.map +1 -0
- package/dist/chunk-XNTQTTJU.js +145 -0
- package/dist/chunk-XNTQTTJU.js.map +1 -0
- package/dist/client/index.css +2 -0
- package/dist/client/index.css.map +1 -0
- package/dist/client/index.js +375 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/styles.css +584 -0
- package/dist/client/variables.css +304 -0
- package/dist/config/index.d.ts +54 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config-BmEdBDo_.d.ts +220 -0
- package/dist/content-BWR52vD-.d.ts +64 -0
- package/dist/discovery/index.d.ts +310 -0
- package/dist/discovery/index.js +38 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/errors-C0iYiDTv.d.ts +107 -0
- package/dist/filesystem/index.d.ts +1292 -0
- package/dist/filesystem/index.js +203 -0
- package/dist/filesystem/index.js.map +1 -0
- package/dist/image-FP7w5ZIs.d.ts +47 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/loader-55LWCXHA.js +12 -0
- package/dist/loader-55LWCXHA.js.map +1 -0
- package/dist/loader-CrdnaAWR.d.ts +327 -0
- package/dist/server/index.d.ts +357 -0
- package/dist/server/index.js +37 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +94 -0
- package/src/client/App.tsx +900 -0
- package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
- package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
- package/src/client/components/ConfigPanel/index.ts +6 -0
- package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
- package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
- package/src/client/components/CreateContentModal/index.ts +7 -0
- package/src/client/components/Editor/Editor.css +885 -0
- package/src/client/components/Editor/Editor.tsx +484 -0
- package/src/client/components/Editor/ImageDialog.css +344 -0
- package/src/client/components/Editor/ImageDialog.tsx +367 -0
- package/src/client/components/Editor/LinkDialog.css +326 -0
- package/src/client/components/Editor/LinkDialog.tsx +332 -0
- package/src/client/components/Editor/index.ts +6 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
- package/src/client/components/FrontmatterForm/index.ts +7 -0
- package/src/client/components/Header/Header.css +300 -0
- package/src/client/components/Header/Header.tsx +300 -0
- package/src/client/components/Header/index.ts +7 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
- package/src/client/components/KeyboardShortcuts/index.ts +6 -0
- package/src/client/components/LazyEditor.tsx +75 -0
- package/src/client/components/LiveRegion/LiveRegion.css +19 -0
- package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
- package/src/client/components/LiveRegion/index.ts +7 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
- package/src/client/components/SearchReplace/index.ts +7 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
- package/src/client/components/SelectCollectionModal/index.ts +7 -0
- package/src/client/components/Sidebar/Sidebar.css +570 -0
- package/src/client/components/Sidebar/Sidebar.tsx +617 -0
- package/src/client/components/Sidebar/index.ts +7 -0
- package/src/client/components/SkipLink/SkipLink.css +51 -0
- package/src/client/components/SkipLink/SkipLink.tsx +67 -0
- package/src/client/components/SkipLink/index.ts +7 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
- package/src/client/components/UnsavedChangesModal/index.ts +1 -0
- package/src/client/components/VersionHistory/DiffViewer.css +430 -0
- package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
- package/src/client/components/VersionHistory/VersionActions.css +318 -0
- package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
- package/src/client/components/VersionHistory/index.ts +9 -0
- package/src/client/context/ApiContext.tsx +154 -0
- package/src/client/context/ThemeContext.tsx +172 -0
- package/src/client/hooks/useAnnounce.ts +201 -0
- package/src/client/hooks/useApi.ts +374 -0
- package/src/client/hooks/useArrowNavigation.ts +286 -0
- package/src/client/hooks/useAutosave.ts +241 -0
- package/src/client/hooks/useFocusTrap.ts +178 -0
- package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
- package/src/client/hooks/useSearch.ts +206 -0
- package/src/client/hooks/useVersionHistory.ts +451 -0
- package/src/client/index.tsx +70 -0
- package/src/client/styles.css +584 -0
- package/src/client/utils/focus.ts +57 -0
- package/src/client/utils/openInEditor.ts +130 -0
- package/src/client/variables.css +304 -0
- package/src/config/defaults.ts +109 -0
- package/src/config/index.ts +32 -0
- package/src/config/loader.ts +174 -0
- package/src/config/schema.ts +161 -0
- package/src/core/constants.ts +39 -0
- package/src/core/errors.ts +739 -0
- package/src/core/index.ts +11 -0
- package/src/discovery/collections.ts +216 -0
- package/src/discovery/index.ts +33 -0
- package/src/discovery/patterns.ts +702 -0
- package/src/discovery/schema.ts +453 -0
- package/src/filesystem/images.ts +798 -0
- package/src/filesystem/index.ts +107 -0
- package/src/filesystem/reader.ts +452 -0
- package/src/filesystem/version-config.ts +390 -0
- package/src/filesystem/versions.ts +1339 -0
- package/src/filesystem/watcher.ts +226 -0
- package/src/filesystem/writer.ts +540 -0
- package/src/index.ts +61 -0
- package/src/integration.ts +228 -0
- package/src/server/assets.ts +254 -0
- package/src/server/cache.ts +355 -0
- package/src/server/index.ts +33 -0
- package/src/server/middleware.ts +209 -0
- package/src/server/routes.ts +1428 -0
- package/src/types/api.ts +61 -0
- package/src/types/config.ts +134 -0
- package/src/types/content.ts +64 -0
- package/src/types/image.ts +48 -0
- package/src/types/index.ts +58 -0
- 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
|
+
}
|