@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 +5 -1
- package/lib/clipboard-model.js +18 -3
- package/lib/notifications.js +83 -0
- package/main.js +142 -42
- package/package.json +1 -1
- package/preload.js +2 -1
- package/site/index.html +11 -7
- package/site/shared/clipboard-ui-core.js +32 -5
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
|
-
|
|
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/lib/clipboard-model.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
'<': '<', '>': '>', '&': '&', '"': '"', "'": ''',
|
|
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":
|
|
4893
|
-
// one go. Images can't concatenate into text, so they're skipped.
|
|
4894
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5071
|
-
//
|
|
5072
|
-
//
|
|
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
|
-
|
|
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
|
|
5134
|
-
//
|
|
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
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
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
|
|
5454
|
-
if (
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
3442
|
-
// actions: activateClip(id),
|
|
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.
|
|
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,
|