@tobq/boardclip 2.1.114 → 2.1.115

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/index.html CHANGED
@@ -827,7 +827,8 @@ function renderShortcutSettings(settings, { clearStatus = true } = {}) {
827
827
  const quickRecord = shortcutRecordEl('quickPaste');
828
828
  const quickShortcut = info.quickPaste || '';
829
829
  if (quickShortcut) {
830
- quickRecord.textContent = `${shortcutLabel(quickShortcut).replace(/\s\+\s[1-9]$/, '')} + 1-9`;
830
+ const numpad = /\+num[1-9]$/i.test(quickShortcut);
831
+ quickRecord.textContent = `${shortcutLabel(quickShortcut.replace(/\+(?:num)?[1-9]$/i, ''))} + ${numpad ? 'Num ' : ''}1-9`;
831
832
  quickRecord.title = `${quickShortcut.replace(/[1-9]$/, '1')} ... ${quickShortcut.replace(/[1-9]$/, '9')}`;
832
833
  } else {
833
834
  quickRecord.textContent = 'Not set';
@@ -945,25 +946,35 @@ for (const kind of Object.keys(SHORTCUT_KINDS)) {
945
946
  shortcutResetEl(kind).addEventListener('click', (e) => applyShortcut(kind, e.currentTarget.dataset.apply || ''));
946
947
  }
947
948
 
948
- document.addEventListener('keydown', async (e) => {
949
- if (!shortcutRecording) return;
949
+ // The recording row takes one chord: shortcut = its accelerator ('' = not a
950
+ // shortcut), key = its non-modifier key's name ('' = none or no name).
951
+ async function recordShortcut(shortcut, key) {
950
952
  const kind = shortcutRecording;
951
- e.preventDefault();
952
- e.stopPropagation();
953
- if (e.key === 'Escape') {
954
- await cancelShortcutRecording();
955
- return;
956
- }
957
- const shortcut = shortcutFromEvent(e);
953
+ if (!kind) return;
958
954
  if (!shortcut) {
959
- setShortcutStatus(kind, shortcutKeyFromEvent(e) ? 'Add a shortcut modifier.' : 'Press a non-modifier key.', true);
955
+ setShortcutStatus(kind, key ? 'Add a shortcut modifier.' : 'Press a non-modifier key.', true);
960
956
  return;
961
957
  }
962
958
  const resolved = kind === 'show' && window.api.resolveShowShortcut
963
959
  ? await window.api.resolveShowShortcut(shortcut)
964
960
  : shortcut;
965
961
  await applyShortcut(kind, resolved);
962
+ }
963
+
964
+ document.addEventListener('keydown', async (e) => {
965
+ if (!shortcutRecording) return;
966
+ e.preventDefault();
967
+ e.stopPropagation();
968
+ if (e.key === 'Escape') {
969
+ await cancelShortcutRecording();
970
+ return;
971
+ }
972
+ await recordShortcut(shortcutFromEvent(e), shortcutKeyFromEvent(e));
966
973
  }, true);
974
+ // Windows: every Win/Ctrl/Alt chord pressed while recording is taken by the
975
+ // keyboard hook before Windows acts on it (Win+1 would switch to a taskbar
976
+ // app and hide the popup) and arrives here instead of as a keydown.
977
+ if (window.api.onShortcutRecorded) window.api.onShortcutRecorded((chord) => recordShortcut(chord.shortcut, chord.key));
967
978
 
968
979
  document.getElementById('addGroupBtn').addEventListener('click', () => controller.addGroup(null));
969
980
  document.getElementById('syncAccounts').addEventListener('change', async (e) => {
@@ -4,6 +4,8 @@
4
4
  // decision, kept Win32-free so it is unit-tested without installing a hook
5
5
  // (test/windows-hook.test.js):
6
6
  // - STATE: the SharedArrayBuffer layout main writes and the worker reads;
7
+ // - the chord table: the Win chords main hands the hook (writeChords) and
8
+ // the accelerator <-> key-code names both sides read them by;
7
9
  // - createHookClaimer: which key presses the hook takes.
8
10
 
9
11
  // --- Shared state layout (one byte each; main writes, the worker reads) ---
@@ -11,8 +13,12 @@ const STATE = Object.freeze({
11
13
  POPUP_VISIBLE: 0, // the popup is open: plain numpad keys type into it
12
14
  SLOT_FIRST: 1, // bytes 1-9: numpad slot N holds a clip
13
15
  SLOT_LAST: 9,
14
- NEW_NOTE_HOTKEY: 10, // Win+N is the new-note shortcut (not off, not a recorded chord, not recording)
15
- SIZE: 16,
16
+ RECORDING: 10, // a Settings shortcut row is recording: chords go to it
17
+ CHORD_COUNT: 11, // live entries in the chord table
18
+ CHORDS: 12, // CHORD_BYTES each: key code, MOD bits, action, slot
19
+ CHORD_BYTES: 4,
20
+ MAX_CHORDS: 12, // show + new note + quick paste 1-9 = 11
21
+ SIZE: 64,
16
22
  });
17
23
 
18
24
  const VK = Object.freeze({
@@ -20,18 +26,102 @@ const VK = Object.freeze({
20
26
  LWIN: 0x5B, RWIN: 0x5C,
21
27
  N: 0x4E, V: 0x56,
22
28
  NUMPAD1: 0x61, NUMPAD9: 0x69,
29
+ OEM_PLUS: 0xBB,
23
30
  });
24
31
  const LLKHF_ALTDOWN = 0x20;
32
+ // The modifiers held at a keydown, as bits (a chord matches on all four).
33
+ const MOD = Object.freeze({ WIN: 1, CTRL: 2, ALT: 4, SHIFT: 8 });
34
+ const PRIMARY = MOD.WIN | MOD.CTRL | MOD.ALT; // what makes a press a shortcut
35
+ // Shift / Ctrl / Alt, generic and left / right (the hook sees the sided codes).
36
+ const MODIFIER_KEYS = new Set([VK.SHIFT, VK.CONTROL, VK.MENU, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5]);
25
37
 
26
- // A claimed key fires only with EXACTLY its modifiers: never with Ctrl, Shift
27
- // or Alt held, and Win as `win` says (true = held, false = up, null = either).
28
- // A Win chord is Win alone (Win+Ctrl+N is Windows' Narrator settings, Win+Ctrl+V
29
- // its sound output panel: those stay Windows'); a numpad slot is a plain digit
30
- // or Win+digit (both documented), never Alt+digit (Alt codes) or Ctrl+digit.
31
- // mods = { win, ctrl, shift, alt } as held at the keydown.
32
- function exactModifiers(mods, { win = false } = {}) {
33
- if (mods.ctrl || mods.shift || mods.alt) return false;
34
- return win == null || !!mods.win === win;
38
+ function modBits(mods) {
39
+ return (mods.win ? MOD.WIN : 0) | (mods.ctrl ? MOD.CTRL : 0) | (mods.alt ? MOD.ALT : 0) | (mods.shift ? MOD.SHIFT : 0);
40
+ }
41
+
42
+ // --- Key names: the accelerator spelling of each key code ---
43
+ // The names the Settings recorder writes for a key it sees (index.html
44
+ // shortcutKeyFromEvent), so a chord the hook hears while recording reads and
45
+ // saves exactly like one the page heard; punctuation at its US position.
46
+ const KEY_NAMES = new Map([
47
+ [0x08, 'Backspace'], [0x09, 'Tab'], [0x0D, 'Enter'], [0x14, 'Capslock'], [0x20, 'Space'],
48
+ [0x21, 'PageUp'], [0x22, 'PageDown'], [0x23, 'End'], [0x24, 'Home'],
49
+ [0x25, 'Left'], [0x26, 'Up'], [0x27, 'Right'], [0x28, 'Down'], [0x2D, 'Insert'], [0x2E, 'Delete'],
50
+ [0x90, 'Numlock'], [0x91, 'Scrolllock'],
51
+ [0xBA, ';'], [VK.OEM_PLUS, '='], [0xBC, ','], [0xBD, 'Minus'], [0xBE, '.'], [0xBF, '/'], [0xC0, '`'],
52
+ [0xDB, '['], [0xDC, 'Backslash'], [0xDD, ']'], [0xDE, "'"],
53
+ ]);
54
+ for (let i = 0; i <= 9; i++) KEY_NAMES.set(0x30 + i, String(i));
55
+ for (let i = 0; i < 26; i++) KEY_NAMES.set(0x41 + i, String.fromCharCode(0x41 + i));
56
+ for (let i = 1; i <= 24; i++) KEY_NAMES.set(0x6F + i, `F${i}`);
57
+ for (let i = 0; i <= 9; i++) KEY_NAMES.set(0x60 + i, `num${i}`);
58
+ // Numpad operators last: the page names them like the main keys, so a saved
59
+ // name reads back as the main key (what Electron registers for it too).
60
+ for (const [vk, name] of [[0x6A, '*'], [0x6B, 'Plus'], [0x6D, 'Minus'], [0x6E, '.'], [0x6F, '/']]) KEY_NAMES.set(vk, name);
61
+
62
+ const KEY_CODES = new Map([['plus', VK.OEM_PLUS], ['esc', 0x1B], ['escape', 0x1B], ['return', 0x0D],
63
+ ['numadd', 0x6B], ['numsub', 0x6D], ['nummult', 0x6A], ['numdiv', 0x6F], ['numdec', 0x6E]]);
64
+ for (const [vk, name] of KEY_NAMES) if (!KEY_CODES.has(name.toLowerCase())) KEY_CODES.set(name.toLowerCase(), vk);
65
+
66
+ const MOD_NAMES = new Map([
67
+ ['super', MOD.WIN], ['meta', MOD.WIN],
68
+ ['control', MOD.CTRL], ['ctrl', MOD.CTRL], ['commandorcontrol', MOD.CTRL], ['commandorctrl', MOD.CTRL], ['cmdorctrl', MOD.CTRL],
69
+ ['alt', MOD.ALT], ['option', MOD.ALT],
70
+ ['shift', MOD.SHIFT],
71
+ ]);
72
+
73
+ function acceleratorParts(accelerator) {
74
+ return String(accelerator || '').split('+').map(p => p.trim().toLowerCase()).filter(Boolean);
75
+ }
76
+
77
+ // A Win chord: Windows' shell holds most of them at the RegisterHotKey layer
78
+ // (Win+V Clipboard History, Win+N notifications, Win+1-9 the taskbar), where
79
+ // a globalShortcut silently loses, so on Windows every one is the hook's.
80
+ function isWinChord(accelerator) {
81
+ return acceleratorParts(accelerator).some(part => MOD_NAMES.get(part) === MOD.WIN);
82
+ }
83
+
84
+ // accelerator -> { vk, mods } for the chord table; null when it is not one
85
+ // key with a shortcut modifier, or names a key the hook has no code for.
86
+ function chordFromAccelerator(accelerator) {
87
+ let mods = 0;
88
+ let vk = 0;
89
+ for (const part of acceleratorParts(accelerator)) {
90
+ if (MOD_NAMES.has(part)) { mods |= MOD_NAMES.get(part); continue; }
91
+ if (vk || !KEY_CODES.has(part)) return null;
92
+ vk = KEY_CODES.get(part);
93
+ }
94
+ return vk && (mods & PRIMARY) ? { vk, mods } : null;
95
+ }
96
+
97
+ // What a recording heard, as the page recorder writes it: Super, Control,
98
+ // Alt, Shift, then the key. key '' = a key with no name ("Press a
99
+ // non-modifier key."); shortcut '' with a key = no shortcut modifier.
100
+ function recordedChord(vk, mods) {
101
+ const key = vk === VK.OEM_PLUS && (mods & MOD.SHIFT) ? 'Plus' : (KEY_NAMES.get(vk) || '');
102
+ const parts = [[MOD.WIN, 'Super'], [MOD.CTRL, 'Control'], [MOD.ALT, 'Alt'], [MOD.SHIFT, 'Shift']]
103
+ .filter(([bit]) => mods & bit).map(([, name]) => name);
104
+ return { key, shortcut: key && (mods & PRIMARY) ? [...parts, key].join('+') : '' };
105
+ }
106
+
107
+ // --- Chord table (main writes it whole, the hook reads it per key) ---
108
+ const ACTIONS = Object.freeze(['show', 'new-note', 'quick-paste']); // stored as index + 1
109
+
110
+ // chords = [{ vk, mods, type, slot? }]. The count drops to 0 while the
111
+ // entries change, so the hook never reads a half-written one. Returns
112
+ // whether every chord fit.
113
+ function writeChords(state, chords) {
114
+ const list = chords.slice(0, STATE.MAX_CHORDS);
115
+ Atomics.store(state, STATE.CHORD_COUNT, 0);
116
+ list.forEach((chord, i) => {
117
+ const at = STATE.CHORDS + i * STATE.CHORD_BYTES;
118
+ state[at] = chord.vk;
119
+ state[at + 1] = chord.mods;
120
+ state[at + 2] = ACTIONS.indexOf(chord.type) + 1;
121
+ state[at + 3] = chord.slot || 0;
122
+ });
123
+ Atomics.store(state, STATE.CHORD_COUNT, list.length);
124
+ return list.length === chords.length;
35
125
  }
36
126
 
37
127
  // One verdict per press: a key's auto-repeats and its keyup follow its first
@@ -47,6 +137,7 @@ function exactModifiers(mods, { win = false } = {}) {
47
137
  function createPressTracker() {
48
138
  const held = new Map(); // vk -> claimed, for keys down since their first keydown
49
139
  return {
140
+ holds(vk) { return held.has(vk); },
50
141
  event(vk, down, claim, wasDown) {
51
142
  if (!down) {
52
143
  const claimed = held.get(vk) === true;
@@ -66,30 +157,55 @@ function createPressTracker() {
66
157
 
67
158
  // The hook's claim decision for one key event. `state` is the shared-state
68
159
  // byte array; io.readMods(flags) and io.isKeyDown(vk) are the worker's Win32
69
- // reads (GetAsyncKeyState), called only when a decision needs them, so a key
70
- // the hook never takes costs a Map lookup.
71
- // Win+V -> post { type: 'show' }, Clipboard History never opens;
72
- // Win+N -> post { type: 'new-note' } while STATE.NEW_NOTE_HOTKEY;
73
- // Numpad1-9 -> post { type: 'numpad', slot } with the popup closed and the
74
- // slot assigned (open, the digit types into the search).
160
+ // reads (GetAsyncKeyState), called only for a key the shared bytes say could
161
+ // be claimed, so any other key costs a few byte reads.
162
+ // recording -> every press with Win, Ctrl or Alt, while the popup is open:
163
+ // post { type: 'record', vk, mods } (Windows never acts on it,
164
+ // so Win+1 records instead of switching to a taskbar app);
165
+ // a chord -> its EXACT modifiers: post { type } ('show', 'new-note') or
166
+ // { type: 'quick-paste', slot };
167
+ // Numpad1-9 -> plain or with Win, the popup closed and the slot assigned:
168
+ // post { type: 'numpad', slot } (open, the digit types into
169
+ // the search; Ctrl/Shift/Alt+digit stay the apps', Alt codes).
75
170
  // The Win key itself always passes. A press swallowed while Win was held
76
171
  // leaves Windows a bare Win tap, so the Win release after one says
77
172
  // markWinUsed and the worker taps a key that keeps the Start menu shut.
78
173
  // Returns { swallow, post, markWinUsed }.
79
174
  const PASS = Object.freeze({ swallow: false, post: null, markWinUsed: false });
80
175
  function createHookClaimer(state, io) {
81
- // Win+<key> chords taken before Windows sees them: Win+V (Clipboard
82
- // History's) shows the popup; Win+N (the notification panel's) opens a new
83
- // note while main says it is the new-note shortcut. win = the Win rule for
84
- // exactModifiers; ready() = main wants the key now.
85
- const KEYS = new Map([
86
- [VK.V, { type: 'show', win: true, ready: () => true }],
87
- [VK.N, { type: 'new-note', win: true, ready: () => state[STATE.NEW_NOTE_HOTKEY] === 1 }],
88
- ]);
89
- for (let slot = 1; slot <= 9; slot++) {
90
- KEYS.set(VK.NUMPAD1 + slot - 1, { type: 'numpad', slot, win: null,
91
- ready: () => state[STATE.POPUP_VISIBLE] !== 1 && state[STATE.SLOT_FIRST + slot - 1] === 1 });
92
- }
176
+ const recording = () => state[STATE.RECORDING] === 1 && state[STATE.POPUP_VISIBLE] === 1;
177
+ // The table entry for vk (with exactly `mods`, or any when null), else -1.
178
+ const chordIndex = (vk, mods) => {
179
+ const count = Math.min(state[STATE.CHORD_COUNT], STATE.MAX_CHORDS);
180
+ for (let i = 0; i < count; i++) {
181
+ const at = STATE.CHORDS + i * STATE.CHORD_BYTES;
182
+ if (state[at] === vk && (mods == null || state[at + 1] === mods)) return i;
183
+ }
184
+ return -1;
185
+ };
186
+ const isNumpadDigit = (vk) => vk >= VK.NUMPAD1 && vk <= VK.NUMPAD9;
187
+ const numpadSlot = (vk) => {
188
+ const slot = isNumpadDigit(vk) ? vk - VK.NUMPAD1 + 1 : 0;
189
+ return slot && state[STATE.POPUP_VISIBLE] !== 1 && state[STATE.SLOT_FIRST + slot - 1] === 1 ? slot : 0;
190
+ };
191
+ // Tracked keys get one verdict per press. A numpad digit always is, so a
192
+ // press that began as a digit stays one when the popup closes mid-press.
193
+ const tracked = (vk) => !MODIFIER_KEYS.has(vk) && (recording() || isNumpadDigit(vk) || chordIndex(vk, null) >= 0);
194
+ // Whether this press could be claimed at all, from the shared bytes alone:
195
+ // only then are the modifiers read.
196
+ const claimable = (vk) => recording() || numpadSlot(vk) > 0 || chordIndex(vk, null) >= 0;
197
+ const decide = (vk, mods) => {
198
+ if (recording()) return mods & PRIMARY ? { type: 'record', vk, mods } : null;
199
+ const i = chordIndex(vk, mods);
200
+ if (i >= 0) {
201
+ const at = STATE.CHORDS + i * STATE.CHORD_BYTES;
202
+ const type = ACTIONS[state[at + 2] - 1];
203
+ if (type === 'quick-paste') return { type, slot: state[at + 3] };
204
+ if (type) return { type };
205
+ }
206
+ const slot = numpadSlot(vk);
207
+ return slot && !(mods & (MOD.CTRL | MOD.ALT | MOD.SHIFT)) ? { type: 'numpad', slot } : null;
208
+ };
93
209
  const tracker = createPressTracker();
94
210
  let winUsed = false;
95
211
  return {
@@ -99,16 +215,15 @@ function createHookClaimer(state, io) {
99
215
  winUsed = false;
100
216
  return { swallow: false, post: null, markWinUsed: true };
101
217
  }
102
- const key = KEYS.get(vk);
103
- if (!key) return PASS;
104
- let mods = null;
218
+ if (!tracker.holds(vk) && !(down && tracked(vk))) return PASS;
219
+ let mods = 0;
220
+ let post = null;
105
221
  const r = tracker.event(vk, down,
106
- () => key.ready() && exactModifiers(mods = io.readMods(flags), { win: key.win }),
222
+ () => claimable(vk) && !!(post = decide(vk, mods = modBits(io.readMods(flags)))),
107
223
  () => io.isKeyDown(vk));
108
224
  if (!r.swallow) return PASS;
109
- if (r.fire && mods.win) winUsed = true;
110
- const post = !r.fire ? null : key.slot ? { type: key.type, slot: key.slot } : { type: key.type };
111
- return { swallow: true, post, markWinUsed: false };
225
+ if (r.fire && (mods & MOD.WIN)) winUsed = true;
226
+ return { swallow: true, post: r.fire ? post : null, markWinUsed: false };
112
227
  },
113
228
  };
114
229
  }
@@ -124,4 +239,7 @@ function modsFrom(isKeyDown, flags) {
124
239
  };
125
240
  }
126
241
 
127
- module.exports = { STATE, VK, LLKHF_ALTDOWN, exactModifiers, createPressTracker, createHookClaimer, modsFrom };
242
+ module.exports = {
243
+ STATE, VK, MOD, LLKHF_ALTDOWN, modBits, isWinChord, chordFromAccelerator, recordedChord,
244
+ writeChords, createPressTracker, createHookClaimer, modsFrom,
245
+ };
@@ -103,8 +103,8 @@ const keybd_event = user32.func(
103
103
  );
104
104
 
105
105
  const isKeyDown = (vk) => (GetAsyncKeyState(vk) & 0x8000) !== 0;
106
- // Win+V, Win+N and Numpad1-9: each claimed only with exactly its modifiers,
107
- // one verdict per press (lib/windows-hook-policy.js).
106
+ // Main's chord table, a Settings recording and Numpad1-9: each claimed only
107
+ // with exactly its modifiers, one verdict per press (lib/windows-hook-policy.js).
108
108
  const claimer = createHookClaimer(sharedState, {
109
109
  isKeyDown,
110
110
  readMods: (flags) => modsFrom(isKeyDown, flags),
@@ -4,31 +4,33 @@
4
4
  // On Windows we can't use Electron's `globalShortcut.register('Super+V')`
5
5
  // because Windows Clipboard History owns Win+V at the RegisterHotKey layer
6
6
  // and silently rejects our registration. This module installs a
7
- // WH_KEYBOARD_LL hook on a dedicated worker thread which intercepts Win+V,
8
- // Win+N (new note, while that is the shortcut) and numpad keys *before*
9
- // Windows Clipboard History / the notification panel see them.
7
+ // WH_KEYBOARD_LL hook on a dedicated worker thread which intercepts every
8
+ // Win chord BoardClip uses (Win+V, Win+N and any recorded one, see the chord
9
+ // table in lib/windows-hook-policy.js), the numpad slot keys, and the chord a
10
+ // Settings row is recording, *before* Windows' own handlers see them.
10
11
  //
11
12
  // See lib/windows-hook-worker.js for the hook implementation and the
12
13
  // rationale for running it in a worker.
13
14
  //
14
- // Shared state (popup visible, which numpad slots are assigned, Win+N on;
15
- // layout = STATE in lib/windows-hook-policy.js) is passed via a
15
+ // Shared state (popup visible, which numpad slots are assigned, recording,
16
+ // the chord table; layout = STATE in lib/windows-hook-policy.js) is passed via a
16
17
  // SharedArrayBuffer because the worker is synchronously blocked in
17
18
  // GetMessageW and can't process postMessage events from its JS event loop.
18
19
 
19
20
  const { Worker } = require('worker_threads');
20
21
  const path = require('path');
21
- const { STATE } = require('./windows-hook-policy');
22
+ const { STATE, writeChords } = require('./windows-hook-policy');
22
23
 
23
24
  let currentHook = null;
24
25
 
25
- function install({ onShowPopup, onNumpadPaste, onNewNote }) {
26
+ function install({ onShowPopup, onNumpadPaste, onQuickPaste, onNewNote, onRecordKey }) {
26
27
  if (process.platform !== 'win32') {
27
28
  // Non-Windows platforms use Electron's globalShortcut; no-op API here.
28
29
  return {
29
30
  setPopupVisible() {},
30
31
  setSlotAssignments() {},
31
- setNewNoteHotkey() {},
32
+ setChords() { return true; },
33
+ setRecording() {},
32
34
  uninstall() {},
33
35
  };
34
36
  }
@@ -49,8 +51,14 @@ function install({ onShowPopup, onNumpadPaste, onNewNote }) {
49
51
  case 'numpad':
50
52
  try { onNumpadPaste(msg.slot); } catch (e) { console.error('[windows-hook] onNumpadPaste:', e); }
51
53
  break;
54
+ case 'quick-paste':
55
+ try { onQuickPaste(msg.slot); } catch (e) { console.error('[windows-hook] onQuickPaste:', e); }
56
+ break;
52
57
  case 'new-note':
53
- try { if (onNewNote) onNewNote(); } catch (e) { console.error('[windows-hook] onNewNote:', e); }
58
+ try { onNewNote(); } catch (e) { console.error('[windows-hook] onNewNote:', e); }
59
+ break;
60
+ case 'record':
61
+ try { onRecordKey(msg.vk, msg.mods); } catch (e) { console.error('[windows-hook] onRecordKey:', e); }
54
62
  break;
55
63
  case 'ready':
56
64
  // Hook installed successfully — no action needed.
@@ -81,10 +89,14 @@ function install({ onShowPopup, onNumpadPaste, onNewNote }) {
81
89
  sharedState[STATE.SLOT_FIRST + n - 1] = assignedSet && assignedSet.has(n) ? 1 : 0;
82
90
  }
83
91
  },
84
- // Win+N opens a new note only while it is the new-note shortcut (not
85
- // while it is off or another chord is recorded, nor during a recording).
86
- setNewNoteHotkey(enabled) {
87
- sharedState[STATE.NEW_NOTE_HOTKEY] = enabled ? 1 : 0;
92
+ // The Win chords the hook claims: [{ vk, mods, type, slot? }] (main's
93
+ // registerShortcuts; empty while a recording runs). False = some did not fit.
94
+ setChords(chords) {
95
+ return writeChords(sharedState, chords);
96
+ },
97
+ // A Settings row is recording: every Win/Ctrl/Alt press goes to it.
98
+ setRecording(on) {
99
+ sharedState[STATE.RECORDING] = on ? 1 : 0;
88
100
  },
89
101
  uninstall() {
90
102
  if (!currentHook) return;
package/main.js CHANGED
@@ -50,6 +50,7 @@ const conflictModel = require('./lib/conflict-model');
50
50
  const { ControlServer } = require('./lib/control-server');
51
51
  const windowsConsole = require('./lib/windows-console');
52
52
  const windowsDwm = require('./lib/windows-dwm');
53
+ const hookPolicy = require('./lib/windows-hook-policy');
53
54
  const appearance = require('./lib/appearance');
54
55
  const { createNotifier } = require('./lib/notifications');
55
56
 
@@ -676,9 +677,9 @@ function effectiveQuickPasteShortcut() {
676
677
  return settings.quick_paste_shortcut || defaultQuickPasteShortcut();
677
678
  }
678
679
 
679
- // Windows: Win+N, taken by the keyboard hook like Win+V (it is Windows' own
680
- // notification panel chord, which RegisterHotKey cannot win). Elsewhere none
681
- // until one is recorded.
680
+ // Windows: Win+N, taken by the keyboard hook like every Win chord (it is
681
+ // Windows' own notification panel chord, which RegisterHotKey cannot win).
682
+ // Elsewhere none until one is recorded.
682
683
  function defaultNewNoteShortcut() {
683
684
  return process.platform === 'win32' ? 'Super+N' : '';
684
685
  }
@@ -687,22 +688,11 @@ function defaultNewNoteShortcut() {
687
688
  // (Windows: Win+N goes back to Windows' notification panel). '' = the default.
688
689
  const SHORTCUT_OFF = 'none';
689
690
 
690
- // The chord the user recorded ('' for the default or off).
691
- function recordedNewNoteShortcut() {
692
- const value = settings.new_note_shortcut || '';
693
- return value === SHORTCUT_OFF ? '' : value;
694
- }
695
-
696
691
  function effectiveNewNoteShortcut() {
697
692
  if (settings.new_note_shortcut === SHORTCUT_OFF) return '';
698
693
  return settings.new_note_shortcut || defaultNewNoteShortcut();
699
694
  }
700
695
 
701
- function globalShowShortcut() {
702
- if (process.platform === 'darwin' && shortcutUsesFn(settings.show_shortcut)) return '';
703
- return settings.show_shortcut || (process.platform === 'win32' ? '' : DEFAULT_SHOW_SHORTCUT);
704
- }
705
-
706
696
  function globalQuickPasteShortcut() {
707
697
  const shortcut = effectiveQuickPasteShortcut();
708
698
  if (shortcutUsesFn(shortcut)) return '';
@@ -761,7 +751,8 @@ function quickPasteShortcutForSlot(shortcut, slot) {
761
751
  if (!parts.length) return '';
762
752
  const keyIndex = parts.length - 1;
763
753
  if (quickPasteSlotFromShortcut(parts[keyIndex]) == null) return '';
764
- parts[keyIndex] = String(slot);
754
+ // A numpad digit stays a numpad digit (Ctrl+Alt+num1 is Ctrl+Alt+num1..num9).
755
+ parts[keyIndex] = (/^num/i.test(parts[keyIndex]) ? 'num' : '') + slot;
765
756
  return parts.join('+');
766
757
  }
767
758
 
@@ -8022,15 +8013,45 @@ function commitShortcutSetting(key, next, registeredFlag, error) {
8022
8013
  }
8023
8014
 
8024
8015
  function registerShortcuts() {
8025
- // What each shortcut needs: an OS registration (globalShortcut / Carbon), the
8026
- // Windows hook, or nothing. A recorded new-note chord is an ordinary global
8027
- // shortcut; Windows' default (Win+N) is the hook's; off is neither.
8028
- const macosShowKey = process.platform === 'darwin' ? effectiveShowShortcut() : '';
8029
- const showKey = process.platform === 'darwin' ? '' : globalShowShortcut();
8030
- const quickPasteKey = globalQuickPasteShortcut();
8031
- const recordedNewNote = recordedNewNoteShortcut();
8032
- const newNoteKey = shortcutUsesFn(recordedNewNote) ? '' : recordedNewNote;
8033
- const newNoteHookKey = process.platform === 'win32' && !settings.new_note_shortcut;
8016
+ // Where each shortcut lives: macOS's popup hotkey (Carbon, Globe-capable),
8017
+ // the Windows keyboard hook, a globalShortcut, or nowhere (off). On Windows
8018
+ // EVERY Win chord is the hook's (hookPolicy.isWinChord: the shell holds most
8019
+ // of them, Win+V / Win+N / Win+1-9 included, where a globalShortcut silently
8020
+ // loses); the defaults Win+V and Win+N are Win chords like a recorded one.
8021
+ const viaHook = (shortcut) => process.platform === 'win32' && hookPolicy.isWinChord(shortcut);
8022
+ const showShortcut = effectiveShowShortcut();
8023
+ const macosShowKey = process.platform === 'darwin' ? showShortcut : '';
8024
+ const showHook = viaHook(showShortcut);
8025
+ const showKey = macosShowKey || showHook ? '' : showShortcut;
8026
+ const quickPasteShortcut = globalQuickPasteShortcut();
8027
+ const quickPasteHook = viaHook(quickPasteShortcut);
8028
+ const quickPasteKey = quickPasteHook ? '' : quickPasteShortcut;
8029
+ const newNoteShortcut = shortcutUsesFn(effectiveNewNoteShortcut()) ? '' : effectiveNewNoteShortcut();
8030
+ const newNoteHook = viaHook(newNoteShortcut);
8031
+ const newNoteKey = newNoteHook ? '' : newNoteShortcut;
8032
+
8033
+ // The hook's chord table, first come first served like globalShortcut: a
8034
+ // chord two shortcuts share, or a key the hook has no code for, fails the
8035
+ // later one the way an already-registered globalShortcut does.
8036
+ const hookChords = [];
8037
+ const addHookChord = (shortcut, type, slot) => {
8038
+ const chord = hookPolicy.chordFromAccelerator(shortcut);
8039
+ if (!chord || hookChords.some(c => c.vk === chord.vk && c.mods === chord.mods)) return false;
8040
+ hookChords.push({ ...chord, type, slot });
8041
+ return true;
8042
+ };
8043
+ const showHookOk = !showHook || addHookChord(showShortcut, 'show');
8044
+ const quickPasteRegistrations = [];
8045
+ let quickPasteHookOk = true;
8046
+ if (quickPasteHook) {
8047
+ for (let n = 1; n <= 9; n++) {
8048
+ const key = quickPasteShortcutForSlot(quickPasteShortcut, n);
8049
+ const registered = !!key && addHookChord(key, 'quick-paste', n);
8050
+ quickPasteRegistrations.push({ slot: n, key, registered, backend: 'hook' });
8051
+ if (!registered) quickPasteHookOk = false;
8052
+ }
8053
+ }
8054
+ const newNoteHookOk = !newNoteHook || addHookChord(newNoteShortcut, 'new-note');
8034
8055
 
8035
8056
  // A throwaway QA instance (BOARDCLIP_ISOLATED=1) never takes the keyboard: its
8036
8057
  // low-level hook would sit in front of the real app's, so Win+V would open the
@@ -8040,11 +8061,11 @@ function registerShortcuts() {
8040
8061
  // live, as it does for real (it cannot fail), so its row can be saved in QA.
8041
8062
  if (process.env.BOARDCLIP_ISOLATED === '1') {
8042
8063
  const isolated = {
8043
- showShortcutRegistered: !(macosShowKey || showKey),
8064
+ showShortcutRegistered: showHookOk && !(macosShowKey || showKey),
8044
8065
  showShortcut: effectiveShowShortcut(),
8045
- quickPasteRegistered: !quickPasteKey,
8066
+ quickPasteRegistered: quickPasteHookOk && !quickPasteKey,
8046
8067
  quickPasteShortcut: effectiveQuickPasteShortcut(),
8047
- newNoteRegistered: !newNoteKey,
8068
+ newNoteRegistered: newNoteHookOk && !newNoteKey,
8048
8069
  newNoteShortcut: effectiveNewNoteShortcut(),
8049
8070
  };
8050
8071
  diagnostics.record('shortcut.register', { platform: process.platform, isolated: true, show_registered: isolated.showShortcutRegistered, quick_paste_registered: isolated.quickPasteRegistered, new_note_registered: isolated.newNoteRegistered });
@@ -8062,22 +8083,26 @@ function registerShortcuts() {
8062
8083
  }
8063
8084
  globalShortcut.unregisterAll();
8064
8085
 
8065
- let showShortcutRegistered = true;
8066
- let quickPasteRegistered = true;
8067
- let newNoteRegistered = true;
8086
+ let showShortcutRegistered = showHookOk;
8087
+ let quickPasteRegistered = quickPasteHookOk;
8088
+ let newNoteRegistered = newNoteHookOk;
8068
8089
 
8069
8090
  if (process.platform === 'win32') {
8070
- // Windows Clipboard History owns Win+V and Win+Numpad1-9, so we can't use
8071
- // Electron's globalShortcut (RegisterHotKey) here — it silently fails.
8072
- // Instead, install a WH_KEYBOARD_LL hook on a dedicated worker thread that
8073
- // intercepts these keys *before* Windows Clipboard History sees them.
8091
+ // The WH_KEYBOARD_LL hook on its own worker thread (lib/windows-hook.js):
8092
+ // it takes the chord table, the numpad slot keys and a recording before
8093
+ // Windows' own handlers see them.
8074
8094
  const { install } = require('./lib/windows-hook');
8075
8095
  windowsHook = install({
8076
8096
  onShowPopup: showPopup,
8077
8097
  onNumpadPaste: handleNumpad,
8098
+ onQuickPaste: (slot) => handleQuickPaste(slot).catch(error => {
8099
+ diagnostics.record('shortcut.quick_paste_error', { slot, source: 'hook', error: error && error.message }, { forceFile: true });
8100
+ }),
8078
8101
  onNewNote: handleNewNoteShortcut,
8102
+ onRecordKey: handleHookRecordedKey,
8079
8103
  });
8080
- windowsHook.setNewNoteHotkey(newNoteHookKey);
8104
+ windowsHook.setRecording(false);
8105
+ windowsHook.setChords(hookChords);
8081
8106
  // Seed the shared state with current history so plain numpad keys
8082
8107
  // immediately intercept for already-assigned slots.
8083
8108
  syncHookState();
@@ -8099,7 +8124,6 @@ function registerShortcuts() {
8099
8124
  if (!showShortcutRegistered) logSafe(`Warning: Could not register popup shortcut ${showKey}`);
8100
8125
  }
8101
8126
 
8102
- const quickPasteRegistrations = [];
8103
8127
  if (quickPasteKey) {
8104
8128
  if (process.platform === 'darwin') {
8105
8129
  const hook = getMacosHotkey();
@@ -8148,15 +8172,15 @@ function registerShortcuts() {
8148
8172
  }
8149
8173
  diagnostics.record('shortcut.register', {
8150
8174
  platform: process.platform,
8151
- show_key: macosShowKey || showKey,
8175
+ show_key: showShortcut,
8152
8176
  show_registered: showShortcutRegistered,
8153
- show_backend: macosShowKey ? 'carbon' : (showKey ? 'electron' : ''),
8154
- quick_paste_key: quickPasteKey,
8177
+ show_backend: macosShowKey ? 'carbon' : (showHook ? 'hook' : 'electron'),
8178
+ quick_paste_key: quickPasteShortcut,
8155
8179
  quick_paste_registered: quickPasteRegistered,
8156
8180
  quick_paste_registrations: quickPasteRegistrations,
8157
- new_note_key: newNoteKey || (newNoteHookKey ? defaultNewNoteShortcut() : ''),
8181
+ new_note_key: newNoteShortcut,
8158
8182
  new_note_registered: newNoteRegistered,
8159
- new_note_backend: newNoteKey ? 'electron' : (newNoteHookKey ? 'hook' : ''),
8183
+ new_note_backend: newNoteHook ? 'hook' : (newNoteKey ? 'electron' : ''),
8160
8184
  }, { forceFile: true });
8161
8185
 
8162
8186
  return {
@@ -8172,8 +8196,14 @@ function registerShortcuts() {
8172
8196
  function suspendShortcutsForRecording() {
8173
8197
  shortcutsSuspended = true;
8174
8198
  globalShortcut.unregisterAll();
8175
- // The hook's Win+N too: a recording never opens a note (resume re-arms it).
8176
- if (windowsHook) windowsHook.setNewNoteHotkey(false);
8199
+ // The hook's chords too, and every Win/Ctrl/Alt press now goes to the
8200
+ // recorder instead of its app (resume re-arms them). Without this Windows
8201
+ // acted on a Win chord first: Win+1 switched to a taskbar app, the popup
8202
+ // lost focus and hid mid-recording, nothing was saved.
8203
+ if (windowsHook) {
8204
+ windowsHook.setChords([]);
8205
+ windowsHook.setRecording(true);
8206
+ }
8177
8207
  if (macosHotkey) {
8178
8208
  macosHotkey.clearRuntimeShortcut();
8179
8209
  macosHotkey.clearQuickPasteShortcuts();
@@ -8182,6 +8212,15 @@ function suspendShortcutsForRecording() {
8182
8212
  return { ok: true };
8183
8213
  }
8184
8214
 
8215
+ // A chord the hook took while a Settings row records (Windows): handed to the
8216
+ // popup's recorder as the chord its own keydown would have been.
8217
+ function handleHookRecordedKey(vk, mods) {
8218
+ if (!shortcutsSuspended || !win || win.isDestroyed()) return;
8219
+ const chord = hookPolicy.recordedChord(vk, mods);
8220
+ diagnostics.record('shortcut.record_key', { vk, mods, shortcut: chord.shortcut }, { forceFile: diagnostics.isEnabled() });
8221
+ win.webContents.send('shortcut-recorded', chord);
8222
+ }
8223
+
8185
8224
  function resumeShortcutsAfterRecording() {
8186
8225
  shortcutsSuspended = false;
8187
8226
  const result = registerShortcuts();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tobq/boardclip",
3
3
  "productName": "BoardClip",
4
- "version": "2.1.114",
4
+ "version": "2.1.115",
5
5
  "description": "Cross-platform clipboard history tray app with numpad quick-paste and native cloud sync",
6
6
  "keywords": [
7
7
  "clipboard",
package/preload.js CHANGED
@@ -35,6 +35,13 @@ contextBridge.exposeInMainWorld('api', {
35
35
  suspendShortcuts: () => ipcRenderer.invoke('suspend-shortcuts'),
36
36
  resumeShortcuts: () => ipcRenderer.invoke('resume-shortcuts'),
37
37
  resolveShowShortcut: (shortcut) => ipcRenderer.invoke('resolve-show-shortcut', shortcut),
38
+ // Windows: the chord the keyboard hook took while a row records ({ shortcut, key },
39
+ // what the page recorder makes of a keydown); the page never sees those keys.
40
+ onShortcutRecorded: (callback) => {
41
+ const listener = (_, chord) => callback(chord);
42
+ ipcRenderer.on('shortcut-recorded', listener);
43
+ return () => ipcRenderer.removeListener('shortcut-recorded', listener);
44
+ },
38
45
  groupCreate: (text) => ipcRenderer.invoke('group-create', text), // -> the tag names ("a, b" = two), or false
39
46
  groupDelete: (name) => ipcRenderer.invoke('group-delete', name), // -> { ok, name, ids, shared }, or false
40
47
  groupRestore: (payload) => ipcRenderer.invoke('group-restore', payload), // the delete's Undo