@tobq/boardclip 2.1.111 → 2.1.112
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/lib/window-placement.js +80 -0
- package/main.js +49 -5
- package/package.json +1 -1
- package/site/shared/clipboard-popup.css +10 -3
- package/site/shared/clipboard-ui-core.js +9 -8
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Where a clip window (editor, viewer, unify, conflict) opens, as pure maths so
|
|
4
|
+
// it is unit-tested (test/window-placement.test.js); main.js supplies the saved
|
|
5
|
+
// bounds, the display's work area, the clip windows already showing and the
|
|
6
|
+
// popup's bounds when the popup stays up. Owner 2026-10-13: "spawns under BC
|
|
7
|
+
// itself" / "multi spawning they dont stack, it jsut all sits on top".
|
|
8
|
+
// - A clip window already open at the spot pushes the new one a cascade step on,
|
|
9
|
+
// wrapping inside the work area, like the OS cascades documents.
|
|
10
|
+
// - With the popup up (keepPopup opens) the window goes beside the popup, never
|
|
11
|
+
// under it: the popup is always on top. It fans out away from the popup.
|
|
12
|
+
|
|
13
|
+
const CASCADE_STEP = 28;
|
|
14
|
+
const POPUP_GAP = 8;
|
|
15
|
+
function rectsOverlap(a, b) {
|
|
16
|
+
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
|
17
|
+
}
|
|
18
|
+
function clampInto(rect, area) {
|
|
19
|
+
return {
|
|
20
|
+
...rect,
|
|
21
|
+
x: Math.max(area.x, Math.min(rect.x, area.x + area.width - rect.width)),
|
|
22
|
+
y: Math.max(area.y, Math.min(rect.y, area.y + area.height - rect.height)),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Pure placement (unit-tested): base = the saved bounds (x/y may be missing),
|
|
26
|
+
// area = the work area to stay in, taken = top-left corners of the clip windows
|
|
27
|
+
// already showing, avoid = the open popup's bounds when it stays up.
|
|
28
|
+
function placeClipWindowBounds(base, { area, taken = [], avoid = null } = {}) {
|
|
29
|
+
const width = Math.min(base.width, area.width);
|
|
30
|
+
const height = Math.min(base.height, area.height);
|
|
31
|
+
let start = Number.isFinite(base.x) && Number.isFinite(base.y)
|
|
32
|
+
? clampInto({ x: base.x, y: base.y, width, height }, area)
|
|
33
|
+
: { x: Math.round(area.x + (area.width - width) / 2), y: Math.round(area.y + (area.height - height) / 2), width, height };
|
|
34
|
+
// Cascade direction: down and right, unless the window sits left of / above
|
|
35
|
+
// the popup, where it fans out away from the popup instead.
|
|
36
|
+
let dx = 1;
|
|
37
|
+
let dy = 1;
|
|
38
|
+
if (avoid && rectsOverlap(start, avoid)) {
|
|
39
|
+
const midY = avoid.y + Math.round((avoid.height - height) / 2);
|
|
40
|
+
const midX = avoid.x + Math.round((avoid.width - width) / 2);
|
|
41
|
+
const sides = [
|
|
42
|
+
{ x: avoid.x + avoid.width + POPUP_GAP, y: midY, dx: 1, dy: 1 },
|
|
43
|
+
{ x: avoid.x - POPUP_GAP - width, y: midY, dx: -1, dy: 1 },
|
|
44
|
+
{ x: midX, y: avoid.y + avoid.height + POPUP_GAP, dx: 1, dy: 1 },
|
|
45
|
+
{ x: midX, y: avoid.y - POPUP_GAP - height, dx: 1, dy: -1 },
|
|
46
|
+
].map((s) => ({ ...s, rect: clampInto({ x: s.x, y: s.y, width, height }, area) }));
|
|
47
|
+
const overlapArea = (r) => Math.max(0, Math.min(r.x + width, avoid.x + avoid.width) - Math.max(r.x, avoid.x))
|
|
48
|
+
* Math.max(0, Math.min(r.y + height, avoid.y + avoid.height) - Math.max(r.y, avoid.y));
|
|
49
|
+
const best = sides.find((s) => !rectsOverlap(s.rect, avoid))
|
|
50
|
+
|| sides.slice().sort((a, b) => overlapArea(a.rect) - overlapArea(b.rect))[0];
|
|
51
|
+
start = best.rect;
|
|
52
|
+
dx = best.dx;
|
|
53
|
+
dy = best.dy;
|
|
54
|
+
}
|
|
55
|
+
// The cascade walks diagonals (one step down and along per window); a
|
|
56
|
+
// diagonal that leaves the work area starts the next one a step further
|
|
57
|
+
// along at the area's top (or bottom, when fanning upward), like the OS
|
|
58
|
+
// cascade. The first spot no showing clip window holds wins.
|
|
59
|
+
const near = (p, q) => Math.abs(p.x - q.x) < 8 && Math.abs(p.y - q.y) < 8;
|
|
60
|
+
const fits = (p) => p.x >= area.x && p.y >= area.y && p.x + width <= area.x + area.width && p.y + height <= area.y + area.height;
|
|
61
|
+
// A wrapped spot never lands under the popup (unless no side had room for
|
|
62
|
+
// the window, when the least-covered spot is the best there is).
|
|
63
|
+
const besideFits = !(avoid && rectsOverlap(start, avoid));
|
|
64
|
+
const underPopup = (r) => !!avoid && besideFits && rectsOverlap(r, avoid);
|
|
65
|
+
const spanX = Math.max(1, area.width - width + 1);
|
|
66
|
+
const wrapX = (x) => area.x + ((((x - area.x) % spanX) + spanX) % spanX);
|
|
67
|
+
for (let column = 0; column < 64; column += 1) {
|
|
68
|
+
const x0 = wrapX(start.x + dx * column * CASCADE_STEP);
|
|
69
|
+
const y0 = column === 0 ? start.y : dy > 0 ? area.y : area.y + area.height - height;
|
|
70
|
+
for (let step = 0; step < 64; step += 1) {
|
|
71
|
+
const p = { x: x0 + dx * step * CASCADE_STEP, y: y0 + dy * step * CASCADE_STEP };
|
|
72
|
+
if (!fits(p)) break;
|
|
73
|
+
if (underPopup({ ...p, width, height })) continue;
|
|
74
|
+
if (!taken.some((t) => near(t, p))) return { x: p.x, y: p.y, width, height };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { x: start.x, y: start.y, width, height };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { placeClipWindowBounds, rectsOverlap, clampInto, CASCADE_STEP, POPUP_GAP };
|
package/main.js
CHANGED
|
@@ -14,6 +14,7 @@ const macPaste = require('./lib/macos-paste');
|
|
|
14
14
|
const winClipboard = require('./lib/windows-clipboard');
|
|
15
15
|
const { createQuickPaster } = require('./lib/quick-paste');
|
|
16
16
|
const { createClipboardFollower } = require('./lib/clipboard-follow');
|
|
17
|
+
const { placeClipWindowBounds } = require('./lib/window-placement');
|
|
17
18
|
const { dragFileNames } = require('./lib/drag-files');
|
|
18
19
|
const { createHistoryFeed } = require('./lib/history-feed');
|
|
19
20
|
// Shared UI core (pure UMD, no DOM at load): main reuses its chord tests.
|
|
@@ -5359,6 +5360,7 @@ function windowBoundsFromSettings(key, defaults) {
|
|
|
5359
5360
|
return out;
|
|
5360
5361
|
}
|
|
5361
5362
|
const saveBoundsTimers = new Map();
|
|
5363
|
+
const placedBounds = new WeakMap(); // clip window -> the bounds it was opened at
|
|
5362
5364
|
function scheduleWindowBoundsSave(childWin, key) {
|
|
5363
5365
|
if (!childWin || childWin.isDestroyed()) return;
|
|
5364
5366
|
const prior = saveBoundsTimers.get(key);
|
|
@@ -5368,6 +5370,14 @@ function scheduleWindowBoundsSave(childWin, key) {
|
|
|
5368
5370
|
if (!childWin || childWin.isDestroyed()) return;
|
|
5369
5371
|
const maximized = childWin.isMaximized() || childWin.isFullScreen();
|
|
5370
5372
|
const { x, y, width, height } = maximized ? childWin.getNormalBounds() : childWin.getBounds();
|
|
5373
|
+
// A window still where it was placed (a cascade step, beside the popup)
|
|
5374
|
+
// never becomes the saved spot: only a move or resize by the user does, so
|
|
5375
|
+
// untouched windows do not drift the next one's start.
|
|
5376
|
+
const placed = placedBounds.get(childWin);
|
|
5377
|
+
const savedNow = settings[key] || {};
|
|
5378
|
+
if (placed && placed.x === x && placed.y === y && placed.width === width && placed.height === height
|
|
5379
|
+
&& maximized === (savedNow.maximized === true)) return;
|
|
5380
|
+
placedBounds.delete(childWin);
|
|
5371
5381
|
settings[key] = { x, y, width: Math.max(360, width), height: Math.max(240, height), maximized };
|
|
5372
5382
|
saveSettingsFile({ localOnly: true });
|
|
5373
5383
|
}, 300);
|
|
@@ -5389,15 +5399,45 @@ function restoreWindowMaximized(childWin, key, options) {
|
|
|
5389
5399
|
if (!saved || saved.maximized !== true || (options && options.keepPopup)) return;
|
|
5390
5400
|
try { childWin.maximize(); } catch {}
|
|
5391
5401
|
}
|
|
5392
|
-
|
|
5402
|
+
// Every clip window (editor, viewer, unify, conflict) opens at its kind's saved
|
|
5403
|
+
// bounds, then cascades past the clip windows already showing and, when the
|
|
5404
|
+
// popup stays up (keepPopup), goes beside the popup instead of under it
|
|
5405
|
+
// (lib/window-placement.js). The work area is the popup's display, else the
|
|
5406
|
+
// saved spot's, else the cursor's.
|
|
5407
|
+
const clipWindows = new Set();
|
|
5408
|
+
function trackClipWindow(childWin) {
|
|
5409
|
+
clipWindows.add(childWin);
|
|
5410
|
+
try { placedBounds.set(childWin, childWin.getBounds()); } catch {}
|
|
5411
|
+
childWin.once('closed', () => clipWindows.delete(childWin));
|
|
5412
|
+
}
|
|
5413
|
+
function clipWindowBoundsFromSettings(key, defaults, options) {
|
|
5414
|
+
const base = windowBoundsFromSettings(key, defaults);
|
|
5415
|
+
const keepPopup = !!(options && options.keepPopup) && isPopupOpen();
|
|
5416
|
+
const avoid = keepPopup && win && !win.isDestroyed() ? win.getBounds() : null;
|
|
5417
|
+
const anchor = avoid
|
|
5418
|
+
? { x: avoid.x + Math.round(avoid.width / 2), y: avoid.y + Math.round(avoid.height / 2) }
|
|
5419
|
+
: Number.isFinite(base.x) ? { x: base.x + Math.round(base.width / 2), y: base.y + Math.round(base.height / 2) }
|
|
5420
|
+
: screen.getCursorScreenPoint();
|
|
5421
|
+
const area = screen.getDisplayNearestPoint(anchor).workArea;
|
|
5422
|
+
const taken = [];
|
|
5423
|
+
for (const other of clipWindows) {
|
|
5424
|
+
try {
|
|
5425
|
+
if (other.isDestroyed() || !other.isVisible() || other.isMinimized() || other.isMaximized()) continue;
|
|
5426
|
+
const b = other.getBounds();
|
|
5427
|
+
taken.push({ x: b.x, y: b.y });
|
|
5428
|
+
} catch {}
|
|
5429
|
+
}
|
|
5430
|
+
return placeClipWindowBounds(base, { area, taken, avoid });
|
|
5431
|
+
}
|
|
5432
|
+
function editorBoundsFromSettings(options) { return clipWindowBoundsFromSettings('editor_bounds', { width: 560, height: 520 }, options); }
|
|
5393
5433
|
// The merge windows (unify, conflict) keep their own size: two or three panes
|
|
5394
5434
|
// with a head each need width a plain editor does not (and a widened unify
|
|
5395
5435
|
// must not leave the next editor wide).
|
|
5396
|
-
function mergeBoundsFromSettings() { return
|
|
5436
|
+
function mergeBoundsFromSettings(options) { return clipWindowBoundsFromSettings('merge_bounds', { width: 780, height: 560 }, options); }
|
|
5397
5437
|
|
|
5398
5438
|
function createEditorWindow(session, presentOptions = {}) {
|
|
5399
5439
|
const editorWin = new BrowserWindow({
|
|
5400
|
-
...editorBoundsFromSettings(),
|
|
5440
|
+
...editorBoundsFromSettings(presentOptions),
|
|
5401
5441
|
minWidth: 360,
|
|
5402
5442
|
minHeight: 240,
|
|
5403
5443
|
resizable: true,
|
|
@@ -5414,6 +5454,7 @@ function createEditorWindow(session, presentOptions = {}) {
|
|
|
5414
5454
|
},
|
|
5415
5455
|
});
|
|
5416
5456
|
noteSecondarySurface(editorWin);
|
|
5457
|
+
trackClipWindow(editorWin);
|
|
5417
5458
|
session.win = editorWin;
|
|
5418
5459
|
editorWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
|
|
5419
5460
|
// Show + focus the editor, then dismiss the popup so it doesn't linger behind
|
|
@@ -5477,7 +5518,7 @@ function sourceGroupsForNewNote(options) {
|
|
|
5477
5518
|
// Bring an editor/viewer window on screen. Default = a deliberate hand-off: the
|
|
5478
5519
|
// window takes focus and the popup is dismissed (a popup lingering behind the
|
|
5479
5520
|
// editor felt "sticky" - you had to click back to it + Escape). With
|
|
5480
|
-
// options.keepPopup (
|
|
5521
|
+
// options.keepPopup (the "open several" gestures: middle-click, right-click on the open button, alt+click)
|
|
5481
5522
|
// the window is shown WITHOUT activating and the popup stays where it is, so
|
|
5482
5523
|
// several results can be opened one after another; the popup still hides on
|
|
5483
5524
|
// its own blur the moment the user clicks into one of the windows.
|
|
@@ -5648,7 +5689,7 @@ function openImageViewer(id, options = {}) {
|
|
|
5648
5689
|
return;
|
|
5649
5690
|
}
|
|
5650
5691
|
const viewerWin = new BrowserWindow({
|
|
5651
|
-
...
|
|
5692
|
+
...clipWindowBoundsFromSettings('viewer_bounds', { width: 720, height: 560 }, options),
|
|
5652
5693
|
minWidth: 360,
|
|
5653
5694
|
minHeight: 240,
|
|
5654
5695
|
resizable: true,
|
|
@@ -5665,6 +5706,7 @@ function openImageViewer(id, options = {}) {
|
|
|
5665
5706
|
},
|
|
5666
5707
|
});
|
|
5667
5708
|
noteSecondarySurface(viewerWin);
|
|
5709
|
+
trackClipWindow(viewerWin);
|
|
5668
5710
|
viewerWindows.set(key, viewerWin);
|
|
5669
5711
|
viewerWin.loadFile(path.join(SCRIPT_DIR, 'viewer.html'));
|
|
5670
5712
|
viewerWin.once('ready-to-show', () => {
|
|
@@ -5821,6 +5863,7 @@ function openConflictWindow(conflictId) {
|
|
|
5821
5863
|
},
|
|
5822
5864
|
});
|
|
5823
5865
|
noteSecondarySurface(conflictWin);
|
|
5866
|
+
trackClipWindow(conflictWin);
|
|
5824
5867
|
conflictWindows.set(id, conflictWin);
|
|
5825
5868
|
const sessionId = `conflict:${id}`;
|
|
5826
5869
|
conflictWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
|
|
@@ -5938,6 +5981,7 @@ function openUnifyWindow(session) {
|
|
|
5938
5981
|
},
|
|
5939
5982
|
});
|
|
5940
5983
|
noteSecondarySurface(unifyWin);
|
|
5984
|
+
trackClipWindow(unifyWin);
|
|
5941
5985
|
session.win = unifyWin;
|
|
5942
5986
|
unifyWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
|
|
5943
5987
|
unifyWin.once('ready-to-show', () => {
|
package/package.json
CHANGED
|
@@ -316,7 +316,13 @@
|
|
|
316
316
|
display: inline-flex; align-items: center; gap: var(--sp-1); vertical-align: top; font-family: inherit; }
|
|
317
317
|
.filter-tag.icon-filter { min-width: var(--ctl-sm); height: var(--ctl-sm); justify-content: center; padding: 0 var(--sp-2); }
|
|
318
318
|
.filter-tag:hover { background: var(--hover); color: var(--text); }
|
|
319
|
-
|
|
319
|
+
/* A group name reads in the accent text shade wherever it shows: its chip, a
|
|
320
|
+
clip's meta line and window bar, every group picker and chip dropdown row,
|
|
321
|
+
Settings > Groups (owner 2026-10-09 on the chips: "too hard to see before
|
|
322
|
+
selected"; 2026-10-13: "blue tags should be consistent everywhere"). Hover
|
|
323
|
+
keeps the colour (the wash says hover); a picked chip's fill and an excluded
|
|
324
|
+
name's strike override it. */
|
|
325
|
+
.filter-tag.group-tag, .filter-tag.group-tag:hover, .meta-tags > .meta-tag, .bc-menu-item.group-name > .bc-menu-label, .group-row .si-name { color: var(--accent-text); }
|
|
320
326
|
.filter-tag.active, .filter-tag.active:hover { background: var(--accent); color: var(--active-fg); --icon-fill: 1; }
|
|
321
327
|
.filter-tag.active:hover { background: var(--accent-hover); }
|
|
322
328
|
.filter-tag.excluded { color: var(--text-dim); text-decoration: line-through; text-decoration-thickness: 1px; opacity: .6; }
|
|
@@ -414,7 +420,7 @@
|
|
|
414
420
|
/* A group chip's dropdown holds menu rows (renderTagTreeMenuNode, depth > 0):
|
|
415
421
|
an excluded group is struck through like its chip, a greyed one dims in the
|
|
416
422
|
chips' two tiers, and a parent row stays lit while its submenu is out. */
|
|
417
|
-
.bc-menu-item.excluded > .bc-menu-label { text-decoration: line-through; }
|
|
423
|
+
.bc-menu-item.excluded > .bc-menu-label { color: var(--text-dim); text-decoration: line-through; }
|
|
418
424
|
.bc-menu-item.is-disabled { opacity: .4; cursor: default; }
|
|
419
425
|
.bc-menu-item.is-disabled.dis-structural { opacity: .25; }
|
|
420
426
|
.bc-menu-item.is-disabled:hover { background: none; }
|
|
@@ -494,7 +500,8 @@
|
|
|
494
500
|
at the last name and the + follows it instead of the box's far edge. */
|
|
495
501
|
.item:hover .meta-tag.meta-cut, .item:focus-within .meta-tag.meta-cut, .item.actions-held .meta-tag.meta-cut { display: none; }
|
|
496
502
|
.meta-tag { display: block; flex: 0 1 auto; min-width: 0; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
|
|
497
|
-
.meta-np:hover, .meta-
|
|
503
|
+
.meta-np:hover, .meta-ghost:hover { color: var(--text); }
|
|
504
|
+
.meta-np:hover, .meta-tag:hover, .meta-ghost:hover { background: var(--hover); }
|
|
498
505
|
.meta-ghost { display: inline-flex; align-items: center; justify-content: center; width: var(--meta-h); padding: 0; }
|
|
499
506
|
.meta-ghost .mi { font-size: var(--icon-sm); }
|
|
500
507
|
.meta > .meta-reveal, .bc-tag-strip > .meta-reveal { margin-left: calc(-1 * var(--sp-3)); --reveal-gap: var(--sp-3); }
|
|
@@ -220,7 +220,7 @@
|
|
|
220
220
|
check: state === 'include',
|
|
221
221
|
label: node.label || group,
|
|
222
222
|
hint: disabled ? '' : String(count),
|
|
223
|
-
cls: `group-filter-row${state === 'exclude' ? ' excluded' : ''}${disabled ? ` is-disabled${v.kind === 'structural' ? ' dis-structural' : ''}` : ''}`,
|
|
223
|
+
cls: `group-filter-row group-name${state === 'exclude' ? ' excluded' : ''}${disabled ? ` is-disabled${v.kind === 'structural' ? ' dis-structural' : ''}` : ''}`,
|
|
224
224
|
attrs: { 'data-group': group, title, 'aria-disabled': disabled ? 'true' : null },
|
|
225
225
|
};
|
|
226
226
|
return hasChildren
|
|
@@ -1166,7 +1166,7 @@
|
|
|
1166
1166
|
const group = normalizeTagName(node && node.name);
|
|
1167
1167
|
if (!group) return '';
|
|
1168
1168
|
const [icon, checked] = MEMBERSHIP_GLYPH[state(group)] || MEMBERSHIP_GLYPH.none;
|
|
1169
|
-
const row = { icon, check: true, label: node.label || group, role: 'menuitemcheckbox', attrs: { 'data-action': action, 'data-group': group, 'aria-checked': checked, title: group } };
|
|
1169
|
+
const row = { icon, check: true, label: node.label || group, cls: 'group-name', role: 'menuitemcheckbox', attrs: { 'data-action': action, 'data-group': group, 'aria-checked': checked, title: group } };
|
|
1170
1170
|
return node.children && node.children.length
|
|
1171
1171
|
? submenuNodeHtml(row, renderGroupChecklist(node.children, state, action))
|
|
1172
1172
|
: menuRowHtml(row);
|
|
@@ -4986,18 +4986,19 @@
|
|
|
4986
4986
|
refresh();
|
|
4987
4987
|
return true;
|
|
4988
4988
|
}
|
|
4989
|
-
//
|
|
4990
|
-
//
|
|
4991
|
-
//
|
|
4992
|
-
|
|
4989
|
+
// A plain click on any Open (the row's own open button or the clip menu's
|
|
4990
|
+
// Open in editor / Open image) is a hand-off: the window takes the focus
|
|
4991
|
+
// and the popup closes. Only the "open several" gestures keep the popup:
|
|
4992
|
+
// middle-click, right-click on the open button, Alt+click (owner
|
|
4993
|
+
// 2026-10-13: "when i click on note, bc doesnt close... inconsisntent").
|
|
4993
4994
|
const openImg = t.closest('[data-action="open-img"]');
|
|
4994
|
-
if (openImg) { event.stopPropagation(); await a.openImage(a.itemById(openImg.dataset.id)
|
|
4995
|
+
if (openImg) { event.stopPropagation(); await a.openImage(a.itemById(openImg.dataset.id)); return true; }
|
|
4995
4996
|
const openImgExt = t.closest('[data-action="open-img-ext"]');
|
|
4996
4997
|
if (openImgExt) { event.stopPropagation(); if (a.openImageExternal) await a.openImageExternal(a.itemById(openImgExt.dataset.id)); return true; }
|
|
4997
4998
|
const saveImg = t.closest('[data-action="save-img"]');
|
|
4998
4999
|
if (saveImg) { event.stopPropagation(); toast(await a.saveImage(a.itemById(saveImg.dataset.id))); return true; }
|
|
4999
5000
|
const edit = t.closest('[data-action="edit"]');
|
|
5000
|
-
if (edit) { event.stopPropagation(); await a.editClip(edit.dataset.id, edit.closest('.item')
|
|
5001
|
+
if (edit) { event.stopPropagation(); await a.editClip(edit.dataset.id, edit.closest('.item')); return true; }
|
|
5001
5002
|
const rename = t.closest('[data-action="rename"]');
|
|
5002
5003
|
if (rename) { event.stopPropagation(); await renameClip(rename.dataset.id); return true; }
|
|
5003
5004
|
const revert = t.closest('[data-action="revert"]');
|