@griddo/ax 12.9.0 → 12.9.1-rc.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/package.json +2 -2
- package/src/__tests__/hooks/shortcuts/engine.test.ts +344 -0
- package/src/__tests__/hooks/shortcuts/hooks.test.tsx +259 -0
- package/src/components/ConfigPanel/Form/ConnectedField/PageConnectedField/Field/index.tsx +1 -1
- package/src/components/ConfigPanel/Form/ConnectedField/PageConnectedField/TemplateManager/index.tsx +52 -58
- package/src/hooks/shortcuts/README.md +515 -0
- package/src/hooks/shortcuts/engine.ts +205 -0
- package/src/hooks/shortcuts/index.ts +48 -0
- package/src/hooks/shortcuts/presets.ts +144 -0
- package/src/hooks/shortcuts/types.ts +23 -0
- package/src/hooks/shortcuts/useShortcuts.ts +106 -0
package/src/components/ConfigPanel/Form/ConnectedField/PageConnectedField/TemplateManager/index.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import Field from "../Field";
|
|
|
7
7
|
|
|
8
8
|
import * as S from "./style";
|
|
9
9
|
|
|
10
|
-
export const TemplateManager = (props: IProps)
|
|
10
|
+
export const TemplateManager = (props: IProps) => {
|
|
11
11
|
const {
|
|
12
12
|
template,
|
|
13
13
|
selectedTab,
|
|
@@ -52,13 +52,8 @@ export const TemplateManager = (props: IProps): JSX.Element => {
|
|
|
52
52
|
const isComputedField = Object.prototype.hasOwnProperty.call(field, "computed");
|
|
53
53
|
|
|
54
54
|
const addedModules: string[] = modulesDataPacks.reduce((acc: string[], current: any) => {
|
|
55
|
-
if (
|
|
56
|
-
current.
|
|
57
|
-
current.sectionList[template.component] &&
|
|
58
|
-
current.sectionList[template.component].includes(key) &&
|
|
59
|
-
!acc.includes(current.id)
|
|
60
|
-
) {
|
|
61
|
-
return [...acc, current.id];
|
|
55
|
+
if (current.sectionList?.[template.component]?.includes(key) && !acc.includes(current.id)) {
|
|
56
|
+
acc.push(current.id);
|
|
62
57
|
}
|
|
63
58
|
return acc;
|
|
64
59
|
}, []);
|
|
@@ -84,57 +79,56 @@ export const TemplateManager = (props: IProps): JSX.Element => {
|
|
|
84
79
|
return (
|
|
85
80
|
<>
|
|
86
81
|
{isConfig && templateFields && !isForm && <S.Title>Template Options</S.Title>}
|
|
87
|
-
{templateFields
|
|
88
|
-
|
|
89
|
-
.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
} = getFieldProps(templateField);
|
|
82
|
+
{templateFields
|
|
83
|
+
?.filter((templateField: ISchemaField) => {
|
|
84
|
+
const isHidden = templateField.hidden || (isForm && !templateField.overwrite);
|
|
85
|
+
return !isHidden;
|
|
86
|
+
})
|
|
87
|
+
.map((templateField: ISchemaField, index: number) => {
|
|
88
|
+
const {
|
|
89
|
+
whiteList,
|
|
90
|
+
categories,
|
|
91
|
+
key,
|
|
92
|
+
fieldObjKey,
|
|
93
|
+
mappedField,
|
|
94
|
+
currentContent,
|
|
95
|
+
handleUpdate,
|
|
96
|
+
error,
|
|
97
|
+
readonly,
|
|
98
|
+
disabledField,
|
|
99
|
+
} = getFieldProps(templateField);
|
|
106
100
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
101
|
+
return (
|
|
102
|
+
<Field
|
|
103
|
+
whiteList={whiteList}
|
|
104
|
+
key={`${objKey}.${key}${index}`}
|
|
105
|
+
objKey={fieldObjKey}
|
|
106
|
+
field={mappedField}
|
|
107
|
+
selectedContent={currentContent}
|
|
108
|
+
goTo={goTo}
|
|
109
|
+
updateValue={handleUpdate}
|
|
110
|
+
pages={pages}
|
|
111
|
+
actions={actions}
|
|
112
|
+
site={site}
|
|
113
|
+
disabled={disabled || disabledField}
|
|
114
|
+
activatedModules={activatedModules}
|
|
115
|
+
isTemplateActivated={isTemplateActivated}
|
|
116
|
+
categories={categories}
|
|
117
|
+
error={error}
|
|
118
|
+
deleteError={deleteError}
|
|
119
|
+
errors={errors}
|
|
120
|
+
theme={theme}
|
|
121
|
+
moduleCopy={moduleCopy}
|
|
122
|
+
availableDataPacks={availableDataPacks}
|
|
123
|
+
template={template}
|
|
124
|
+
setHistoryPush={setHistoryPush}
|
|
125
|
+
lang={lang}
|
|
126
|
+
readonly={readonly}
|
|
127
|
+
scrollEditorID={scrollEditorID}
|
|
128
|
+
languages={languages}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
138
132
|
</>
|
|
139
133
|
);
|
|
140
134
|
};
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
# Keyboard Shortcuts
|
|
2
|
+
|
|
3
|
+
Sistema declarativo de atajos de teclado para Griddo AX. Registra shortcuts en componentes React con cleanup automático, soporte de secuencias (chords) y resolución de conflictos por prioridad.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
import { useShortcuts, shortcut } from "@ax/hooks";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Tabla de contenidos
|
|
12
|
+
|
|
13
|
+
1. [Inicio rápido](#inicio-rápido)
|
|
14
|
+
2. [Conceptos clave](#conceptos-clave)
|
|
15
|
+
3. [API base: `useShortcuts` + `shortcut`](#api-base)
|
|
16
|
+
4. [Presets](#presets)
|
|
17
|
+
5. [Global shortcuts (safety net)](#global-shortcuts)
|
|
18
|
+
6. [Prioridad](#prioridad)
|
|
19
|
+
7. [Secuencias de teclas (chords)](#secuencias-de-teclas-chords)
|
|
20
|
+
8. [Scoped shortcuts (ref)](#scoped-shortcuts)
|
|
21
|
+
9. [Iframe bridge](#iframe-bridge)
|
|
22
|
+
10. [Comportamiento en inputs](#comportamiento-en-inputs)
|
|
23
|
+
11. [Arquitectura](#arquitectura)
|
|
24
|
+
12. [Referencia de archivos](#referencia-de-archivos)
|
|
25
|
+
13. [Recetas](#recetas)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Inicio rápido
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { useSaveShortcut } from "@ax/hooks";
|
|
33
|
+
|
|
34
|
+
function MyEditor() {
|
|
35
|
+
const canSave = /* ... */;
|
|
36
|
+
|
|
37
|
+
useSaveShortcut(() => {
|
|
38
|
+
saveDocument();
|
|
39
|
+
}, canSave);
|
|
40
|
+
|
|
41
|
+
return <div>...</div>;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Eso es todo. `mod+s` queda registrado, funciona dentro de inputs, y se desregistra automáticamente al desmontar el componente.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Conceptos clave
|
|
50
|
+
|
|
51
|
+
### `mod` = Cmd (Mac) / Ctrl (Windows/Linux)
|
|
52
|
+
|
|
53
|
+
Usa `"mod"` en vez de `"ctrl"` o `"meta"`. El engine detecta la plataforma automáticamente.
|
|
54
|
+
|
|
55
|
+
### Cleanup automático
|
|
56
|
+
|
|
57
|
+
Los shortcuts se desregistran cuando el componente se desmonta o cuando cambian las dependencias del hook. No necesitas hacer cleanup manual.
|
|
58
|
+
|
|
59
|
+
### Prioridad
|
|
60
|
+
|
|
61
|
+
Cuando dos shortcuts compiten por la misma combinación de teclas, gana el de mayor prioridad. Si un handler retorna `false`, el engine pasa al siguiente en la cola.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## API base
|
|
66
|
+
|
|
67
|
+
### `shortcut(id, keys, handler, overrides?)`
|
|
68
|
+
|
|
69
|
+
Crea un objeto `ShortcutConfig`. No registra nada por sí solo — es solo un builder.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
shortcut("save", "mod+s", () => handleSave());
|
|
73
|
+
shortcut("delete", "del", () => handleDelete());
|
|
74
|
+
shortcut("find", "mod+shift+f", () => openSearch());
|
|
75
|
+
shortcut("goto-list", "g l", () => navigateToList()); // chord: pulsa g, luego l
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Parámetros:**
|
|
79
|
+
|
|
80
|
+
| Param | Tipo | Descripción |
|
|
81
|
+
| ----------- | ----------------------------------------- | -------------------------------------------------------- |
|
|
82
|
+
| `id` | `string` | Identificador único (ej. `"save"`, `"close:modal"`) |
|
|
83
|
+
| `keys` | `string` | Combinación de teclas (ver [sintaxis](#sintaxis-de-keys)) |
|
|
84
|
+
| `handler` | `(event: KeyboardEvent) => void \| false` | Callback. Retorna `false` para no hacer `preventDefault` |
|
|
85
|
+
| `overrides` | `object` (opcional) | Ver tabla abajo |
|
|
86
|
+
|
|
87
|
+
**Overrides:**
|
|
88
|
+
|
|
89
|
+
| Campo | Tipo | Default | Descripción |
|
|
90
|
+
| -------------- | --------- | ----------- | -------------------------------------------------------- |
|
|
91
|
+
| `allowInInput` | `boolean` | `false` | Si `true`, funciona dentro de `<input>`, `<textarea>`, etc |
|
|
92
|
+
| `priority` | `number` | `0` | Mayor = más prioridad en conflictos |
|
|
93
|
+
| `enabled` | `boolean` | `true` | `false` desactiva sin desregistrar |
|
|
94
|
+
| `description` | `string` | `undefined` | Texto descriptivo (para debug o UI de shortcuts) |
|
|
95
|
+
|
|
96
|
+
### Sintaxis de keys
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
"mod+s" → Cmd+S / Ctrl+S
|
|
100
|
+
"shift+alt+n" → Shift+Alt+N
|
|
101
|
+
"escape" → Escape
|
|
102
|
+
"del" → Delete
|
|
103
|
+
"mod+shift+z" → Cmd+Shift+Z / Ctrl+Shift+Z
|
|
104
|
+
"g l" → Chord: pulsa G, luego L (ver sección de chords)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Modificadores disponibles: `mod`, `shift`, `alt`.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
### `useShortcuts(shortcuts, options?)`
|
|
112
|
+
|
|
113
|
+
Registra un array de shortcuts en el engine. Es la API de bajo nivel.
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
useShortcuts([
|
|
117
|
+
shortcut("bold", "mod+b", () => toggleBold()),
|
|
118
|
+
shortcut("italic", "mod+i", () => toggleItalic()),
|
|
119
|
+
shortcut("underline", "mod+u", () => toggleUnderline()),
|
|
120
|
+
]);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Options:**
|
|
124
|
+
|
|
125
|
+
| Campo | Tipo | Default | Descripción |
|
|
126
|
+
| --------- | ----------------------- | ---------- | ------------------------------------------------- |
|
|
127
|
+
| `enabled` | `boolean` | `true` | Desregistra todos los shortcuts cuando es `false` |
|
|
128
|
+
| `ref` | `RefObject<HTMLElement>` | `window` | Elemento donde escuchar keydown (ver [ref](#scoped-shortcuts)) |
|
|
129
|
+
|
|
130
|
+
> **Nota sobre `enabled`:** Cuando es `false`, los shortcuts se **desregistran por completo**.
|
|
131
|
+
> Esto significa que el browser default no se previene. Si necesitas bloquear el browser
|
|
132
|
+
> default incluso cuando el shortcut no está activo (ej. `mod+s` → Save As), usa
|
|
133
|
+
> `useGlobalShortcuts()` en el root (ver [Global shortcuts](#global-shortcuts)).
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Presets
|
|
138
|
+
|
|
139
|
+
Hooks preconfigurados para shortcuts comunes. Encapsulan la combinación de teclas, el id y las opciones — solo pasas el handler.
|
|
140
|
+
|
|
141
|
+
### `useSaveShortcut(handler, enabled?)`
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
useSaveShortcut(() => {
|
|
145
|
+
isPublished ? publishChanges() : saveDraft();
|
|
146
|
+
}, canSave);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- Tecla: `mod+s`
|
|
150
|
+
- `allowInInput`: siempre `true`
|
|
151
|
+
- Id: `"save"`
|
|
152
|
+
|
|
153
|
+
### `useEscapeShortcut(handler, enabled?)`
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
useEscapeShortcut(() => {
|
|
157
|
+
closePanel();
|
|
158
|
+
}, isPanelOpen);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- Tecla: `Escape`
|
|
162
|
+
- `allowInInput`: siempre `true`
|
|
163
|
+
- Id: `"escape"`
|
|
164
|
+
|
|
165
|
+
### `useUndoRedoShortcuts(onUndo, onRedo, enabled?)`
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
useUndoRedoShortcuts(
|
|
169
|
+
() => dispatch(undo()),
|
|
170
|
+
() => dispatch(redo()),
|
|
171
|
+
isEditing,
|
|
172
|
+
);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- Teclas: `mod+z` / `mod+shift+z`
|
|
176
|
+
- `allowInInput`: siempre `true`
|
|
177
|
+
- Ids: `"undo"`, `"redo"`
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Global shortcuts
|
|
182
|
+
|
|
183
|
+
`useGlobalShortcuts()` es un safety net que se monta **una vez en el root de la app**
|
|
184
|
+
(`App/index.tsx`). Registra shortcuts con prioridad baja (`-1`) que solo hacen
|
|
185
|
+
`preventDefault` para bloquear defaults del navegador.
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
// App/index.tsx — ya está configurado
|
|
189
|
+
const App = (props: IProps) => {
|
|
190
|
+
useGlobalShortcuts();
|
|
191
|
+
// ...
|
|
192
|
+
};
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Cómo funciona
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
┌─────────────────────────────────────────────────────────┐
|
|
199
|
+
│ Usuario pulsa Cmd+S │
|
|
200
|
+
│ │
|
|
201
|
+
│ ┌─ ShortcutEngine (prioridad descendente) ───────────┐ │
|
|
202
|
+
│ │ │ │
|
|
203
|
+
│ │ 1. useSaveShortcut (priority: 0) ← si está │ │
|
|
204
|
+
│ │ montado, ejecuta el handler del componente │ │
|
|
205
|
+
│ │ │ │
|
|
206
|
+
│ │ 2. useGlobalShortcuts (priority: -1) ← fallback │ │
|
|
207
|
+
│ │ si ningún componente lo maneja, solo hace │ │
|
|
208
|
+
│ │ preventDefault (bloquea Save As) │ │
|
|
209
|
+
│ │ │ │
|
|
210
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
211
|
+
└─────────────────────────────────────────────────────────┘
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Escenarios
|
|
215
|
+
|
|
216
|
+
| Ruta/estado | `mod+s` lo maneja... | Resultado |
|
|
217
|
+
| ---------------------- | ------------------------ | ------------------------ |
|
|
218
|
+
| PageEditor montado | `useSaveShortcut` (p:0) | Guarda la página |
|
|
219
|
+
| PageEditor con `enabled=false` | `useGlobalShortcuts` (p:-1) | Bloquea Save As, no hace nada más |
|
|
220
|
+
| Cualquier otra ruta | `useGlobalShortcuts` (p:-1) | Bloquea Save As |
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Prioridad
|
|
225
|
+
|
|
226
|
+
El engine despacha shortcuts por **prioridad descendente**. El primero que matchea gana y hace `preventDefault` + `stopPropagation`. Si su handler retorna `false`, el engine continúa al siguiente.
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
// Este shortcut SIEMPRE gana sobre otro "mod+s" con priority < 10
|
|
230
|
+
shortcut("save:critical", "mod+s", handler, { priority: 10 });
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Valores de referencia:**
|
|
234
|
+
|
|
235
|
+
| Nivel | Valor | Uso |
|
|
236
|
+
| ---------- | ----- | ---------------------------------------- |
|
|
237
|
+
| Global | `-1` | Safety nets (ej. `useGlobalShortcuts`) |
|
|
238
|
+
| Default | `0` | Shortcuts normales de componente |
|
|
239
|
+
| Override | `> 0` | Modales, overlays que deben ganar siempre |
|
|
240
|
+
|
|
241
|
+
### Ejemplo: modal que captura Escape
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
function ConfirmModal({ isOpen, onClose }) {
|
|
245
|
+
// priority: 10 — gana sobre cualquier otro Escape
|
|
246
|
+
useShortcuts(
|
|
247
|
+
[shortcut("close:confirm-modal", "escape", () => onClose(), { priority: 10 })],
|
|
248
|
+
{ enabled: isOpen },
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return isOpen ? <div>...</div> : null;
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Secuencias de teclas (chords)
|
|
258
|
+
|
|
259
|
+
Un chord es una secuencia de teclas que se pulsan una tras otra (no simultáneamente). Se definen separando combinaciones con espacio:
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
shortcut("goto:list", "g l", () => navigateTo("/list"));
|
|
263
|
+
// Pulsa G, luego L (en menos de 1 segundo)
|
|
264
|
+
|
|
265
|
+
shortcut("goto:settings", "g s", () => navigateTo("/settings"));
|
|
266
|
+
// Pulsa G, luego S
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Cómo funciona
|
|
270
|
+
|
|
271
|
+
1. Pulsas `G` → el engine detecta que hay un chord en progreso y hace `preventDefault`
|
|
272
|
+
2. Tienes **1 segundo** para pulsar `L`
|
|
273
|
+
3. Si pulsas `L` a tiempo → se ejecuta el handler
|
|
274
|
+
4. Si pulsas otra tecla o pasa el timeout → el chord se cancela
|
|
275
|
+
|
|
276
|
+
### Chords con modificadores
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
shortcut("debug:panel", "mod+k mod+d", () => openDebugPanel());
|
|
280
|
+
// Cmd+K, luego Cmd+D
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Scoped shortcuts
|
|
286
|
+
|
|
287
|
+
Por defecto, los shortcuts escuchan en `window`. Puedes limitarlos a un elemento específico con `ref`:
|
|
288
|
+
|
|
289
|
+
```tsx
|
|
290
|
+
function TextEditor() {
|
|
291
|
+
const editorRef = useRef<HTMLDivElement>(null);
|
|
292
|
+
|
|
293
|
+
useShortcuts(
|
|
294
|
+
[
|
|
295
|
+
shortcut("bold", "mod+b", () => toggleBold()),
|
|
296
|
+
shortcut("italic", "mod+i", () => toggleItalic()),
|
|
297
|
+
],
|
|
298
|
+
{ ref: editorRef },
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return <div ref={editorRef} tabIndex={0}>...</div>;
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Los shortcuts con ref solo se disparan cuando el evento se origina dentro de ese elemento. Gracias a `stopPropagation`, si el scoped shortcut matchea, no llega a los shortcuts globales en `window`.
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Iframe bridge
|
|
310
|
+
|
|
311
|
+
Los eventos `keydown` dentro de un `<iframe>` no llegan al `window` del padre — el browser los confina al documento del iframe. Esto significa que shortcuts como `mod+s` no se disparan si el foco está en el iframe de preview.
|
|
312
|
+
|
|
313
|
+
Para resolverlo, el sistema usa un puente de dos hooks que conectan el iframe con el engine del padre vía `postMessage`:
|
|
314
|
+
|
|
315
|
+
### `useForwardShortcutKeys()` (lado iframe)
|
|
316
|
+
|
|
317
|
+
Se usa dentro del iframe (ej. `FramePreview`). Escucha `keydown` y reenvía los eventos relevantes al padre.
|
|
318
|
+
|
|
319
|
+
```tsx
|
|
320
|
+
// FramePreview/index.tsx
|
|
321
|
+
import { useForwardShortcutKeys } from "@ax/hooks";
|
|
322
|
+
|
|
323
|
+
const FramePreview = () => {
|
|
324
|
+
useForwardShortcutKeys();
|
|
325
|
+
// ...
|
|
326
|
+
};
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Qué reenvía:** Eventos con `metaKey`/`ctrlKey` o `Escape`.
|
|
330
|
+
|
|
331
|
+
**Qué no reenvía:** Shortcuts de clipboard (`mod+c`, `mod+v`, `mod+x`, `mod+a`) para que funcionen normalmente en el iframe.
|
|
332
|
+
|
|
333
|
+
### `useIframeShortcutBridge()` (lado padre)
|
|
334
|
+
|
|
335
|
+
Se usa una vez en el root de la app, junto a `useGlobalShortcuts()`. Escucha los mensajes del iframe y re-despacha un `KeyboardEvent` sintético en `window`, donde el `ShortcutEngine` lo procesa normalmente.
|
|
336
|
+
|
|
337
|
+
```tsx
|
|
338
|
+
// App/index.tsx — ya está configurado
|
|
339
|
+
const App = (props: IProps) => {
|
|
340
|
+
useGlobalShortcuts();
|
|
341
|
+
useIframeShortcutBridge();
|
|
342
|
+
// ...
|
|
343
|
+
};
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Flujo
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
350
|
+
│ Usuario pulsa Cmd+S dentro del iframe │
|
|
351
|
+
│ │
|
|
352
|
+
│ ┌─ Iframe (FramePreview) ──────────────────────────────┐ │
|
|
353
|
+
│ │ useForwardShortcutKeys() │ │
|
|
354
|
+
│ │ → captura keydown │ │
|
|
355
|
+
│ │ → preventDefault (bloquea Save As en el iframe) │ │
|
|
356
|
+
│ │ → postMessage({ type: "shortcut-keydown", ... }) │ │
|
|
357
|
+
│ └───────────────────────────────────────────────────────┘ │
|
|
358
|
+
│ │ │
|
|
359
|
+
│ ▼ │
|
|
360
|
+
│ ┌─ Parent (App) ────────────────────────────────────────┐ │
|
|
361
|
+
│ │ useIframeShortcutBridge() │ │
|
|
362
|
+
│ │ → recibe message │ │
|
|
363
|
+
│ │ → window.dispatchEvent(new KeyboardEvent(...)) │ │
|
|
364
|
+
│ │ │ │
|
|
365
|
+
│ │ ShortcutEngine │ │
|
|
366
|
+
│ │ → procesa el evento como cualquier otro keydown │ │
|
|
367
|
+
│ │ → useSaveShortcut handler se ejecuta │ │
|
|
368
|
+
│ └───────────────────────────────────────────────────────┘ │
|
|
369
|
+
└─────────────────────────────────────────────────────────────┘
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## Comportamiento en inputs
|
|
375
|
+
|
|
376
|
+
Por defecto, los shortcuts **no se disparan** cuando el foco está en:
|
|
377
|
+
|
|
378
|
+
- `<input>`
|
|
379
|
+
- `<textarea>`
|
|
380
|
+
- `<select>`
|
|
381
|
+
- Elementos con `contentEditable`
|
|
382
|
+
|
|
383
|
+
**Excepciones:**
|
|
384
|
+
|
|
385
|
+
1. **`allowInInput: true`** — El shortcut funciona en cualquier contexto. Todos los presets lo activan.
|
|
386
|
+
2. **`Escape`** — Siempre funciona en inputs, incluso sin `allowInInput`. Es una decisión de UX: Escape se usa para cerrar/cancelar y nunca produce texto.
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
// Funciona siempre, incluso dentro de un input
|
|
390
|
+
shortcut("save", "mod+s", handler, { allowInInput: true });
|
|
391
|
+
|
|
392
|
+
// Solo funciona fuera de inputs
|
|
393
|
+
shortcut("delete", "del", handler);
|
|
394
|
+
|
|
395
|
+
// Escape siempre funciona (caso especial del engine)
|
|
396
|
+
shortcut("close", "escape", handler);
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## Arquitectura
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
405
|
+
│ Componente React │
|
|
406
|
+
│ │
|
|
407
|
+
│ useShortcuts([...]) ← Hook (registra/desregistra) │
|
|
408
|
+
│ │ │
|
|
409
|
+
│ │ usa ref-based handlers para evitar stale closures │
|
|
410
|
+
│ ▼ │
|
|
411
|
+
│ shortcutEngine.register(config, target) │
|
|
412
|
+
│ │ │
|
|
413
|
+
│ │ un solo listener `keydown` por target │
|
|
414
|
+
│ ▼ │
|
|
415
|
+
│ ┌─ ShortcutEngine ──────────────────────────────────────┐ │
|
|
416
|
+
│ │ │ │
|
|
417
|
+
│ │ keydown event │ │
|
|
418
|
+
│ │ → recopila shortcuts del target │ │
|
|
419
|
+
│ │ → ordena por prioridad (desc) │ │
|
|
420
|
+
│ │ → itera: el primero que matchea, gana │ │
|
|
421
|
+
│ │ → execute(): handler() + preventDefault + stopProp │ │
|
|
422
|
+
│ │ → si handler retorna false: continúa al siguiente │ │
|
|
423
|
+
│ │ │ │
|
|
424
|
+
│ └────────────────────────────────────────────────────────┘ │
|
|
425
|
+
└──────────────────────────────────────────────────────────────┘
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Un solo listener por target
|
|
429
|
+
|
|
430
|
+
El engine **no** crea un `addEventListener` por shortcut. Mantiene un solo listener por cada target (`window`, o un ref de elemento). Cuando llega un `keydown`, recorre los shortcuts registrados en ese target ordenados por prioridad.
|
|
431
|
+
|
|
432
|
+
Esto garantiza que:
|
|
433
|
+
|
|
434
|
+
- La resolución de prioridad funciona correctamente
|
|
435
|
+
- No hay race conditions entre listeners independientes
|
|
436
|
+
- El cleanup es eficiente (el listener se remueve solo cuando el último shortcut del target se desregistra)
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## Referencia de archivos
|
|
441
|
+
|
|
442
|
+
| Archivo | Responsabilidad |
|
|
443
|
+
| ------------------ | ------------------------------------------------------------ |
|
|
444
|
+
| `types.ts` | Tipos: `KeyCombo`, `ModifierKey`, `ShortcutConfig`, `UseShortcutsOptions` |
|
|
445
|
+
| `engine.ts` | `ShortcutEngine` (singleton), `parseShortcut`, matching de teclas |
|
|
446
|
+
| `useShortcuts.ts` | Hook base `useShortcuts` + builder `shortcut()` |
|
|
447
|
+
| `presets.ts` | Hooks preconfigurados: `useSaveShortcut`, `useEscapeShortcut`, `useUndoRedoShortcuts`, `useGlobalShortcuts`, `useIframeShortcutBridge`, `useForwardShortcutKeys` — lado iframe del bridge |
|
|
448
|
+
| `index.ts` | Barrel export |
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Recetas
|
|
453
|
+
|
|
454
|
+
### Shortcut condicional
|
|
455
|
+
|
|
456
|
+
```tsx
|
|
457
|
+
const isAdmin = usePermission("admin");
|
|
458
|
+
|
|
459
|
+
useShortcuts([
|
|
460
|
+
shortcut("admin:panel", "mod+shift+a", () => openAdminPanel()),
|
|
461
|
+
], { enabled: isAdmin });
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Múltiples shortcuts en un componente
|
|
465
|
+
|
|
466
|
+
```tsx
|
|
467
|
+
useShortcuts([
|
|
468
|
+
shortcut("save", "mod+s", handleSave, { allowInInput: true }),
|
|
469
|
+
shortcut("close", "escape", handleClose),
|
|
470
|
+
shortcut("search", "mod+k", handleSearch, { allowInInput: true }),
|
|
471
|
+
shortcut("delete", "del", handleDelete),
|
|
472
|
+
]);
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Shortcut que no previene el default del browser
|
|
476
|
+
|
|
477
|
+
Si necesitas que el browser siga procesando la tecla después de tu handler, retorna `false`:
|
|
478
|
+
|
|
479
|
+
```tsx
|
|
480
|
+
shortcut("log-typing", "mod+a", (event) => {
|
|
481
|
+
analytics.track("select-all");
|
|
482
|
+
return false; // deja que el browser haga Select All
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### Combinar preset + shortcuts custom
|
|
487
|
+
|
|
488
|
+
```tsx
|
|
489
|
+
function PageEditor() {
|
|
490
|
+
useSaveShortcut(() => savePage(), canSave);
|
|
491
|
+
useEscapeShortcut(() => closePanel(), isPanelOpen);
|
|
492
|
+
|
|
493
|
+
// Shortcuts adicionales específicos de este componente
|
|
494
|
+
useShortcuts([
|
|
495
|
+
shortcut("preview", "mod+shift+p", () => togglePreview()),
|
|
496
|
+
shortcut("publish", "mod+shift+enter", () => publish()),
|
|
497
|
+
]);
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### Modal con Escape de alta prioridad
|
|
502
|
+
|
|
503
|
+
```tsx
|
|
504
|
+
function DeleteConfirmModal({ isOpen, onConfirm, onCancel }) {
|
|
505
|
+
useShortcuts(
|
|
506
|
+
[
|
|
507
|
+
shortcut("cancel:delete", "escape", () => onCancel(), { priority: 10 }),
|
|
508
|
+
shortcut("confirm:delete", "enter", () => onConfirm(), { priority: 10 }),
|
|
509
|
+
],
|
|
510
|
+
{ enabled: isOpen },
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// ...
|
|
514
|
+
}
|
|
515
|
+
```
|