@tobq/boardclip 2.1.113 → 2.1.114

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/README.md CHANGED
@@ -93,10 +93,13 @@ when you intentionally want Git to autostash local code changes.
93
93
  | --- | --- |
94
94
  | Cmd+Shift+V / Win+V | Toggle popup |
95
95
  | Cmd+Numpad 1-9 / Win+Numpad 1-9 | Quick-paste slot |
96
- | 1-9 in empty search | Quick-paste slot |
96
+ | 1-9 right after the popup opens | Quick-paste slot |
97
+ | N right after the popup opens | New note |
98
+ | Win+N (Windows; record one on macOS in Settings) | New note |
97
99
  | Arrow keys | Navigate items |
98
100
  | Enter | Paste selected item |
99
101
  | Escape | Close popup |
102
+ | Cmd+N / Ctrl+N in the note editor | New note |
100
103
 
101
104
  ## Development
102
105
 
@@ -148,7 +151,7 @@ kill.bat
148
151
  - `preload.js`: safe renderer API bridge.
149
152
  - `lib/clipboard-model.js`: shared pure history/pin/group merge logic.
150
153
  - `lib/cloud-accounts.js`: Google Drive, OneDrive, and iCloud folder discovery.
151
- - `lib/windows-hook*`: Windows low-level keyboard hook for Win+V / Win+Numpad.
154
+ - `lib/windows-hook*`: Windows low-level keyboard hook for Win+V / Win+N / Numpad.
152
155
  - `site/`: static landing page and remote install/update scripts.
153
156
 
154
157
  ## Data
