hotkey-engine 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +343 -2
- package/dist/fonts/Vazir-Bold.woff2 +0 -0
- package/dist/fonts/Vazir-Regular.woff2 +0 -0
- package/dist/index.cjs +415 -0
- package/dist/index.d.cts +89 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +380 -0
- package/dist/styles.css +352 -0
- package/package.json +65 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// src/react/context.tsx
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
|
|
3
|
+
|
|
4
|
+
// src/core/storage.ts
|
|
5
|
+
var KEY = "hotkey-engine:v1";
|
|
6
|
+
function loadSettings() {
|
|
7
|
+
if (typeof window === "undefined") return { version: 1, shortcuts: {} };
|
|
8
|
+
try {
|
|
9
|
+
const value = window.localStorage.getItem(KEY);
|
|
10
|
+
return value ? JSON.parse(value) : { version: 1, shortcuts: {} };
|
|
11
|
+
} catch {
|
|
12
|
+
return { version: 1, shortcuts: {} };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function saveSettings(settings) {
|
|
16
|
+
try {
|
|
17
|
+
window.localStorage.setItem(KEY, JSON.stringify(settings));
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/core/shortcut.ts
|
|
23
|
+
var MODIFIERS = /* @__PURE__ */ new Set(["ctrl", "control", "alt", "shift", "meta", "cmd", "command", "mod"]);
|
|
24
|
+
var NAMED_CODES = {
|
|
25
|
+
enter: "Enter",
|
|
26
|
+
escape: "Escape",
|
|
27
|
+
esc: "Escape",
|
|
28
|
+
space: "Space",
|
|
29
|
+
tab: "Tab",
|
|
30
|
+
backspace: "Backspace",
|
|
31
|
+
delete: "Delete",
|
|
32
|
+
arrowup: "ArrowUp",
|
|
33
|
+
arrowdown: "ArrowDown",
|
|
34
|
+
arrowleft: "ArrowLeft",
|
|
35
|
+
arrowright: "ArrowRight",
|
|
36
|
+
slash: "Slash"
|
|
37
|
+
};
|
|
38
|
+
var RESERVED = /* @__PURE__ */ new Set(["ctrl+r", "ctrl+w", "ctrl+t", "ctrl+n", "ctrl+l", "ctrl+k", "ctrl+p", "ctrl+shift+t", "meta+r", "meta+w", "meta+t", "meta+n", "meta+l", "meta+k", "meta+p", "meta+q", "alt+f4"]);
|
|
39
|
+
function codeFor(key) {
|
|
40
|
+
const normalized = key.toLowerCase();
|
|
41
|
+
if (NAMED_CODES[normalized]) return NAMED_CODES[normalized];
|
|
42
|
+
if (/^[a-z]$/.test(normalized)) return `Key${normalized.toUpperCase()}`;
|
|
43
|
+
if (/^[0-9]$/.test(normalized)) return `Digit${normalized}`;
|
|
44
|
+
if (/^f([1-9]|1[0-2])$/.test(normalized)) return normalized.toUpperCase();
|
|
45
|
+
throw new Error(`Unsupported shortcut key: ${key}`);
|
|
46
|
+
}
|
|
47
|
+
function parseShortcut(shortcut) {
|
|
48
|
+
const parts = shortcut.toLowerCase().replace(/\s/g, "").split("+").filter(Boolean);
|
|
49
|
+
if (!parts.length) throw new Error("A shortcut is required");
|
|
50
|
+
const key = parts.pop();
|
|
51
|
+
if (MODIFIERS.has(key)) throw new Error("A shortcut needs a non-modifier key");
|
|
52
|
+
const modifiers = { ctrl: false, alt: false, shift: false, meta: false, mod: false };
|
|
53
|
+
for (const part of parts) {
|
|
54
|
+
if (!MODIFIERS.has(part)) throw new Error(`Unknown modifier: ${part}`);
|
|
55
|
+
if (part === "ctrl" || part === "control") modifiers.ctrl = true;
|
|
56
|
+
else if (part === "alt") modifiers.alt = true;
|
|
57
|
+
else if (part === "shift") modifiers.shift = true;
|
|
58
|
+
else if (part === "meta" || part === "cmd" || part === "command") modifiers.meta = true;
|
|
59
|
+
else modifiers.mod = true;
|
|
60
|
+
}
|
|
61
|
+
return { modifiers, code: codeFor(key) };
|
|
62
|
+
}
|
|
63
|
+
function isMacPlatform(platform = typeof navigator === "undefined" ? "" : navigator.platform) {
|
|
64
|
+
return /mac|iphone|ipad/i.test(platform);
|
|
65
|
+
}
|
|
66
|
+
function shortcutMatches(event, shortcut) {
|
|
67
|
+
const parsed = parseShortcut(shortcut);
|
|
68
|
+
const mac = isMacPlatform();
|
|
69
|
+
const expectedCtrl = parsed.modifiers.ctrl || parsed.modifiers.mod && !mac;
|
|
70
|
+
const expectedMeta = parsed.modifiers.meta || parsed.modifiers.mod && mac;
|
|
71
|
+
return event.code === parsed.code && event.ctrlKey === expectedCtrl && event.altKey === parsed.modifiers.alt && event.shiftKey === parsed.modifiers.shift && event.metaKey === expectedMeta;
|
|
72
|
+
}
|
|
73
|
+
function normalizeShortcut(shortcut) {
|
|
74
|
+
const parsed = parseShortcut(shortcut);
|
|
75
|
+
const names = [parsed.modifiers.ctrl && "ctrl", parsed.modifiers.alt && "alt", parsed.modifiers.shift && "shift", parsed.modifiers.meta && "meta", parsed.modifiers.mod && "mod"].filter(Boolean);
|
|
76
|
+
const key = parsed.code.startsWith("Key") ? parsed.code.slice(3).toLowerCase() : parsed.code.startsWith("Digit") ? parsed.code.slice(5) : parsed.code.toLowerCase();
|
|
77
|
+
return [...names, key].join("+");
|
|
78
|
+
}
|
|
79
|
+
function reservedReason(shortcut) {
|
|
80
|
+
const normalized = normalizeShortcut(shortcut).replace("mod", isMacPlatform() ? "meta" : "ctrl");
|
|
81
|
+
return RESERVED.has(normalized) ? "This shortcut is commonly reserved by the browser or operating system." : void 0;
|
|
82
|
+
}
|
|
83
|
+
function displayShortcut(shortcut, mac = isMacPlatform()) {
|
|
84
|
+
const parsed = parseShortcut(shortcut);
|
|
85
|
+
const labels = [
|
|
86
|
+
parsed.modifiers.ctrl && (mac ? "\u2303" : "Ctrl"),
|
|
87
|
+
parsed.modifiers.alt && (mac ? "\u2325" : "Alt"),
|
|
88
|
+
parsed.modifiers.shift && (mac ? "\u21E7" : "Shift"),
|
|
89
|
+
parsed.modifiers.meta && (mac ? "\u2318" : "Meta"),
|
|
90
|
+
parsed.modifiers.mod && (mac ? "\u2318" : "Ctrl")
|
|
91
|
+
].filter(Boolean);
|
|
92
|
+
const key = parsed.code.startsWith("Key") ? parsed.code.slice(3) : parsed.code.startsWith("Digit") ? parsed.code.slice(5) : parsed.code.replace(/^Arrow/, "Arrow ");
|
|
93
|
+
return [...labels, key].join(mac ? "" : " + ");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/locales/messages.ts
|
|
97
|
+
var messages = {
|
|
98
|
+
en: { settings: "Shortcut settings", search: "Search shortcuts", edit: "Edit", disable: "Disable", reset: "Reset", resetAll: "Reset all", save: "Save", cancel: "Cancel", close: "Close", recording: "Press a shortcut", disabled: "Disabled", conflict: "This shortcut is already used by", reserved: "This shortcut is reserved by your browser or operating system. Please choose another shortcut.", unavailable: "Shortcut unavailable", resetConfirm: "Reset all shortcuts to their defaults?", confirm: "Confirm", floatingLabel: "Keyboard shortcut settings" },
|
|
99
|
+
fa: { settings: "\u062A\u0646\u0638\u06CC\u0645\u0627\u062A \u0645\u06CC\u0627\u0646\u0628\u0631\u0647\u0627", search: "\u062C\u0633\u062A\u062C\u0648\u06CC \u0645\u06CC\u0627\u0646\u0628\u0631\u0647\u0627", edit: "\u0648\u06CC\u0631\u0627\u06CC\u0634", disable: "\u063A\u06CC\u0631\u0641\u0639\u0627\u0644\u200C\u0633\u0627\u0632\u06CC", reset: "\u0628\u0627\u0632\u0646\u0634\u0627\u0646\u06CC", resetAll: "\u0628\u0627\u0632\u0646\u0634\u0627\u0646\u06CC \u0647\u0645\u0647", save: "\u0630\u062E\u06CC\u0631\u0647", cancel: "\u0644\u063A\u0648", close: "\u0628\u0633\u062A\u0646", recording: "\u06CC\u06A9 \u0645\u06CC\u0627\u0646\u0628\u0631 \u0631\u0627 \u0641\u0634\u0627\u0631 \u062F\u0647\u06CC\u062F", disabled: "\u063A\u06CC\u0631\u0641\u0639\u0627\u0644", conflict: "\u0627\u06CC\u0646 \u0645\u06CC\u0627\u0646\u0628\u0631 \u0647\u0645\u200C\u0627\u06A9\u0646\u0648\u0646 \u0627\u0633\u062A\u0641\u0627\u062F\u0647 \u0645\u06CC\u200C\u0634\u0648\u062F \u062A\u0648\u0633\u0637", reserved: "\u0627\u06CC\u0646 \u0645\u06CC\u0627\u0646\u0628\u0631 \u062A\u0648\u0633\u0637 \u0645\u0631\u0648\u0631\u06AF\u0631 \u06CC\u0627 \u0633\u06CC\u0633\u062A\u0645\u200C\u0639\u0627\u0645\u0644 \u0631\u0632\u0631\u0648 \u0634\u062F\u0647 \u0627\u0633\u062A\u061B \u0644\u0637\u0641\u0627\u064B \u0645\u06CC\u0627\u0646\u0628\u0631 \u062F\u06CC\u06AF\u0631\u06CC \u0627\u0646\u062A\u062E\u0627\u0628 \u06A9\u0646\u06CC\u062F.", unavailable: "\u0627\u06CC\u0646 \u0645\u06CC\u0627\u0646\u0628\u0631 \u062F\u0631 \u062F\u0633\u062A\u0631\u0633 \u0646\u06CC\u0633\u062A", resetConfirm: "\u0647\u0645\u0647\u0654 \u0645\u06CC\u0627\u0646\u0628\u0631\u0647\u0627 \u0628\u0647 \u062D\u0627\u0644\u062A \u067E\u06CC\u0634\u200C\u0641\u0631\u0636 \u0628\u0631\u06AF\u0631\u062F\u0646\u062F\u061F", confirm: "\u062A\u0623\u06CC\u06CC\u062F", floatingLabel: "\u062A\u0646\u0638\u06CC\u0645\u0627\u062A \u0645\u06CC\u0627\u0646\u0628\u0631\u0647\u0627\u06CC \u06A9\u06CC\u0628\u0648\u0631\u062F" }
|
|
100
|
+
};
|
|
101
|
+
function defaultDir(locale) {
|
|
102
|
+
return locale === "fa" ? "rtl" : "ltr";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/ui/SettingsModal.tsx
|
|
106
|
+
import { useEffect, useRef, useState } from "react";
|
|
107
|
+
import { createPortal } from "react-dom";
|
|
108
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
109
|
+
function Recorder({ onSave, onCancel }) {
|
|
110
|
+
const { locale } = useHotkey();
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const handle = (event) => {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
event.stopPropagation();
|
|
115
|
+
if (event.key === "Escape") {
|
|
116
|
+
onCancel();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (["Control", "Alt", "Shift", "Meta"].includes(event.key) || event.repeat) return;
|
|
120
|
+
const parts = [event.ctrlKey && "ctrl", event.altKey && "alt", event.shiftKey && "shift", event.metaKey && "meta", event.code.startsWith("Key") ? event.code.slice(3).toLowerCase() : event.code.startsWith("Digit") ? event.code.slice(5) : event.code.toLowerCase()].filter(Boolean);
|
|
121
|
+
onSave(parts.join("+"));
|
|
122
|
+
};
|
|
123
|
+
window.addEventListener("keydown", handle, true);
|
|
124
|
+
return () => window.removeEventListener("keydown", handle, true);
|
|
125
|
+
}, [onSave, onCancel]);
|
|
126
|
+
return /* @__PURE__ */ jsxs("div", { className: "hotkey-recorder", role: "status", "aria-live": "polite", children: [
|
|
127
|
+
/* @__PURE__ */ jsxs("span", { className: "hotkey-recording-label", children: [
|
|
128
|
+
messages[locale].recording,
|
|
129
|
+
/* @__PURE__ */ jsxs("span", { "aria-hidden": "true", className: "hotkey-recording-dots", children: [
|
|
130
|
+
/* @__PURE__ */ jsx("i", {}),
|
|
131
|
+
/* @__PURE__ */ jsx("i", {}),
|
|
132
|
+
/* @__PURE__ */ jsx("i", {})
|
|
133
|
+
] })
|
|
134
|
+
] }),
|
|
135
|
+
/* @__PURE__ */ jsx("button", { type: "button", onClick: onCancel, children: messages[locale].cancel })
|
|
136
|
+
] });
|
|
137
|
+
}
|
|
138
|
+
function HotkeySettingsModal() {
|
|
139
|
+
const { open, closeSettings, actions, updateShortcut, resetShortcut, resetAll, locale, dir, theme } = useHotkey();
|
|
140
|
+
const t = messages[locale];
|
|
141
|
+
const dialog = useRef(null);
|
|
142
|
+
const previous = useRef(null);
|
|
143
|
+
const [query, setQuery] = useState("");
|
|
144
|
+
const [recording, setRecording] = useState();
|
|
145
|
+
const [validationError, setValidationError] = useState();
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!open) return;
|
|
148
|
+
previous.current = document.activeElement;
|
|
149
|
+
dialog.current?.focus();
|
|
150
|
+
const keyboard = (event) => {
|
|
151
|
+
if (event.key === "Escape" && !recording) {
|
|
152
|
+
closeSettings();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (event.key !== "Tab" || !dialog.current) return;
|
|
156
|
+
const focusable = Array.from(dialog.current.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'));
|
|
157
|
+
if (!focusable.length) {
|
|
158
|
+
event.preventDefault();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const first = focusable[0], last = focusable[focusable.length - 1];
|
|
162
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
last.focus();
|
|
165
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
first.focus();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
window.addEventListener("keydown", keyboard);
|
|
171
|
+
return () => {
|
|
172
|
+
window.removeEventListener("keydown", keyboard);
|
|
173
|
+
previous.current?.focus();
|
|
174
|
+
};
|
|
175
|
+
}, [open, recording, closeSettings]);
|
|
176
|
+
if (!open || typeof document === "undefined") return null;
|
|
177
|
+
const visible = actions.filter((action) => action.label.toLowerCase().includes(query.toLowerCase()));
|
|
178
|
+
const save = (shortcut) => {
|
|
179
|
+
if (!recording) return;
|
|
180
|
+
const result = updateShortcut(recording, shortcut);
|
|
181
|
+
if (result.ok) {
|
|
182
|
+
setValidationError(void 0);
|
|
183
|
+
setRecording(void 0);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const message = result.reason === "conflict" ? `${t.conflict}: ${result.action?.label}` : locale === "fa" ? `${displayShortcut(shortcut)} \u0631\u0632\u0631\u0648 \u0627\u0633\u062A\u061B \u0645\u06CC\u0627\u0646\u0628\u0631 \u062F\u06CC\u06AF\u0631\u06CC \u0627\u0646\u062A\u062E\u0627\u0628 \u06A9\u0646\u06CC\u062F.` : `${displayShortcut(shortcut)} is reserved. Choose another shortcut.`;
|
|
187
|
+
setValidationError({ actionId: recording, message });
|
|
188
|
+
};
|
|
189
|
+
const content = /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx("div", { className: "hotkey-overlay", "data-hotkey-theme": theme, onMouseDown: (event) => {
|
|
190
|
+
if (event.target === event.currentTarget && !recording) closeSettings();
|
|
191
|
+
}, children: /* @__PURE__ */ jsx("div", { ref: dialog, className: "hotkey-dialog", "data-hotkey-theme": theme, role: "dialog", "aria-modal": "true", "aria-labelledby": "hotkey-title", tabIndex: -1, dir, children: /* @__PURE__ */ jsxs("div", { className: "hotkey-glass-layout", children: [
|
|
192
|
+
/* @__PURE__ */ jsxs("header", { className: "hotkey-glass-header", children: [
|
|
193
|
+
/* @__PURE__ */ jsxs("div", { className: "hotkey-glass-title", children: [
|
|
194
|
+
/* @__PURE__ */ jsx("span", { className: "hotkey-glass-icon", "aria-hidden": "true", children: /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", children: [
|
|
195
|
+
/* @__PURE__ */ jsx("rect", { x: "3", y: "5", width: "18", height: "14", rx: "3", stroke: "currentColor", strokeWidth: "1.8" }),
|
|
196
|
+
/* @__PURE__ */ jsx("path", { d: "M7 10h1M11 10h1M15 10h2M7 14h6M16 14h1", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round" })
|
|
197
|
+
] }) }),
|
|
198
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
199
|
+
/* @__PURE__ */ jsx("h2", { id: "hotkey-title", children: locale === "fa" ? t.settings : "Shortcut Settings" }),
|
|
200
|
+
/* @__PURE__ */ jsx("p", { children: locale === "fa" ? "\u0645\u06CC\u0627\u0646\u0628\u0631\u0647\u0627\u06CC \u0633\u0627\u06CC\u062A \u062E\u0648\u062F \u0631\u0627 \u0634\u062E\u0635\u06CC\u200C\u0633\u0627\u0632\u06CC \u06A9\u0646\u06CC\u062F" : "Personalize shortcuts for this site" })
|
|
201
|
+
] })
|
|
202
|
+
] }),
|
|
203
|
+
/* @__PURE__ */ jsxs("div", { className: "hotkey-header-actions", children: [
|
|
204
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "hotkey-dialog-close", "aria-label": t.close, onClick: closeSettings, children: "\xD7" }),
|
|
205
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "hotkey-reset-all-icon", "aria-label": t.resetAll, onClick: resetAll, children: /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", children: [
|
|
206
|
+
/* @__PURE__ */ jsx("path", { d: "M4 11a8 8 0 1 1 2.3 5.7", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round" }),
|
|
207
|
+
/* @__PURE__ */ jsx("path", { d: "M4 5v6h6", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round" })
|
|
208
|
+
] }) })
|
|
209
|
+
] })
|
|
210
|
+
] }),
|
|
211
|
+
/* @__PURE__ */ jsxs("section", { className: "hotkey-glass-body", children: [
|
|
212
|
+
/* @__PURE__ */ jsxs("div", { className: "hotkey-glass-toolbar", children: [
|
|
213
|
+
/* @__PURE__ */ jsxs("div", { className: "hotkey-search-wrap", children: [
|
|
214
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\u2315" }),
|
|
215
|
+
/* @__PURE__ */ jsx("input", { className: "hotkey-search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: t.search, "aria-label": t.search })
|
|
216
|
+
] }),
|
|
217
|
+
/* @__PURE__ */ jsx("span", { className: "hotkey-action-count", children: actions.length })
|
|
218
|
+
] }),
|
|
219
|
+
/* @__PURE__ */ jsx("ul", { className: "hotkey-action-list", children: visible.map((action) => /* @__PURE__ */ jsxs("li", { className: "hotkey-action-item", children: [
|
|
220
|
+
/* @__PURE__ */ jsxs("div", { className: "hotkey-action-row", children: [
|
|
221
|
+
/* @__PURE__ */ jsxs("div", { className: "hotkey-action-info", children: [
|
|
222
|
+
/* @__PURE__ */ jsx("strong", { children: action.label }),
|
|
223
|
+
/* @__PURE__ */ jsx("span", { children: action.shortcut ? displayShortcut(action.shortcut) : t.disabled })
|
|
224
|
+
] }),
|
|
225
|
+
recording === action.id ? /* @__PURE__ */ jsx(Recorder, { onSave: save, onCancel: () => {
|
|
226
|
+
setValidationError(void 0);
|
|
227
|
+
setRecording(void 0);
|
|
228
|
+
} }) : /* @__PURE__ */ jsxs("div", { className: "hotkey-row-actions", children: [
|
|
229
|
+
action.editable !== false && /* @__PURE__ */ jsx("button", { type: "button", className: "hotkey-action-edit", onClick: () => {
|
|
230
|
+
setValidationError(void 0);
|
|
231
|
+
setRecording(action.id);
|
|
232
|
+
}, children: t.edit }),
|
|
233
|
+
action.editable !== false && /* @__PURE__ */ jsx("button", { type: "button", className: "hotkey-action-disable", onClick: () => updateShortcut(action.id, null), children: t.disable }),
|
|
234
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "hotkey-action-reset", onClick: () => resetShortcut(action.id), children: t.reset })
|
|
235
|
+
] })
|
|
236
|
+
] }),
|
|
237
|
+
validationError?.actionId === action.id && /* @__PURE__ */ jsxs("div", { className: "hotkey-inline-feedback", role: "alert", "aria-live": "assertive", children: [
|
|
238
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "!" }),
|
|
239
|
+
/* @__PURE__ */ jsx("p", { children: validationError.message })
|
|
240
|
+
] })
|
|
241
|
+
] }, action.id)) })
|
|
242
|
+
] })
|
|
243
|
+
] }) }) }) });
|
|
244
|
+
return createPortal(content, document.body);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/ui/FloatingTrigger.tsx
|
|
248
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
249
|
+
function HotkeySettingsTrigger({ children, className }) {
|
|
250
|
+
const { openSettings } = useHotkeySettings();
|
|
251
|
+
return /* @__PURE__ */ jsx2("button", { type: "button", className, onClick: openSettings, children: children ?? "Keyboard shortcuts" });
|
|
252
|
+
}
|
|
253
|
+
function FloatingTrigger({ position, offset, zIndex }) {
|
|
254
|
+
const { openSettings } = useHotkeySettings();
|
|
255
|
+
const { locale } = useHotkey();
|
|
256
|
+
const value = typeof offset === "number" ? { x: offset, y: offset } : offset;
|
|
257
|
+
const [vertical, horizontal] = position.split("-");
|
|
258
|
+
const style = { position: "fixed", [vertical]: value.y, [horizontal]: value.x, zIndex };
|
|
259
|
+
return /* @__PURE__ */ jsx2("button", { type: "button", className: "hotkey-floating-trigger", style, "aria-label": messages[locale].floatingLabel, title: messages[locale].floatingLabel, onClick: openSettings, children: /* @__PURE__ */ jsxs2("svg", { className: "hotkey-trigger-icon", "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
|
|
260
|
+
/* @__PURE__ */ jsx2("rect", { x: "3", y: "4", width: "18", height: "16", rx: "3" }),
|
|
261
|
+
/* @__PURE__ */ jsx2("path", { d: "M7 9h.01M10 9h.01M13 9h.01M16 9h1M7 13h1M10 13h1M13 13h4M7 16h10" })
|
|
262
|
+
] }) });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/react/context.tsx
|
|
266
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
267
|
+
var Context = createContext(null);
|
|
268
|
+
function isEditable(target) {
|
|
269
|
+
const el = target;
|
|
270
|
+
return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
|
271
|
+
}
|
|
272
|
+
function scopesOverlap(a, b) {
|
|
273
|
+
return !a || !b || a === b;
|
|
274
|
+
}
|
|
275
|
+
function HotkeyProvider({ actions, children, showFloatingButton = true, position = "bottom-left", offset = { x: 24, y: 24 }, zIndex = 1e3, theme = "system", locale = "en", dir, className, allowReservedShortcuts = false }) {
|
|
276
|
+
const [open, setOpen] = useState2(false);
|
|
277
|
+
const [activeLocale, setActiveLocale] = useState2(locale);
|
|
278
|
+
const [settings, setSettings] = useState2({});
|
|
279
|
+
const [hydrated, setHydrated] = useState2(false);
|
|
280
|
+
const [activeScopes, setActiveScopes] = useState2([]);
|
|
281
|
+
const actionsRef = useRef2(actions);
|
|
282
|
+
actionsRef.current = actions;
|
|
283
|
+
useEffect2(() => setActiveLocale(locale), [locale]);
|
|
284
|
+
useEffect2(() => {
|
|
285
|
+
const loaded = loadSettings();
|
|
286
|
+
setSettings(loaded.shortcuts);
|
|
287
|
+
setHydrated(true);
|
|
288
|
+
}, []);
|
|
289
|
+
useEffect2(() => {
|
|
290
|
+
if (hydrated) saveSettings({ version: 1, shortcuts: settings });
|
|
291
|
+
}, [hydrated, settings]);
|
|
292
|
+
const resolved = useMemo(() => actions.map((action) => ({ ...action, shortcut: Object.prototype.hasOwnProperty.call(settings, action.id) ? settings[action.id] : action.defaultShortcut })), [actions, settings]);
|
|
293
|
+
const resolvedRef = useRef2(resolved);
|
|
294
|
+
resolvedRef.current = resolved;
|
|
295
|
+
const scopesRef = useRef2(activeScopes);
|
|
296
|
+
scopesRef.current = activeScopes;
|
|
297
|
+
useEffect2(() => {
|
|
298
|
+
const onKeyDown = (event) => {
|
|
299
|
+
if (event.repeat || open) return;
|
|
300
|
+
const rankedScopes = [...scopesRef.current].reverse().concat("global");
|
|
301
|
+
for (const scope of rankedScopes) {
|
|
302
|
+
const action = resolvedRef.current.find((item) => (item.scope ?? "global") === scope && item.shortcut && (!isEditable(event.target) || item.allowInEditable) && shortcutMatches(event, item.shortcut));
|
|
303
|
+
if (action) {
|
|
304
|
+
if (action.preventDefault || allowReservedShortcuts && reservedReason(action.shortcut)) event.preventDefault();
|
|
305
|
+
action.onTrigger(event);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
window.addEventListener("keydown", onKeyDown);
|
|
311
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
312
|
+
}, [open, allowReservedShortcuts]);
|
|
313
|
+
const updateShortcut = useCallback((id, shortcut) => {
|
|
314
|
+
const changed = resolvedRef.current.find((item) => item.id === id);
|
|
315
|
+
if (!changed) throw new Error(`Unknown Hotkey action: ${id}`);
|
|
316
|
+
if (shortcut) {
|
|
317
|
+
let normalized;
|
|
318
|
+
try {
|
|
319
|
+
normalized = normalizeShortcut(shortcut);
|
|
320
|
+
} catch {
|
|
321
|
+
return { ok: false, reason: "reserved" };
|
|
322
|
+
}
|
|
323
|
+
if (reservedReason(normalized) && !allowReservedShortcuts) return { ok: false, reason: "reserved" };
|
|
324
|
+
const conflict = resolvedRef.current.find((item) => item.id !== id && item.shortcut && normalizeShortcut(item.shortcut) === normalized && scopesOverlap(item.scope, changed.scope));
|
|
325
|
+
if (conflict) return { ok: false, reason: "conflict", action: conflict };
|
|
326
|
+
shortcut = normalized;
|
|
327
|
+
}
|
|
328
|
+
setSettings((current) => ({ ...current, [id]: shortcut }));
|
|
329
|
+
return { ok: true };
|
|
330
|
+
}, []);
|
|
331
|
+
const resetShortcut = useCallback((id) => setSettings((current) => {
|
|
332
|
+
const next = { ...current };
|
|
333
|
+
delete next[id];
|
|
334
|
+
return next;
|
|
335
|
+
}), []);
|
|
336
|
+
const resetAll = useCallback(() => setSettings({}), []);
|
|
337
|
+
const activateScope = useCallback((scope) => {
|
|
338
|
+
setActiveScopes((current) => [...current.filter((item) => item !== scope), scope]);
|
|
339
|
+
return () => setActiveScopes((current) => current.filter((item) => item !== scope));
|
|
340
|
+
}, []);
|
|
341
|
+
const activeDir = dir ?? defaultDir(activeLocale);
|
|
342
|
+
const value = useMemo(() => ({ actions: resolved, open, openSettings: () => setOpen(true), closeSettings: () => setOpen(false), updateShortcut, resetShortcut, resetAll, activeScopes, activateScope, locale: activeLocale, setLocale: setActiveLocale, dir: activeDir, theme, allowReservedShortcuts }), [resolved, open, updateShortcut, resetShortcut, resetAll, activeScopes, activateScope, activeLocale, activeDir, theme, allowReservedShortcuts]);
|
|
343
|
+
return /* @__PURE__ */ jsx3(Context.Provider, { value, children: /* @__PURE__ */ jsxs3("div", { className, "data-hotkey-theme": theme, dir: activeDir, children: [
|
|
344
|
+
children,
|
|
345
|
+
showFloatingButton && /* @__PURE__ */ jsx3(FloatingTrigger, { position, offset, zIndex }),
|
|
346
|
+
/* @__PURE__ */ jsx3(HotkeySettingsModal, {})
|
|
347
|
+
] }) });
|
|
348
|
+
}
|
|
349
|
+
function useHotkeySettings() {
|
|
350
|
+
const value = useContext(Context);
|
|
351
|
+
if (!value) throw new Error("useHotkeySettings must be used within HotkeyProvider");
|
|
352
|
+
return value;
|
|
353
|
+
}
|
|
354
|
+
function useHotkeyLocale() {
|
|
355
|
+
const value = useContext(Context);
|
|
356
|
+
if (!value) throw new Error("useHotkeyLocale must be used within HotkeyProvider");
|
|
357
|
+
return value;
|
|
358
|
+
}
|
|
359
|
+
function useHotkeyScope(scope, active = true) {
|
|
360
|
+
const value = useContext(Context);
|
|
361
|
+
if (!value) throw new Error("useHotkeyScope must be used within HotkeyProvider");
|
|
362
|
+
const { activateScope } = value;
|
|
363
|
+
useEffect2(() => active ? activateScope(scope) : void 0, [active, scope, activateScope]);
|
|
364
|
+
}
|
|
365
|
+
function useHotkey() {
|
|
366
|
+
const value = useContext(Context);
|
|
367
|
+
if (!value) throw new Error("useHotkey must be used within HotkeyProvider");
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
export {
|
|
371
|
+
HotkeyProvider,
|
|
372
|
+
HotkeySettingsTrigger,
|
|
373
|
+
displayShortcut,
|
|
374
|
+
normalizeShortcut,
|
|
375
|
+
parseShortcut,
|
|
376
|
+
shortcutMatches,
|
|
377
|
+
useHotkeyLocale,
|
|
378
|
+
useHotkeyScope,
|
|
379
|
+
useHotkeySettings
|
|
380
|
+
};
|