@tobq/boardclip 2.1.107 → 2.1.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Tobi
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.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tobi
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.
package/index.html CHANGED
@@ -385,7 +385,8 @@ const appAdapter = {
385
385
  // Always the id-based path: window.api.copy() only takes a string, so it
386
386
  // would silently drop the clip's html/rtf. paste(id) puts every captured
387
387
  // format on the clipboard for both text and image clips.
388
- copyNumpadSlot: (id) => { const it = itemById(id); if (!it) return; return window.api.paste(id); },
388
+ copyClip: (id) => window.api.copyClip(id),
389
+ copyMany: (ids) => window.api.copyMany(ids),
389
390
  activateClip: (id) => window.api.pasteAndHide(id),
390
391
  editClip: (id, _itemEl, options) => window.api.openEditor(id, { ...Core.editorFindFor(query), ...(options || {}) }),
391
392
  setClipTitle: (id, title) => revGuard(() => window.api.setClipTitle(id, title, clipRevOf(id)), false),
@@ -491,6 +492,9 @@ document.getElementById('copyDiagnostics').addEventListener('click', async () =>
491
492
 
492
493
  // --- Search ---
493
494
  searchEl.addEventListener('keydown', e => {
495
+ // A digit typed while a menu or popover is open belongs to it, never a
496
+ // quick-paste (typing "1ndex" with the + picker open pasted slot 1).
497
+ if (controller.isMenuOpen()) return;
494
498
  if (query === '' && !e.altKey && !e.ctrlKey && !e.metaKey && e.key >= '1' && e.key <= '9') {
495
499
  const n = +e.key;
496
500
  const nmap = numpadMap();
package/main.js CHANGED
@@ -4889,9 +4889,9 @@ async function pasteAndHide(id) {
4889
4889
  }
4890
4890
  }
4891
4891
 
4892
- // "Paste all": join the selected TEXT clips (newline-separated) and paste them in
4893
- // one go. Images can't concatenate into text, so they're skipped.
4894
- async function pasteMany(ids) {
4892
+ // "Paste all" / "Copy all": the selected TEXT clips joined (newline-separated)
4893
+ // in one go. Images can't concatenate into text, so they're skipped.
4894
+ function joinedClipText(ids) {
4895
4895
  const parts = [];
4896
4896
  for (const id of (Array.isArray(ids) ? ids : [])) {
4897
4897
  const item = findHistoryItem(id);
@@ -4899,18 +4899,30 @@ async function pasteMany(ids) {
4899
4899
  textBlobStore.hydrateTextItem(item, TEXT_DIR);
4900
4900
  parts.push(item.text || '');
4901
4901
  }
4902
- const text = parts.join('\n');
4902
+ return parts.join('\n');
4903
+ }
4904
+ // The joined text onto the clipboard, never captured back as a new clip.
4905
+ function writeJoinedText(text) {
4906
+ clipboard.writeText(text);
4907
+ lastText = text;
4908
+ lastImgHash = '';
4909
+ }
4910
+ async function pasteMany(ids) {
4911
+ const text = joinedClipText(ids);
4903
4912
  if (!text) { hidePopup(); return; }
4904
4913
  pollGate = false;
4905
4914
  try {
4906
- clipboard.writeText(text);
4907
- lastText = text; // don't re-capture our own joined paste as a new clip
4908
- lastImgHash = '';
4915
+ writeJoinedText(text);
4909
4916
  await hideAndPasteForeground();
4910
4917
  } finally {
4911
4918
  pollGate = true;
4912
4919
  }
4913
4920
  }
4921
+ // "Copy all": the same text, nothing pasted; the popup stays.
4922
+ function copyMany(ids) {
4923
+ const text = joinedClipText(ids);
4924
+ if (text) writeJoinedText(text);
4925
+ }
4914
4926
 
4915
4927
  function validNumpadSlot(slot) {
4916
4928
  const slotNum = Number(slot);
@@ -6739,7 +6751,9 @@ function setupIPC() {
6739
6751
  },
6740
6752
  }));
6741
6753
 
6742
- ipcMain.handle('paste', (_, id) => {
6754
+ // Copy without pasting: the clip onto the clipboard (the poller then sees it
6755
+ // like any copy, so it moves to the top as a paste does).
6756
+ ipcMain.handle('copy-clip', (_, id) => {
6743
6757
  const item = findHistoryItem(id);
6744
6758
  if (!item) return;
6745
6759
  setClipboardToItem(item);
@@ -6966,6 +6980,7 @@ function setupIPC() {
6966
6980
  ipcMain.handle('restore-items', (_, snaps) => applyRestoreItems(snaps));
6967
6981
  ipcMain.handle('group-assign-many', (_, targets, group, shouldHave) => applyGroupAssignMany(targets, group, shouldHave));
6968
6982
  ipcMain.handle('paste-many', (_, ids) => pasteMany(ids));
6983
+ ipcMain.handle('copy-many', (_, ids) => copyMany(ids));
6969
6984
  ipcMain.handle('start-unify', (_, ids) => startUnify(ids));
6970
6985
  ipcMain.handle('unify-step', (_, sessionId, payload) => unifyStep(sessionId, payload));
6971
6986
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tobq/boardclip",
3
3
  "productName": "BoardClip",
4
- "version": "2.1.107",
4
+ "version": "2.1.109",
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
@@ -9,7 +9,7 @@ contextBridge.exposeInMainWorld('api', {
9
9
  return () => ipcRenderer.removeListener('history-changed', listener);
10
10
  },
11
11
  getSettings: () => ipcRenderer.invoke('get-settings'),
12
- paste: (id) => ipcRenderer.invoke('paste', id),
12
+ copyClip: (id) => ipcRenderer.invoke('copy-clip', id),
13
13
  pasteAndHide: (id) => ipcRenderer.invoke('paste-and-hide', id),
14
14
  numpadPasteAndHide: (slot) => ipcRenderer.invoke('numpad-paste-and-hide', slot),
15
15
  hidePopup: () => ipcRenderer.invoke('hide-popup'),
@@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld('api', {
22
22
  restoreItems: (snaps) => ipcRenderer.invoke('restore-items', snaps),
23
23
  groupAssignMany: (targets, group, shouldHave) => ipcRenderer.invoke('group-assign-many', targets, group, shouldHave), // [{ id, rev }]
24
24
  pasteMany: (ids) => ipcRenderer.invoke('paste-many', ids),
25
+ copyMany: (ids) => ipcRenderer.invoke('copy-many', ids),
25
26
  startUnify: (ids) => ipcRenderer.invoke('start-unify', ids),
26
27
  deleteAll: () => ipcRenderer.invoke('delete-all'),
27
28
  pin: (id, rev) => ipcRenderer.invoke('pin', id, rev),
package/site/index.html CHANGED
@@ -859,20 +859,24 @@
859
859
  deleteClip: (id) => { clips = Core.deleteItem(clips, id); },
860
860
  clearUnpinned: () => { clips = clips.filter((clip) => Core.isPinned(clip)); },
861
861
  setGroupSharedAi: (group) => { if (demoSharedGroups.has(group)) demoSharedGroups.delete(group); else demoSharedGroups.add(group); },
862
- copyNumpadSlot: async (id) => {
862
+ // Copy without pasting (the controller says "Copied"). A page cannot paste
863
+ // into another app, so the demo's click (activateClip) copies too.
864
+ copyClip: async (id) => {
863
865
  const clip = demoAdapter.itemById(id);
864
866
  if (!clip) return;
865
867
  if (clip.type === "image") { const ok = await writeImageClipboard(clip).catch(() => false); if (!ok) await writeBrowserClipboard(clip.text || ""); }
866
868
  else await writeBrowserClipboard(clip.text || "");
869
+ clips = Core.touchItem(clips, id);
870
+ renderClips();
871
+ },
872
+ // Copy all: the app's Paste all text (text clips, one per line).
873
+ copyMany: async (ids) => {
874
+ const text = ids.map((id) => demoAdapter.itemById(id)).filter((clip) => clip && clip.type !== "image").map((clip) => clip.text || "").join("\n");
875
+ if (text) await writeBrowserClipboard(text);
867
876
  },
868
877
  activateClip: async (id) => {
869
- const clip = demoAdapter.itemById(id);
870
- if (!clip) return;
871
- if (clip.type === "image") { const ok = await writeImageClipboard(clip).catch(() => false); if (!ok) await writeBrowserClipboard(clip.text || ""); }
872
- else await writeBrowserClipboard(clip.text || "");
873
- clips = Core.touchItem(clips, id);
878
+ await demoAdapter.copyClip(id);
874
879
  showToast("Copied");
875
- renderClips();
876
880
  },
877
881
  editClip: (id, _itemEl, options) => openDemoEditor(id, { ...Core.editorFindFor(state.query), ...(options || {}) }),
878
882
  // The open editor's title field is authoritative for its clip (it rides
@@ -898,7 +898,7 @@
898
898
  // (popup, editor, viewer), so an action looks the same wherever it appears
899
899
  // and no two actions share a glyph.
900
900
  const CLIP_ACTION_ICONS = {
901
- pin: 'star', edit: 'edit_note', 'open-img': 'open_in_full', 'open-img-ext': 'open_in_new', 'save-img': 'download',
901
+ copy: 'content_copy', pin: 'star', edit: 'edit_note', 'open-img': 'open_in_full', 'open-img-ext': 'open_in_new', 'save-img': 'download',
902
902
  rename: 'drive_file_rename_outline', revert: 'history', 'select-similar': 'select_all', del: 'delete',
903
903
  };
904
904
  function renderClipActions(item, options) {
@@ -906,7 +906,9 @@
906
906
  const id = itemId(item) || '';
907
907
  const isImage = item && item.type === 'image';
908
908
  const I = CLIP_ACTION_ICONS;
909
- let html = '';
909
+ // Copy (no paste) leads every row: the clip goes on the clipboard and the
910
+ // popup stays.
911
+ let html = `<button class="icon-btn" data-action="copy" data-id="${escapeHtml(id)}" title="Copy" aria-label="Copy"><span class="mi">${I.copy}</span></button>`;
910
912
  if (isImage) {
911
913
  html += `<button class="icon-btn" data-action="open-img" data-id="${escapeHtml(id)}" title="Open image"><span class="mi">${I['open-img']}</span></button><button class="icon-btn" data-action="save-img" data-id="${escapeHtml(id)}" title="Copy to Downloads"><span class="mi">${I['save-img']}</span></button>`;
912
914
  } else {
@@ -1025,6 +1027,7 @@
1025
1027
  const nmap = opts.numpadMap || numpadMap(items);
1026
1028
  const row = (action, label, cls, extra) => menuRowHtml({ icon: CLIP_ACTION_ICONS[action], label, cls, ...(extra || {}), attrs: { 'data-action': action, 'data-id': id, ...((extra && extra.attrs) || {}) } });
1027
1029
  let html = '<div class="bc-menu-list">';
1030
+ html += row('copy', 'Copy');
1028
1031
  html += row('pin', pinned ? 'Unpin' : 'Pin');
1029
1032
  if (isImage) {
1030
1033
  if (context !== 'viewer') html += row('open-img', 'Open image');
@@ -1071,6 +1074,7 @@
1071
1074
  const row = (action, icon, label, cls) => menuRowHtml({ icon, label, cls, attrs: { 'data-action': action } });
1072
1075
  let html = '<div class="bc-menu-list">';
1073
1076
  html += row('bulk-paste', 'content_paste', `Paste all (${count})`);
1077
+ html += row('bulk-copy', CLIP_ACTION_ICONS.copy, `Copy all (${count})`);
1074
1078
  html += submenuNodeHtml({ icon: 'sell', label: 'Add to group' }, bulkGroupTreeHtml(groups, selItems, Search.groupWeights(opts.items || [])));
1075
1079
  if (allText) html += row('bulk-unify', 'merge', `Unify (${count})`);
1076
1080
  html += MENU_SEPARATOR + row('bulk-delete', CLIP_ACTION_ICONS.del, `Delete (${count})`, 'danger');
@@ -1098,6 +1102,7 @@
1098
1102
  return `<span class="selection-count">${count} selected</span>
1099
1103
  <div class="selection-actions">
1100
1104
  <button class="icon-btn" type="button" data-action="bulk-paste" title="Paste all (Enter)" aria-label="Paste all"><span class="mi">content_paste</span></button>
1105
+ <button class="icon-btn" type="button" data-action="bulk-copy" title="Copy all" aria-label="Copy all"><span class="mi">${CLIP_ACTION_ICONS.copy}</span></button>
1101
1106
  <button class="icon-btn" type="button" data-action="bulk-group-open" title="Group" aria-label="Group" aria-haspopup="true"><span class="mi">sell</span></button>
1102
1107
  ${allText ? '<button class="icon-btn" type="button" data-action="bulk-unify" title="Unify into one clip" aria-label="Unify into one clip"><span class="mi">merge</span></button>' : ''}
1103
1108
  <button class="icon-btn danger" type="button" data-action="bulk-delete" title="Delete selected" aria-label="Delete selected"><span class="mi">delete</span></button>
@@ -3438,8 +3443,9 @@
3438
3443
  // mutate: pin(id), numpadAssign(id,slot), numpadUnassign(slot),
3439
3444
  // toggleGroup(id,group) [add-or-remove], createGroup(name),
3440
3445
  // deleteGroup(group), deleteClip(id), clearUnpinned(),
3441
- // setGroupSharedAi(group), copyNumpadSlot(id)
3442
- // actions: activateClip(id), editClip(id,itemEl), openImage(item),
3446
+ // setGroupSharedAi(group)
3447
+ // actions: activateClip(id), copyClip(id) [the clipboard only, no paste],
3448
+ // copyMany(ids) [Paste all's joined text, no paste], editClip(id,itemEl), openImage(item),
3443
3449
  // saveImage(item)->feedbackString|null
3444
3450
  // keyboard:isSettingsOpen(), closeSettings(), hidePopup(),
3445
3451
  // moveSelection(dir), activateSelected()
@@ -4500,6 +4506,14 @@
4500
4506
  try { if (a.pasteMany) await a.pasteMany(ids); }
4501
4507
  finally { clearSelection(); } // always drop the selection (app hides the popup; demo hides the bar)
4502
4508
  }
4509
+ // Copy all: Paste all's joined text onto the clipboard, nothing pasted;
4510
+ // the popup and the selection stay.
4511
+ async function copySelection() {
4512
+ const ids = selectionInfo().ids;
4513
+ if (!ids.length || !a.copyMany) return;
4514
+ await a.copyMany(ids);
4515
+ toast('Copied');
4516
+ }
4503
4517
  async function deleteIds(ids) {
4504
4518
  if (!ids || !ids.length) return;
4505
4519
  ids.forEach((id) => selectedIds.delete(id));
@@ -4709,6 +4723,16 @@
4709
4723
  const bulkGroupOpen = t.closest('[data-action="bulk-group-open"]');
4710
4724
  if (bulkGroupOpen) { event.stopPropagation(); const r = bulkGroupOpen.getBoundingClientRect(); openBulkGroupMenu(r.left, r.bottom + 2); return true; }
4711
4725
  if (t.closest('[data-action="bulk-paste"]')) { event.stopPropagation(); pasteSelection(); return true; }
4726
+ if (t.closest('[data-action="bulk-copy"]')) { event.stopPropagation(); copySelection(); return true; }
4727
+ // Copy without pasting (a row's Copy, the clip menu's Copy): the row
4728
+ // button carries data-id, the menu root does (closest).
4729
+ const copyBtn = t.closest('[data-action="copy"]');
4730
+ if (copyBtn) {
4731
+ event.stopPropagation();
4732
+ const owner = copyBtn.dataset.id ? copyBtn : copyBtn.closest('[data-id]');
4733
+ if (owner && owner.dataset.id) { await a.copyClip(owner.dataset.id); toast('Copied'); }
4734
+ return true;
4735
+ }
4712
4736
  if (t.closest('[data-action="bulk-unify"]')) { event.stopPropagation(); unifySelection(); return true; }
4713
4737
  if (t.closest('[data-action="bulk-delete"]')) { event.stopPropagation(); deleteSelection(); return true; }
4714
4738
  if (t.closest('[data-action="bulk-clear"]')) { event.stopPropagation(); clearSelection(); return true; }
@@ -4763,7 +4787,7 @@
4763
4787
  const npRemove = t.closest('.np-remove');
4764
4788
  if (npRemove) { event.stopPropagation(); await a.numpadUnassign(Number(npRemove.dataset.slot)); refresh(); return true; }
4765
4789
  const slotEl = t.closest('.np-slot.has-content');
4766
- if (slotEl && slotEl.dataset.slotId) { event.stopPropagation(); await a.copyNumpadSlot(slotEl.dataset.slotId); toast('Copied'); return true; }
4790
+ if (slotEl && slotEl.dataset.slotId) { event.stopPropagation(); await a.copyClip(slotEl.dataset.slotId); toast('Copied'); return true; }
4767
4791
  const share = t.closest('.gp-share');
4768
4792
  if (share) { event.stopPropagation(); await a.setGroupSharedAi(share.dataset.group); refresh(); return true; }
4769
4793
  const gpDel = t.closest('.gp-del');
@@ -4996,6 +5020,9 @@
4996
5020
  // re-painting selection classes after a full list rebuild) + exposed for tests.
4997
5021
  selection: selectionInfo,
4998
5022
  focusedId: () => focusId,
5023
+ // An open menu or popover owns the keys (the app's empty-search digit
5024
+ // quick-paste must not fire under it).
5025
+ isMenuOpen: () => menu.isOpen(),
4999
5026
  clearSelection,
5000
5027
  selectAll,
5001
5028
  moveFocus,