package/editor-preload.js CHANGED
@@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld('editorApi', {
31
31
  resolveConflict: (payload) => ipcRenderer.invoke('resolve-conflict', payload),
32
32
  unifyStep: (payload) => ipcRenderer.invoke('unify-step', sessionId, payload),
33
33
  close: () => ipcRenderer.send('editor-close', sessionId),
34
+ // Ctrl/Cmd+N here: the popup's New note, through the same IPC.
35
+ newNote: (options) => ipcRenderer.invoke('new-note', options || {}),
34
36
  // The find bar's mode + match case, remembered per device.
35
37
  saveFindPrefs: (prefs) => ipcRenderer.invoke('save-settings', { find_regex: !!(prefs && prefs.regex), find_case: !!(prefs && prefs.caseSensitive) }),
36
38
  // A drag on the idle title moves this window (main's window-drag, sender only).
package/editor.html CHANGED
@@ -174,6 +174,18 @@ document.addEventListener('contextmenu', (e) => {
174
174
  openClipMenu(e.clientX, e.clientY);
175
175
  });
176
176
 
177
+ // Ctrl+N (Cmd+N on a Mac) while this window has the focus: a new note, through
178
+ // the popup's own New note action (the 'new-note' IPC -> openEditor, one path).
179
+ // This note is saved first. A held chord opens one note, not one per key
180
+ // repeat; the merge view has no note, so it opens none.
181
+ document.addEventListener('keydown', (e) => {
182
+ if (!editor || !Core.newNoteKey(e)) return;
183
+ e.preventDefault();
184
+ if (e.repeat) return;
185
+ editor.commit();
186
+ window.editorApi.newNote();
187
+ });
188
+
177
189
  if (window.editorApi.onFind) {
178
190
  window.editorApi.onFind((find) => {
179
191
  if (!editor || !find) return;
package/index.html CHANGED
@@ -38,7 +38,7 @@ let groups = [];
38
38
  // as tokens inside `query`; the chip bar's active/excluded state is DERIVED from it. No
39
39
  // separate filter Sets (that dual-model was the drift Forge killed).
40
40
  function parsedQuery() { return Core.search.parseQuery(query); }
41
- function facetState() { return Core.search.facetState(parsedQuery()); }
41
+ function facetState() { return Core.search.facetState(parsedQuery(), groups); }
42
42
  function activeFilterSet() { return facetState().active; }
43
43
  // Ranking override for the Best/Recent toggle: null = default (relevance while searching,
44
44
  // history order when idle), 'best' | 'new' when the user has flipped it for this query.
@@ -51,9 +51,11 @@ let diagnosticsEnabled = false;
51
51
  let searchDocs = [], searchTextLower = [];
52
52
  let itemLookup = new Map(), groupCounts = new Map(), builtinCounts = {}, numpadIdMap = {};
53
53
 
54
- document.getElementById('newNoteBtn').addEventListener('click', () => {
54
+ // New note: the header button and the popup's N quick key.
55
+ function newNote() {
55
56
  window.api.newNote({ sourceGroups: Core.sourceGroupsFromFilters(activeFilterSet()) });
56
- });
57
+ }
58
+ document.getElementById('newNoteBtn').addEventListener('click', newNote);
57
59
 
58
60
  // Renderer exceptions used to vanish (a tray popup has no DevTools open): a thrown
59
61
  // handler left the UI half-updated with nothing in the log. Route window errors +
@@ -384,7 +386,10 @@ const revGuard = (run, fallback) => Core.guardRevision(run, { toast: showToast,
384
386
  const appAdapter = {
385
387
  itemById: (id) => itemById(id),
386
388
  numpadMap: () => numpadMap(),
387
- protectedGroups: () => new Set([aiGroupName]),
389
+ // The popup's quick keys (controller.armQuickKeys): 1-9 and N right after it opens.
390
+ quickPaste: (slot) => window.api.numpadPasteAndHide(slot),
391
+ newNote: () => newNote(),
392
+ protectedGroups:() => new Set([aiGroupName]),
388
393
  // The query TEXT is the source of truth: a chip click rewrites it through the
389
394
  // controller (Search.applyFacet with the remembered or / and).
390
395
  getQuery: () => query,
@@ -404,17 +409,20 @@ const appAdapter = {
404
409
  numpadAssign: (id, slot) => revGuard(() => window.api.numpadAssign(id, slot, clipRevOf(id)), false),
405
410
  numpadUnassign: (slot) => window.api.numpadUnassign(slot),
406
411
  toggleGroup: (id, group) => revGuard(() => window.api.groupAssign(id, group, clipRevOf(id)), false), // applyGroupAssign toggles
407
- createGroup: async (name) => { await window.api.groupCreate(name); await refreshGroups({ render: false }); },
412
+ // "todo, forge" creates (or reuses) both: main answers with the tag names.
413
+ createGroup: async (text) => { const names = await window.api.groupCreate(text); await refreshGroups({ render: false }); return names; },
408
414
  deleteGroup: async (group) => {
409
415
  // Drop every group: / -group: token of the deleted group from the query.
410
416
  query = Core.search.stripFacet(query, { kind: 'group', value: group });
411
417
  if (typeof searchBox !== 'undefined' && searchBox) searchBox.setQuery(query);
412
- await window.api.groupDelete(group);
418
+ const result = await window.api.groupDelete(group); // { ok, name, ids, shared }: what its Undo puts back
413
419
  await refreshGroups({ render: false });
420
+ return result;
414
421
  },
422
+ restoreGroup: async (result) => { await window.api.groupRestore(result); await refreshGroups({ render: false }); },
415
423
  deleteClip: (id) => revGuard(() => window.api.deleteItem(id, clipRevOf(id)), false),
416
424
  clearUnpinned: () => window.api.deleteAll(),
417
- setGroupSharedAi: (group) => window.api.setGroupSharedAi(group, !aiSharedGroups.has(group)),
425
+ setGroupSharedAi: (group, shared) => window.api.setGroupSharedAi(group, shared), // shared: the switch's new state
418
426
  // Always the id-based path: window.api.copy() only takes a string, so it
419
427
  // would silently drop the clip's html/rtf. paste(id) puts every captured
420
428
  // format on the clipboard for both text and image clips.
@@ -447,11 +455,7 @@ const appAdapter = {
447
455
  groupAssignMany: (ids, group, shouldHave) => revGuard(() => window.api.groupAssignMany(Core.revisionTargets(ids, clipRevOf), group, shouldHave), false),
448
456
  pasteMany: (ids) => window.api.pasteMany(ids),
449
457
  startUnify: (ids) => window.api.startUnify(ids),
450
- offerUndo: ({ count, undo }) => Core.showActionToast(toast, {
451
- message: `Deleted ${count} clip${count === 1 ? '' : 's'}`,
452
- actionLabel: 'Undo',
453
- onAction: undo,
454
- }),
458
+ offerUndo: ({ message, undo }) => Core.showActionToast(toast, { message, actionLabel: 'Undo', onAction: undo }),
455
459
  toast: (msg) => showToast(msg),
456
460
  render: () => rerenderList(), // view-only re-render (expand/filter): no IPC
457
461
  refresh: async () => { await refresh({ force: true }); if (document.getElementById('settingsView').classList.contains('show')) openSettings(); }, // after a mutation: re-fetch history THEN re-render settings if open (await so the settings panel reads rebuilt indexes, not stale ones)
@@ -486,9 +490,12 @@ document.addEventListener('wheel', (e) => controller.onWheel(e), { passive: fals
486
490
  document.addEventListener('dragstart', (e) => controller.onDragstart(e));
487
491
  document.addEventListener('mouseover', (e) => controller.onMouseover(e));
488
492
  document.addEventListener('mouseout', (e) => controller.onMouseout(e));
493
+ // Any press in the popup, the search field included, ends the quick keys.
494
+ document.addEventListener('pointerdown', (e) => controller.onPointerdown(e), true);
489
495
 
490
- // Popup reset (called by the main process when the window is shown).
496
+ // Popup reset (called by the main process when the window is shown and hidden).
491
497
  window.resetPopupState = function() {
498
+ controller.armQuickKeys(); // 1-9 and N act until the first click or other key
492
499
  appDialogs.dismiss();
493
500
  controller.closeMenu(); // a popover left open at blur-hide would eat keys on re-show
494
501
  controller.clearSelection({ paint: false });
@@ -498,6 +505,7 @@ window.resetPopupState = function() {
498
505
  document.getElementById('mainView')?.classList.remove('hidden');
499
506
  document.body.style.pointerEvents = '';
500
507
  searchBox.closeOptions({ instant: true }); // every open starts with the options panel shut
508
+ cancelShortcutRecording(); // Settings closed above: a recording never outlives its row
501
509
  window.focusSearch();
502
510
  };
503
511
 
@@ -524,20 +532,6 @@ document.getElementById('copyDiagnostics').addEventListener('click', async () =>
524
532
  });
525
533
 
526
534
  // --- Search ---
527
- searchEl.addEventListener('keydown', e => {
528
- // A digit typed while a menu or popover is open belongs to it, never a
529
- // quick-paste (typing "1ndex" with the + picker open pasted slot 1).
530
- if (controller.isMenuOpen()) return;
531
- if (query === '' && !e.altKey && !e.ctrlKey && !e.metaKey && e.key >= '1' && e.key <= '9') {
532
- const n = +e.key;
533
- const nmap = numpadMap();
534
- if (n in nmap) {
535
- e.preventDefault();
536
- window.api.numpadPasteAndHide(n);
537
- return;
538
- }
539
- }
540
- });
541
535
  const searchClear = document.getElementById('searchClear');
542
536
  // Shared query-state change path (typed input AND autocomplete-applied tokens).
543
537
  function onQueryChanged(v) {
@@ -748,44 +742,89 @@ function shortcutLabel(accelerator) {
748
742
  .replace(/\+/g, ' + ');
749
743
  }
750
744
 
745
+ // The recordable shortcuts: their Settings rows (the shared settings body)
746
+ // and the main-process setter each recording is saved through. canTurnOff:
747
+ // the shortcut may be none even though its default is a chord (the setter
748
+ // takes SHORTCUT_OFF; Windows' Win+N then stays Windows').
749
+ const SHORTCUT_OFF = 'none';
750
+ const SHORTCUT_KINDS = {
751
+ show: { status: 'shortcutStatus', record: 'shortcutRecord', reset: 'shortcutReset', set: (s) => window.api.setShowShortcut(s) },
752
+ quickPaste: { status: 'quickPasteStatus', record: 'quickPasteRecord', reset: 'quickPasteReset', set: (s) => window.api.setQuickPasteShortcut(s) },
753
+ newNote: { status: 'newNoteShortcutStatus', record: 'newNoteShortcutRecord', reset: 'newNoteShortcutReset', set: (s) => window.api.setNewNoteShortcut(s),
754
+ canTurnOff: (info) => !!info.newNoteDefault },
755
+ };
756
+
751
757
  function shortcutStatusEl(kind) {
752
- return document.getElementById(kind === 'quickPaste' ? 'quickPasteStatus' : 'shortcutStatus');
758
+ return document.getElementById(SHORTCUT_KINDS[kind].status);
753
759
  }
754
760
 
755
761
  function shortcutRecordEl(kind) {
756
- return document.getElementById(kind === 'quickPaste' ? 'quickPasteRecord' : 'shortcutRecord');
762
+ return document.getElementById(SHORTCUT_KINDS[kind].record);
757
763
  }
758
764
 
759
765
  function shortcutResetEl(kind) {
760
- return document.getElementById(kind === 'quickPaste' ? 'quickPasteReset' : 'shortcutReset');
766
+ return document.getElementById(SHORTCUT_KINDS[kind].reset);
767
+ }
768
+
769
+ // The row's reset slot holds the one action its state has: back to the
770
+ // default once changed (restart_alt), Turn off at a default chord that can be
771
+ // off (close), else nothing, the slot kept so the row never shifts.
772
+ function renderShortcutReset(kind, info, custom) {
773
+ const button = shortcutResetEl(kind);
774
+ if (!button.dataset.resetTitle) button.dataset.resetTitle = button.title;
775
+ const canTurnOff = SHORTCUT_KINDS[kind].canTurnOff;
776
+ const off = !custom && !!(canTurnOff && canTurnOff(info));
777
+ const title = off ? 'Turn off' : button.dataset.resetTitle;
778
+ button.dataset.apply = off ? SHORTCUT_OFF : '';
779
+ button.title = title;
780
+ button.setAttribute('aria-label', title);
781
+ button.querySelector('.mi').textContent = off ? 'close' : 'restart_alt';
782
+ button.style.visibility = custom || off ? 'visible' : 'hidden';
761
783
  }
762
784
 
763
785
  function setShortcutStatus(kind, message, isError = false) {
764
786
  Core.setSettingHelp(shortcutStatusEl(kind), message, isError ? 'error' : '');
765
787
  }
766
788
 
789
+ // A start waits for main to suspend the shortcuts; a stop in that wait ends
790
+ // the start there (the generation moved) and still resumes them.
791
+ let shortcutRecordStarting = false;
792
+ let shortcutRecordGen = 0;
767
793
  function stopShortcutRecording({ resume = true } = {}) {
768
794
  const kind = shortcutRecording;
795
+ const starting = shortcutRecordStarting;
769
796
  shortcutRecording = null;
770
- for (const button of [shortcutRecordEl('show'), shortcutRecordEl('quickPaste')]) {
797
+ shortcutRecordStarting = false;
798
+ shortcutRecordGen += 1;
799
+ for (const button of Object.keys(SHORTCUT_KINDS).map(shortcutRecordEl)) {
771
800
  if (button) button.classList.remove('recording');
772
801
  }
773
- if (resume && kind && window.api.resumeShortcuts) {
802
+ if (resume && (kind || starting) && window.api.resumeShortcuts) {
774
803
  window.api.resumeShortcuts().catch(() => {});
775
804
  }
776
805
  return kind;
777
806
  }
778
807
 
808
+ // A recording lives only while its row is on screen: Esc, the popup hiding or
809
+ // showing (resetPopupState) and Settings closing all cancel it the same way,
810
+ // which resumes the shortcuts main suspended for it and puts the row's label
811
+ // back. (Left running, the recorder ate every key the search got and saved the
812
+ // next chord as a global shortcut.)
813
+ async function cancelShortcutRecording() {
814
+ if (!shortcutRecording && !shortcutRecordStarting) return;
815
+ stopShortcutRecording();
816
+ const settings = await window.api.getSettings().catch(() => null);
817
+ if (settings) renderShortcutSettings(settings);
818
+ }
819
+
779
820
  function renderShortcutSettings(settings, { clearStatus = true } = {}) {
780
821
  const info = settings.shortcut_info || { show: 'CommandOrControl+Shift+V', custom: false };
781
822
  const record = shortcutRecordEl('show');
782
- const reset = shortcutResetEl('show');
783
823
  record.textContent = shortcutLabel(info.show);
784
824
  record.title = info.show;
785
825
  record.classList.remove('recording');
786
- reset.style.visibility = info.custom ? 'visible' : 'hidden';
826
+ renderShortcutReset('show', info, info.custom);
787
827
  const quickRecord = shortcutRecordEl('quickPaste');
788
- const quickReset = shortcutResetEl('quickPaste');
789
828
  const quickShortcut = info.quickPaste || '';
790
829
  if (quickShortcut) {
791
830
  quickRecord.textContent = `${shortcutLabel(quickShortcut).replace(/\s\+\s[1-9]$/, '')} + 1-9`;
@@ -796,10 +835,16 @@ function renderShortcutSettings(settings, { clearStatus = true } = {}) {
796
835
  }
797
836
  quickRecord.classList.toggle('unset', !quickShortcut);
798
837
  quickRecord.classList.remove('recording');
799
- quickReset.style.visibility = info.quickPasteCustom ? 'visible' : 'hidden';
838
+ renderShortcutReset('quickPaste', info, info.quickPasteCustom);
839
+ const noteRecord = shortcutRecordEl('newNote');
840
+ const noteShortcut = info.newNote || '';
841
+ noteRecord.textContent = noteShortcut ? shortcutLabel(noteShortcut) : 'Not set';
842
+ noteRecord.title = noteShortcut || 'Record new note shortcut';
843
+ noteRecord.classList.toggle('unset', !noteShortcut);
844
+ noteRecord.classList.remove('recording');
845
+ renderShortcutReset('newNote', info, info.newNoteCustom);
800
846
  if (clearStatus) {
801
- setShortcutStatus('show', '');
802
- setShortcutStatus('quickPaste', '');
847
+ for (const kind of Object.keys(SHORTCUT_KINDS)) setShortcutStatus(kind, '');
803
848
  }
804
849
  }
805
850
 
@@ -868,9 +913,7 @@ function shortcutFromEvent(e) {
868
913
 
869
914
  async function applyShortcut(kind, shortcut) {
870
915
  stopShortcutRecording({ resume: false });
871
- const result = kind === 'quickPaste'
872
- ? await window.api.setQuickPasteShortcut(shortcut)
873
- : await window.api.setShowShortcut(shortcut);
916
+ const result = await SHORTCUT_KINDS[kind].set(shortcut);
874
917
  if (!result || !result.ok) {
875
918
  const settings = await window.api.getSettings().catch(() => null);
876
919
  if (settings) renderShortcutSettings(settings, { clearStatus: false });
@@ -883,9 +926,13 @@ async function applyShortcut(kind, shortcut) {
883
926
 
884
927
  async function startShortcutRecording(kind) {
885
928
  stopShortcutRecording();
929
+ const gen = shortcutRecordGen;
930
+ shortcutRecordStarting = true;
886
931
  if (window.api.suspendShortcuts) {
887
932
  await window.api.suspendShortcuts().catch(() => {});
888
933
  }
934
+ if (gen !== shortcutRecordGen) return; // cancelled while main suspended them (that stop resumed them)
935
+ shortcutRecordStarting = false;
889
936
  shortcutRecording = kind;
890
937
  const record = shortcutRecordEl(kind);
891
938
  record.classList.add('recording');
@@ -893,10 +940,10 @@ async function startShortcutRecording(kind) {
893
940
  setShortcutStatus(kind, '');
894
941
  }
895
942
 
896
- document.getElementById('shortcutRecord').addEventListener('click', () => startShortcutRecording('show'));
897
- document.getElementById('quickPasteRecord').addEventListener('click', () => startShortcutRecording('quickPaste'));
898
- document.getElementById('shortcutReset').addEventListener('click', () => applyShortcut('show', ''));
899
- document.getElementById('quickPasteReset').addEventListener('click', () => applyShortcut('quickPaste', ''));
943
+ for (const kind of Object.keys(SHORTCUT_KINDS)) {
944
+ shortcutRecordEl(kind).addEventListener('click', () => startShortcutRecording(kind));
945
+ shortcutResetEl(kind).addEventListener('click', (e) => applyShortcut(kind, e.currentTarget.dataset.apply || ''));
946
+ }
900
947
 
901
948
  document.addEventListener('keydown', async (e) => {
902
949
  if (!shortcutRecording) return;
@@ -904,9 +951,7 @@ document.addEventListener('keydown', async (e) => {
904
951
  e.preventDefault();
905
952
  e.stopPropagation();
906
953
  if (e.key === 'Escape') {
907
- stopShortcutRecording();
908
- const settings = await window.api.getSettings().catch(() => null);
909
- if (settings) renderShortcutSettings(settings);
954
+ await cancelShortcutRecording();
910
955
  return;
911
956
  }
912
957
  const shortcut = shortcutFromEvent(e);
@@ -1186,6 +1231,7 @@ async function renderConflicts() {
1186
1231
  }
1187
1232
 
1188
1233
  function closeSettings() {
1234
+ cancelShortcutRecording();
1189
1235
  document.getElementById('settingsView').classList.remove('show');
1190
1236
  document.getElementById('mainView').classList.remove('hidden');
1191
1237
  searchEl.focus();