@tobq/boardclip 2.1.111 → 2.1.113

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.
@@ -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.
@@ -4466,10 +4467,10 @@ function createPopup() {
4466
4467
  setTimeout(() => {
4467
4468
  if (Date.now() < ignoreBlurUntil || !isPopupOpen()) return;
4468
4469
  if (!win.isFocused()) {
4469
- // Focus moved to one of BoardClip's OWN windows (e.g. an editor opened
4470
- // from the popup)? Keep the popup open so the user can open several
4471
- // items in a row. It still dismisses when focus leaves to another app
4472
- // (getFocusedWindow() is null then) or via Escape / the close button.
4470
+ // Focus moved to one of BoardClip's OWN windows: a clip window's own
4471
+ // focus hook (trackClipWindow) closes the popup; any other (the AI
4472
+ // approval prompt) leaves it open. Focus leaving to another app
4473
+ // (getFocusedWindow() is null then) closes it here.
4473
4474
  const focused = BrowserWindow.getFocusedWindow();
4474
4475
  if (focused && focused !== win) return;
4475
4476
  hidePopup();
@@ -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,56 @@ 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
- function editorBoundsFromSettings() { return windowBoundsFromSettings('editor_bounds', { width: 560, height: 520 }); }
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
+ // Going INTO a clip window (clicking it, or any other way it takes the focus)
5409
+ // closes the popup, on every platform: the "open several" gestures leave the
5410
+ // popup up only while the new windows open behind the scenes (showInactive).
5411
+ // Owner 2026-10-13: "still doesnt close going to clip. inconsisnte" - the
5412
+ // popup's blur handler kept it open whenever the focus went to one of our own
5413
+ // windows, and macOS has no blur-to-hide at all.
5414
+ let clipFocusHideHeldUntil = 0;
5415
+ function trackClipWindow(childWin) {
5416
+ clipWindows.add(childWin);
5417
+ try { placedBounds.set(childWin, childWin.getBounds()); } catch {}
5418
+ childWin.on('focus', () => {
5419
+ if (Date.now() < clipFocusHideHeldUntil || !isPopupOpen()) return;
5420
+ hidePopup();
5421
+ });
5422
+ childWin.once('closed', () => clipWindows.delete(childWin));
5423
+ }
5424
+ function clipWindowBoundsFromSettings(key, defaults, options) {
5425
+ const base = windowBoundsFromSettings(key, defaults);
5426
+ const keepPopup = !!(options && options.keepPopup) && isPopupOpen();
5427
+ const avoid = keepPopup && win && !win.isDestroyed() ? win.getBounds() : null;
5428
+ const anchor = avoid
5429
+ ? { x: avoid.x + Math.round(avoid.width / 2), y: avoid.y + Math.round(avoid.height / 2) }
5430
+ : Number.isFinite(base.x) ? { x: base.x + Math.round(base.width / 2), y: base.y + Math.round(base.height / 2) }
5431
+ : screen.getCursorScreenPoint();
5432
+ const area = screen.getDisplayNearestPoint(anchor).workArea;
5433
+ const taken = [];
5434
+ for (const other of clipWindows) {
5435
+ try {
5436
+ if (other.isDestroyed() || !other.isVisible() || other.isMinimized() || other.isMaximized()) continue;
5437
+ const b = other.getBounds();
5438
+ taken.push({ x: b.x, y: b.y });
5439
+ } catch {}
5440
+ }
5441
+ return placeClipWindowBounds(base, { area, taken, avoid });
5442
+ }
5443
+ function editorBoundsFromSettings(options) { return clipWindowBoundsFromSettings('editor_bounds', { width: 560, height: 520 }, options); }
5393
5444
  // The merge windows (unify, conflict) keep their own size: two or three panes
5394
5445
  // with a head each need width a plain editor does not (and a widened unify
5395
5446
  // must not leave the next editor wide).
5396
- function mergeBoundsFromSettings() { return windowBoundsFromSettings('merge_bounds', { width: 780, height: 560 }); }
5447
+ function mergeBoundsFromSettings(options) { return clipWindowBoundsFromSettings('merge_bounds', { width: 780, height: 560 }, options); }
5397
5448
 
5398
5449
  function createEditorWindow(session, presentOptions = {}) {
5399
5450
  const editorWin = new BrowserWindow({
5400
- ...editorBoundsFromSettings(),
5451
+ ...editorBoundsFromSettings(presentOptions),
5401
5452
  minWidth: 360,
5402
5453
  minHeight: 240,
5403
5454
  resizable: true,
@@ -5414,6 +5465,7 @@ function createEditorWindow(session, presentOptions = {}) {
5414
5465
  },
5415
5466
  });
5416
5467
  noteSecondarySurface(editorWin);
5468
+ trackClipWindow(editorWin);
5417
5469
  session.win = editorWin;
5418
5470
  editorWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
5419
5471
  // Show + focus the editor, then dismiss the popup so it doesn't linger behind
@@ -5477,13 +5529,16 @@ function sourceGroupsForNewNote(options) {
5477
5529
  // Bring an editor/viewer window on screen. Default = a deliberate hand-off: the
5478
5530
  // window takes focus and the popup is dismissed (a popup lingering behind the
5479
5531
  // editor felt "sticky" - you had to click back to it + Escape). With
5480
- // options.keepPopup (mouse-driven opens: middle-click, alt+click, the row menu)
5532
+ // options.keepPopup (the "open several" gestures: middle-click, right-click on the open button, alt+click)
5481
5533
  // the window is shown WITHOUT activating and the popup stays where it is, so
5482
5534
  // several results can be opened one after another; the popup still hides on
5483
5535
  // its own blur the moment the user clicks into one of the windows.
5484
5536
  function presentSecondaryWindow(targetWin, options = {}) {
5485
5537
  if (!targetWin || targetWin.isDestroyed()) return;
5486
5538
  if (options && options.keepPopup) {
5539
+ // Backstop: a focus event the inactive show raises on its own must not
5540
+ // close the popup the user is still opening clips from.
5541
+ clipFocusHideHeldUntil = Date.now() + 300;
5487
5542
  targetWin.showInactive();
5488
5543
  if (typeof targetWin.moveTop === 'function') { try { targetWin.moveTop(); } catch {} }
5489
5544
  return;
@@ -5648,7 +5703,7 @@ function openImageViewer(id, options = {}) {
5648
5703
  return;
5649
5704
  }
5650
5705
  const viewerWin = new BrowserWindow({
5651
- ...windowBoundsFromSettings('viewer_bounds', { width: 720, height: 560 }),
5706
+ ...clipWindowBoundsFromSettings('viewer_bounds', { width: 720, height: 560 }, options),
5652
5707
  minWidth: 360,
5653
5708
  minHeight: 240,
5654
5709
  resizable: true,
@@ -5665,6 +5720,7 @@ function openImageViewer(id, options = {}) {
5665
5720
  },
5666
5721
  });
5667
5722
  noteSecondarySurface(viewerWin);
5723
+ trackClipWindow(viewerWin);
5668
5724
  viewerWindows.set(key, viewerWin);
5669
5725
  viewerWin.loadFile(path.join(SCRIPT_DIR, 'viewer.html'));
5670
5726
  viewerWin.once('ready-to-show', () => {
@@ -5821,6 +5877,7 @@ function openConflictWindow(conflictId) {
5821
5877
  },
5822
5878
  });
5823
5879
  noteSecondarySurface(conflictWin);
5880
+ trackClipWindow(conflictWin);
5824
5881
  conflictWindows.set(id, conflictWin);
5825
5882
  const sessionId = `conflict:${id}`;
5826
5883
  conflictWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
@@ -5938,6 +5995,7 @@ function openUnifyWindow(session) {
5938
5995
  },
5939
5996
  });
5940
5997
  noteSecondarySurface(unifyWin);
5998
+ trackClipWindow(unifyWin);
5941
5999
  session.win = unifyWin;
5942
6000
  unifyWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
5943
6001
  unifyWin.once('ready-to-show', () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tobq/boardclip",
3
3
  "productName": "BoardClip",
4
- "version": "2.1.111",
4
+ "version": "2.1.113",
5
5
  "description": "Cross-platform clipboard history tray app with numpad quick-paste and native cloud sync",
6
6
  "keywords": [
7
7
  "clipboard",
@@ -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
- .filter-tag.group-tag, .filter-tag.group-tag:hover { color: var(--accent-text); }
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-tag:hover, .meta-ghost:hover { color: var(--text); background: var(--hover); }
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
- // The row's primary open button is the "normal" open: a hand-off (the
4990
- // popup closes, like before). Only the detached "..."/right-click MENU's
4991
- // Open in editor / Open image keeps the popup, alongside middle/alt-click.
4992
- const fromMenu = !!t.closest('.bc-menu-item');
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), fromMenu ? KEEP_POPUP : undefined); return true; }
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'), fromMenu ? KEEP_POPUP : undefined); return true; }
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"]');