@tobq/boardclip 2.1.110 → 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/index.html +42 -9
- package/lib/mcp-search-thread.js +58 -0
- package/lib/window-placement.js +80 -0
- package/main.js +75 -8
- package/mcp/boardclip-mcp.js +10 -5
- package/package.json +1 -1
- package/site/index.html +32 -6
- package/site/shared/clip-search.js +34 -0
- package/site/shared/clipboard-popup.css +13 -3
- package/site/shared/clipboard-ui-core.js +209 -23
- package/site/shared/search-worker.js +33 -0
package/index.html
CHANGED
|
@@ -242,22 +242,54 @@ function renderGroupFilters() {
|
|
|
242
242
|
// relaxCache: the same for the empty-result nudge (Core.search.bestRelaxation).
|
|
243
243
|
const searchRefineCache = {};
|
|
244
244
|
const relaxCache = {};
|
|
245
|
+
// A query with a /regex/ is searched on a worker (Core.createSearchWorker): the
|
|
246
|
+
// list keeps its rows, dimmed after a moment, until the answer arrives.
|
|
247
|
+
// shownQuery is the query the rows on screen were found with, so a row built
|
|
248
|
+
// meanwhile (a scroll) highlights against it, never against a pattern the
|
|
249
|
+
// worker has not run yet. regexFirst: where each regex match first sits in its
|
|
250
|
+
// body (the worker found it; the row never scans the body itself).
|
|
251
|
+
let shownQuery = '';
|
|
252
|
+
let regexFirst = null;
|
|
253
|
+
const searchWorker = Core.createSearchWorker({
|
|
254
|
+
onBusy: (busy) => { listEl.classList.toggle('searching', busy); listEl.setAttribute('aria-busy', busy ? 'true' : 'false'); updateCount(); },
|
|
255
|
+
onError: (message) => { const p = window.api.recordDiagnostics?.('renderer.search_worker_error', { message }); if (p && p.catch) p.catch(() => {}); },
|
|
256
|
+
});
|
|
245
257
|
function applyFilter() {
|
|
246
258
|
const startedAt = performance.now();
|
|
247
|
-
|
|
259
|
+
if (Core.search.hasRegexTerm(parsedQuery())) {
|
|
260
|
+
const q = query;
|
|
261
|
+
const docs = searchDocs;
|
|
262
|
+
searchWorker.search({ docs, hays: searchTextLower, query: q, sortMode, now: Date.now() }, (r) => {
|
|
263
|
+
if (docs !== searchDocs) { scheduleRerenderList(); return; } // the history moved on: search again
|
|
264
|
+
regexFirst = new Map();
|
|
265
|
+
r.indexes.forEach((ri, k) => regexFirst.set(ri, r.first[k]));
|
|
266
|
+
showFiltered(q, r.indexes, r.relax);
|
|
267
|
+
updateCount();
|
|
268
|
+
recordRendererPerf('list.regex_search', startedAt, { rendered: renderedCount(), regex: true }, 400);
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
searchWorker.cancel();
|
|
273
|
+
regexFirst = null;
|
|
274
|
+
showFiltered(query, Core.filterItemIndexes(items, { query, docs: searchDocs, searchTextLower, sortMode, cache: searchRefineCache }));
|
|
275
|
+
recordRendererPerf('list.apply_filter', startedAt, { rendered: renderedCount(), regex: searchBox.getRegex() }, 60);
|
|
276
|
+
}
|
|
277
|
+
// relax: the worker's empty-result nudge (a regex query), else worked out here.
|
|
278
|
+
function showFiltered(q, rows, relax) {
|
|
279
|
+
shownQuery = q;
|
|
280
|
+
const parsed = Core.search.parseQuery(q);
|
|
281
|
+
const firstTerm = parsed.terms.find(c => !c.regex && c.scope !== 'title');
|
|
248
282
|
previewTerm = firstTerm ? String(firstTerm.value).trim().toLowerCase() || null : null;
|
|
249
|
-
filtered =
|
|
250
|
-
const rows = filtered;
|
|
283
|
+
filtered = rows;
|
|
251
284
|
const rowItems = items;
|
|
252
285
|
clipList.update({
|
|
253
286
|
ids: rows.map(ri => itemId(rowItems[ri])),
|
|
254
287
|
tsAt: (i) => Number(rowItems[rows[i]] && rowItems[rows[i]].ts) || 0,
|
|
255
|
-
mode: Core.search.rankMode(
|
|
256
|
-
queryKey:
|
|
288
|
+
mode: Core.search.rankMode(parsed, sortMode),
|
|
289
|
+
queryKey: q,
|
|
257
290
|
// Only when the list IS empty: the one filter whose removal brings clips back.
|
|
258
|
-
emptyHtml: () => Core.renderEmptyState({ total: items.length, query, groups, nudgeHtml: Core.renderRelaxNudge(Core.search.bestRelaxation(searchDocs,
|
|
291
|
+
emptyHtml: () => Core.renderEmptyState({ total: items.length, query: q, groups, nudgeHtml: Core.renderRelaxNudge(relax !== undefined ? relax : Core.search.bestRelaxation(searchDocs, q, { searchTextLower, cache: relaxCache })) }),
|
|
259
292
|
});
|
|
260
|
-
recordRendererPerf('list.apply_filter', startedAt, { rendered: renderedCount(), regex: searchBox.getRegex() }, 60);
|
|
261
293
|
}
|
|
262
294
|
function renderedCount() { const w = clipList.window(); return w.end - w.start; }
|
|
263
295
|
|
|
@@ -299,6 +331,7 @@ function scheduleRelativeTimeRefresh() {
|
|
|
299
331
|
// haystack (no scan of the body itself); null lets the preview search instead.
|
|
300
332
|
let previewTerm = null;
|
|
301
333
|
function previewMatchIndex(ri) {
|
|
334
|
+
if (regexFirst) return regexFirst.has(ri) ? regexFirst.get(ri) : -1;
|
|
302
335
|
if (!previewTerm || !searchDocs[ri]) return null;
|
|
303
336
|
return Core.search.bodyIndexOf(searchDocs[ri], searchTextLower[ri], previewTerm);
|
|
304
337
|
}
|
|
@@ -308,7 +341,7 @@ function buildItem(it, ri) {
|
|
|
308
341
|
// similar rows (it lives in the shared controller).
|
|
309
342
|
return Core.renderClipItem(it, {
|
|
310
343
|
imageSrc: item => `clip-img:///${esc(item.image)}`,
|
|
311
|
-
query,
|
|
344
|
+
query: shownQuery,
|
|
312
345
|
matchIndex: previewMatchIndex(ri),
|
|
313
346
|
selection: controller ? controller.selection() : null,
|
|
314
347
|
actionsHtml: Core.renderClipActions(it),
|
|
@@ -318,7 +351,7 @@ function buildItem(it, ri) {
|
|
|
318
351
|
function updateCount() {
|
|
319
352
|
const total = items.length;
|
|
320
353
|
const visible = filtered.length;
|
|
321
|
-
countEl.textContent = Core.itemCountLabel(total, visible, { query });
|
|
354
|
+
countEl.textContent = searchWorker.busy() ? 'Searching...' : Core.itemCountLabel(total, visible, { query });
|
|
322
355
|
}
|
|
323
356
|
|
|
324
357
|
// The clear / sort / mode / options buttons slide in and out with the field's
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// search_clips for the MCP helper and the app's approved full search, with a
|
|
4
|
+
// query that holds a /regex/ run on a worker thread: one pattern can backtrack
|
|
5
|
+
// for minutes on a long line (12 Oct 2026, the popup froze on
|
|
6
|
+
// y.*t.*b.*\?.*t= against a 20,000-char line), and on the calling thread that
|
|
7
|
+
// would hang the helper, or the app's main process for an approved search.
|
|
8
|
+
// The worker ends when the caller gives up (its abort signal) or, as a
|
|
9
|
+
// backstop for a caller that never does, after MAX_MS. A query without a regex
|
|
10
|
+
// runs right here, as before (a literal scan is linear).
|
|
11
|
+
//
|
|
12
|
+
// This file is also the worker's entry (workerData.mcpSearch).
|
|
13
|
+
|
|
14
|
+
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
|
|
15
|
+
const mcpCore = require('./mcp-core');
|
|
16
|
+
const search = require('../site/shared/clip-search');
|
|
17
|
+
|
|
18
|
+
const MAX_MS = 2 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
if (!isMainThread && workerData && workerData.mcpSearch) {
|
|
21
|
+
const { history, settings, opts } = workerData.mcpSearch;
|
|
22
|
+
parentPort.postMessage(mcpCore.searchClips(history, settings, opts));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hasRegex(opts) {
|
|
26
|
+
const o = opts || {};
|
|
27
|
+
return search.hasRegexTerm(search.parseQuery(o.regex ? search.legacyRegexQuery(o.query) : o.query));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolves to searchClips' result; rejects with a message for the AI client
|
|
31
|
+
// when the pattern ran past the caller's patience.
|
|
32
|
+
function searchClipsOffThread(history, settings, opts, { signal, maxMs = MAX_MS } = {}) {
|
|
33
|
+
if (!hasRegex(opts)) return Promise.resolve(mcpCore.searchClips(history, settings, opts));
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const worker = new Worker(__filename, { workerData: { mcpSearch: { history, settings, opts } } });
|
|
36
|
+
let done = false;
|
|
37
|
+
const finish = (fn, value) => {
|
|
38
|
+
if (done) return;
|
|
39
|
+
done = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
42
|
+
worker.terminate().catch(() => {});
|
|
43
|
+
fn(value);
|
|
44
|
+
};
|
|
45
|
+
const tooSlow = () => new Error('This regular expression took too long on the clips (a few .* in a row backtrack on long lines). Narrow it, e.g. anchor it with literal text.');
|
|
46
|
+
const onAbort = () => finish(reject, new Error('cancelled'));
|
|
47
|
+
const timer = setTimeout(() => finish(reject, tooSlow()), maxMs);
|
|
48
|
+
if (signal) {
|
|
49
|
+
if (signal.aborted) { onAbort(); return; }
|
|
50
|
+
signal.addEventListener('abort', onAbort);
|
|
51
|
+
}
|
|
52
|
+
worker.once('message', (result) => finish(resolve, result));
|
|
53
|
+
worker.once('error', (error) => finish(reject, error));
|
|
54
|
+
worker.once('exit', (code) => finish(reject, new Error(`search worker exited (${code})`)));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { searchClipsOffThread, MAX_MS };
|
|
@@ -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.
|
|
@@ -41,6 +42,7 @@ const syncJournal = require('./lib/sync-journal');
|
|
|
41
42
|
const fsProbe = require('./lib/fs-probe');
|
|
42
43
|
const tailscale = require('./lib/tailscale');
|
|
43
44
|
const mcpCore = require('./lib/mcp-core');
|
|
45
|
+
const mcpSearchThread = require('./lib/mcp-search-thread');
|
|
44
46
|
const mcpPaths = require('./lib/mcp-paths');
|
|
45
47
|
const mcpInstallers = require('./lib/mcp-installers');
|
|
46
48
|
const { Worker } = require('worker_threads');
|
|
@@ -156,6 +158,9 @@ const HISTORY_BACKUP_MAX_BYTES = 1024 * 1024 * 1024;
|
|
|
156
158
|
const HISTORY_BACKUP_MAX_MANIFESTS = 2000;
|
|
157
159
|
const HISTORY_BACKUP_MIN_INTERVAL_MS = 60 * 1000;
|
|
158
160
|
const IMAGE_ORPHAN_RECOVERY_WINDOW_MS = 30 * 60 * 1000;
|
|
161
|
+
// Once Chromium calls the popup renderer unresponsive, how long it may stay so
|
|
162
|
+
// before it is ended and reloaded (createPopup's 'unresponsive' backstop).
|
|
163
|
+
const POPUP_HANG_RELOAD_MS = 5000;
|
|
159
164
|
const IMAGE_ORPHAN_RECOVERY_MAX_FILES = 20;
|
|
160
165
|
const SYNC_REMOTE_WRITE_TIMEOUT_MS = 8000;
|
|
161
166
|
const SYNC_PROVIDER_READ_TIMEOUT_MS = 12000;
|
|
@@ -4389,12 +4394,30 @@ function createPopup() {
|
|
|
4389
4394
|
onFailed(null, null, error && error.message);
|
|
4390
4395
|
}
|
|
4391
4396
|
});
|
|
4397
|
+
// Backstop: a popup renderer that STAYS hung (a page script that never
|
|
4398
|
+
// returns, as a /regex/ on the page's thread did, 12 Oct 2026) used to sit
|
|
4399
|
+
// dead until someone killed it. It is ended now and the render-process-gone
|
|
4400
|
+
// recovery above reloads it; the popup holds no unsaved work. Every firing is
|
|
4401
|
+
// a bug report (popup.renderer_hung_reload).
|
|
4402
|
+
let popupHangTimer = null;
|
|
4392
4403
|
win.webContents.on('unresponsive', () => {
|
|
4393
4404
|
diagnostics.record('popup.renderer_unresponsive_native', {
|
|
4394
4405
|
visible: isPopupOpen(),
|
|
4395
4406
|
}, { forceFile: true });
|
|
4407
|
+
if (popupHangTimer) return;
|
|
4408
|
+
popupHangTimer = setTimeout(() => {
|
|
4409
|
+
popupHangTimer = null;
|
|
4410
|
+
if (!win || win.isDestroyed() || app.isQuitting) return;
|
|
4411
|
+
diagnostics.record('popup.renderer_hung_reload', { visible: isPopupOpen(), after_ms: POPUP_HANG_RELOAD_MS }, { forceFile: true });
|
|
4412
|
+
try {
|
|
4413
|
+
win.webContents.forcefullyCrashRenderer();
|
|
4414
|
+
} catch (error) {
|
|
4415
|
+
diagnostics.record('popup.renderer_hung_reload_failed', { error: error && error.message }, { forceFile: true });
|
|
4416
|
+
}
|
|
4417
|
+
}, POPUP_HANG_RELOAD_MS);
|
|
4396
4418
|
});
|
|
4397
4419
|
win.webContents.on('responsive', () => {
|
|
4420
|
+
if (popupHangTimer) { clearTimeout(popupHangTimer); popupHangTimer = null; }
|
|
4398
4421
|
diagnostics.record('popup.renderer_responsive_native', {
|
|
4399
4422
|
visible: isPopupOpen(),
|
|
4400
4423
|
}, { forceFile: true });
|
|
@@ -5337,6 +5360,7 @@ function windowBoundsFromSettings(key, defaults) {
|
|
|
5337
5360
|
return out;
|
|
5338
5361
|
}
|
|
5339
5362
|
const saveBoundsTimers = new Map();
|
|
5363
|
+
const placedBounds = new WeakMap(); // clip window -> the bounds it was opened at
|
|
5340
5364
|
function scheduleWindowBoundsSave(childWin, key) {
|
|
5341
5365
|
if (!childWin || childWin.isDestroyed()) return;
|
|
5342
5366
|
const prior = saveBoundsTimers.get(key);
|
|
@@ -5346,6 +5370,14 @@ function scheduleWindowBoundsSave(childWin, key) {
|
|
|
5346
5370
|
if (!childWin || childWin.isDestroyed()) return;
|
|
5347
5371
|
const maximized = childWin.isMaximized() || childWin.isFullScreen();
|
|
5348
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);
|
|
5349
5381
|
settings[key] = { x, y, width: Math.max(360, width), height: Math.max(240, height), maximized };
|
|
5350
5382
|
saveSettingsFile({ localOnly: true });
|
|
5351
5383
|
}, 300);
|
|
@@ -5367,15 +5399,45 @@ function restoreWindowMaximized(childWin, key, options) {
|
|
|
5367
5399
|
if (!saved || saved.maximized !== true || (options && options.keepPopup)) return;
|
|
5368
5400
|
try { childWin.maximize(); } catch {}
|
|
5369
5401
|
}
|
|
5370
|
-
|
|
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); }
|
|
5371
5433
|
// The merge windows (unify, conflict) keep their own size: two or three panes
|
|
5372
5434
|
// with a head each need width a plain editor does not (and a widened unify
|
|
5373
5435
|
// must not leave the next editor wide).
|
|
5374
|
-
function mergeBoundsFromSettings() { return
|
|
5436
|
+
function mergeBoundsFromSettings(options) { return clipWindowBoundsFromSettings('merge_bounds', { width: 780, height: 560 }, options); }
|
|
5375
5437
|
|
|
5376
5438
|
function createEditorWindow(session, presentOptions = {}) {
|
|
5377
5439
|
const editorWin = new BrowserWindow({
|
|
5378
|
-
...editorBoundsFromSettings(),
|
|
5440
|
+
...editorBoundsFromSettings(presentOptions),
|
|
5379
5441
|
minWidth: 360,
|
|
5380
5442
|
minHeight: 240,
|
|
5381
5443
|
resizable: true,
|
|
@@ -5392,6 +5454,7 @@ function createEditorWindow(session, presentOptions = {}) {
|
|
|
5392
5454
|
},
|
|
5393
5455
|
});
|
|
5394
5456
|
noteSecondarySurface(editorWin);
|
|
5457
|
+
trackClipWindow(editorWin);
|
|
5395
5458
|
session.win = editorWin;
|
|
5396
5459
|
editorWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
|
|
5397
5460
|
// Show + focus the editor, then dismiss the popup so it doesn't linger behind
|
|
@@ -5455,7 +5518,7 @@ function sourceGroupsForNewNote(options) {
|
|
|
5455
5518
|
// Bring an editor/viewer window on screen. Default = a deliberate hand-off: the
|
|
5456
5519
|
// window takes focus and the popup is dismissed (a popup lingering behind the
|
|
5457
5520
|
// editor felt "sticky" - you had to click back to it + Escape). With
|
|
5458
|
-
// options.keepPopup (
|
|
5521
|
+
// options.keepPopup (the "open several" gestures: middle-click, right-click on the open button, alt+click)
|
|
5459
5522
|
// the window is shown WITHOUT activating and the popup stays where it is, so
|
|
5460
5523
|
// several results can be opened one after another; the popup still hides on
|
|
5461
5524
|
// its own blur the moment the user clicks into one of the windows.
|
|
@@ -5626,7 +5689,7 @@ function openImageViewer(id, options = {}) {
|
|
|
5626
5689
|
return;
|
|
5627
5690
|
}
|
|
5628
5691
|
const viewerWin = new BrowserWindow({
|
|
5629
|
-
...
|
|
5692
|
+
...clipWindowBoundsFromSettings('viewer_bounds', { width: 720, height: 560 }, options),
|
|
5630
5693
|
minWidth: 360,
|
|
5631
5694
|
minHeight: 240,
|
|
5632
5695
|
resizable: true,
|
|
@@ -5643,6 +5706,7 @@ function openImageViewer(id, options = {}) {
|
|
|
5643
5706
|
},
|
|
5644
5707
|
});
|
|
5645
5708
|
noteSecondarySurface(viewerWin);
|
|
5709
|
+
trackClipWindow(viewerWin);
|
|
5646
5710
|
viewerWindows.set(key, viewerWin);
|
|
5647
5711
|
viewerWin.loadFile(path.join(SCRIPT_DIR, 'viewer.html'));
|
|
5648
5712
|
viewerWin.once('ready-to-show', () => {
|
|
@@ -5799,6 +5863,7 @@ function openConflictWindow(conflictId) {
|
|
|
5799
5863
|
},
|
|
5800
5864
|
});
|
|
5801
5865
|
noteSecondarySurface(conflictWin);
|
|
5866
|
+
trackClipWindow(conflictWin);
|
|
5802
5867
|
conflictWindows.set(id, conflictWin);
|
|
5803
5868
|
const sessionId = `conflict:${id}`;
|
|
5804
5869
|
conflictWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
|
|
@@ -5916,6 +5981,7 @@ function openUnifyWindow(session) {
|
|
|
5916
5981
|
},
|
|
5917
5982
|
});
|
|
5918
5983
|
noteSecondarySurface(unifyWin);
|
|
5984
|
+
trackClipWindow(unifyWin);
|
|
5919
5985
|
session.win = unifyWin;
|
|
5920
5986
|
unifyWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
|
|
5921
5987
|
unifyWin.once('ready-to-show', () => {
|
|
@@ -6717,10 +6783,10 @@ async function mcpHandleRequest(reqPath, payload, ctx = {}) {
|
|
|
6717
6783
|
// Re-check after the (possibly long) approval await: if AI access was toggled
|
|
6718
6784
|
// off while the modal was open, refuse before performing the action.
|
|
6719
6785
|
if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
|
|
6720
|
-
return mcpExecute(tool, args);
|
|
6786
|
+
return mcpExecute(tool, args, ctx);
|
|
6721
6787
|
}
|
|
6722
6788
|
|
|
6723
|
-
function mcpExecute(tool, args) {
|
|
6789
|
+
function mcpExecute(tool, args, ctx = {}) {
|
|
6724
6790
|
switch (tool) {
|
|
6725
6791
|
case 'read_clip': {
|
|
6726
6792
|
const item = findHistoryItem(args.id);
|
|
@@ -6731,7 +6797,8 @@ function mcpExecute(tool, args) {
|
|
|
6731
6797
|
return { ...mcpCore.fullTextResult(item, sharedSet), viaApproval: true };
|
|
6732
6798
|
}
|
|
6733
6799
|
case 'search_all':
|
|
6734
|
-
|
|
6800
|
+
// A /regex/ runs on a worker thread: it must never hang this process.
|
|
6801
|
+
return mcpSearchThread.searchClipsOffThread(history, settings, { query: args.query, regex: !!args.regex, scope: 'all', limit: args.limit || mcpCore.DEFAULT_LIST_LIMIT }, { signal: ctx.signal });
|
|
6735
6802
|
case 'image_path': {
|
|
6736
6803
|
const item = findHistoryItem(args.id);
|
|
6737
6804
|
if (!item || item.type !== 'image') throw new Error('not_an_image');
|
package/mcp/boardclip-mcp.js
CHANGED
|
@@ -26,6 +26,7 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
|
|
|
26
26
|
const model = require('../lib/clipboard-model');
|
|
27
27
|
const textBlobStore = require('../lib/text-blob-store');
|
|
28
28
|
const mcpCore = require('../lib/mcp-core');
|
|
29
|
+
const { searchClipsOffThread } = require('../lib/mcp-search-thread');
|
|
29
30
|
const mcpPaths = require('../lib/mcp-paths');
|
|
30
31
|
const controlClient = require('../lib/control-client');
|
|
31
32
|
|
|
@@ -136,14 +137,18 @@ server.registerTool('search_clips', {
|
|
|
136
137
|
include_unshared: z.boolean().optional(),
|
|
137
138
|
limit: z.number().int().min(1).max(200).optional(),
|
|
138
139
|
},
|
|
139
|
-
}, async ({ query, regex, include_unshared, limit }) => {
|
|
140
|
+
}, async ({ query, regex, include_unshared, limit }, extra) => {
|
|
140
141
|
if (include_unshared) {
|
|
141
142
|
return runForward('search_all', { query, regex: !!regex, limit: limit || mcpCore.DEFAULT_LIST_LIMIT });
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
try {
|
|
145
|
+
const res = await searchClipsOffThread(readHistory(), readSettings(), {
|
|
146
|
+
query, regex: !!regex, limit: limit || mcpCore.DEFAULT_LIST_LIMIT,
|
|
147
|
+
}, { signal: extra && extra.signal });
|
|
148
|
+
return jsonResult(res);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return errorResult(error && error.message ? error.message : String(error));
|
|
151
|
+
}
|
|
147
152
|
});
|
|
148
153
|
|
|
149
154
|
server.registerTool('get_clip', {
|
package/package.json
CHANGED
package/site/index.html
CHANGED
|
@@ -493,28 +493,54 @@
|
|
|
493
493
|
// Rows render through the SAME shared list as the app (Core.createClipList,
|
|
494
494
|
// created below): it keeps your place across every rebuild.
|
|
495
495
|
let visibleRows = [];
|
|
496
|
+
// As in the app: a query with a /regex/ is searched on the shared worker
|
|
497
|
+
// (Core.createSearchWorker); the rows on screen keep the query they were
|
|
498
|
+
// found with (shownQuery) and where each regex match first sits (regexFirst).
|
|
499
|
+
let shownQuery = "";
|
|
500
|
+
let regexFirst = null;
|
|
501
|
+
const demoSearchWorker = Core.createSearchWorker({
|
|
502
|
+
onBusy: (busy) => { clipList.classList.toggle("searching", busy); clipList.setAttribute("aria-busy", busy ? "true" : "false"); if (busy) clipCount.textContent = "Searching..."; },
|
|
503
|
+
});
|
|
496
504
|
function renderClipRow(clip) {
|
|
505
|
+
const id = Core.itemId(clip);
|
|
497
506
|
return Core.renderClipItem(clip, {
|
|
498
507
|
imageSrc: item => item.imageSrc,
|
|
499
|
-
query:
|
|
508
|
+
query: shownQuery,
|
|
509
|
+
...(regexFirst ? { matchIndex: regexFirst.has(id) ? regexFirst.get(id) : -1 } : {}),
|
|
500
510
|
selection: controller ? controller.selection() : null,
|
|
501
511
|
actionsHtml: Core.renderClipActions(clip),
|
|
502
512
|
});
|
|
503
513
|
}
|
|
504
514
|
function renderClips() {
|
|
505
|
-
const visible = Core.filterItems(clips, state);
|
|
506
|
-
clipCount.textContent = Core.itemCountLabel(clips.length, visible.length, state);
|
|
507
515
|
updateClearControls();
|
|
508
516
|
updateDemoSortButton(); // defined below; renderClips first runs at the bottom of this script
|
|
509
517
|
renderFilterChips();
|
|
510
518
|
renderDemoSettings();
|
|
519
|
+
const q = state.query;
|
|
520
|
+
if (Core.search.hasRegexTerm(Core.search.parseQuery(q))) {
|
|
521
|
+
const list = clips;
|
|
522
|
+
demoSearchWorker.search({ docs: demoSearchDocs(), query: q, sortMode: state.sortMode, now: Date.now() }, (r) => {
|
|
523
|
+
if (list !== clips) { renderClips(); return; } // the clips moved on: search again
|
|
524
|
+
regexFirst = new Map(r.indexes.map((i, k) => [Core.itemId(list[i]), r.first[k]]));
|
|
525
|
+
showDemoRows(q, r.indexes.map((i) => list[i]), r.relax);
|
|
526
|
+
});
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
demoSearchWorker.cancel();
|
|
530
|
+
regexFirst = null;
|
|
531
|
+
showDemoRows(q, Core.filterItems(clips, state));
|
|
532
|
+
}
|
|
533
|
+
// relax: the worker's empty-result nudge (a regex query), else worked out here.
|
|
534
|
+
function showDemoRows(q, visible, relax) {
|
|
535
|
+
shownQuery = q;
|
|
536
|
+
clipCount.textContent = Core.itemCountLabel(clips.length, visible.length, { query: q });
|
|
511
537
|
visibleRows = visible;
|
|
512
538
|
demoList.update({
|
|
513
539
|
ids: visible.map((clip) => Core.itemId(clip)),
|
|
514
540
|
tsAt: (i) => Number(visible[i] && visible[i].ts) || 0,
|
|
515
|
-
mode: Core.search.rankMode(Core.search.parseQuery(
|
|
516
|
-
queryKey:
|
|
517
|
-
emptyHtml: () => Core.renderEmptyState({ total: clips.length, query:
|
|
541
|
+
mode: Core.search.rankMode(Core.search.parseQuery(q), state.sortMode),
|
|
542
|
+
queryKey: q,
|
|
543
|
+
emptyHtml: () => Core.renderEmptyState({ total: clips.length, query: q, groups: labels, nudgeHtml: Core.renderRelaxNudge(relax !== undefined ? relax : Core.search.bestRelaxation(demoSearchDocs(), q, { cache: demoRelaxCache })) }),
|
|
518
544
|
});
|
|
519
545
|
}
|
|
520
546
|
|
|
@@ -1224,6 +1224,39 @@
|
|
|
1224
1224
|
return scored.map((s) => s.i);
|
|
1225
1225
|
}
|
|
1226
1226
|
|
|
1227
|
+
// Does the query hold a /regex/ anywhere (a top-level term, an excluded one,
|
|
1228
|
+
// or a leaf of a custom expression)? A regex is the one term whose cost has
|
|
1229
|
+
// no bound: y.*t.*b.*\?.*t= backtracked for minutes on a 20,000-char line
|
|
1230
|
+
// and froze the popup for good (12 Oct 2026), so such a query is searched
|
|
1231
|
+
// off the page's thread (searchJob in Core.createSearchWorker's worker).
|
|
1232
|
+
function hasRegexTerm(parsed) {
|
|
1233
|
+
const p = parsed || {};
|
|
1234
|
+
const inNode = (n) => !!n && (n.type === 'leaf' ? !!(n.leaf && n.leaf.regex) : (n.children || []).some(inNode));
|
|
1235
|
+
return (p.content || []).some((c) => c.regex) || (p.terms || []).some((c) => c.regex) || (p.compound || []).some(inNode);
|
|
1236
|
+
}
|
|
1237
|
+
// One whole search, as the worker runs it: the ranked ORIGINAL indexes, where
|
|
1238
|
+
// each match first sits in its body (the row's preview window; -1 = none),
|
|
1239
|
+
// and the empty-result nudge. It also marks every match's title and body
|
|
1240
|
+
// once (termSpans), so a pattern that takes too long on a matched clip
|
|
1241
|
+
// stalls HERE, off the page's thread; the row highlight then only marks a
|
|
1242
|
+
// short window of those same texts. req: { query, sortMode, now }.
|
|
1243
|
+
function searchJob(docs, hays, req) {
|
|
1244
|
+
const r = req || {};
|
|
1245
|
+
const parsed = parseQuery(r.query || '');
|
|
1246
|
+
const now = r.now || Date.now();
|
|
1247
|
+
const list = docs || [];
|
|
1248
|
+
const indexes = filterRankIndexes(null, parsed, { docs: list, searchTextLower: hays || null, now, sortMode: r.sortMode || null });
|
|
1249
|
+
const first = new Array(indexes.length);
|
|
1250
|
+
for (let k = 0; k < indexes.length; k += 1) {
|
|
1251
|
+
const doc = list[indexes[k]];
|
|
1252
|
+
first[k] = doc.type === 'image' ? -1 : firstMatchIndex(parsed, doc.body);
|
|
1253
|
+
termSpans(parsed, doc.title, 'title', 100);
|
|
1254
|
+
if (doc.type !== 'image') termSpans(parsed, doc.body, 'body', 100);
|
|
1255
|
+
}
|
|
1256
|
+
const relax = indexes.length ? null : bestRelaxation(list, parsed, { now, searchTextLower: hays || null });
|
|
1257
|
+
return { indexes, first, relax };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1227
1260
|
// ── IntelliJ camel-hump fuzzy matcher (ported from Forge src/renderer/lib/fuzzy.ts,
|
|
1228
1261
|
// React stripped). `sdi` -> "Sync Data-loss Incident". Returns {score, positions}|null. ──
|
|
1229
1262
|
const F_BONUS_WORD_START = 24, F_BONUS_STRING_START = 12, F_BONUS_CONSECUTIVE = 16, F_BONUS_CASE = 2, F_PENALTY_GAP = -2, F_PENALTY_LEAD = -1, F_LEAD_CAP = -6;
|
|
@@ -2315,6 +2348,7 @@
|
|
|
2315
2348
|
tokenizeQuery, quoteToken, parseQuery, scanQuery, serializeQuery, applyFacet, stripFacet, clearFacet, facetState, facetTokenState,
|
|
2316
2349
|
anyFilterActive, hasSearchTerms, resolveTimeMs,
|
|
2317
2350
|
matchDoc, relevanceScore, recencyScore, decayWeight, groupWeights, compareGroupUse, rankMode, filterRankIndexes, bodyIndexOf,
|
|
2351
|
+
hasRegexTerm, searchJob,
|
|
2318
2352
|
compileTerm, escapeRegExp, termSpans, firstMatchIndex,
|
|
2319
2353
|
regexTypingEdit, regexBackspaceEdit, toggleRegexAt, legacyRegexQuery, dimConnective, setDimConnective, normalizeJoins,
|
|
2320
2354
|
promptOptionState, promptOptionLabel, FIELD_EXAMPLE,
|
|
@@ -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; }
|
|
@@ -352,6 +358,9 @@
|
|
|
352
358
|
.bc-scroll-fade, Core.createClipList); scroll-padding keeps the keyboard
|
|
353
359
|
cursor out of the fades when it is scrolled into view. */
|
|
354
360
|
.list { flex: 1; overflow-y: auto; padding: 0 0 var(--sp-4); min-height: 0; scroll-padding-block: 36px; }
|
|
361
|
+
/* A /regex/ search still running on the search worker: the rows on screen are the last answer, not this one. */
|
|
362
|
+
.list { transition: opacity var(--dur) var(--ease); }
|
|
363
|
+
.list.searching { opacity: .55; }
|
|
355
364
|
.item { padding: var(--sp-4) var(--gutter); cursor: pointer; transition: background-color var(--dur) var(--ease); position: relative; border-bottom: 1px solid var(--line-faint); color: inherit; }
|
|
356
365
|
/* Row states: hover = the --hover overlay; the keyboard cursor (the row Enter
|
|
357
366
|
pastes) = --accent-bg; a candidate duplicate of the hovered or cursor row
|
|
@@ -411,7 +420,7 @@
|
|
|
411
420
|
/* A group chip's dropdown holds menu rows (renderTagTreeMenuNode, depth > 0):
|
|
412
421
|
an excluded group is struck through like its chip, a greyed one dims in the
|
|
413
422
|
chips' two tiers, and a parent row stays lit while its submenu is out. */
|
|
414
|
-
.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; }
|
|
415
424
|
.bc-menu-item.is-disabled { opacity: .4; cursor: default; }
|
|
416
425
|
.bc-menu-item.is-disabled.dis-structural { opacity: .25; }
|
|
417
426
|
.bc-menu-item.is-disabled:hover { background: none; }
|
|
@@ -491,7 +500,8 @@
|
|
|
491
500
|
at the last name and the + follows it instead of the box's far edge. */
|
|
492
501
|
.item:hover .meta-tag.meta-cut, .item:focus-within .meta-tag.meta-cut, .item.actions-held .meta-tag.meta-cut { display: none; }
|
|
493
502
|
.meta-tag { display: block; flex: 0 1 auto; min-width: 0; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
|
|
494
|
-
.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); }
|
|
495
505
|
.meta-ghost { display: inline-flex; align-items: center; justify-content: center; width: var(--meta-h); padding: 0; }
|
|
496
506
|
.meta-ghost .mi { font-size: var(--icon-sm); }
|
|
497
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
|
|
@@ -290,6 +290,176 @@
|
|
|
290
290
|
const list = items || [];
|
|
291
291
|
return filterItemIndexes(list, state).map((i) => list[i]);
|
|
292
292
|
}
|
|
293
|
+
// A search that holds a /regex/ (and the find bar's regex) runs on a worker
|
|
294
|
+
// thread (search-worker.js beside this file): a pattern can backtrack for
|
|
295
|
+
// minutes on one long line (y.*t.*b.*\?.*t= froze the popup for good on a
|
|
296
|
+
// 20,000-char line, 12 Oct 2026), and nothing can interrupt a RegExp on the
|
|
297
|
+
// page's thread. The latest request wins. A worker still busy GRACE ms after
|
|
298
|
+
// its result stopped being wanted is ended (it may be stuck for hours) and
|
|
299
|
+
// the next request starts a fresh one. The worker holds its own copy of the
|
|
300
|
+
// docs, synced by doc OBJECT, so a new history revision sends only new or
|
|
301
|
+
// changed clips. opts: { url, graceMs, onBusy(busy) (a wanted result has been
|
|
302
|
+
// outstanding for busyMs), busyMs, onError(message) (the worker failed; the job
|
|
303
|
+
// then runs on the page, as before) }.
|
|
304
|
+
const SEARCH_WORKER_URL = (() => {
|
|
305
|
+
try { return new URL('search-worker.js', document.currentScript.src).href; } catch { return ''; }
|
|
306
|
+
})();
|
|
307
|
+
function createSearchWorker(opts) {
|
|
308
|
+
const o = opts || {};
|
|
309
|
+
const url = o.url || SEARCH_WORKER_URL;
|
|
310
|
+
const graceMs = o.graceMs != null ? o.graceMs : 250;
|
|
311
|
+
const busyMs = o.busyMs != null ? o.busyMs : 150;
|
|
312
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
313
|
+
const keys = new WeakMap();
|
|
314
|
+
let nextKey = 1;
|
|
315
|
+
let worker = null;
|
|
316
|
+
let sent = new WeakSet();
|
|
317
|
+
let syncedDocs = null;
|
|
318
|
+
let running = null; // { job, startedAt }
|
|
319
|
+
let queued = null;
|
|
320
|
+
let wanted = 0;
|
|
321
|
+
let seq = 0;
|
|
322
|
+
let killTimer = null;
|
|
323
|
+
let busyTimer = null;
|
|
324
|
+
let busy = false;
|
|
325
|
+
let idleWaiters = [];
|
|
326
|
+
const setBusy = (next) => {
|
|
327
|
+
if (busy === next) return;
|
|
328
|
+
busy = next;
|
|
329
|
+
if (typeof o.onBusy === 'function') o.onBusy(next);
|
|
330
|
+
};
|
|
331
|
+
const settleIdle = () => {
|
|
332
|
+
if (wanted) return;
|
|
333
|
+
clearTimeout(busyTimer);
|
|
334
|
+
setBusy(false);
|
|
335
|
+
const waiters = idleWaiters;
|
|
336
|
+
idleWaiters = [];
|
|
337
|
+
waiters.forEach((fn) => fn());
|
|
338
|
+
};
|
|
339
|
+
function kill() {
|
|
340
|
+
clearTimeout(killTimer);
|
|
341
|
+
killTimer = null;
|
|
342
|
+
if (worker) worker.terminate();
|
|
343
|
+
worker = null;
|
|
344
|
+
running = null;
|
|
345
|
+
sent = new WeakSet();
|
|
346
|
+
syncedDocs = null;
|
|
347
|
+
}
|
|
348
|
+
function keyOf(doc) {
|
|
349
|
+
let k = keys.get(doc);
|
|
350
|
+
if (!k) { k = nextKey; nextKey += 1; keys.set(doc, k); }
|
|
351
|
+
return k;
|
|
352
|
+
}
|
|
353
|
+
function sync(docs, hays) {
|
|
354
|
+
if (docs === syncedDocs) return;
|
|
355
|
+
const list = docs || [];
|
|
356
|
+
const order = new Array(list.length);
|
|
357
|
+
const add = [];
|
|
358
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
359
|
+
const doc = list[i];
|
|
360
|
+
if (!doc) { order[i] = 0; continue; }
|
|
361
|
+
const k = keyOf(doc);
|
|
362
|
+
order[i] = k;
|
|
363
|
+
if (!sent.has(doc)) { sent.add(doc); add.push([k, doc, hays ? hays[i] : null]); }
|
|
364
|
+
}
|
|
365
|
+
worker.postMessage({ type: 'sync', order, add });
|
|
366
|
+
syncedDocs = docs;
|
|
367
|
+
}
|
|
368
|
+
// No worker (it failed to load): the job runs here, as before.
|
|
369
|
+
function runHere(job) {
|
|
370
|
+
const m = job.msg;
|
|
371
|
+
if (m.type === 'search') return { type: 'search', id: m.id, ...Search.searchJob(job.docs, job.hays, m) };
|
|
372
|
+
const term = Search.compileTerm(m.query, { regex: !!m.regex, caseSensitive: !!m.caseSensitive });
|
|
373
|
+
return { type: 'find', id: m.id, matches: term.all(String(m.text || '')), valid: term.valid, error: term.error };
|
|
374
|
+
}
|
|
375
|
+
function deliver(job, result) {
|
|
376
|
+
if (job.id !== wanted) return;
|
|
377
|
+
wanted = 0;
|
|
378
|
+
try { job.cb(result); } finally { settleIdle(); }
|
|
379
|
+
}
|
|
380
|
+
function start(job) {
|
|
381
|
+
if (!worker && url && typeof Worker === 'function') {
|
|
382
|
+
try {
|
|
383
|
+
worker = new Worker(url);
|
|
384
|
+
worker.onmessage = onMessage;
|
|
385
|
+
worker.onerror = onError;
|
|
386
|
+
} catch { worker = null; }
|
|
387
|
+
}
|
|
388
|
+
if (!worker) { deliver(job, runHere(job)); return; }
|
|
389
|
+
if (job.msg.type === 'search') sync(job.docs, job.hays);
|
|
390
|
+
running = { job, startedAt: now() };
|
|
391
|
+
worker.postMessage(job.msg);
|
|
392
|
+
}
|
|
393
|
+
function next() {
|
|
394
|
+
const job = queued;
|
|
395
|
+
queued = null;
|
|
396
|
+
if (job) start(job);
|
|
397
|
+
}
|
|
398
|
+
function onMessage(event) {
|
|
399
|
+
const m = event.data || {};
|
|
400
|
+
if (!running || m.id !== running.job.id) return;
|
|
401
|
+
const job = running.job;
|
|
402
|
+
running = null;
|
|
403
|
+
clearTimeout(killTimer);
|
|
404
|
+
killTimer = null;
|
|
405
|
+
deliver(job, m);
|
|
406
|
+
next();
|
|
407
|
+
}
|
|
408
|
+
// The worker failed (it could not load, or ran out of memory): it is
|
|
409
|
+
// ended, and the job it held runs here so its answer still arrives.
|
|
410
|
+
function onError(event) {
|
|
411
|
+
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
|
412
|
+
if (typeof o.onError === 'function') o.onError(String(event && event.message || 'search worker failed'));
|
|
413
|
+
const job = running && running.job;
|
|
414
|
+
kill();
|
|
415
|
+
if (job) deliver(job, runHere(job));
|
|
416
|
+
next();
|
|
417
|
+
}
|
|
418
|
+
// The running job's result is not wanted any more: end its worker once it
|
|
419
|
+
// has had graceMs (a quick job just finishes and the queue moves on).
|
|
420
|
+
function armKill() {
|
|
421
|
+
if (!running || running.job.id === wanted) return;
|
|
422
|
+
clearTimeout(killTimer);
|
|
423
|
+
killTimer = setTimeout(() => {
|
|
424
|
+
killTimer = null;
|
|
425
|
+
if (!running || running.job.id === wanted) return;
|
|
426
|
+
kill();
|
|
427
|
+
next();
|
|
428
|
+
}, Math.max(0, graceMs - (now() - running.startedAt)));
|
|
429
|
+
}
|
|
430
|
+
function request(type, msg, cb, docs, hays) {
|
|
431
|
+
seq += 1;
|
|
432
|
+
const job = { id: seq, msg: { ...msg, type, id: seq }, cb, docs, hays };
|
|
433
|
+
wanted = job.id;
|
|
434
|
+
clearTimeout(busyTimer);
|
|
435
|
+
busyTimer = setTimeout(() => { if (wanted) setBusy(true); }, busyMs);
|
|
436
|
+
if (running) { queued = job; armKill(); } else start(job);
|
|
437
|
+
return job.id;
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
// req: { docs, hays (the lowercased haystacks, or null), query, sortMode, now }
|
|
441
|
+
search(req, cb) {
|
|
442
|
+
const r = req || {};
|
|
443
|
+
return request('search', { query: r.query || '', sortMode: r.sortMode || null, now: r.now || Date.now() }, cb, r.docs || [], r.hays || null);
|
|
444
|
+
},
|
|
445
|
+
// req: { text, query, regex, caseSensitive }
|
|
446
|
+
find(req, cb) {
|
|
447
|
+
const r = req || {};
|
|
448
|
+
return request('find', { text: String(r.text || ''), query: String(r.query || ''), regex: !!r.regex, caseSensitive: !!r.caseSensitive }, cb);
|
|
449
|
+
},
|
|
450
|
+
// The outstanding result is not wanted any more.
|
|
451
|
+
cancel() {
|
|
452
|
+
wanted = 0;
|
|
453
|
+
queued = null;
|
|
454
|
+
armKill();
|
|
455
|
+
settleIdle();
|
|
456
|
+
},
|
|
457
|
+
busy: () => busy,
|
|
458
|
+
// Resolves once no wanted result is outstanding (QA drivers await it).
|
|
459
|
+
whenIdle: () => (wanted ? new Promise((resolve) => idleWaiters.push(resolve)) : Promise.resolve()),
|
|
460
|
+
dispose() { wanted = 0; queued = null; kill(); settleIdle(); },
|
|
461
|
+
};
|
|
462
|
+
}
|
|
293
463
|
function itemCountLabel(total, visible, state) {
|
|
294
464
|
const count = Number(total) || 0;
|
|
295
465
|
const shown = Number(visible) || 0;
|
|
@@ -996,7 +1166,7 @@
|
|
|
996
1166
|
const group = normalizeTagName(node && node.name);
|
|
997
1167
|
if (!group) return '';
|
|
998
1168
|
const [icon, checked] = MEMBERSHIP_GLYPH[state(group)] || MEMBERSHIP_GLYPH.none;
|
|
999
|
-
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 } };
|
|
1000
1170
|
return node.children && node.children.length
|
|
1001
1171
|
? submenuNodeHtml(row, renderGroupChecklist(node.children, state, action))
|
|
1002
1172
|
: menuRowHtml(row);
|
|
@@ -4816,18 +4986,19 @@
|
|
|
4816
4986
|
refresh();
|
|
4817
4987
|
return true;
|
|
4818
4988
|
}
|
|
4819
|
-
//
|
|
4820
|
-
//
|
|
4821
|
-
//
|
|
4822
|
-
|
|
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").
|
|
4823
4994
|
const openImg = t.closest('[data-action="open-img"]');
|
|
4824
|
-
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; }
|
|
4825
4996
|
const openImgExt = t.closest('[data-action="open-img-ext"]');
|
|
4826
4997
|
if (openImgExt) { event.stopPropagation(); if (a.openImageExternal) await a.openImageExternal(a.itemById(openImgExt.dataset.id)); return true; }
|
|
4827
4998
|
const saveImg = t.closest('[data-action="save-img"]');
|
|
4828
4999
|
if (saveImg) { event.stopPropagation(); toast(await a.saveImage(a.itemById(saveImg.dataset.id))); return true; }
|
|
4829
5000
|
const edit = t.closest('[data-action="edit"]');
|
|
4830
|
-
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; }
|
|
4831
5002
|
const rename = t.closest('[data-action="rename"]');
|
|
4832
5003
|
if (rename) { event.stopPropagation(); await renameClip(rename.dataset.id); return true; }
|
|
4833
5004
|
const revert = t.closest('[data-action="revert"]');
|
|
@@ -5784,14 +5955,30 @@
|
|
|
5784
5955
|
findHl.innerHTML = `${html}\n`;
|
|
5785
5956
|
syncHlScroll();
|
|
5786
5957
|
}
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5958
|
+
// A regex runs on the search worker (createSearchWorker): one pattern can
|
|
5959
|
+
// backtrack for minutes on a long line, and the page's thread cannot stop
|
|
5960
|
+
// it. A plain word is a linear scan, done here. `after` runs once the
|
|
5961
|
+
// matches are in (at once for a word, on the worker's answer for a regex).
|
|
5962
|
+
let findWorker = null;
|
|
5963
|
+
function recomputeMatches(after) {
|
|
5964
|
+
const query = findInput.value;
|
|
5965
|
+
const term = query ? Search.compileTerm(query, { regex: findRegex, caseSensitive: findCase }) : null;
|
|
5966
|
+
const apply = (list) => {
|
|
5967
|
+
matches = list;
|
|
5968
|
+
findCount.title = term && !term.valid ? `Not a valid regular expression: ${term.error}.` : '';
|
|
5969
|
+
findBar.classList.toggle('bad-pattern', !!(term && !term.valid));
|
|
5970
|
+
if (!matches.length) { findIdx = -1; findCount.textContent = term && !term.valid ? 'Invalid' : query ? '0/0' : ''; }
|
|
5971
|
+
else if (findIdx < 0 || findIdx >= matches.length) findIdx = 0;
|
|
5972
|
+
renderFindHighlights();
|
|
5973
|
+
if (after) after();
|
|
5974
|
+
};
|
|
5975
|
+
if (term && term.valid && term.regex) {
|
|
5976
|
+
if (!findWorker) findWorker = createSearchWorker({ onBusy: (busy) => { if (busy) findCount.textContent = '...'; } });
|
|
5977
|
+
findWorker.find({ text: area.value, query, regex: true, caseSensitive: findCase }, (r) => apply(r.matches || []));
|
|
5978
|
+
return;
|
|
5979
|
+
}
|
|
5980
|
+
if (findWorker) findWorker.cancel();
|
|
5981
|
+
apply(term ? term.all(area.value) : []);
|
|
5795
5982
|
}
|
|
5796
5983
|
function step(dir) {
|
|
5797
5984
|
if (!matches.length) return;
|
|
@@ -5805,8 +5992,7 @@
|
|
|
5805
5992
|
if (opt.regex != null) findRegex = !!opt.regex;
|
|
5806
5993
|
updateFindButtons();
|
|
5807
5994
|
findIdx = -1;
|
|
5808
|
-
recomputeMatches();
|
|
5809
|
-
selectMatch({ preserveFocus: true });
|
|
5995
|
+
recomputeMatches(() => selectMatch({ preserveFocus: true }));
|
|
5810
5996
|
findInput.focus();
|
|
5811
5997
|
if (opt.select !== false) findInput.select();
|
|
5812
5998
|
}
|
|
@@ -5817,8 +6003,7 @@
|
|
|
5817
6003
|
if (sel && !sel.includes('\n')) findInput.value = sel.slice(0, 120);
|
|
5818
6004
|
updateFindButtons();
|
|
5819
6005
|
findIdx = -1;
|
|
5820
|
-
recomputeMatches();
|
|
5821
|
-
selectMatch({ preserveFocus: true });
|
|
6006
|
+
recomputeMatches(() => selectMatch({ preserveFocus: true }));
|
|
5822
6007
|
findInput.focus();
|
|
5823
6008
|
findInput.select();
|
|
5824
6009
|
}
|
|
@@ -5843,12 +6028,12 @@
|
|
|
5843
6028
|
else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { e.preventDefault(); openFind(); }
|
|
5844
6029
|
else if (e.key === 'Enter') { e.preventDefault(); commit(); area.focus(); } // done naming: back to the text
|
|
5845
6030
|
});
|
|
5846
|
-
findInput.addEventListener('input', () => { findIdx = -1; recomputeMatches()
|
|
6031
|
+
findInput.addEventListener('input', () => { findIdx = -1; recomputeMatches(() => selectMatch({ preserveFocus: true })); });
|
|
5847
6032
|
findInput.addEventListener('keydown', (e) => {
|
|
5848
6033
|
if (e.key === 'Enter') { e.preventDefault(); step(e.shiftKey ? -1 : 1); }
|
|
5849
6034
|
else if (e.key === 'Escape') { e.preventDefault(); closeFind(); }
|
|
5850
6035
|
});
|
|
5851
|
-
const refind = () => { updateFindButtons(); findIdx = -1; recomputeMatches()
|
|
6036
|
+
const refind = () => { updateFindButtons(); findIdx = -1; recomputeMatches(() => selectMatch({ preserveFocus: true })); };
|
|
5852
6037
|
// The shared chord toggles: Regex (Alt+R) and match case (Alt+C).
|
|
5853
6038
|
const findRegexToggle = attachToggle(findRegexBtn, {
|
|
5854
6039
|
label: 'Regex',
|
|
@@ -5923,7 +6108,7 @@
|
|
|
5923
6108
|
revert,
|
|
5924
6109
|
// A host that drops the editor without closing its window (the demo's
|
|
5925
6110
|
// overlay) lets go of its chords.
|
|
5926
|
-
destroy() { findRegexToggle.destroy(); findCaseToggle.destroy(); },
|
|
6111
|
+
destroy() { findRegexToggle.destroy(); findCaseToggle.destroy(); if (findWorker) findWorker.dispose(); },
|
|
5927
6112
|
};
|
|
5928
6113
|
}
|
|
5929
6114
|
// Shared in-app IMAGE VIEWER — the image twin of createEditor, mounted by the
|
|
@@ -6920,6 +7105,7 @@
|
|
|
6920
7105
|
createImageZoom,
|
|
6921
7106
|
createClipController,
|
|
6922
7107
|
findAllMatches,
|
|
7108
|
+
createSearchWorker,
|
|
6923
7109
|
editorFindFor,
|
|
6924
7110
|
flipChildren,
|
|
6925
7111
|
attachToggle,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// BoardClip search worker: runs every search that holds a /regex/, and the
|
|
2
|
+
// editor find bar's regex, off the page's thread. Hosted by
|
|
3
|
+
// Core.createSearchWorker (clipboard-ui-core.js), which ends this worker when a
|
|
4
|
+
// pattern runs on after its result is no longer wanted. Messages:
|
|
5
|
+
// { type: 'sync', order: [key], add: [[key, doc, hay]] } the docs, by key
|
|
6
|
+
// { type: 'search', id, query, sortMode, now } -> { type, id, indexes, first, relax }
|
|
7
|
+
// { type: 'find', id, text, query, regex, caseSensitive } -> { type, id, matches, valid, error }
|
|
8
|
+
importScripts('clip-search.js');
|
|
9
|
+
const Search = self.BoardClipSearch;
|
|
10
|
+
const byKey = new Map();
|
|
11
|
+
let docs = [];
|
|
12
|
+
let hays = null;
|
|
13
|
+
|
|
14
|
+
self.onmessage = (event) => {
|
|
15
|
+
const m = event.data || {};
|
|
16
|
+
if (m.type === 'sync') {
|
|
17
|
+
for (const [key, doc, hay] of m.add) byKey.set(key, { doc, hay });
|
|
18
|
+
const keep = new Set(m.order);
|
|
19
|
+
for (const key of byKey.keys()) if (!keep.has(key)) byKey.delete(key);
|
|
20
|
+
const entries = m.order.map((key) => byKey.get(key) || { doc: null, hay: null });
|
|
21
|
+
docs = entries.map((e) => e.doc);
|
|
22
|
+
hays = entries.some((e) => e.hay != null) ? entries.map((e) => e.hay) : null;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (m.type === 'search') {
|
|
26
|
+
self.postMessage({ type: 'search', id: m.id, ...Search.searchJob(docs, hays, m) });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (m.type === 'find') {
|
|
30
|
+
const term = Search.compileTerm(m.query, { regex: !!m.regex, caseSensitive: !!m.caseSensitive });
|
|
31
|
+
self.postMessage({ type: 'find', id: m.id, matches: term.all(String(m.text || '')), valid: term.valid, error: term.error });
|
|
32
|
+
}
|
|
33
|
+
};
|