lazy_hotkeys 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +228 -0
- data/Rakefile +3 -0
- data/lib/generators/lazy_hotkeys/install/install_generator.rb +24 -0
- data/lib/generators/lazy_hotkeys/install/templates/lazy_hotkeys.js +248 -0
- data/lib/lazy_hotkeys/hotkeys_helper.rb +36 -0
- data/lib/lazy_hotkeys/railtie.rb +13 -0
- data/lib/lazy_hotkeys/version.rb +5 -0
- data/lib/lazy_hotkeys.rb +6 -0
- metadata +152 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 83d9da819a7a654425f85d5483735a7c3b39a9efe223ffeb745ff6cad2929237
|
|
4
|
+
data.tar.gz: a70db76d82d03af34cf84d8d98165a2106007ed27dc1d751e9258694f14af591
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 90938fc72535c798f603c5b412be32cd5fc49e73125969fa77969f1016253c94bdb6e759918bc11718a9ab4c252d72f8366a65dd8fafd7cdeeb6e3abd2fce3b1
|
|
7
|
+
data.tar.gz: c3a164be79bad857716d4dc19ceecb422f5e141ea1d407192443130d6f6b16f7fa14ccac51cfc9a06cd56c3b6d4e4aa64231d0a3b1b78f2a0b8f5d154339185b
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 QuarkOnRails
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Lazy Hotkeys for Lazy People
|
|
2
|
+
|
|
3
|
+
**Stop writing JavaScript. Your keyboard already knows what to do.**
|
|
4
|
+
|
|
5
|
+
Declarative keyboard shortcuts for Rails. Define hotkeys in your views, watch them work. No Stimulus controllers. No event listeners. No `addEventListener` for the 10,000th time.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
**1. Add the gem:**
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bundle add lazy_hotkeys
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**2. Run the generator:**
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
gem "lazy_hotkeys", "~> 0.1"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This copies `lazy_hotkeys.js` to `app/javascript/`
|
|
22
|
+
|
|
23
|
+
**3. Load it:**
|
|
24
|
+
|
|
25
|
+
**Importmap:**
|
|
26
|
+
```bash
|
|
27
|
+
pin "lazy_hotkeys", to: "lazy_hotkeys.js"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then import it:
|
|
31
|
+
```javascript
|
|
32
|
+
// app/javascript/application.js
|
|
33
|
+
import "lazy_hotkeys"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**esbuild/rollup/webpack or Vite:**
|
|
37
|
+
|
|
38
|
+
Import with relative path in your entry point:
|
|
39
|
+
```javascript
|
|
40
|
+
// app/javascript/application.js (or entrypoints/application.js in Vite)
|
|
41
|
+
import "./lazy_hotkeys"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Done. Hotkeys work now. (hopefully)
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 1. Hotkeys
|
|
49
|
+
|
|
50
|
+
Press keys. Things happen. Stop pretending you like writing JavaScript.
|
|
51
|
+
|
|
52
|
+
### Navigate Somewhere
|
|
53
|
+
|
|
54
|
+
```erb
|
|
55
|
+
<%= hotkey("g i", visit: "/inbox") %>
|
|
56
|
+
<%= hotkey("ctrl+n", visit: new_post_path) %>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Press `g` then `i`. You're in your inbox. Magic? No. Just less suffering.
|
|
60
|
+
|
|
61
|
+
### Send a Request
|
|
62
|
+
|
|
63
|
+
```erb
|
|
64
|
+
<%= hotkey("ctrl+s", to: "/save", method: :post) %>
|
|
65
|
+
<%= hotkey("ctrl+d", to: post_path(@post), method: :delete) %>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Ctrl+S sends a POST. Ctrl+D deletes. Your mouse is crying. Good.
|
|
69
|
+
|
|
70
|
+
### Dispatch an Action
|
|
71
|
+
|
|
72
|
+
```erb
|
|
73
|
+
<%= hotkey("ctrl+k", action: "open-command-palette") %>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```javascript
|
|
77
|
+
// Some Stimulus controller, somewhere
|
|
78
|
+
document.addEventListener('lazy-hotkeys:action', (e) => {
|
|
79
|
+
if (e.detail.action === 'open-command-palette') {
|
|
80
|
+
this.open();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
For when you absolutely must write JavaScript.
|
|
86
|
+
|
|
87
|
+
### Chain Multiple Actions
|
|
88
|
+
|
|
89
|
+
```erb
|
|
90
|
+
<%= hotkey("ctrl+s", chain: [
|
|
91
|
+
{ type: "dom", target: "#status", set_text: "Saving..." },
|
|
92
|
+
{ type: "request", to: "/save", method: "post" },
|
|
93
|
+
{ type: "dom", target: "#status", set_text: "Saved!" }
|
|
94
|
+
]) %>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
One key. Multiple actions. Sequential.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 2. DOM Manipulation
|
|
102
|
+
|
|
103
|
+
### Show/Hide Things
|
|
104
|
+
|
|
105
|
+
```erb
|
|
106
|
+
<%= hotkey("?", dom: { target: "#help", toggle_class: "hidden" }) %>
|
|
107
|
+
<%= hotkey("esc", dom: { target: ".modal", add_class: "hidden" }) %>
|
|
108
|
+
<%= hotkey("ctrl+h", dom: { target: "#sidebar", remove_class: "collapsed" }) %>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Change Text
|
|
112
|
+
|
|
113
|
+
```erb
|
|
114
|
+
<%= hotkey("ctrl+shift+c", dom: {
|
|
115
|
+
target: "#counter",
|
|
116
|
+
set_text: "9999"
|
|
117
|
+
}) %>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Click Things
|
|
121
|
+
|
|
122
|
+
```erb
|
|
123
|
+
<%= hotkey("ctrl+enter", dom: { target: "form button", click: true }) %>
|
|
124
|
+
<%= hotkey("/", dom: { target: "#search-input", focus: true }) %>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Your hands never leave the keyboard. Your mouse collects dust. Evolution.
|
|
128
|
+
|
|
129
|
+
### Set Attributes
|
|
130
|
+
|
|
131
|
+
```erb
|
|
132
|
+
<%= hotkey("ctrl+d", dom: { target: "#mode", set_attr: { "data-theme": "dark" }}) %>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Use a Template (For more complex HTML)
|
|
136
|
+
|
|
137
|
+
```erb
|
|
138
|
+
<%= hotkey("ctrl+p", dom: { target: "#preview", replace_with: "template" }) do %>
|
|
139
|
+
<div class="preview-panel">
|
|
140
|
+
<h3>Preview</h3>
|
|
141
|
+
<p>Your content here</p>
|
|
142
|
+
</div>
|
|
143
|
+
<% end %>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Press the key. The template replaces the target. No `innerHTML`. No XSS. Just works.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Options
|
|
151
|
+
|
|
152
|
+
| Option | Description |
|
|
153
|
+
|--------|-------------|
|
|
154
|
+
| `visit: "/path"` | Navigate to URL (Turbo or regular) |
|
|
155
|
+
| `to: "/endpoint"` | Send request (GET/POST/PATCH/DELETE) |
|
|
156
|
+
| `method: :post` | HTTP method (with `to:`) |
|
|
157
|
+
| `params: { ... }` | Request params (with `to:`) |
|
|
158
|
+
| `action: "name"` | Dispatch custom event |
|
|
159
|
+
| `dom: { ... }` | Manipulate DOM (see below) |
|
|
160
|
+
| `chain: [...]` | Multiple actions in sequence |
|
|
161
|
+
| `scope: "#form"` | Only works when element exists |
|
|
162
|
+
| `prevent_default: false` | Allow browser default (default: true) |
|
|
163
|
+
| `same_origin: false` | Allow cross-origin (default: true) |
|
|
164
|
+
| `hint: "Save"` | Tooltip/hint text |
|
|
165
|
+
|
|
166
|
+
### DOM Options
|
|
167
|
+
|
|
168
|
+
| Option | Description | Example |
|
|
169
|
+
|--------|-------------|---------|
|
|
170
|
+
| `target: "#id"` | CSS selector | Required |
|
|
171
|
+
| `click: true` | Click the element | `{ target: "button", click: true }` |
|
|
172
|
+
| `focus: true` | Focus the element | `{ target: "input", focus: true }` |
|
|
173
|
+
| `set_text: "..."` | Change text (safe) | `{ target: "#status", set_text: "Done" }` |
|
|
174
|
+
| `add_class: "..."` | Add CSS class | `{ target: "#box", add_class: "active" }` |
|
|
175
|
+
| `remove_class: "..."` | Remove CSS class | `{ target: "#box", remove_class: "hidden" }` |
|
|
176
|
+
| `toggle_class: "..."` | Toggle CSS class | `{ target: "#menu", toggle_class: "open" }` |
|
|
177
|
+
| `set_attr: { k: v }` | Set attributes (whitelisted) | `{ target: "#mode", set_attr: { "data-theme": "dark" } }` |
|
|
178
|
+
| `remove_attr: "..."` | Remove attribute | `{ target: "#input", remove_attr: "disabled" }` |
|
|
179
|
+
| `replace_with: "template"` | Replace with template content | See template example above |
|
|
180
|
+
|
|
181
|
+
### Cross-Origin
|
|
182
|
+
|
|
183
|
+
By default, requests to other domains are blocked:
|
|
184
|
+
|
|
185
|
+
```erb
|
|
186
|
+
<%# This works %>
|
|
187
|
+
<%= hotkey("ctrl+s", to: "/save") %>
|
|
188
|
+
|
|
189
|
+
<%# This is blocked %>
|
|
190
|
+
<%= hotkey("ctrl+x", to: "https://github.com/Plan-Vert/open-letter") %>
|
|
191
|
+
|
|
192
|
+
<%# This works (you asked for it) %>
|
|
193
|
+
<%= hotkey("?", visit: "https://discord.gg/BUtwjJTwxt", same_origin: false) %>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`javascript:`, `data:`, and `file:` URLs are always blocked, even with `same_origin: false`.
|
|
197
|
+
|
|
198
|
+
**Attribute whitelist:**
|
|
199
|
+
Check `lazy_hotkeys.js` and adjust attribute whitelist to your needs, define only what you need.
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Config
|
|
203
|
+
|
|
204
|
+
Minimal config at the top of `lazy_hotkeys.js`:
|
|
205
|
+
|
|
206
|
+
```javascript
|
|
207
|
+
const CFG = {
|
|
208
|
+
normalizeCmdToCtrl: true, // Cmd on Mac = Ctrl on Windows
|
|
209
|
+
skipInInputs: true, // Don't fire when typing in inputs
|
|
210
|
+
sequenceTimeoutMs: 800 // How long to wait for sequences like "g i"
|
|
211
|
+
};
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Change these values directly in the file. That's it.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Requirements
|
|
219
|
+
|
|
220
|
+
Rails 5.1+ (needs `tag.template` helper)
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT
|
|
227
|
+
|
|
228
|
+
---
|
data/Rakefile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "rails/generators"
|
|
3
|
+
|
|
4
|
+
module LazyHotkeys
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("../templates", __FILE__)
|
|
8
|
+
|
|
9
|
+
class_option :path, type: :string, default: "app/javascript/lazy_hotkeys.js",
|
|
10
|
+
desc: "Where to place the JS file (use app/assets/javascripts/... for sprockets)"
|
|
11
|
+
|
|
12
|
+
def copy_js_runtime
|
|
13
|
+
template "lazy_hotkeys.js", options[:path]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def say_next_steps
|
|
17
|
+
say <<~MSG
|
|
18
|
+
Added lazy_hotkeys JS runtime at #{options[:path]}.
|
|
19
|
+
Ensure it's loaded on the page (Importmap pin or pack include).
|
|
20
|
+
MSG
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Scans <template data-hotkey ...> generated by "hotkey" helpers and installs key handlers.
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
if (window.LazyHotkeys) return; // avoid double-boot
|
|
5
|
+
|
|
6
|
+
// Config,
|
|
7
|
+
const CFG = {
|
|
8
|
+
normalizeCmdToCtrl: true,
|
|
9
|
+
skipInInputs: true,
|
|
10
|
+
sequenceTimeoutMs: 800
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Utils
|
|
14
|
+
function isTyping(el) {
|
|
15
|
+
if (!CFG.skipInInputs) return false;
|
|
16
|
+
if (!el || !el.closest) return false;
|
|
17
|
+
return !!el.closest("input, textarea, [contenteditable]");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Normalize printable vs named keys
|
|
21
|
+
function keyName(e) {
|
|
22
|
+
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
|
|
23
|
+
return k === " " ? "space" : k;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function metaOrCtrl(e) {
|
|
27
|
+
return (CFG.normalizeCmdToCtrl && (e.metaKey || e.ctrlKey)) || e.ctrlKey;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function eventToCombo(e) {
|
|
31
|
+
const parts = [];
|
|
32
|
+
if (metaOrCtrl(e)) parts.push("ctrl");
|
|
33
|
+
if (e.shiftKey) parts.push("shift");
|
|
34
|
+
if (e.altKey) parts.push("alt");
|
|
35
|
+
const k = keyName(e);
|
|
36
|
+
if (!["control", "shift", "alt", "meta"].includes(k)) parts.push(k);
|
|
37
|
+
return parts.join("+");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJSON(s) {
|
|
41
|
+
try { return s ? JSON.parse(s) : null; } catch (_) { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toArray(v) { return Array.isArray(v) ? v : [v]; }
|
|
45
|
+
|
|
46
|
+
function isSafeAttribute(name) {
|
|
47
|
+
const safePrefixes = ["data-", "aria-"];
|
|
48
|
+
const safeAttributes = [
|
|
49
|
+
"class", "id", "title", "lang", "dir",
|
|
50
|
+
"role", "tabindex", "alt",
|
|
51
|
+
"name", "value", "type", "for", "placeholder", "autocomplete", "autofocus",
|
|
52
|
+
"checked", "selected", "disabled", "readonly", "required", "multiple",
|
|
53
|
+
"maxlength", "minlength", "max", "min", "step", "pattern", "size",
|
|
54
|
+
"accept", "cols", "rows", "wrap", "spellcheck",
|
|
55
|
+
"hidden", "draggable"
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const lowerName = name.toLowerCase();
|
|
59
|
+
|
|
60
|
+
if (safeAttributes.includes(lowerName)) return true;
|
|
61
|
+
if (safePrefixes.some(prefix => lowerName.startsWith(prefix))) return true;
|
|
62
|
+
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isSafeUrl(url, allowCrossOrigin) {
|
|
67
|
+
try {
|
|
68
|
+
const u = new URL(url, location.href);
|
|
69
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") { return false; }
|
|
70
|
+
if (!allowCrossOrigin && (u.origin !== location.origin)) { return false; }
|
|
71
|
+
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Combos like "g i"
|
|
79
|
+
let sequence = [];
|
|
80
|
+
let seqTimer = null;
|
|
81
|
+
function pushSequence(key) {
|
|
82
|
+
if (seqTimer) clearTimeout(seqTimer);
|
|
83
|
+
sequence.push(key);
|
|
84
|
+
seqTimer = setTimeout(() => { sequence = []; }, CFG.sequenceTimeoutMs);
|
|
85
|
+
return sequence.join(" ");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fetch helpers
|
|
89
|
+
function csrfToken() {
|
|
90
|
+
return document.querySelector("meta[name=csrf-token]")?.content || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function request(def, allowCrossOrigin) {
|
|
94
|
+
if (!isSafeUrl(def.to, allowCrossOrigin)) return;
|
|
95
|
+
|
|
96
|
+
const method = (def.method || "GET").toUpperCase();
|
|
97
|
+
const headers = {
|
|
98
|
+
"Accept": "text/vnd.turbo-stream.html, text/html, application/json"
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let body;
|
|
102
|
+
if (!["GET", "HEAD"].includes(method)) {
|
|
103
|
+
headers["Content-Type"] = "application/json";
|
|
104
|
+
const token = csrfToken();
|
|
105
|
+
if (token) headers["X-CSRF-Token"] = token;
|
|
106
|
+
body = JSON.stringify(def.params || {});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const res = await fetch(def.to, { method, headers, body });
|
|
110
|
+
const ct = (res.headers.get("Content-Type") || "").toLowerCase();
|
|
111
|
+
if (res.ok && ct.includes("turbo-stream")) {
|
|
112
|
+
const html = await res.text();
|
|
113
|
+
document.documentElement.insertAdjacentHTML("beforeend", html);
|
|
114
|
+
}
|
|
115
|
+
return res;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function visit(url, allowCrossOrigin) {
|
|
119
|
+
if (!isSafeUrl(url, allowCrossOrigin)) return;
|
|
120
|
+
if (window.Turbo && typeof Turbo.visit === "function") Turbo.visit(url);
|
|
121
|
+
else window.location.href = url;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Sends a document-level event other controllers can catch.
|
|
125
|
+
|
|
126
|
+
function dispatchAction(action) {
|
|
127
|
+
const evt = new CustomEvent("lazy-hotkeys:action", { detail: { action } });
|
|
128
|
+
document.dispatchEvent(evt);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// DOM operations
|
|
132
|
+
function performDom(def, tplEl) {
|
|
133
|
+
const el = document.querySelector(def.target);
|
|
134
|
+
if (!el) return;
|
|
135
|
+
|
|
136
|
+
if (def.add_class) el.classList.add(...toArray(def.add_class));
|
|
137
|
+
if (def.remove_class) el.classList.remove(...toArray(def.remove_class));
|
|
138
|
+
if (def.toggle_class) el.classList.toggle(def.toggle_class);
|
|
139
|
+
|
|
140
|
+
if (def.set_attr) Object.entries(def.set_attr).forEach(([k, v]) => { if (isSafeAttribute(k)) el.setAttribute(k, String(v)); });
|
|
141
|
+
if (def.remove_attr) toArray(def.remove_attr).forEach((k) => el.removeAttribute(k));
|
|
142
|
+
|
|
143
|
+
if (def.set_text != null) el.textContent = String(def.set_text);
|
|
144
|
+
|
|
145
|
+
if (def.replace_with === "template" && tplEl) {
|
|
146
|
+
el.innerHTML = "";
|
|
147
|
+
el.append(tplEl.content.cloneNode(true));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (def.focus) el.focus();
|
|
151
|
+
if (def.click) el.click();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Def parsing ---
|
|
155
|
+
function parseDefs() {
|
|
156
|
+
const els = document.querySelectorAll("[data-hotkey='true']");
|
|
157
|
+
const defs = [];
|
|
158
|
+
els.forEach((el) => {
|
|
159
|
+
const type = el.dataset.hotkeyType; // "visit"|"request"|"action"|"dom"|"chain"
|
|
160
|
+
const opts = parseJSON(el.dataset.hotkeyOpts) || {};
|
|
161
|
+
defs.push({
|
|
162
|
+
el,
|
|
163
|
+
keys: (el.dataset.hotkeyKeys || "").toLowerCase(),
|
|
164
|
+
type,
|
|
165
|
+
prevent: el.dataset.hotkeyPrevent === "true",
|
|
166
|
+
sameOrigin: el.dataset.hotkeySameOrigin !== "false",
|
|
167
|
+
scope: el.dataset.hotkeyScope,
|
|
168
|
+
hint: el.dataset.hotkeyHint,
|
|
169
|
+
opts
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
return defs;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function inScope(def) {
|
|
176
|
+
if (!def.scope) return true;
|
|
177
|
+
return !!document.querySelector(def.scope);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Registry & handlers ---
|
|
181
|
+
const registry = new Map(); // "ctrl+s" or "g i" -> def
|
|
182
|
+
|
|
183
|
+
function register(def) {
|
|
184
|
+
registry.set(def.keys, def);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function bootScan() {
|
|
188
|
+
registry.clear();
|
|
189
|
+
parseDefs().forEach(register);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function handle(def) {
|
|
193
|
+
const opts = def.opts || {};
|
|
194
|
+
const allowCrossOrigin = !def.sameOrigin;
|
|
195
|
+
switch (def.type) {
|
|
196
|
+
case "visit":
|
|
197
|
+
visit(opts.to, allowCrossOrigin);
|
|
198
|
+
break;
|
|
199
|
+
case "request":
|
|
200
|
+
await request(opts, allowCrossOrigin);
|
|
201
|
+
break;
|
|
202
|
+
case "action":
|
|
203
|
+
dispatchAction(opts.action);
|
|
204
|
+
break;
|
|
205
|
+
case "dom":
|
|
206
|
+
performDom(opts, def.el);
|
|
207
|
+
break;
|
|
208
|
+
case "chain":
|
|
209
|
+
for (const step of opts) {
|
|
210
|
+
if (step.type === "dom") performDom(step, def.el);
|
|
211
|
+
else if (step.type === "visit") visit(step.to, allowCrossOrigin);
|
|
212
|
+
else if (step.type === "request") await request(step, allowCrossOrigin);
|
|
213
|
+
else if (step.type === "action") dispatchAction(step.action);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function onKeydown(e) {
|
|
220
|
+
if (isTyping(e.target)) return;
|
|
221
|
+
|
|
222
|
+
const comboKey = eventToCombo(e); // e.g., "ctrl+s"
|
|
223
|
+
const seq = pushSequence(comboKey); // e.g., "g i" after two presses
|
|
224
|
+
|
|
225
|
+
const def = registry.get(seq) || registry.get(comboKey);
|
|
226
|
+
if (!def) return;
|
|
227
|
+
|
|
228
|
+
if (!inScope(def)) return;
|
|
229
|
+
if (def.prevent) e.preventDefault();
|
|
230
|
+
|
|
231
|
+
handle(def);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function boot() {
|
|
235
|
+
bootScan();
|
|
236
|
+
document.addEventListener("keydown", onKeydown);
|
|
237
|
+
|
|
238
|
+
// Re-scan after Turbo visits
|
|
239
|
+
document.addEventListener("turbo:load", bootScan);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
window.LazyHotkeys = { boot };
|
|
243
|
+
if (document.readyState === "loading") {
|
|
244
|
+
document.addEventListener("DOMContentLoaded", boot);
|
|
245
|
+
} else {
|
|
246
|
+
boot();
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LazyHotkeys
|
|
4
|
+
module HotkeysHelper
|
|
5
|
+
def hotkey(keys, **opts, &block)
|
|
6
|
+
type, payload =
|
|
7
|
+
if opts[:visit]
|
|
8
|
+
[:visit, { to: opts[:visit] }]
|
|
9
|
+
elsif opts[:to]
|
|
10
|
+
[:request, { to: opts[:to], method: (opts[:method] || :get).to_s.upcase, params: opts[:params] }]
|
|
11
|
+
elsif opts[:action]
|
|
12
|
+
[:action, { action: opts[:action].to_s }]
|
|
13
|
+
elsif opts[:dom]
|
|
14
|
+
[:dom, opts[:dom]]
|
|
15
|
+
elsif opts[:chain]
|
|
16
|
+
[:chain, opts[:chain]]
|
|
17
|
+
else
|
|
18
|
+
raise ArgumentError, "Specify one of: visit:, to:, action:, dom:, chain:"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
data = {
|
|
22
|
+
"data-hotkey" => "true",
|
|
23
|
+
"data-hotkey-keys" => keys,
|
|
24
|
+
"data-hotkey-type" => type,
|
|
25
|
+
"data-hotkey-opts" => payload.to_json,
|
|
26
|
+
"data-hotkey-scope" => opts[:scope],
|
|
27
|
+
"data-hotkey-prevent" => (opts.key?(:prevent_default) ? opts[:prevent_default] : true).to_s,
|
|
28
|
+
"data-hotkey-same-origin" => (opts.key?(:same_origin) ? opts[:same_origin] : true).to_s,
|
|
29
|
+
"data-hotkey-hint" => opts[:hint]
|
|
30
|
+
}.compact
|
|
31
|
+
|
|
32
|
+
content = capture(&block) if block_given?
|
|
33
|
+
tag.template(content, **data)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "rails/railtie"
|
|
3
|
+
|
|
4
|
+
module LazyHotkeys
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer "lazy_hotkeys.view_helper" do
|
|
7
|
+
ActiveSupport.on_load(:action_view) do
|
|
8
|
+
require_relative "hotkeys_helper"
|
|
9
|
+
include LazyHotkeys::HotkeysHelper
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/lazy_hotkeys.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lazy_hotkeys
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- QuarkXZ
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: railties
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '5.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: actionview
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '5.1'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9.0'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '5.1'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9.0'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: activesupport
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '5.1'
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '9.0'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '5.1'
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '9.0'
|
|
72
|
+
- !ruby/object:Gem::Dependency
|
|
73
|
+
name: bundler
|
|
74
|
+
requirement: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '2.5'
|
|
79
|
+
type: :development
|
|
80
|
+
prerelease: false
|
|
81
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '2.5'
|
|
86
|
+
- !ruby/object:Gem::Dependency
|
|
87
|
+
name: rake
|
|
88
|
+
requirement: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - "~>"
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '13.0'
|
|
93
|
+
type: :development
|
|
94
|
+
prerelease: false
|
|
95
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - "~>"
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '13.0'
|
|
100
|
+
description: Define hotkey events in views, trigger visit/request/client actions,
|
|
101
|
+
and simple DOM mutations without boilerplate JS.
|
|
102
|
+
email:
|
|
103
|
+
- quarkXZ@proton.me
|
|
104
|
+
executables: []
|
|
105
|
+
extensions: []
|
|
106
|
+
extra_rdoc_files: []
|
|
107
|
+
files:
|
|
108
|
+
- LICENSE
|
|
109
|
+
- README.md
|
|
110
|
+
- Rakefile
|
|
111
|
+
- lib/generators/lazy_hotkeys/install/install_generator.rb
|
|
112
|
+
- lib/generators/lazy_hotkeys/install/templates/lazy_hotkeys.js
|
|
113
|
+
- lib/lazy_hotkeys.rb
|
|
114
|
+
- lib/lazy_hotkeys/hotkeys_helper.rb
|
|
115
|
+
- lib/lazy_hotkeys/railtie.rb
|
|
116
|
+
- lib/lazy_hotkeys/version.rb
|
|
117
|
+
homepage: https://github.com/you/lazy_hotkeys
|
|
118
|
+
licenses:
|
|
119
|
+
- MIT
|
|
120
|
+
metadata:
|
|
121
|
+
source_code_uri: https://github.com/you/lazy_hotkeys
|
|
122
|
+
post_install_message: |2+
|
|
123
|
+
|
|
124
|
+
⚡ LazyHotkeys installed!
|
|
125
|
+
|
|
126
|
+
Run: rails generate lazy_hotkeys:install
|
|
127
|
+
|
|
128
|
+
Then add to your importmap or JS bundler:
|
|
129
|
+
# Importmap
|
|
130
|
+
bin/importmap pin lazy_hotkeys
|
|
131
|
+
|
|
132
|
+
# Or require in your JavaScript entry point like application.js
|
|
133
|
+
import "lazy_hotkeys"
|
|
134
|
+
|
|
135
|
+
rdoc_options: []
|
|
136
|
+
require_paths:
|
|
137
|
+
- lib
|
|
138
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
139
|
+
requirements:
|
|
140
|
+
- - ">="
|
|
141
|
+
- !ruby/object:Gem::Version
|
|
142
|
+
version: '0'
|
|
143
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
144
|
+
requirements:
|
|
145
|
+
- - ">="
|
|
146
|
+
- !ruby/object:Gem::Version
|
|
147
|
+
version: '0'
|
|
148
|
+
requirements: []
|
|
149
|
+
rubygems_version: 3.6.9
|
|
150
|
+
specification_version: 4
|
|
151
|
+
summary: Declarative keyboard shortcuts for Rails (Turbo/Stimulus-friendly).
|
|
152
|
+
test_files: []
|