@tobq/boardclip 2.1.108 → 2.1.110

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
@@ -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();
@@ -274,6 +274,13 @@ function conflictSnapshot(item, fallback = {}) {
274
274
  };
275
275
  }
276
276
 
277
+ // Two sides with the same text and title leave nothing to merge.
278
+ function withoutIdenticalSides(conflict) {
279
+ const { left, right } = conflict || {};
280
+ if (left && right && left.text === right.text && left.title === right.title) return null;
281
+ return conflict;
282
+ }
283
+
277
284
  function titleConflict(localItem, remoteItem) {
278
285
  if (!localItem || !remoteItem || localItem.type === 'image' || remoteItem.type === 'image') return false;
279
286
  if (itemKey(localItem) !== itemKey(remoteItem)) return false;
@@ -890,7 +897,15 @@ function applyTextEdit(items, options = {}) {
890
897
  ensureItemId(item);
891
898
  }
892
899
 
893
- const currentIndex = id ? history.findIndex(item => itemKey(item) === id) : -1;
900
+ let currentIndex = id ? history.findIndex(item => itemKey(item) === id) : -1;
901
+ // A base edited since this edit began (in another window or on another
902
+ // device) is gone under its old id. Its newest version is found through the
903
+ // edit lineage, so the fork below carries a conflict record against it and
904
+ // the two can be merged, instead of a second copy with nothing to merge.
905
+ if (currentIndex < 0 && id && options.supersedes) {
906
+ const headId = supersedeMap(options.supersedes).get(id);
907
+ if (headId) currentIndex = history.findIndex(item => item.type !== 'image' && itemKey(item) === headId);
908
+ }
894
909
  const current = currentIndex >= 0 ? history[currentIndex] : null;
895
910
  const originalTitle = titleEditRequested
896
911
  ? (options.originalTitle !== undefined ? cleanTitle(options.originalTitle) : titleOf(current))
@@ -949,13 +964,13 @@ function applyTextEdit(items, options = {}) {
949
964
  dedupeNumpadSlots(history);
950
965
  const titleMergeConflict = titleConflict(existingBefore, edited);
951
966
  const conflict = current && (!canUpdateCurrent || titleDiverged || titleMergeConflict)
952
- ? {
967
+ ? withoutIdenticalSides({
953
968
  kind: titleDiverged || titleMergeConflict ? 'title' : 'editor',
954
969
  base: { id, type: 'text', text: originalText, title: originalTitle },
955
970
  left: titleMergeConflict ? existingBefore : (currentBefore || existingBefore),
956
971
  right: conflictSnapshot(edited, { text: newText, title: newTitle }),
957
972
  targetId: itemKey(existing),
958
- }
973
+ })
959
974
  : null;
960
975
  return {
961
976
  changed: true,
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ // OS notifications with an optional action button, on Windows and macOS.
4
+ // - Windows: Electron's Notification takes a button only through custom toast
5
+ // XML. A foreground-activated button reaches the app as the notification's
6
+ // 'click' (Electron maps every activation to it), so the body and the button
7
+ // run the same action.
8
+ // - macOS: Electron's `actions` ('action' event). They show only when the app's
9
+ // notification style is Alerts; otherwise the body click is the way in.
10
+ // Clicking the notification runs the action everywhere: that is the generic
11
+ // path, and a button is the extra a platform adds on top.
12
+
13
+ function escapeXml(value) {
14
+ return String(value == null ? '' : value).replace(/[<>&"']/g, (ch) => ({
15
+ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;',
16
+ })[ch]);
17
+ }
18
+
19
+ function toastXml({ title, body, action, silent = true }) {
20
+ return '<toast activationType="foreground" launch="open">'
21
+ + '<visual><binding template="ToastGeneric">'
22
+ + `<text>${escapeXml(title)}</text>`
23
+ + (body ? `<text>${escapeXml(body)}</text>` : '')
24
+ + '</binding></visual>'
25
+ + (action ? `<actions><action content="${escapeXml(action)}" arguments="action" activationType="foreground"/></actions>` : '')
26
+ + (silent ? '<audio silent="true"/>' : '')
27
+ + '</toast>';
28
+ }
29
+
30
+ function notificationOptions({ title, body, action, silent = true, platform = process.platform } = {}) {
31
+ const options = { title: String(title || ''), body: String(body || ''), silent: !!silent };
32
+ if (action && platform === 'win32') options.toastXml = toastXml({ title, body, action, silent });
33
+ if (action && platform === 'darwin') options.actions = [{ type: 'button', text: String(action) }];
34
+ return options;
35
+ }
36
+
37
+ // show() keeps each notification referenced until it is acted on or closed:
38
+ // an unreferenced Notification can be garbage-collected before its click.
39
+ // A toast Windows rejects ('failed') is shown again without the button, so the
40
+ // message still arrives and its click still runs the action; onError hears of
41
+ // every failure (each one is a bug to fix, not a path to rely on).
42
+ function createNotifier({ Notification, platform = process.platform, onError = () => {} } = {}) {
43
+ const live = new Set();
44
+ const present = (options, act) => {
45
+ const notification = new Notification(options);
46
+ live.add(notification);
47
+ const done = () => { live.delete(notification); };
48
+ notification.on('click', () => { done(); act(); });
49
+ notification.on('action', () => { done(); act(); });
50
+ notification.on('close', done);
51
+ notification.on('failed', (_event, error) => {
52
+ done();
53
+ onError(new Error(String(error || 'notification failed')));
54
+ if (options.toastXml) {
55
+ const { toastXml, ...plain } = options;
56
+ try { present(plain, act); } catch (retryError) { onError(retryError); }
57
+ }
58
+ });
59
+ notification.show();
60
+ return notification;
61
+ };
62
+ return {
63
+ show({ title, body, action, onAction, silent = true } = {}) {
64
+ try {
65
+ if (!Notification || typeof Notification.isSupported !== 'function' || !Notification.isSupported()) return null;
66
+ const withAction = typeof onAction === 'function';
67
+ let acted = false;
68
+ const act = () => {
69
+ if (!withAction || acted) return;
70
+ acted = true;
71
+ try { onAction(); } catch (error) { onError(error); }
72
+ };
73
+ return present(notificationOptions({ title, body, action: withAction ? action : '', silent, platform }), act);
74
+ } catch (error) {
75
+ onError(error);
76
+ return null;
77
+ }
78
+ },
79
+ liveCount: () => live.size,
80
+ };
81
+ }
82
+
83
+ module.exports = { escapeXml, toastXml, notificationOptions, createNotifier };
package/main.js CHANGED
@@ -49,6 +49,7 @@ const { ControlServer } = require('./lib/control-server');
49
49
  const windowsConsole = require('./lib/windows-console');
50
50
  const windowsDwm = require('./lib/windows-dwm');
51
51
  const appearance = require('./lib/appearance');
52
+ const { createNotifier } = require('./lib/notifications');
52
53
 
53
54
  function guardBrokenPipe(stream) {
54
55
  try {
@@ -931,6 +932,7 @@ function applyExternalTextEdit({ id, originalText, originalTitle, sourceGroups,
931
932
  newTitle,
932
933
  sourceGroups,
933
934
  groupTombstones: settings.group_tombstones,
935
+ supersedes: settings.supersedes,
934
936
  now: Date.now(),
935
937
  ignoreBlank: true,
936
938
  });
@@ -4889,9 +4891,9 @@ async function pasteAndHide(id) {
4889
4891
  }
4890
4892
  }
4891
4893
 
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) {
4894
+ // "Paste all" / "Copy all": the selected TEXT clips joined (newline-separated)
4895
+ // in one go. Images can't concatenate into text, so they're skipped.
4896
+ function joinedClipText(ids) {
4895
4897
  const parts = [];
4896
4898
  for (const id of (Array.isArray(ids) ? ids : [])) {
4897
4899
  const item = findHistoryItem(id);
@@ -4899,18 +4901,30 @@ async function pasteMany(ids) {
4899
4901
  textBlobStore.hydrateTextItem(item, TEXT_DIR);
4900
4902
  parts.push(item.text || '');
4901
4903
  }
4902
- const text = parts.join('\n');
4904
+ return parts.join('\n');
4905
+ }
4906
+ // The joined text onto the clipboard, never captured back as a new clip.
4907
+ function writeJoinedText(text) {
4908
+ clipboard.writeText(text);
4909
+ lastText = text;
4910
+ lastImgHash = '';
4911
+ }
4912
+ async function pasteMany(ids) {
4913
+ const text = joinedClipText(ids);
4903
4914
  if (!text) { hidePopup(); return; }
4904
4915
  pollGate = false;
4905
4916
  try {
4906
- clipboard.writeText(text);
4907
- lastText = text; // don't re-capture our own joined paste as a new clip
4908
- lastImgHash = '';
4917
+ writeJoinedText(text);
4909
4918
  await hideAndPasteForeground();
4910
4919
  } finally {
4911
4920
  pollGate = true;
4912
4921
  }
4913
4922
  }
4923
+ // "Copy all": the same text, nothing pasted; the popup stays.
4924
+ function copyMany(ids) {
4925
+ const text = joinedClipText(ids);
4926
+ if (text) writeJoinedText(text);
4927
+ }
4914
4928
 
4915
4929
  function validNumpadSlot(slot) {
4916
4930
  const slotNum = Number(slot);
@@ -5067,11 +5081,10 @@ function refreshTray() {
5067
5081
  // prefix so they're retained but not resurrected).
5068
5082
  // 5. Disk retention - finished drafts are kept (LRU-pruned) as a last resort.
5069
5083
  //
5070
- // editSessions: sessionId -> session. editWindowsByClip: originalId -> sessionId
5071
- // (enforces one window per clip). A session re-anchors baseText/currentId after
5072
- // each commit so the chain stays contiguous (in-place, not a fork).
5084
+ // editSessions: sessionId -> session (one window per note: findEditSessionForClip).
5085
+ // A session re-anchors baseText/currentId after each commit so the chain stays
5086
+ // contiguous (in-place, not a fork).
5073
5087
  const editSessions = new Map();
5074
- const editWindowsByClip = new Map();
5075
5088
  const conflictWindows = new Map();
5076
5089
  const EDIT_DRAFT_RE = /^boardclip-edit-[0-9a-f]{12}-\d+(?:-\d+)?\.txt$/; // in-flight drafts only (legacy `-<ts>` and current `-<ts>-<seq>` names)
5077
5090
  let editSessionSeq = 0;
@@ -5122,22 +5135,34 @@ function readDraftFile(sessionOrPath, fallback = {}) {
5122
5135
  return editorPayloadFrom(raw, fallback);
5123
5136
  }
5124
5137
 
5138
+ const notifier = createNotifier({
5139
+ Notification,
5140
+ onError: (error) => diagnostics.record('notification.error', { error: error && error.message }, { forceFile: true }),
5141
+ });
5142
+
5125
5143
  function showEditNotification(title, body) {
5126
- try {
5127
- if (Notification && Notification.isSupported && Notification.isSupported()) {
5128
- new Notification({ title, body, silent: true }).show();
5129
- }
5130
- } catch {}
5144
+ notifier.show({ title, body });
5131
5145
  }
5132
5146
 
5133
- // Surface the conflict-fork outcome so a stale-based commit is never mistaken
5134
- // for a clobber: the user's edit was kept as a new clip, untouched.
5147
+ // Surface a stale-based save so it is never mistaken for a clobber: the edit
5148
+ // was kept beside the note, and Merge (or a click on the notification) opens
5149
+ // the merge view that folds the two back into one note.
5135
5150
  function notifyEditOutcome(result) {
5136
- if (!result || (result.reason !== 'conflict_created' && result.reason !== 'conflict_merged')) return;
5137
- showEditNotification(
5138
- 'BoardClip - saved as a separate clip',
5139
- 'This clip changed while you were editing, so your version was kept as a new clip instead of overwriting. Nothing was lost.'
5140
- );
5151
+ if (!result) return;
5152
+ const record = result.conflictRecord;
5153
+ if (record && record.kind === 'editor') {
5154
+ notifier.show({
5155
+ title: 'BoardClip - this note changed while you were editing',
5156
+ body: 'Your version is kept as a separate clip, so nothing is lost.',
5157
+ action: 'Merge',
5158
+ onAction: () => showConflict(record.id),
5159
+ });
5160
+ } else if (!record && result.reason === 'conflict_created') {
5161
+ showEditNotification(
5162
+ 'BoardClip - saved as a new clip',
5163
+ 'The clip you were editing was deleted, so your version is kept as a new clip.'
5164
+ );
5165
+ }
5141
5166
  }
5142
5167
 
5143
5168
  // --- Editor <-> clipboard follow (lib/clipboard-follow.js) ---
@@ -5206,6 +5231,7 @@ function commitEditSession(session, payload) {
5206
5231
  notifyEditOutcome(result);
5207
5232
  if (result.conflictRecord && session.win && !session.win.isDestroyed()) {
5208
5233
  session.inConflict = true;
5234
+ session.conflictId = result.conflictRecord.id;
5209
5235
  try { session.win.webContents.send('editor-conflict', conflictModel.normalizeConflictRecord(result.conflictRecord)); } catch {}
5210
5236
  }
5211
5237
  }
@@ -5403,9 +5429,6 @@ function createEditorWindow(session, presentOptions = {}) {
5403
5429
  trackWindowBounds(editorWin, 'editor_bounds');
5404
5430
  editorWin.on('closed', () => {
5405
5431
  editSessions.delete(session.id);
5406
- if (session.originalId != null && editWindowsByClip.get(session.originalId) === session.id) {
5407
- editWindowsByClip.delete(session.originalId);
5408
- }
5409
5432
  // Safety net: commit the latest on-disk draft (the IPC commit may have raced
5410
5433
  // the window teardown), then mark the draft finished so it's retained but not
5411
5434
  // re-recovered, and prune. suppressCommit = the clip was deliberately deleted
@@ -5448,19 +5471,47 @@ function presentSecondaryWindow(targetWin, options = {}) {
5448
5471
  hidePopup();
5449
5472
  }
5450
5473
 
5474
+ // A note's id changes on every save (content-addressed), so its open editor is
5475
+ // found by the note's edit lineage, never by the id it opened with. Matching
5476
+ // only that id let a reopen start a second editor on the same note; the
5477
+ // first one's next save then left the second stale, and typing in it forked
5478
+ // the note.
5479
+ function findEditSessionForClip(id) {
5480
+ const key = String(id || '');
5481
+ if (!key) return null;
5482
+ const lineage = clipboardModel.supersedeMap(settings.supersedes);
5483
+ const head = (clipId) => lineage.get(clipId) || clipId;
5484
+ const target = head(key);
5485
+ for (const session of editSessions.values()) {
5486
+ if (!session.currentId || !session.win || session.win.isDestroyed()) continue;
5487
+ if (session.currentId === key || head(session.currentId) === target) return session;
5488
+ }
5489
+ return null;
5490
+ }
5491
+
5492
+ // The merge view of a conflict: the editor already showing it (an editor turns
5493
+ // into the merge view when its save conflicts), else a conflict window.
5494
+ function showConflict(conflictId) {
5495
+ const id = String(conflictId || '');
5496
+ for (const session of editSessions.values()) {
5497
+ if (session.conflictId === id && session.win && !session.win.isDestroyed()) {
5498
+ presentSecondaryWindow(session.win);
5499
+ return { ok: true };
5500
+ }
5501
+ }
5502
+ return openConflictWindow(id);
5503
+ }
5504
+
5451
5505
  function openEditor(id, options = {}) {
5452
5506
  if (id != null) {
5453
- const openSessionId = editWindowsByClip.get(id);
5454
- if (openSessionId) {
5455
- const s = editSessions.get(openSessionId);
5456
- if (s && s.win && !s.win.isDestroyed()) {
5457
- try {
5458
- presentSecondaryWindow(s.win, options);
5459
- if (options && options.find) s.win.webContents.send('editor-find', { query: options.find, findRegex: !!options.findRegex });
5460
- if (options && options.focusTitle) s.win.webContents.send('editor-find', { focusTitle: true });
5461
- } catch {}
5462
- return;
5463
- }
5507
+ const s = findEditSessionForClip(id);
5508
+ if (s) {
5509
+ try {
5510
+ presentSecondaryWindow(s.win, options);
5511
+ if (options && options.find) s.win.webContents.send('editor-find', { query: options.find, findRegex: !!options.findRegex });
5512
+ if (options && options.focusTitle) s.win.webContents.send('editor-find', { focusTitle: true });
5513
+ } catch {}
5514
+ return;
5464
5515
  }
5465
5516
  }
5466
5517
  let baseText = '';
@@ -5510,7 +5561,6 @@ function openEditor(id, options = {}) {
5510
5561
  session.follow.observe(editorNoteTexts(session));
5511
5562
  writeDraftFile(session);
5512
5563
  editSessions.set(session.id, session);
5513
- if (originalId != null) editWindowsByClip.set(originalId, session.id);
5514
5564
  createEditorWindow(session, options);
5515
5565
  }
5516
5566
 
@@ -5637,6 +5687,51 @@ function clipWindowState(id) {
5637
5687
  return { item, items, groups: [...(settings.groups || [])], aiGroup: mcpCore.AI_GROUP_NAME };
5638
5688
  }
5639
5689
 
5690
+ // A clip by an id a conflict record holds, followed to its newest version
5691
+ // when it was edited since.
5692
+ function liveTextClip(id) {
5693
+ const key = String(id || '');
5694
+ if (!key) return null;
5695
+ const item = findHistoryItem(key) || findHistoryItem(clipboardModel.supersedeMap(settings.supersedes).get(key));
5696
+ return item && item.type !== 'image' ? item : null;
5697
+ }
5698
+
5699
+ // An editor conflict joins two clips: the note (left) and the edit kept beside
5700
+ // it (targetId). Resolving it makes them one note again.
5701
+ function conflictForkPair(record) {
5702
+ if (!record || record.kind !== 'editor') return null;
5703
+ const note = liveTextClip(record.left && record.left.id);
5704
+ const fork = liveTextClip(record.targetId || (record.right && record.right.id));
5705
+ return note && fork && note !== fork ? { note, fork } : null;
5706
+ }
5707
+
5708
+ // The resolved text went into the note, which keeps its pin, numpad key and
5709
+ // lineage. The fork folds into it like an edit: its groups (and a numpad key
5710
+ // the note lacks) join the note, and a supersede link sends a stale copy or
5711
+ // an open editor of it to the note.
5712
+ function foldForkInto(fork, note) {
5713
+ if (!fork || !note || fork === note) return;
5714
+ const forkIndex = history.indexOf(fork);
5715
+ if (forkIndex < 0) return; // the write already merged the two (same text)
5716
+ const forkId = itemKey(fork);
5717
+ const noteId = itemKey(note);
5718
+ const forkSlot = numpadSlotOf(fork);
5719
+ const noteGroups = groupsOf(note);
5720
+ const newGroups = groupsOf(fork).filter(group => !noteGroups.includes(group));
5721
+ if (newGroups.length) {
5722
+ const pin = ensurePin(note);
5723
+ pin.groups = [...(pin.groups || []), ...newGroups];
5724
+ touchPinGroups(note);
5725
+ }
5726
+ if (isPinned(fork) && !note.pin) ensurePin(note);
5727
+ deleteHistoryIndex(forkIndex, { tombstone: false });
5728
+ addTombstone(forkId, null);
5729
+ addSupersede({ from: forkId, to: noteId, updatedAt: Date.now() });
5730
+ saveSettingsFile();
5731
+ saveHistory();
5732
+ if (forkSlot != null && numpadSlotOf(note) == null) applyNumpadAssign(noteId, forkSlot, clipboardModel.clipRevision(note));
5733
+ }
5734
+
5640
5735
  function applyConflictResolution(resolution) {
5641
5736
  const payload = resolution && typeof resolution === 'object' ? resolution : {};
5642
5737
  const conflictId = String(payload.id || '');
@@ -5649,9 +5744,11 @@ function applyConflictResolution(resolution) {
5649
5744
  const text = write.text;
5650
5745
  const title = snapshot ? titleOf(snapshot) : clipboardModel.titleOf({ title: write.title });
5651
5746
  const targetId = record.targetId || snapshot && snapshot.id || record.right && record.right.id || record.left && record.left.id || '';
5652
- const target = findHistoryItem(targetId);
5747
+ const fork = conflictForkPair(record);
5748
+ const target = fork ? fork.note : findHistoryItem(targetId);
5653
5749
  if (target && target.type !== 'image') {
5654
- applyTextEditToItem(target, { newText: text, newTitle: title });
5750
+ const written = applyTextEditToItem(target, { newText: text, newTitle: title });
5751
+ if (fork) foldForkInto(fork.fork, (written && written.item) || fork.note);
5655
5752
  } else if (text.trim()) {
5656
5753
  applyExternalTextEdit({
5657
5754
  id: '',
@@ -6739,7 +6836,9 @@ function setupIPC() {
6739
6836
  },
6740
6837
  }));
6741
6838
 
6742
- ipcMain.handle('paste', (_, id) => {
6839
+ // Copy without pasting: the clip onto the clipboard (the poller then sees it
6840
+ // like any copy, so it moves to the top as a paste does).
6841
+ ipcMain.handle('copy-clip', (_, id) => {
6743
6842
  const item = findHistoryItem(id);
6744
6843
  if (!item) return;
6745
6844
  setClipboardToItem(item);
@@ -6959,13 +7058,14 @@ function setupIPC() {
6959
7058
  });
6960
7059
  ipcMain.handle('resolve-conflict', (_, payload) => applyConflictResolution(payload));
6961
7060
  ipcMain.handle('get-conflicts', () => conflictListForRenderer());
6962
- ipcMain.handle('open-conflict', (_, id) => openConflictWindow(id));
7061
+ ipcMain.handle('open-conflict', (_, id) => showConflict(id));
6963
7062
 
6964
7063
  // Multi-select bulk operations.
6965
7064
  ipcMain.handle('delete-items', (_, targets) => applyDeleteItems(targets));
6966
7065
  ipcMain.handle('restore-items', (_, snaps) => applyRestoreItems(snaps));
6967
7066
  ipcMain.handle('group-assign-many', (_, targets, group, shouldHave) => applyGroupAssignMany(targets, group, shouldHave));
6968
7067
  ipcMain.handle('paste-many', (_, ids) => pasteMany(ids));
7068
+ ipcMain.handle('copy-many', (_, ids) => copyMany(ids));
6969
7069
  ipcMain.handle('start-unify', (_, ids) => startUnify(ids));
6970
7070
  ipcMain.handle('unify-step', (_, sessionId, payload) => unifyStep(sessionId, payload));
6971
7071
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tobq/boardclip",
3
3
  "productName": "BoardClip",
4
- "version": "2.1.108",
4
+ "version": "2.1.110",
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,