@yargram/react 1.0.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/.storybook/main.ts +21 -0
- package/.storybook/preview.ts +21 -0
- package/dist/components/LogWindow/LogEntryRow.d.ts +8 -0
- package/dist/components/LogWindow/LogEntryRow.d.ts.map +1 -0
- package/dist/components/LogWindow/LogEntryRow.js +14 -0
- package/dist/components/LogWindow/LogWindow.d.ts +41 -0
- package/dist/components/LogWindow/LogWindow.d.ts.map +1 -0
- package/dist/components/LogWindow/LogWindow.js +144 -0
- package/dist/components/LogWindow/LogWindow.stories.d.ts +29 -0
- package/dist/components/LogWindow/LogWindow.stories.d.ts.map +1 -0
- package/dist/components/LogWindow/LogWindow.stories.js +183 -0
- package/dist/components/LogWindow/LogWindow.test.d.ts +2 -0
- package/dist/components/LogWindow/LogWindow.test.d.ts.map +1 -0
- package/dist/components/LogWindow/LogWindow.test.js +61 -0
- package/dist/components/LogWindow/LogWindowEscapeDemo.d.ts +12 -0
- package/dist/components/LogWindow/LogWindowEscapeDemo.d.ts.map +1 -0
- package/dist/components/LogWindow/LogWindowEscapeDemo.js +56 -0
- package/dist/components/LogWindow/NetworkEntryRow.d.ts +8 -0
- package/dist/components/LogWindow/NetworkEntryRow.d.ts.map +1 -0
- package/dist/components/LogWindow/NetworkEntryRow.js +32 -0
- package/dist/components/LogWindow/index.d.ts +7 -0
- package/dist/components/LogWindow/index.d.ts.map +1 -0
- package/dist/components/LogWindow/index.js +4 -0
- package/dist/components/LogWindow/types.d.ts +36 -0
- package/dist/components/LogWindow/types.d.ts.map +1 -0
- package/dist/components/LogWindow/types.js +1 -0
- package/dist/components/LoginWindow/LoginForm.d.ts +11 -0
- package/dist/components/LoginWindow/LoginForm.d.ts.map +1 -0
- package/dist/components/LoginWindow/LoginForm.js +28 -0
- package/dist/components/LoginWindow/LoginForm.test.d.ts +2 -0
- package/dist/components/LoginWindow/LoginForm.test.d.ts.map +1 -0
- package/dist/components/LoginWindow/LoginForm.test.js +34 -0
- package/dist/components/LoginWindow/LoginWindow.d.ts +15 -0
- package/dist/components/LoginWindow/LoginWindow.d.ts.map +1 -0
- package/dist/components/LoginWindow/LoginWindow.js +27 -0
- package/dist/components/LoginWindow/index.d.ts +3 -0
- package/dist/components/LoginWindow/index.d.ts.map +1 -0
- package/dist/components/LoginWindow/index.js +1 -0
- package/dist/contexts/ApiContext.d.ts +35 -0
- package/dist/contexts/ApiContext.d.ts.map +1 -0
- package/dist/contexts/ApiContext.js +82 -0
- package/dist/contexts/ApiContext.test.d.ts +2 -0
- package/dist/contexts/ApiContext.test.d.ts.map +1 -0
- package/dist/contexts/ApiContext.test.js +45 -0
- package/dist/contexts/PrinterContext.d.ts +12 -0
- package/dist/contexts/PrinterContext.d.ts.map +1 -0
- package/dist/contexts/PrinterContext.js +17 -0
- package/dist/contexts/PrinterContext.test.d.ts +2 -0
- package/dist/contexts/PrinterContext.test.d.ts.map +1 -0
- package/dist/contexts/PrinterContext.test.js +19 -0
- package/dist/contexts/YahmanContext.d.ts +69 -0
- package/dist/contexts/YahmanContext.d.ts.map +1 -0
- package/dist/contexts/YahmanContext.js +414 -0
- package/dist/contexts/YahmanContext.stories.d.ts +16 -0
- package/dist/contexts/YahmanContext.stories.d.ts.map +1 -0
- package/dist/contexts/YahmanContext.stories.js +64 -0
- package/dist/contexts/YargramContext.d.ts +69 -0
- package/dist/contexts/YargramContext.d.ts.map +1 -0
- package/dist/contexts/YargramContext.js +414 -0
- package/dist/contexts/YargramContext.stories.d.ts +16 -0
- package/dist/contexts/YargramContext.stories.d.ts.map +1 -0
- package/dist/contexts/YargramContext.stories.js +64 -0
- package/dist/contexts/YargramContext.test.d.ts +2 -0
- package/dist/contexts/YargramContext.test.d.ts.map +1 -0
- package/dist/contexts/YargramContext.test.js +54 -0
- package/dist/hooks/useLogWindowShortcut.d.ts +24 -0
- package/dist/hooks/useLogWindowShortcut.d.ts.map +1 -0
- package/dist/hooks/useLogWindowShortcut.js +61 -0
- package/dist/hooks/useLogWindowShortcut.test.d.ts +2 -0
- package/dist/hooks/useLogWindowShortcut.test.d.ts.map +1 -0
- package/dist/hooks/useLogWindowShortcut.test.js +93 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/test/setup.d.ts +2 -0
- package/dist/test/setup.d.ts.map +1 -0
- package/dist/test/setup.js +1 -0
- package/package.json +49 -0
- package/src/components/LogWindow/LogEntryRow.tsx +38 -0
- package/src/components/LogWindow/LogWindow.css +614 -0
- package/src/components/LogWindow/LogWindow.stories.tsx +206 -0
- package/src/components/LogWindow/LogWindow.test.tsx +68 -0
- package/src/components/LogWindow/LogWindow.tsx +379 -0
- package/src/components/LogWindow/LogWindowEscapeDemo.tsx +100 -0
- package/src/components/LogWindow/NetworkEntryRow.tsx +102 -0
- package/src/components/LogWindow/index.ts +13 -0
- package/src/components/LogWindow/types.ts +40 -0
- package/src/components/LoginWindow/LoginForm.test.tsx +38 -0
- package/src/components/LoginWindow/LoginForm.tsx +78 -0
- package/src/components/LoginWindow/LoginWindow.css +198 -0
- package/src/components/LoginWindow/LoginWindow.tsx +90 -0
- package/src/components/LoginWindow/index.ts +2 -0
- package/src/contexts/ApiContext.test.tsx +68 -0
- package/src/contexts/ApiContext.tsx +155 -0
- package/src/contexts/PrinterContext.test.tsx +37 -0
- package/src/contexts/PrinterContext.tsx +35 -0
- package/src/contexts/YargramContext.stories.tsx +148 -0
- package/src/contexts/YargramContext.test.tsx +105 -0
- package/src/contexts/YargramContext.tsx +676 -0
- package/src/hooks/useLogWindowShortcut.test.ts +111 -0
- package/src/hooks/useLogWindowShortcut.ts +96 -0
- package/src/index.ts +14 -0
- package/src/test/setup.ts +1 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useState, useCallback, useMemo, useId, useRef, useEffect, } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { createPrinter } from '@yargram/core';
|
|
5
|
+
import { ApolloClient, ApolloProvider, InMemoryCache, } from '@apollo/client';
|
|
6
|
+
import { getOperationAST, print } from 'graphql';
|
|
7
|
+
import { ApiProvider, ApiContext } from './ApiContext';
|
|
8
|
+
import { PrinterProvider } from './PrinterContext';
|
|
9
|
+
import { useLogWindowShortcut } from '../hooks/useLogWindowShortcut';
|
|
10
|
+
import { LogWindow } from '../components/LogWindow/LogWindow';
|
|
11
|
+
import '../components/LogWindow/LogWindow.css';
|
|
12
|
+
const AUTH_STORAGE_KEY = 'yargram_auth_token';
|
|
13
|
+
const DEFAULT_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7日
|
|
14
|
+
function isProductionOrStaging() {
|
|
15
|
+
if (typeof process === 'undefined')
|
|
16
|
+
return false;
|
|
17
|
+
const env = process.env?.NODE_ENV;
|
|
18
|
+
return env === 'production' || env === 'staging';
|
|
19
|
+
}
|
|
20
|
+
function isStorybook() {
|
|
21
|
+
if (typeof window === 'undefined')
|
|
22
|
+
return false;
|
|
23
|
+
const w = window;
|
|
24
|
+
return w.IS_STORYBOOK === true || Boolean(w.__STORYBOOK_CLIENT_API__);
|
|
25
|
+
}
|
|
26
|
+
function storybookSimulateProduction(prop) {
|
|
27
|
+
if (typeof window === 'undefined')
|
|
28
|
+
return false;
|
|
29
|
+
const w = window;
|
|
30
|
+
return prop === true || w.__YARGRAM_STORYBOOK_SIMULATE_PRODUCTION__ === true;
|
|
31
|
+
}
|
|
32
|
+
function getCurrentDomain() {
|
|
33
|
+
if (typeof window === 'undefined')
|
|
34
|
+
return '';
|
|
35
|
+
return window.location.hostname || '';
|
|
36
|
+
}
|
|
37
|
+
function loadPersistedAuth() {
|
|
38
|
+
if (typeof localStorage === 'undefined')
|
|
39
|
+
return false;
|
|
40
|
+
try {
|
|
41
|
+
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
|
|
42
|
+
if (!raw)
|
|
43
|
+
return false;
|
|
44
|
+
const data = JSON.parse(raw);
|
|
45
|
+
if (data &&
|
|
46
|
+
typeof data === 'object' &&
|
|
47
|
+
'token' in data &&
|
|
48
|
+
'domain' in data &&
|
|
49
|
+
'expiresAt' in data &&
|
|
50
|
+
typeof data.domain === 'string' &&
|
|
51
|
+
typeof data.expiresAt === 'number') {
|
|
52
|
+
const { domain, expiresAt } = data;
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
return domain === getCurrentDomain() && expiresAt > now;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function persistAuth(value, ttlMs = DEFAULT_TOKEN_TTL_MS) {
|
|
63
|
+
if (typeof localStorage === 'undefined')
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
if (value) {
|
|
67
|
+
const token = typeof crypto !== 'undefined' && crypto.randomUUID
|
|
68
|
+
? crypto.randomUUID()
|
|
69
|
+
: `t-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
70
|
+
const domain = getCurrentDomain();
|
|
71
|
+
const expiresAt = Date.now() + ttlMs;
|
|
72
|
+
const data = { token, domain, expiresAt };
|
|
73
|
+
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(data));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
localStorage.removeItem(AUTH_STORAGE_KEY);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** デフォルトパスワード "12345678" の SHA-256 (hex) */
|
|
84
|
+
const DEFAULT_PASSWORD_HASH = 'ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f';
|
|
85
|
+
/**
|
|
86
|
+
* 環境変数からログイン用パスワードハッシュを取得。未設定時はデフォルト(12345678 の SHA-256)。
|
|
87
|
+
* パスワードを変更する場合は YAHMAN_LOGIN_PASSWORD_HASH または VITE_YAHMAN_LOGIN_PASSWORD_HASH に、
|
|
88
|
+
* 希望するパスワードの SHA-256 ハッシュ(hex)を設定する。
|
|
89
|
+
*/
|
|
90
|
+
function getAcceptedPasswordHash() {
|
|
91
|
+
const fromProcess = typeof process !== 'undefined' && process.env?.YAHMAN_LOGIN_PASSWORD_HASH
|
|
92
|
+
? String(process.env.YAHMAN_LOGIN_PASSWORD_HASH).trim()
|
|
93
|
+
: '';
|
|
94
|
+
const meta = typeof import.meta !== 'undefined' ? import.meta : null;
|
|
95
|
+
const viteHash = meta?.env?.VITE_YAHMAN_LOGIN_PASSWORD_HASH;
|
|
96
|
+
const fromMeta = viteHash ? String(viteHash).trim() : '';
|
|
97
|
+
const fromEnv = fromProcess || fromMeta;
|
|
98
|
+
return fromEnv || DEFAULT_PASSWORD_HASH;
|
|
99
|
+
}
|
|
100
|
+
/** 文字列を SHA-256 でハッシュし hex 文字列で返す */
|
|
101
|
+
async function sha256Hex(str) {
|
|
102
|
+
const buf = new TextEncoder().encode(str);
|
|
103
|
+
const hash = typeof crypto !== 'undefined' && crypto.subtle
|
|
104
|
+
? await crypto.subtle.digest('SHA-256', buf)
|
|
105
|
+
: new Uint8Array(0);
|
|
106
|
+
return Array.from(new Uint8Array(hash))
|
|
107
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
108
|
+
.join('');
|
|
109
|
+
}
|
|
110
|
+
function resolveBody(body) {
|
|
111
|
+
if (body == null)
|
|
112
|
+
return undefined;
|
|
113
|
+
if (typeof body === 'string' ||
|
|
114
|
+
body instanceof ArrayBuffer ||
|
|
115
|
+
ArrayBuffer.isView(body) ||
|
|
116
|
+
body instanceof FormData ||
|
|
117
|
+
body instanceof URLSearchParams) {
|
|
118
|
+
return body;
|
|
119
|
+
}
|
|
120
|
+
return JSON.stringify(body);
|
|
121
|
+
}
|
|
122
|
+
const YargramContext = createContext(null);
|
|
123
|
+
/** 認証有効時の子要素ラッパー(ログアウトは LogWindow 内のボタンから行う) */
|
|
124
|
+
function AuthEscapeToLogin({ children }) {
|
|
125
|
+
return _jsx(_Fragment, { children: children });
|
|
126
|
+
}
|
|
127
|
+
/** 認証時はログウィンドウをポータル表示。未認証時は LogWindow 内にパスワード画面(production/staging のみ) */
|
|
128
|
+
function LogWindowGate({ instanceId, defaultPosition, loginTitle, isAuthenticated, login, logout, loginError, clearLoginError, logEntries, networkEntries, isLogWindowOpen, closeLogWindow, }) {
|
|
129
|
+
if (!isLogWindowOpen || typeof document === 'undefined') {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return createPortal(_jsx("div", { onClick: (e) => e.stopPropagation(), children: _jsx(LogWindow, { entries: logEntries, networkEntries: networkEntries, draggable: true, animateOnOpen: true, onClose: closeLogWindow, onLogout: isAuthenticated ? logout : undefined, defaultPosition: defaultPosition, showLogin: !isAuthenticated, loginTitle: loginTitle, onLogin: !isAuthenticated ? login : undefined, loginError: loginError, onClearLoginError: clearLoginError }, instanceId) }), document.body);
|
|
133
|
+
}
|
|
134
|
+
function generateId() {
|
|
135
|
+
return typeof crypto !== 'undefined' && crypto.randomUUID
|
|
136
|
+
? crypto.randomUUID()
|
|
137
|
+
: `id-${Math.random().toString(36).slice(2)}`;
|
|
138
|
+
}
|
|
139
|
+
export function YargramProvider({ children, api, printer = {}, logWindow, auth, }) {
|
|
140
|
+
const [logEntries, setLogEntries] = useState([]);
|
|
141
|
+
const [networkEntries, setNetworkEntries] = useState([]);
|
|
142
|
+
const instanceId = useId();
|
|
143
|
+
const simulateProduction = auth && typeof auth === 'object' && isStorybook() && storybookSimulateProduction(auth.storybookSimulateProduction);
|
|
144
|
+
const requiresAuth = auth && (auth === true || (typeof auth === 'object' && (auth.productionOnly !== false)))
|
|
145
|
+
? isProductionOrStaging() || !!simulateProduction
|
|
146
|
+
: false;
|
|
147
|
+
const [isAuthenticated, setIsAuthenticated] = useState(() => auth && requiresAuth ? loadPersistedAuth() : false);
|
|
148
|
+
const [loginError, setLoginError] = useState(undefined);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!requiresAuth)
|
|
151
|
+
return;
|
|
152
|
+
setIsAuthenticated(loadPersistedAuth());
|
|
153
|
+
}, [requiresAuth]);
|
|
154
|
+
const onLoginProp = auth && typeof auth === 'object' ? auth.onLogin : undefined;
|
|
155
|
+
const authPasswordHash = auth && typeof auth === 'object' && auth.passwordHash?.trim()
|
|
156
|
+
? auth.passwordHash.trim()
|
|
157
|
+
: '';
|
|
158
|
+
const tokenTtlMs = auth && typeof auth === 'object' && auth.tokenTtlMs != null
|
|
159
|
+
? auth.tokenTtlMs
|
|
160
|
+
: DEFAULT_TOKEN_TTL_MS;
|
|
161
|
+
const login = useCallback(async (password) => {
|
|
162
|
+
if (onLoginProp) {
|
|
163
|
+
await onLoginProp(password);
|
|
164
|
+
setLoginError(undefined);
|
|
165
|
+
setIsAuthenticated(true);
|
|
166
|
+
persistAuth(true, tokenTtlMs);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
if (!password)
|
|
170
|
+
throw new Error('Password is required.');
|
|
171
|
+
const inputHash = await sha256Hex(password);
|
|
172
|
+
const accepted = authPasswordHash || getAcceptedPasswordHash();
|
|
173
|
+
if (inputHash.toLowerCase() !== accepted.toLowerCase()) {
|
|
174
|
+
setLoginError('Invalid password.');
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
setLoginError(undefined);
|
|
178
|
+
setIsAuthenticated(true);
|
|
179
|
+
persistAuth(true, tokenTtlMs);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}, [onLoginProp, authPasswordHash, tokenTtlMs]);
|
|
183
|
+
const logout = useCallback(() => {
|
|
184
|
+
setIsAuthenticated(false);
|
|
185
|
+
persistAuth(false);
|
|
186
|
+
}, []);
|
|
187
|
+
const handleLogin = useCallback(async (password) => {
|
|
188
|
+
setLoginError(undefined);
|
|
189
|
+
try {
|
|
190
|
+
await login(password);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
const message = err instanceof Error ? err.message : 'Login failed.';
|
|
194
|
+
setLoginError(message);
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
}, [login]);
|
|
198
|
+
const clearLoginError = useCallback(() => setLoginError(undefined), []);
|
|
199
|
+
const addLogEntry = useCallback((entry) => {
|
|
200
|
+
const id = 'id' in entry && entry.id ? entry.id : generateId();
|
|
201
|
+
setLogEntries((prev) => [...prev, { ...entry, id }]);
|
|
202
|
+
}, []);
|
|
203
|
+
const addLogEntryRef = useRef(addLogEntry);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
addLogEntryRef.current = addLogEntry;
|
|
206
|
+
}, [addLogEntry]);
|
|
207
|
+
const addNetworkEntry = useCallback((entry) => {
|
|
208
|
+
const id = 'id' in entry && entry.id ? entry.id : generateId();
|
|
209
|
+
setNetworkEntries((prev) => [...prev, { ...entry, id }]);
|
|
210
|
+
}, []);
|
|
211
|
+
const env = printer.env ?? 'local';
|
|
212
|
+
const wrappedPrinter = useMemo(() => {
|
|
213
|
+
const base = createPrinter(env);
|
|
214
|
+
return {
|
|
215
|
+
info: (msg) => {
|
|
216
|
+
base.info(msg);
|
|
217
|
+
addLogEntryRef.current({ level: 'info', message: msg, source: 'app' });
|
|
218
|
+
},
|
|
219
|
+
warn: (msg) => {
|
|
220
|
+
base.warn(msg);
|
|
221
|
+
addLogEntryRef.current({ level: 'warn', message: msg, source: 'app' });
|
|
222
|
+
},
|
|
223
|
+
error: (msg) => {
|
|
224
|
+
base.error(msg);
|
|
225
|
+
addLogEntryRef.current({ level: 'error', message: msg, source: 'app' });
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}, [env]);
|
|
229
|
+
const restBaseUrl = api.provider === 'rest'
|
|
230
|
+
? (api.baseUrl ?? (typeof process !== 'undefined' ? process.env?.ENDPOINT_URL : '') ?? '')
|
|
231
|
+
: '';
|
|
232
|
+
const makeRestRequest = useCallback((method, path, body, options) => {
|
|
233
|
+
const url = restBaseUrl + path;
|
|
234
|
+
const isJson = body != null &&
|
|
235
|
+
typeof body === 'object' &&
|
|
236
|
+
!(body instanceof FormData) &&
|
|
237
|
+
!(body instanceof URLSearchParams);
|
|
238
|
+
const init = {
|
|
239
|
+
...options,
|
|
240
|
+
method,
|
|
241
|
+
body: resolveBody(body),
|
|
242
|
+
headers: isJson ? { 'Content-Type': 'application/json', ...options?.headers } : options?.headers,
|
|
243
|
+
};
|
|
244
|
+
const requestStr = method === 'GET' || method === 'DELETE'
|
|
245
|
+
? `${method} ${path}`
|
|
246
|
+
: body != null
|
|
247
|
+
? typeof body === 'object' && !(body instanceof FormData) && !(body instanceof URLSearchParams)
|
|
248
|
+
? JSON.stringify(body)
|
|
249
|
+
: String(body)
|
|
250
|
+
: `${method} ${path}`;
|
|
251
|
+
const addEntry = (status, statusText, response) => {
|
|
252
|
+
addNetworkEntry({
|
|
253
|
+
type: 'rest',
|
|
254
|
+
method,
|
|
255
|
+
url,
|
|
256
|
+
status,
|
|
257
|
+
statusText,
|
|
258
|
+
request: requestStr,
|
|
259
|
+
response,
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
return fetch(url, init)
|
|
263
|
+
.then(async (res) => {
|
|
264
|
+
const text = await res.clone().text().catch(() => '(read failed)');
|
|
265
|
+
addEntry(res.status, res.statusText, text);
|
|
266
|
+
return res;
|
|
267
|
+
})
|
|
268
|
+
.catch((err) => {
|
|
269
|
+
addEntry(0, 'Error', String(err?.message ?? err));
|
|
270
|
+
throw err;
|
|
271
|
+
});
|
|
272
|
+
}, [restBaseUrl, addNetworkEntry]);
|
|
273
|
+
const wrappedRestApi = useMemo(() => ({
|
|
274
|
+
provider: 'rest',
|
|
275
|
+
get: (path, options) => makeRestRequest('GET', path, undefined, options),
|
|
276
|
+
post: (path, body, options) => makeRestRequest('POST', path, body, options),
|
|
277
|
+
put: (path, body, options) => makeRestRequest('PUT', path, body, options),
|
|
278
|
+
delete: (path, options) => makeRestRequest('DELETE', path, undefined, options),
|
|
279
|
+
}), [makeRestRequest]);
|
|
280
|
+
const graphqlUri = api.provider === 'graphql'
|
|
281
|
+
? (api.uri ?? (typeof process !== 'undefined' ? process.env?.GRAPHQL_URI : '') ?? '')
|
|
282
|
+
: '';
|
|
283
|
+
const graphqlClient = useMemo(() => {
|
|
284
|
+
if (api.provider !== 'graphql')
|
|
285
|
+
return null;
|
|
286
|
+
const clientOpt = api.client;
|
|
287
|
+
if (clientOpt)
|
|
288
|
+
return clientOpt;
|
|
289
|
+
return new ApolloClient({
|
|
290
|
+
uri: graphqlUri || '/graphql',
|
|
291
|
+
cache: new InMemoryCache(),
|
|
292
|
+
});
|
|
293
|
+
}, [api, graphqlUri]);
|
|
294
|
+
const wrappedGraphqlApi = useMemo(() => {
|
|
295
|
+
if (!graphqlClient || api.provider !== 'graphql')
|
|
296
|
+
return null;
|
|
297
|
+
const url = graphqlUri || '/graphql';
|
|
298
|
+
return {
|
|
299
|
+
provider: 'graphql',
|
|
300
|
+
ransack: (options) => {
|
|
301
|
+
const requestStr = JSON.stringify({
|
|
302
|
+
query: print(options.query),
|
|
303
|
+
variables: options.variables,
|
|
304
|
+
});
|
|
305
|
+
const op = getOperationAST(options.query);
|
|
306
|
+
const operationName = op?.name?.value ?? 'Query';
|
|
307
|
+
return graphqlClient
|
|
308
|
+
.query(options)
|
|
309
|
+
.then((result) => {
|
|
310
|
+
addNetworkEntry({
|
|
311
|
+
type: 'graphql',
|
|
312
|
+
operationName,
|
|
313
|
+
url,
|
|
314
|
+
status: 200,
|
|
315
|
+
statusText: 'OK',
|
|
316
|
+
request: requestStr,
|
|
317
|
+
response: JSON.stringify(result.data ?? result),
|
|
318
|
+
});
|
|
319
|
+
return result;
|
|
320
|
+
})
|
|
321
|
+
.catch((err) => {
|
|
322
|
+
addNetworkEntry({
|
|
323
|
+
type: 'graphql',
|
|
324
|
+
operationName,
|
|
325
|
+
url,
|
|
326
|
+
status: 0,
|
|
327
|
+
statusText: 'Error',
|
|
328
|
+
request: requestStr,
|
|
329
|
+
response: String(err?.message ?? err),
|
|
330
|
+
});
|
|
331
|
+
throw err;
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
handing: (options) => {
|
|
335
|
+
const requestStr = JSON.stringify({
|
|
336
|
+
mutation: print(options.mutation),
|
|
337
|
+
variables: options.variables,
|
|
338
|
+
});
|
|
339
|
+
const op = getOperationAST(options.mutation);
|
|
340
|
+
const operationName = op?.name?.value ?? 'Mutation';
|
|
341
|
+
return graphqlClient
|
|
342
|
+
.mutate(options)
|
|
343
|
+
.then((result) => {
|
|
344
|
+
addNetworkEntry({
|
|
345
|
+
type: 'graphql',
|
|
346
|
+
operationName,
|
|
347
|
+
url,
|
|
348
|
+
status: 200,
|
|
349
|
+
statusText: 'OK',
|
|
350
|
+
request: requestStr,
|
|
351
|
+
response: JSON.stringify(result.data ?? result),
|
|
352
|
+
});
|
|
353
|
+
return result;
|
|
354
|
+
})
|
|
355
|
+
.catch((err) => {
|
|
356
|
+
addNetworkEntry({
|
|
357
|
+
type: 'graphql',
|
|
358
|
+
operationName,
|
|
359
|
+
url,
|
|
360
|
+
status: 0,
|
|
361
|
+
statusText: 'Error',
|
|
362
|
+
request: requestStr,
|
|
363
|
+
response: String(err?.message ?? err),
|
|
364
|
+
});
|
|
365
|
+
throw err;
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}, [graphqlClient, api.provider, graphqlUri, addNetworkEntry]);
|
|
370
|
+
const logWindowShortcut = useLogWindowShortcut(logWindow
|
|
371
|
+
? {
|
|
372
|
+
escapeCount: 5,
|
|
373
|
+
resetAfterMs: 1500,
|
|
374
|
+
closeOnEscape: true,
|
|
375
|
+
}
|
|
376
|
+
: { escapeCount: 5, resetAfterMs: 1500 });
|
|
377
|
+
const { isOpen: isLogWindowOpen, close: closeLogWindow, open: openLogWindow } = logWindowShortcut;
|
|
378
|
+
const defaultLogWindowPosition = useMemo(() => ({
|
|
379
|
+
x: typeof window !== 'undefined' ? Math.max(0, (window.innerWidth - 696) / 2) : 100,
|
|
380
|
+
y: typeof window !== 'undefined' ? Math.max(0, (window.innerHeight - 466) / 2) : 100,
|
|
381
|
+
}), []);
|
|
382
|
+
const yargramValue = useMemo(() => ({
|
|
383
|
+
addLogEntry,
|
|
384
|
+
addNetworkEntry,
|
|
385
|
+
openLogWindow,
|
|
386
|
+
closeLogWindow,
|
|
387
|
+
logEntries,
|
|
388
|
+
networkEntries,
|
|
389
|
+
isLogWindowOpen,
|
|
390
|
+
}), [
|
|
391
|
+
addLogEntry,
|
|
392
|
+
addNetworkEntry,
|
|
393
|
+
openLogWindow,
|
|
394
|
+
closeLogWindow,
|
|
395
|
+
logEntries,
|
|
396
|
+
networkEntries,
|
|
397
|
+
isLogWindowOpen,
|
|
398
|
+
]);
|
|
399
|
+
const apiElement = api.provider === 'rest' ? (_jsx(ApiContext.Provider, { value: wrappedRestApi, children: children })) : wrappedGraphqlApi && graphqlClient ? (_jsx(ApolloProvider, { client: graphqlClient, children: _jsx(ApiContext.Provider, { value: wrappedGraphqlApi, children: children }) })) : (_jsx(ApiProvider, { provider: "graphql", uri: api.uri, client: api.client, children: children }));
|
|
400
|
+
/** 認証なしのときのみここでログウィンドウを表示。認証ありのときは LogWindowGate で表示 */
|
|
401
|
+
const logWindowElement = !auth &&
|
|
402
|
+
isLogWindowOpen &&
|
|
403
|
+
typeof document !== 'undefined' &&
|
|
404
|
+
createPortal(_jsx("div", { onClick: (e) => e.stopPropagation(), children: _jsx(LogWindow, { entries: logEntries, networkEntries: networkEntries, draggable: true, animateOnOpen: true, onClose: closeLogWindow, defaultPosition: defaultLogWindowPosition }, instanceId) }), document.body);
|
|
405
|
+
const content = (_jsx(PrinterProvider, { env: env, printer: wrappedPrinter, children: apiElement }));
|
|
406
|
+
return (_jsxs(YargramContext.Provider, { value: yargramValue, children: [auth ? (_jsxs(_Fragment, { children: [_jsx(AuthEscapeToLogin, { children: content }), _jsx(LogWindowGate, { instanceId: instanceId, defaultPosition: defaultLogWindowPosition, loginTitle: typeof auth === 'object' ? auth.loginTitle : undefined, isAuthenticated: isAuthenticated, login: handleLogin, logout: logout, loginError: loginError, clearLoginError: clearLoginError, logEntries: logEntries, networkEntries: networkEntries, isLogWindowOpen: isLogWindowOpen, closeLogWindow: closeLogWindow })] })) : (content), logWindowElement] }));
|
|
407
|
+
}
|
|
408
|
+
export function useYargram() {
|
|
409
|
+
const ctx = useContext(YargramContext);
|
|
410
|
+
if (!ctx) {
|
|
411
|
+
throw new Error('useYargram must be used within YargramProvider');
|
|
412
|
+
}
|
|
413
|
+
return ctx;
|
|
414
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { YargramProvider } from './YargramContext';
|
|
3
|
+
declare const meta: Meta<typeof YargramProvider>;
|
|
4
|
+
export default meta;
|
|
5
|
+
type Story = StoryObj<typeof YargramProvider>;
|
|
6
|
+
/** Api (REST) + Printer + LogWindow(Escape 5回で表示)。REST は JSONPlaceholder /posts を使用 */
|
|
7
|
+
export declare const Default: Story;
|
|
8
|
+
/**
|
|
9
|
+
* 本番時のみ認証(auth: true)。
|
|
10
|
+
* storybookSimulateProduction: true で Storybook 内だけ本番扱いし、ログイン画面を表示。
|
|
11
|
+
* 本番ビルド時は NODE_ENV=production で同様にログイン要求。
|
|
12
|
+
*/
|
|
13
|
+
export declare const WithAuthProductionOnly: Story;
|
|
14
|
+
/** GraphQL: useApi().ransack (QUERY) / .handing (MUTATION) → Network */
|
|
15
|
+
export declare const GraphQL: Story;
|
|
16
|
+
//# sourceMappingURL=YargramContext.stories.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"YargramContext.stories.d.ts","sourceRoot":"","sources":["../../src/contexts/YargramContext.stories.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,EAAE,eAAe,EAAc,MAAM,kBAAkB,CAAC;AA4F/D,QAAA,MAAM,IAAI,EAAE,IAAI,CAAC,OAAO,eAAe,CAKtC,CAAC;AAEF,eAAe,IAAI,CAAC;AAEpB,KAAK,KAAK,GAAG,QAAQ,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9C,uFAAuF;AACvF,eAAO,MAAM,OAAO,EAAE,KAUrB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,KAWpC,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,OAAO,EAAE,KAUrB,CAAC"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { gql } from '@apollo/client';
|
|
3
|
+
import { YargramProvider, useYargram } from './YargramContext';
|
|
4
|
+
import { usePrinter } from './PrinterContext';
|
|
5
|
+
import { useApi } from './ApiContext';
|
|
6
|
+
function DemoContent() {
|
|
7
|
+
const { openLogWindow } = useYargram();
|
|
8
|
+
const printer = usePrinter();
|
|
9
|
+
const api = useApi();
|
|
10
|
+
const addInfo = () => printer.info('Info from usePrinter');
|
|
11
|
+
const addWarn = () => printer.warn('Warn from usePrinter');
|
|
12
|
+
const addError = () => printer.error('Error from usePrinter');
|
|
13
|
+
if (api.provider === 'rest') {
|
|
14
|
+
return (_jsxs("div", { style: { padding: 24, fontFamily: 'sans-serif' }, children: [_jsx("h3", { children: "YargramProvider \u30C7\u30E2 (REST)" }), _jsx("p", { children: "Escape \u30AD\u30FC\u3092 5 \u56DE\u62BC\u3059\u3068\u30ED\u30B0\u30A6\u30A3\u30F3\u30C9\u30A6\u304C\u958B\u304D\u307E\u3059\u3002" }), _jsx("p", { style: { fontSize: 12, color: '#666', marginTop: 8 }, children: "usePrinter().info / warn / error \u2192 Log \u30BF\u30D6\u3002useApi().get / post / put / delete \u2192 Network \u30BF\u30D6\u306B\u53CD\u6620\u3002" }), _jsxs("div", { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 16 }, children: [_jsx("button", { type: "button", onClick: addInfo, children: "Log: Info" }), _jsx("button", { type: "button", onClick: addWarn, children: "Log: Warn" }), _jsx("button", { type: "button", onClick: addError, children: "Log: Error" }), _jsx("button", { type: "button", onClick: () => api.get('/posts').then((res) => printer.info(`Response: ${res.status} ${res.statusText}`)).catch((err) => printer.error(`Error: ${err.message}`)), children: "Network: GET /posts" }), _jsx("button", { type: "button", onClick: () => api
|
|
15
|
+
.post('/posts', { title: 'Storybook post', body: 'Body from demo', userId: 1 })
|
|
16
|
+
.then((res) => printer.info(`Response: ${res.status} ${res.statusText}`))
|
|
17
|
+
.catch((err) => printer.error(`Error: ${err.message}`)), children: "Network: POST /posts" }), _jsx("button", { type: "button", onClick: () => api
|
|
18
|
+
.put('/posts/1', { id: 1, title: 'Updated title', body: 'Updated body', userId: 1 })
|
|
19
|
+
.then((res) => printer.info(`Response: ${res.status} ${res.statusText}`))
|
|
20
|
+
.catch((err) => printer.error(`Error: ${err.message}`)), children: "Network: PUT /posts/1" }), _jsx("button", { type: "button", onClick: () => api.delete('/posts/1').then((res) => printer.info(`Response: ${res.status} ${res.statusText}`)).catch(() => { }), children: "Network: DELETE /posts/1" }), _jsx("button", { type: "button", onClick: openLogWindow, children: "\u30ED\u30B0\u30A6\u30A3\u30F3\u30C9\u30A6\u3092\u958B\u304F" })] })] }));
|
|
21
|
+
}
|
|
22
|
+
const runQuery = () => {
|
|
23
|
+
api
|
|
24
|
+
.ransack({
|
|
25
|
+
query: gql `
|
|
26
|
+
query GetUser { user(id: "1") { id name } }
|
|
27
|
+
`,
|
|
28
|
+
})
|
|
29
|
+
.catch((err) => printer.error(`Error: ${err.message}`));
|
|
30
|
+
};
|
|
31
|
+
const runMutation = () => {
|
|
32
|
+
api
|
|
33
|
+
.handing({
|
|
34
|
+
mutation: gql `
|
|
35
|
+
mutation UpdateUser { updateUser(id: "1", name: "x") { id name } }
|
|
36
|
+
`,
|
|
37
|
+
})
|
|
38
|
+
.catch((err) => printer.error(`Error: ${err.message}`));
|
|
39
|
+
};
|
|
40
|
+
return (_jsxs("div", { style: { padding: 24, fontFamily: 'sans-serif' }, children: [_jsx("h3", { children: "YargramProvider \u30C7\u30E2 (GraphQL)" }), _jsx("p", { children: "useApi().ransack (QUERY) / .handing (MUTATION) \u3067 Network \u30BF\u30D6\u306B\u53CD\u6620\u3002" }), _jsxs("div", { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 16 }, children: [_jsx("button", { type: "button", onClick: addInfo, children: "Log: Info" }), _jsx("button", { type: "button", onClick: runQuery, children: "Network: ransack (QUERY)" }), _jsx("button", { type: "button", onClick: runMutation, children: "Network: handing (MUTATION)" }), _jsx("button", { type: "button", onClick: openLogWindow, children: "\u30ED\u30B0\u30A6\u30A3\u30F3\u30C9\u30A6\u3092\u958B\u304F" })] })] }));
|
|
41
|
+
}
|
|
42
|
+
const meta = {
|
|
43
|
+
title: 'Contexts/YargramProvider',
|
|
44
|
+
component: YargramProvider,
|
|
45
|
+
parameters: { layout: 'centered' },
|
|
46
|
+
tags: ['autodocs'],
|
|
47
|
+
};
|
|
48
|
+
export default meta;
|
|
49
|
+
/** Api (REST) + Printer + LogWindow(Escape 5回で表示)。REST は JSONPlaceholder /posts を使用 */
|
|
50
|
+
export const Default = {
|
|
51
|
+
render: () => (_jsx(YargramProvider, { api: { provider: 'rest', baseUrl: 'https://jsonplaceholder.typicode.com' }, printer: { env: 'local' }, logWindow: {}, children: _jsx(DemoContent, {}) })),
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* 本番時のみ認証(auth: true)。
|
|
55
|
+
* storybookSimulateProduction: true で Storybook 内だけ本番扱いし、ログイン画面を表示。
|
|
56
|
+
* 本番ビルド時は NODE_ENV=production で同様にログイン要求。
|
|
57
|
+
*/
|
|
58
|
+
export const WithAuthProductionOnly = {
|
|
59
|
+
render: () => (_jsx(YargramProvider, { api: { provider: 'rest', baseUrl: 'https://jsonplaceholder.typicode.com' }, printer: { env: 'local' }, logWindow: {}, auth: { storybookSimulateProduction: true }, children: _jsx(DemoContent, {}) })),
|
|
60
|
+
};
|
|
61
|
+
/** GraphQL: useApi().ransack (QUERY) / .handing (MUTATION) → Network */
|
|
62
|
+
export const GraphQL = {
|
|
63
|
+
render: () => (_jsx(YargramProvider, { api: { provider: 'graphql', uri: 'https://api.example.com/graphql' }, printer: { env: 'local' }, logWindow: {}, children: _jsx(DemoContent, {}) })),
|
|
64
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"YargramContext.test.d.ts","sourceRoot":"","sources":["../../src/contexts/YargramContext.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { YargramProvider, useYargram } from './YargramContext';
|
|
6
|
+
import { usePrinter } from './PrinterContext';
|
|
7
|
+
import { useApi } from './ApiContext';
|
|
8
|
+
vi.mock('react-dom', async () => {
|
|
9
|
+
const actual = await vi.importActual('react-dom');
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
createPortal: (children) => children,
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
function Consumer() {
|
|
16
|
+
const yargram = useYargram();
|
|
17
|
+
const printer = usePrinter();
|
|
18
|
+
const api = useApi();
|
|
19
|
+
return (_jsxs("div", { children: [_jsx("span", { "data-testid": "is-open", children: String(yargram.isLogWindowOpen) }), _jsx("span", { "data-testid": "log-entries-count", children: yargram.logEntries.length }), _jsx("button", { type: "button", onClick: yargram.openLogWindow, children: "Open" }), _jsx("button", { type: "button", onClick: yargram.closeLogWindow, "data-testid": "close-log-window-btn", children: "Close" }), _jsx("button", { type: "button", onClick: () => {
|
|
20
|
+
printer.info('hello');
|
|
21
|
+
yargram.addLogEntry({ level: 'info', message: 'manual', source: 'test' });
|
|
22
|
+
}, children: "Add log" })] }));
|
|
23
|
+
}
|
|
24
|
+
describe('YargramProvider', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK', clone: () => ({ text: () => Promise.resolve('{}') }) }));
|
|
27
|
+
});
|
|
28
|
+
it('renders children and provides useYargram', () => {
|
|
29
|
+
render(_jsx(YargramProvider, { api: { provider: 'rest', baseUrl: 'https://api.example.com' }, logWindow: {}, children: _jsx(Consumer, {}) }));
|
|
30
|
+
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
|
|
31
|
+
expect(screen.getByTestId('log-entries-count')).toHaveTextContent('0');
|
|
32
|
+
});
|
|
33
|
+
it('openLogWindow sets isLogWindowOpen to true', async () => {
|
|
34
|
+
const user = userEvent.setup();
|
|
35
|
+
render(_jsx(YargramProvider, { api: { provider: 'rest', baseUrl: 'https://api.example.com' }, logWindow: {}, children: _jsx(Consumer, {}) }));
|
|
36
|
+
await user.click(screen.getByRole('button', { name: /open/i }));
|
|
37
|
+
expect(screen.getByTestId('is-open')).toHaveTextContent('true');
|
|
38
|
+
});
|
|
39
|
+
it('addLogEntry adds to logEntries', async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
render(_jsx(YargramProvider, { api: { provider: 'rest', baseUrl: 'https://api.example.com' }, logWindow: {}, children: _jsx(Consumer, {}) }));
|
|
42
|
+
expect(screen.getByTestId('log-entries-count')).toHaveTextContent('0');
|
|
43
|
+
await user.click(screen.getByRole('button', { name: /add log/i }));
|
|
44
|
+
expect(screen.getByTestId('log-entries-count')).toHaveTextContent('2');
|
|
45
|
+
});
|
|
46
|
+
it('closeLogWindow sets isLogWindowOpen to false', async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
render(_jsx(YargramProvider, { api: { provider: 'rest', baseUrl: 'https://api.example.com' }, logWindow: {}, children: _jsx(Consumer, {}) }));
|
|
49
|
+
await user.click(screen.getByRole('button', { name: /open/i }));
|
|
50
|
+
expect(screen.getByTestId('is-open')).toHaveTextContent('true');
|
|
51
|
+
await user.click(screen.getByTestId('close-log-window-btn'));
|
|
52
|
+
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type UseLogWindowShortcutOptions = {
|
|
2
|
+
/** ログウィンドウを開くために必要な Escape の連続押下回数 */
|
|
3
|
+
escapeCount?: number;
|
|
4
|
+
/** この時間(ms)以内に次の Escape が押されないとカウントをリセットする */
|
|
5
|
+
resetAfterMs?: number;
|
|
6
|
+
/** ログウィンドウ表示中に Escape を押したら閉じる */
|
|
7
|
+
closeOnEscape?: boolean;
|
|
8
|
+
/** 指定時は threshold 到達時に open の代わりにこのコールバックを呼ぶ(例: 認証時はログアウトしてログインウィンドウ表示) */
|
|
9
|
+
onTrigger?: () => void;
|
|
10
|
+
};
|
|
11
|
+
export type UseLogWindowShortcutResult = {
|
|
12
|
+
/** ログウィンドウを表示するか */
|
|
13
|
+
isOpen: boolean;
|
|
14
|
+
/** ログウィンドウを開く */
|
|
15
|
+
open: () => void;
|
|
16
|
+
/** ログウィンドウを閉じる */
|
|
17
|
+
close: () => void;
|
|
18
|
+
/** トグル */
|
|
19
|
+
toggle: () => void;
|
|
20
|
+
/** 現在の Escape カウント(0 〜 escapeCount-1)。デバッグ・UI 表示用 */
|
|
21
|
+
escapeCount: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function useLogWindowShortcut(options?: UseLogWindowShortcutOptions): UseLogWindowShortcutResult;
|
|
24
|
+
//# sourceMappingURL=useLogWindowShortcut.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useLogWindowShortcut.d.ts","sourceRoot":"","sources":["../../src/hooks/useLogWindowShortcut.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,2BAA2B,GAAG;IACxC,sCAAsC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kCAAkC;IAClC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACvC,oBAAoB;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,iBAAiB;IACjB,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,kBAAkB;IAClB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,UAAU;IACV,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,wBAAgB,oBAAoB,CAClC,OAAO,GAAE,2BAAgC,GACxC,0BAA0B,CAmE5B"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
export function useLogWindowShortcut(options = {}) {
|
|
3
|
+
const { escapeCount: threshold = 5, resetAfterMs = 1500, closeOnEscape = true, onTrigger, } = options;
|
|
4
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
5
|
+
const [escapeCount, setEscapeCount] = useState(0);
|
|
6
|
+
const lastEscapeAt = useRef(0);
|
|
7
|
+
const resetTimer = useRef(null);
|
|
8
|
+
const open = useCallback(() => setIsOpen(true), []);
|
|
9
|
+
const close = useCallback(() => setIsOpen(false), []);
|
|
10
|
+
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const handleKeyDown = (e) => {
|
|
13
|
+
if (e.key !== 'Escape')
|
|
14
|
+
return;
|
|
15
|
+
if (isOpen) {
|
|
16
|
+
if (closeOnEscape) {
|
|
17
|
+
close();
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
if (now - lastEscapeAt.current > resetAfterMs) {
|
|
23
|
+
setEscapeCount(1);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
setEscapeCount((c) => {
|
|
27
|
+
const next = c + 1;
|
|
28
|
+
if (next >= threshold) {
|
|
29
|
+
lastEscapeAt.current = 0;
|
|
30
|
+
if (resetTimer.current) {
|
|
31
|
+
clearTimeout(resetTimer.current);
|
|
32
|
+
resetTimer.current = null;
|
|
33
|
+
}
|
|
34
|
+
if (onTrigger) {
|
|
35
|
+
onTrigger();
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
open();
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
return next;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
lastEscapeAt.current = now;
|
|
46
|
+
if (resetTimer.current)
|
|
47
|
+
clearTimeout(resetTimer.current);
|
|
48
|
+
resetTimer.current = setTimeout(() => {
|
|
49
|
+
setEscapeCount(0);
|
|
50
|
+
resetTimer.current = null;
|
|
51
|
+
}, resetAfterMs);
|
|
52
|
+
};
|
|
53
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
54
|
+
return () => {
|
|
55
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
56
|
+
if (resetTimer.current)
|
|
57
|
+
clearTimeout(resetTimer.current);
|
|
58
|
+
};
|
|
59
|
+
}, [isOpen, threshold, resetAfterMs, closeOnEscape, open, close, onTrigger]);
|
|
60
|
+
return { isOpen, open, close, toggle, escapeCount };
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useLogWindowShortcut.test.d.ts","sourceRoot":"","sources":["../../src/hooks/useLogWindowShortcut.test.ts"],"names":[],"mappings":""}
|