@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.
@@ -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
- 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
+ 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 windowBoundsFromSettings('merge_bounds', { width: 780, height: 560 }); }
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 (mouse-driven opens: middle-click, alt+click, the row menu)
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
- ...windowBoundsFromSettings('viewer_bounds', { width: 720, height: 560 }),
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tobq/boardclip",
3
3
  "productName": "BoardClip",
4
- "version": "2.1.111",
4
+ "version": "2.1.112",
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"]');