▸
- Filter & labels
+ Filter board
-
Filter
+
Match labels
Any
All
-
-
-
Labels
-
- Always
- Selected
- Off
-
-
+
+
+
+
+ ▸ Display
+
+
Show labels on canvas
+
+ Always
+ Selected
+ Off
@@ -3806,12 +3838,13 @@
Keyboard shortcuts
Locked group Select children individually; unlock to move, resize, or drop items in/out
Ctrl A Select all
Ctrl F Find on board
-
Ctrl Shift T Filter the board by tag — matching items stay lit, the rest dim and stop responding
-
Selection bar → tag Opens the tag panel with the cursor ready — tag the selection, clear its tags, set colours and filter, all in one place
-
Right-click → Tags Add a tag, switch one on or off for the whole selection, or set its colour
-
Tag panel → Clear tags Strip every tag from the selection in one go
-
Tag panel → colour well Give a tag a colour and every item wearing it gets a soft glow behind it
-
Tag panel → Labels Show tag labels on the board always, only on the selection, or not at all
+
Ctrl Shift T Open Labels to edit selected items, filter the board, or change label display
+
Selection bar → Labels Choose an existing label or type a new one and press Add or Enter
+
Right-click → Labels Apply a label to all selected items; use × to remove it
+
Labels → Remove all labels Remove labels from selected items; use Undo to restore them
+
Labels → colour dot Give a label a colour and every item wearing it gets a soft glow
+
Labels → Filter board Matching items stay lit; Reset filters restores the full board
+
Labels → Display Show labels on canvas always, only on selected items, or turn them off
Del Delete selection
Ctrl Z / Y Undo / redo
Wheel Zoom at cursor
@@ -4108,6 +4141,7 @@
Export board as PNG
removeTag,
tagGlowColor,
TAG_COLOR_PALETTE,
+ TAGS_MAX_PER_ITEM,
} from './scripts/tags.mjs';
import {
initPerfOverlay,
@@ -5016,6 +5050,7 @@
Export board as PNG
wrap.style.top = top + 'px';
}
let splitContentWidth = 0;
+let splitAnimating = false;
function paneLimit() {
return splitContentWidth > 0 ? splitContentWidth : innerWidth;
}
@@ -5027,6 +5062,19 @@
Export board as PNG
h: Math.max(1, innerHeight - titlebarH()),
};
}
+function boardCanvasSize() {
+ const size = boardLayoutSize();
+ // Keep the complete board painted behind the sliding pane. In particular,
+ // closing must already have pixels ready for the area it is about to reveal.
+ if (splitAnimating && BOARD_PANE === 'primary') {
+ size.w = Math.max(1, innerWidth - chromeLeft());
+ if (document.body.classList.contains('board-active')
+ && !document.body.classList.contains('titlebar-revealed')) {
+ size.h = Math.max(1, innerHeight);
+ }
+ }
+ return size;
+}
function boardRect() {
return canvas.getBoundingClientRect();
}
@@ -5497,6 +5545,7 @@
Export board as PNG
window.RefBoardAPI?.reportBoardSession?.({
path: currentBoardPath || null,
ready: BOARD_PANE === 'secondary' && secondaryImagesReady && !onLanding,
+ startupComplete: appStartupComplete,
});
}
@@ -7122,6 +7171,7 @@
Export board as PNG
title: currentBoardTitle || null,
dirty: boardFileDirty,
boardGray: state.boardGray,
+ tagColors: { ...state.tagColors },
// Emitted for older builds that still read it as board state.
snapEnabled: !!appSettings.snapEnabled,
gridAppearance: state.gridAppearance,
@@ -7287,6 +7337,7 @@
Export board as PNG
* attach an image's pre-edit bytes for a pixel edit.
*/
function pushUndo(opts) {
+ dismissLabelUndo();
let extra = null;
if (opts && opts.imgId && opts.blob instanceof Blob) {
extra = { bitmap: { imgId: opts.imgId, before: createBitmapHistoryRef(opts.imgId, opts.blob), after: null } };
@@ -7294,6 +7345,7 @@
Export board as PNG
boardHistory.begin(extra);
}
function clearUndoHistory() {
+ dismissLabelUndo();
boardHistory.clear();
}
@@ -7401,11 +7453,16 @@
Export board as PNG
underneath it would leave both wrong. Checked before queueing, so a held
key does not pile up work, and again after waiting, in case a gesture
began meanwhile. */
-function undo() {
+function undo(expectedEntry = null) {
if (mode) return Promise.resolve();
return boardHistoryQueue.run(async () => {
await settleDrawCommitForHistory();
if (mode) return;
+ if (expectedEntry) {
+ boardHistory.finalize();
+ if (undoStack.at(-1) !== expectedEntry) return;
+ }
+ dismissLabelUndo();
if (!undoStack.length && !boardHistory.hasPending()) {
// A freshly opened board can enter crop mode before any history exists.
// Ctrl+Z should still return to normal selection in that case.
@@ -7428,6 +7485,7 @@
Export board as PNG
return boardHistoryQueue.run(async () => {
await settleDrawCommitForHistory();
if (mode) return;
+ dismissLabelUndo();
const entry = boardHistory.redo();
if (!entry) return;
await applyUndoEntry(entry, 'after');
@@ -12208,7 +12266,17 @@
Export board as PNG
}
function nextUiFrame() {
- return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
+ return new Promise(resolve => {
+ // A new split pane initializes offscreen, where Chromium may not deliver
+ // frames until it slides into view. Let its startup layout settle without
+ // waiting for the very reveal that startup completion will trigger.
+ const timer = BOARD_PANE === 'secondary' && !appStartupComplete
+ ? setTimeout(resolve, 32) : null;
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (timer !== null) clearTimeout(timer);
+ resolve();
+ }));
+ });
}
function computeOpenTargetView(opts = {}) {
@@ -14818,6 +14886,7 @@
Export board as PNG
state.items = nextItems;
invalidateWorkspaceBBox();
state.boardGray = !!j.boardGray;
+ state.tagColors = normalizeTagColors(j.tagColors);
const ga = normalizeGridAppearance(j.gridAppearance);
if (ga) state.gridAppearance = ga;
state.sel.clear();
@@ -14956,7 +15025,7 @@
Export board as PNG
/* ================= rendering ================= */
function resize() {
if (document.documentElement.classList.contains('window-resizing')
- && splitActive && BOARD_PANE === 'primary' && !splitDragging) {
+ && splitActive && BOARD_PANE === 'primary' && !splitDragging && !splitAnimating) {
const gap = 6;
const ratio = clampSplitRatioSetting(appSettings.splitRatio);
const left = Math.max(1, Math.min(innerWidth - gap - 1, Math.round(innerWidth * ratio)));
@@ -14966,18 +15035,18 @@
Export board as PNG
}
}
const dpr = devicePixelRatio || 1;
- const { w, h } = boardLayoutSize();
+ const { w, h } = boardCanvasSize();
const cw = Math.round(w * dpr), ch = Math.round(h * dpr);
const nextW = w + 'px', nextH = h + 'px';
const sizeChanged = canvas.width !== cw || canvas.height !== ch;
const styleChanged = canvas.style.width !== nextW || canvas.style.height !== nextH;
if (!sizeChanged && !styleChanged) return;
if (sizeChanged) {
- canvas.width = cw;
- canvas.height = ch;
+ if (canvas.width !== cw) canvas.width = cw;
+ if (canvas.height !== ch) canvas.height = ch;
}
- canvas.style.width = nextW;
- canvas.style.height = nextH;
+ if (canvas.style.width !== nextW) canvas.style.width = nextW;
+ if (canvas.style.height !== nextH) canvas.style.height = nextH;
if (sizeChanged) invalidate();
}
let windowResizeEndTimer = 0;
@@ -14991,7 +15060,7 @@
Export board as PNG
function forceBoardCanvasRepaint() {
const dpr = devicePixelRatio || 1;
- const { w, h } = boardLayoutSize();
+ const { w, h } = boardCanvasSize();
// Assign even when the dimensions match. This replaces a backing surface
// Chromium may have discarded while the canvas was hidden or its GPU
// context was lost.
@@ -15048,7 +15117,7 @@
Export board as PNG
if (state.gridAppearance !== 'dots') return;
const { tx, ty } = state.view;
const sr = workspaceScreenRect();
- const { w: bw, h: bh } = boardSize();
+ const { w: bw, h: bh } = boardCanvasSize();
const visL = Math.max(0, sr.x);
const visT = Math.max(0, sr.y);
const visR = Math.min(bw, sr.x + sr.w);
@@ -15924,7 +15993,7 @@
Export board as PNG
function draw() {
const dpr = devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- const { w: W, h: H } = boardSize();
+ const { w: W, h: H } = boardCanvasSize();
const viewport = expandedViewportRect(W, H);
ctx.fillStyle = BG_VOID;
ctx.fillRect(0, 0, W, H);
@@ -17792,44 +17861,9 @@
Export board as PNG
showMenu(e.clientX, e.clientY, it);
});
-/**
- * One row of the right-click Tags submenu: a colour well on the left and the
- * tag itself as a toggle. Deliberately the same pair as the tag panel — one
- * interaction to learn, wherever you reach tags from.
- */
+/** The context menu uses exactly the panel's assignment actions. */
function ctxTagRow(entry) {
- const state3 = selectionTagState(entry.tag);
- const row = document.createElement('div');
- row.className = 'mi mi-tag' + (state3 === 'all' ? ' on' : '');
-
- const dot = document.createElement('button');
- dot.type = 'button';
- dot.className = 'tag-dot';
- const color = state.tagColors[tagKey(entry.tag)] || '';
- dot.style.setProperty('--dot', color || 'transparent');
- dot.title = color ? 'Change the glow colour for this tag' : 'Give this tag a glow colour';
- dot.addEventListener('click', e => {
- e.stopPropagation();
- // The colour popover outlives the menu, so the menu gets out of its way.
- const anchor = dot.getBoundingClientRect();
- closeMenu();
- openTagColorPop(entry.tag, null, anchor.left, anchor.bottom);
- });
- row.appendChild(dot);
-
- const label = document.createElement('span');
- label.textContent = entry.tag; // user text: never innerHTML
- row.appendChild(label);
-
- const mark = document.createElement('span');
- mark.className = 'k';
- // A partial mark matters: without it, one click looks like it did nothing to
- // the items that already carried the tag.
- mark.textContent = state3 === 'all' ? '✓' : state3 === 'some' ? '–' : '';
- row.appendChild(mark);
-
- row.addEventListener('click', () => { closeMenu(); toggleTagOnSelection(entry.tag); });
- return row;
+ return tagAssignmentRow(entry.tag, true);
}
function showMenu(x, y, hitItem) {
@@ -17866,7 +17900,7 @@
Export board as PNG
{ l: sel > 1 ? 'Export images' : 'Export image', k: 'Ctrl+Shift+I', f: exportOriginals },
);
if (selectedTaggableItems().length) {
- entries.push({ l: 'Tags', sub: 'tags' });
+ entries.push({ l: 'Labels', sub: 'tags' });
}
if (sel === 1 && isImageItem(selItems[0])) {
entries.push({ l: 'Extract palette', f: () => void extractPaletteFromItem(selItems[0]) });
@@ -17928,7 +17962,6 @@
Export board as PNG
d.innerHTML = `
${en.l} ▸ `;
d.addEventListener('click', e => {
e.stopPropagation();
- closeAppearMenu();
openCtxSubMenu(d, x, y, {
title: 'Alignment',
items: [
@@ -17941,21 +17974,19 @@
Export board as PNG
d.innerHTML = `
${en.l} ▸ `;
d.addEventListener('click', e => {
e.stopPropagation();
- closeAppearMenu();
- const items = [{ l: 'Add tag…', f: openTagPanelForSelection }];
+ const items = [{ l: 'Add label…', f: openTagPanelForSelection }];
const board = boardTagList();
if (board.length) {
items.push({ sep: true });
for (const entry of board) items.push({ node: ctxTagRow(entry) });
}
- items.push({ sep: true }, { l: 'Tag panel…', k: 'Ctrl+Shift+T', f: () => setTagPanelOpen(true) });
- openCtxSubMenu(d, x, y, { title: 'Tags', items });
+ items.push({ sep: true }, { l: 'Label panel…', k: 'Ctrl+Shift+T', f: () => setTagPanelOpen(true) });
+ openCtxSubMenu(d, x, y, { title: 'Labels · ' + selectedTaggableItems().length + ' selected', items });
});
} else if (en.sub === 'normalize') {
d.innerHTML = `
${en.l} ▸ `;
d.addEventListener('click', e => {
e.stopPropagation();
- closeAppearMenu();
openCtxSubMenu(d, x, y, {
title: 'Normalize',
items: [
@@ -17969,7 +18000,6 @@
Export board as PNG
d.innerHTML = `
${en.l} ▸ `;
d.addEventListener('click', e => {
e.stopPropagation();
- closeAppearMenu();
openCtxSubMenu(d, x, y, {
title: 'Blank canvas',
items: [
@@ -18657,6 +18687,7 @@
Export board as PNG
let toastTimer = 0;
function clearToastAction() {
+ delete toastEl.dataset.labelUndo;
toastEl.classList.remove('actionable');
const btn = toastEl.querySelector('.toast-action');
if (btn) btn.remove();
@@ -19937,6 +19968,29 @@
Export board as PNG
let splitDragging = false;
let splitDragRaf = 0;
let splitDragScreenX = 0;
+let splitAnimationId = null;
+let splitAnimationRaf = 0;
+
+function requestSplitAnimationFrame() {
+ splitAnimationRaf = 0;
+ if (!splitAnimationId) return;
+ window.RefBoardAPI?.splitAnimationFrame?.(splitAnimationId);
+ splitAnimationRaf = requestAnimationFrame(requestSplitAnimationFrame);
+}
+
+function syncSplitAnimationFrames(animationId) {
+ const next = BOARD_PANE === 'primary' && typeof animationId === 'string' && animationId
+ ? animationId : null;
+ if (next === splitAnimationId) return;
+ if (splitAnimationRaf) cancelAnimationFrame(splitAnimationRaf);
+ splitAnimationRaf = 0;
+ splitAnimationId = next;
+ // resize() has marked any new backing surface dirty. The board's existing
+ // render loop paints it before this next-frame request advances the pane.
+ if (next) splitAnimationRaf = requestAnimationFrame(requestSplitAnimationFrame);
+}
+
+window.addEventListener('pagehide', () => syncSplitAnimationFrames(null));
function setSplitToggle(on) {
splitActive = !!on;
@@ -19955,13 +20009,30 @@
Export board as PNG
function applySplitChrome(state) {
const split = !!state?.split;
+ const previousContentWidth = splitContentWidth;
+ const wasAnimating = splitAnimating;
+ splitAnimating = split && !!state?.animating;
setSplitToggle(split);
+ document.body.classList.toggle('split-animating', splitAnimating && BOARD_PANE === 'primary');
if (BOARD_PANE !== 'primary') return;
+ if (splitAnimating) {
+ const progress = Number.isFinite(state?.progress) ? Math.max(0, Math.min(1, state.progress)) : 0;
+ document.documentElement.style.setProperty('--split-progress', String(progress));
+ } else {
+ document.documentElement.style.removeProperty('--split-progress');
+ }
if (split && Number.isFinite(state?.contentWidth) && state.contentWidth > 0) {
- splitContentWidth = state.contentWidth;
- document.documentElement.style.setProperty('--split-left', `${Math.round(state.contentWidth)}px`);
+ const width = Math.round(state.contentWidth);
+ if (splitContentWidth !== width) {
+ splitContentWidth = width;
+ document.documentElement.style.setProperty('--split-left', `${width}px`);
+ }
}
resize();
+ if (previousContentWidth !== splitContentWidth || wasAnimating !== splitAnimating) {
+ drawMinimap(!splitAnimating);
+ }
+ syncSplitAnimationFrames(splitAnimating ? state?.animationId : null);
}
function persistSplitRatio(ratio) {
@@ -20010,6 +20081,7 @@
Export board as PNG
toast(`Up to ${r.limit || 4} RefBoard boards can stay open at once`);
return;
}
+ if (!r?.opened && r?.reason === 'load-failed') toast('Could not open split view');
if (r?.opened) {
setSplitToggle(true);
persistSplitRatio(r.ratio);
@@ -20185,23 +20257,35 @@
Export board as PNG
return collectBoardTags(selectedTaggableItems(), isTaggableItem);
}
-/** 'all' | 'some' | 'none' — how much of the selection carries this tag. */
-function selectionTagState(tag) {
+function dismissLabelUndo() {
+ if ($('#toast')?.dataset.labelUndo) clearToastAction();
+}
+
+function labelEditToast(message) {
+ // Finalise this edit so the button can only undo this particular entry.
+ const entry = boardHistory.finalize();
+ toast(message, entry ? {
+ duration: 6000,
+ actionLabel: 'Undo',
+ onAction: () => { hideToast(); void undo(entry); },
+ } : undefined);
+ if (entry) toastEl.dataset.labelUndo = 'true';
+}
+
+function addLabelsToSelection(incoming) {
const items = selectedTaggableItems();
- if (!items.length) return 'none';
- const key = tagKey(tag);
- const carrying = items.filter(it => (it.tags || []).some(t => t.toLowerCase() === key)).length;
- if (!carrying) return 'none';
- return carrying === items.length ? 'all' : 'some';
+ if (!items.length) return false;
+ const changed = applyTagsToSelection(tags => addTags(tags, incoming));
+ const incomplete = items.some(it => incoming.some(tag => !it.tags?.some(own => tagKey(own) === tagKey(tag))));
+ const limitMessage = 'Some items have ' + TAGS_MAX_PER_ITEM + ' labels. Remove a label to add more.';
+ if (changed) labelEditToast(incomplete ? limitMessage : 'Added labels to ' + changed + (changed === 1 ? ' item' : ' items'));
+ else toast(incomplete ? limitMessage : 'Already applied to all selected items');
+ return !incomplete;
}
-/* Partly-applied counts as off, so one click brings the whole selection up to
- the tag rather than stripping it from the items that already had it. */
-function toggleTagOnSelection(tag) {
- const removing = selectionTagState(tag) === 'all';
- const changed = applyTagsToSelection(tags => (removing ? removeTag(tags, tag) : addTags(tags, tag)));
- if (!changed) return;
- toast((removing ? 'Removed ' : 'Tagged ') + tag);
+function removeLabelFromSelection(tag) {
+ const changed = applyTagsToSelection(tags => removeTag(tags, tag));
+ if (changed) labelEditToast('Removed “' + tag + '” from ' + changed + (changed === 1 ? ' item' : ' items'));
}
function applyTagsToSelection(mutate) {
@@ -20405,7 +20489,7 @@
Export board as PNG
const o = opts || {};
const chip = document.createElement('button');
chip.type = 'button';
- chip.className = 'tag-chip' + (o.active ? ' on' : '') + (o.partial ? ' partial' : '');
+ chip.className = 'tag-chip' + (o.active ? ' on' : '');
chip.dataset.tag = tag;
const label = document.createElement('span');
label.className = 'tag-chip-label';
@@ -20417,12 +20501,6 @@
Export board as PNG
count.textContent = String(o.count);
chip.appendChild(count);
}
- if (o.removable) {
- const cross = document.createElement('span');
- cross.className = 'tag-chip-count';
- cross.textContent = '×';
- chip.appendChild(cross);
- }
return chip;
}
@@ -20434,7 +20512,7 @@
Export board as PNG
if (!board.length) {
const empty = document.createElement('div');
empty.className = 'tag-panel-empty';
- empty.textContent = 'No tags yet.';
+ empty.textContent = 'No labels on this board yet.';
tagPanelListEl.appendChild(empty);
} else {
for (const entry of board) {
@@ -20447,12 +20525,14 @@
Export board as PNG
dot.className = 'tag-dot';
const color = state.tagColors[tagKey(entry.tag)] || '';
dot.style.setProperty('--dot', color || 'transparent');
- dot.title = color ? 'Change the glow colour for this tag' : 'Give this tag a glow colour';
+ dot.title = color ? 'Change the glow colour for this label' : 'Give this label a glow colour';
dot.setAttribute('aria-label', dot.title);
dot.addEventListener('click', e => { e.stopPropagation(); openTagColorPop(entry.tag, dot); });
row.appendChild(dot);
- const chip = tagChipEl(entry.tag, { count: entry.count });
+ const chip = tagChipEl(entry.tag, { count: entry.count, active });
+ chip.setAttribute('aria-pressed', String(active));
+ chip.title = 'Filter board by “' + entry.tag + '”';
chip.addEventListener('click', () => toggleTagFilter(entry.tag));
row.appendChild(chip);
@@ -20548,58 +20628,136 @@
Export board as PNG
invalidate();
}
-function renderTagSelection() {
+/** Text and scope are inert; only the named buttons change assignments. */
+function tagAssignmentRow(tag, inMenu = false) {
const items = selectedTaggableItems();
- tagPopChipsEl.textContent = '';
- for (const entry of selectionTagList()) {
- const partial = entry.count < items.length;
- const chip = tagChipEl(entry.tag, { removable: true, partial });
- chip.title = partial
- ? 'On ' + entry.count + ' of ' + items.length + ' selected — click to remove from all'
- : 'Click to remove';
- chip.addEventListener('click', () => {
- if (applyTagsToSelection(tags => removeTag(tags, entry.tag))) toast('Removed ' + entry.tag);
- });
- tagPopChipsEl.appendChild(chip);
- }
-
- if (tagPopHintEl) {
- tagPopHintEl.textContent = items.length ? '' : 'Select images or notes to tag them.';
+ const count = items.filter(it => it.tags?.some(own => tagKey(own) === tagKey(tag))).length;
+ const row = document.createElement('div');
+ row.className = 'tag-assignment' + (inMenu ? ' mi-tag' : '');
+ row.dataset.tag = tag;
+ const dot = document.createElement('button');
+ dot.type = 'button';
+ dot.className = 'tag-dot';
+ dot.style.setProperty('--dot', state.tagColors[tagKey(tag)] || 'transparent');
+ dot.title = 'Change colour for “' + tag + '” on this board';
+ dot.setAttribute('aria-label', dot.title);
+ dot.addEventListener('click', e => {
+ e.stopPropagation();
+ const anchor = dot.getBoundingClientRect();
+ if (inMenu) closeMenu();
+ openTagColorPop(tag, inMenu ? null : dot, anchor.left, anchor.bottom);
+ });
+ const info = document.createElement('div');
+ info.className = 'tag-assignment-info';
+ const name = document.createElement('span');
+ name.className = 'tag-assignment-name';
+ name.textContent = tag;
+ name.title = tag;
+ const scope = document.createElement('span');
+ scope.className = 'tag-assignment-scope';
+ scope.textContent = count === items.length
+ ? (count === 1 ? '1 item' : 'All ' + count + ' items')
+ : count + ' of ' + items.length + ' items';
+ info.append(name, scope);
+ row.append(dot, info);
+ const act = callback => {
+ if (inMenu) closeMenu();
+ callback();
+ // Rerendering removes the clicked button. Keep focus in the editor.
+ if (!inMenu) tagPopInputEl.focus();
+ };
+ if (count < items.length) {
+ const apply = document.createElement('button');
+ apply.type = 'button';
+ apply.className = 'tag-apply';
+ apply.textContent = count ? 'Apply to all' : 'Add';
+ apply.setAttribute('aria-label', 'Apply “' + tag + '” to all selected items');
+ apply.addEventListener('click', () => act(() => addLabelsToSelection([tag])));
+ row.appendChild(apply);
}
- if (tagSelTitleEl) {
- tagSelTitleEl.textContent = items.length
- ? (items.length === 1 ? '1 selected' : items.length + ' selected')
- : 'Selection';
+ if (count) {
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'tag-remove';
+ remove.textContent = '×';
+ remove.title = 'Remove “' + tag + '” from selected items';
+ remove.setAttribute('aria-label', remove.title);
+ remove.addEventListener('click', () => act(() => removeLabelFromSelection(tag)));
+ row.appendChild(remove);
}
- // An empty selection hides the input rather than offering a box that would
- // do nothing, but the section stays so the panel does not jump about.
+ return row;
+}
+
+function renderTagSelection() {
+ const items = selectedTaggableItems();
+ const labels = selectionTagList();
+ tagPopChipsEl.textContent = '';
+ for (const entry of labels) tagPopChipsEl.appendChild(tagAssignmentRow(entry.tag));
+ if (items.length && !labels.length) {
+ const empty = document.createElement('div');
+ empty.className = 'tag-panel-empty';
+ empty.textContent = 'No labels on selected items.';
+ tagPopChipsEl.appendChild(empty);
+ }
+ if (tagPopHintEl) tagPopHintEl.textContent = items.length
+ ? 'Choose a label, or type and press Add. Separate multiple labels with commas.'
+ : 'Select images, notes, or a group to add labels.';
+ if (tagSelTitleEl) tagSelTitleEl.textContent = items.length ? 'Labels · ' + items.length + ' selected' : 'Labels';
+ $('#tagSelClear').disabled = !labels.length;
tagSelSectionEl?.classList.toggle('empty', !items.length);
+ refreshTagSuggestions();
}
-/* Strip every tag from the selection in one go — the way out of a board that
- has been over-tagged, without clicking each chip. */
function clearTagsOnSelection() {
- const items = selectedTaggableItems();
- if (!items.length) { toast('Select images or notes first'); return; }
- const carrying = items.filter(it => (it.tags || []).length).length;
- if (!carrying) { toast('Nothing tagged in the selection'); return; }
+ const carrying = selectedTaggableItems().filter(it => it.tags?.length).length;
+ if (!carrying) return;
applyTagsToSelection(() => []);
- toast(carrying === 1 ? 'Cleared tags on 1 item' : 'Cleared tags on ' + carrying + ' items');
+ labelEditToast('Removed all labels from ' + carrying + (carrying === 1 ? ' item' : ' items'));
+ tagPopInputEl.focus();
}
-/**
- * Fill the suggestion list. Deliberately not part of renderTagSelection:
- * rebuilding a
while its popover is open crashes the renderer
- * outright — an access violation, not an exception — and renderTagSelection
- * runs after every tag edit. Refreshing when the panel opens avoids that.
- */
+/* Ordinary buttons replace the native datalist, whose open popup could crash
+ Chromium when refreshed. Suggestions can now follow edits immediately. */
function refreshTagSuggestions() {
if (!tagPopSuggestEl) return;
+ const items = selectedTaggableItems();
+ const incoming = parseTagInput(tagPopInputEl.value);
+ const query = incoming.length === 1 ? tagKey(incoming[0]) : '';
+ const board = boardTagList();
+ $('#tagAdd').disabled = !items.length || !incoming.length;
tagPopSuggestEl.textContent = '';
- for (const entry of boardTagList()) {
- const option = document.createElement('option');
- option.value = entry.tag;
- tagPopSuggestEl.appendChild(option);
+ if (!items.length) return;
+ const suggest = (title, detail, tags, disabled = false) => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'tag-suggestion';
+ button.disabled = disabled;
+ const label = document.createElement('span');
+ label.textContent = title;
+ label.title = title;
+ const action = document.createElement('span');
+ action.textContent = detail;
+ button.append(label, action);
+ button.addEventListener('click', () => {
+ if (addLabelsToSelection(tags)) tagPopInputEl.value = '';
+ refreshTagSuggestions();
+ tagPopInputEl.focus();
+ });
+ tagPopSuggestEl.appendChild(button);
+ };
+ if (incoming.length > 1) {
+ suggest('Add ' + incoming.length + ' labels', 'To selection', incoming);
+ return;
+ }
+ const matches = board.map(entry => ({
+ ...entry, count: items.filter(it => it.tags?.some(tag => tagKey(tag) === tagKey(entry.tag))).length,
+ })).filter(entry => query ? tagKey(entry.tag).includes(query) : entry.count === 0);
+ for (const entry of matches.slice(0, 6)) {
+ const count = entry.count;
+ suggest(entry.tag, count === items.length ? 'Applied' : count ? 'Apply to all' : 'Add', [entry.tag], count === items.length);
+ }
+ if (query && !board.some(entry => tagKey(entry.tag) === query)) {
+ suggest('Create “' + incoming[0] + '”', 'Add to selection', incoming);
}
}
@@ -20609,7 +20767,6 @@ Export board as PNG
function openTagPanelForSelection() {
closeMenu();
setTagPanelOpen(true);
- tagPopInputEl.value = '';
if (selectedTaggableItems().length) tagPopInputEl.focus();
}
@@ -20621,6 +20778,8 @@ Export board as PNG
tagPanelEl.setAttribute('aria-hidden', String(!open));
$('#btnTags')?.classList.toggle('open', open);
$('#sTags')?.classList.toggle('on', open);
+ $('#btnTags')?.setAttribute('aria-expanded', String(open));
+ $('#sTags')?.setAttribute('aria-expanded', String(open));
if (open) renderTagPanel();
}
@@ -20635,19 +20794,20 @@ Export board as PNG
tagFilter = [];
tagFilterKeys = [];
tagFilterPass.clear();
+ tagPopInputEl.value = '';
renderTagPanel();
}
function commitTagInput() {
const incoming = parseTagInput(tagPopInputEl.value);
- if (!incoming.length) return;
- const changed = applyTagsToSelection(tags => addTags(tags, incoming));
- tagPopInputEl.value = '';
- renderTagSelection();
- if (!changed) { toast('Already tagged'); return; }
- toast(changed === 1 ? 'Tagged 1 item' : 'Tagged ' + changed + ' items');
+ if (!incoming.length || !selectedTaggableItems().length) return;
+ if (addLabelsToSelection(incoming)) tagPopInputEl.value = '';
+ refreshTagSuggestions();
+ tagPopInputEl.focus();
}
+$('#tagAdd')?.addEventListener('click', commitTagInput);
+tagPopInputEl?.addEventListener('input', refreshTagSuggestions);
$('#btnTags')?.addEventListener('click', e => { e.stopPropagation(); toggleTagPanel(); });
$('#sTags')?.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
@@ -20655,6 +20815,15 @@ Export board as PNG
e.stopPropagation();
openTagPanelForSelection();
});
+$('#sTags')?.addEventListener('click', e => {
+ if (e.detail === 0) openTagPanelForSelection();
+});
+tagPanelEl?.addEventListener('keydown', e => {
+ if (e.key !== 'Escape') return;
+ e.preventDefault(); e.stopPropagation();
+ setTagPanelOpen(false);
+ $('#btnTags').focus();
+});
$('#tagClear')?.addEventListener('click', clearTagFilter);
$('#tagSelClear')?.addEventListener('click', clearTagsOnSelection);
$('#tagModeAny')?.addEventListener('click', () => setTagFilter(tagFilter, { mode: 'any' }));
@@ -20664,13 +20833,7 @@ Export board as PNG
$('#tagLabelsNever')?.addEventListener('click', () => setTagLabelMode('never'));
$('#tagColorNone')?.addEventListener('click', () => { setTagColor(tagColorTarget, ''); closeTagColorPop(); });
tagPopInputEl?.addEventListener('keydown', e => {
- if (e.key === 'Escape') {
- e.preventDefault(); e.stopPropagation();
- setTagPanelOpen(false);
- canvas.focus?.();
- return;
- }
- if (e.key !== 'Enter') return;
+ if (e.key !== 'Enter' || e.isComposing) return;
e.preventDefault();
commitTagInput();
});
diff --git a/main.js b/main.js
index f7717b5..8261cf7 100644
--- a/main.js
+++ b/main.js
@@ -2,17 +2,15 @@
const { app, BrowserWindow, Menu, ipcMain, dialog, clipboard, ClipboardItem, shell, nativeImage, WebContentsView, View, screen } = require('electron');
const { autoUpdater } = require('electron-updater');
const { scanBoardHandle, readBoardImageBytes, readBoardImageBytesFromHandle, readBoardPreview, rewriteBoardFilePreview } = require('./scripts/board-open-stream');
-const { replaceBoardFile, recoverBoardFileIfMissing } = require('./scripts/board-file-replace');
+const { replaceBoardFile, recoverBoardFileIfMissing, cleanupBoardBackup } = require('./scripts/board-file-replace');
const { boardRenameFailureText, renameBoardFile } = require('./scripts/board-rename');
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');
-const { boardHeaderPrefix, boardImageParts } = require('./scripts/board-save-format');
const {
- sidecarStorePath, readSidecarIndex, writeSidecarIndex, openSidecarStore, appendSidecarImage,
- readSidecarImage, sidecarGarbageBytes, shouldCompactSidecar, compactSidecarStore,
+ sidecarStorePath, readSidecarIndex, writeSidecarIndex, openSidecarStore, readSidecarImage,
} = require('./scripts/board-sidecar');
const {
isContainerHead, openContainer, appendContainerImage, readContainerImage, writeContainerIndex,
@@ -35,7 +33,7 @@ const SPLIT_GAP = 6;
const SPLIT_RATIO_MIN = 0.25;
const SPLIT_RATIO_MAX = 0.75;
const SPLIT_RATIO_DEFAULT = 0.5;
-const SPLIT_ANIM_MS = 340;
+const SPLIT_ANIM_MS = 400;
const TITLEBAR_H = 34;
/* Per-window. One process-wide flag let a single confirmed close (or a "Restart
to update") skip the unsaved-changes handshake in every other window. */
@@ -203,18 +201,20 @@ function splitFrame(win) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (!st?.secondaryView) {
- return { w, h, contentWidth: w, secX: w, secW: 0, secY: 0, secH: h };
+ return { w, h, progress: 0, contentWidth: w, secX: w, secW: 0, secY: 0, secH: h };
}
const gap = SPLIT_GAP;
const targetLeft = Math.max(1, Math.min(w - gap - 1, Math.round(w * clampSplitRatio(st.splitRatio))));
const targetSecX = targetLeft + gap;
const p = splitOpenProgress(st);
- const secX = Math.round(targetSecX * p + w * (1 - p));
+ const contentWidth = Math.round(targetLeft * p + w * (1 - p));
+ const secX = contentWidth + Math.round(gap * p);
const chrome = Math.min(TITLEBAR_H, Math.max(0, h - 1));
return {
w,
h,
- contentWidth: Math.max(1, Math.min(w, secX - gap)),
+ progress: p,
+ contentWidth,
secX,
secW: Math.max(1, w - targetSecX),
secY: chrome,
@@ -227,33 +227,47 @@ function layoutViews(win) {
if (!st || win.isDestroyed()) return;
const frame = splitFrame(win);
if (!st.secondaryView) {
- st.primaryView?.setBounds({ x: 0, y: 0, width: frame.w, height: frame.h });
- return;
+ setPaneBounds(st.primaryView, { x: 0, y: 0, width: frame.w, height: frame.h });
+ return frame;
+ }
+ setPaneBounds(st.primaryView, { x: 0, y: 0, width: frame.w, height: frame.h });
+ setPaneBounds(st.secondaryView, { x: frame.secX, y: frame.secY, width: frame.secW, height: frame.secH });
+ return frame;
+}
+
+function setPaneBounds(view, bounds) {
+ if (!view) return;
+ const previous = view.getBounds();
+ if (previous.x !== bounds.x || previous.y !== bounds.y
+ || previous.width !== bounds.width || previous.height !== bounds.height) {
+ view.setBounds(bounds);
}
- st.primaryView.setBounds({ x: 0, y: 0, width: frame.w, height: frame.h });
- st.secondaryView.setBounds({ x: frame.secX, y: frame.secY, width: frame.secW, height: frame.secH });
}
-function easeInOutCubic(t) {
- return t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
+function easeSplitMotion(t) {
+ // Zero velocity and acceleration at both ends, with a gentler midpoint.
+ return t * t * t * (t * (t * 6 - 15) + 10);
}
function splitOpenProgress(st) {
if (!st?.secondaryView) return 0;
+ if (st.secondaryLoading) return 0;
if (!st.splitAnim) return 1;
- const u = Math.max(0, Math.min(1, (Date.now() - st.splitAnim.t0) / Math.max(1, st.splitAnim.ms)));
- const e = easeInOutCubic(u);
- return st.splitAnim.dir === 'out' ? 1 - e : e;
+ const u = Math.max(0, Math.min(1, (performance.now() - st.splitAnim.t0) / Math.max(1, st.splitAnim.ms)));
+ const e = easeSplitMotion(u);
+ return st.splitAnim.from + (st.splitAnim.to - st.splitAnim.from) * e;
}
function stopSplitAnim(win) {
const st = paneState(win);
if (!st) return;
if (st.animTimer) {
- clearInterval(st.animTimer);
+ clearTimeout(st.animTimer);
st.animTimer = 0;
}
st.splitAnim = null;
+ const secondary = st.secondaryView?.webContents;
+ if (secondary && !secondary.isDestroyed()) secondary.setBackgroundThrottling(true);
}
function tickSplitAnim(win) {
@@ -262,9 +276,9 @@ function tickSplitAnim(win) {
stopSplitAnim(win);
return;
}
- layoutViews(win);
- if (Date.now() - st.splitAnim.t0 < st.splitAnim.ms) {
- sendSplitState(win);
+ const frame = layoutViews(win);
+ if (performance.now() - st.splitAnim.t0 < st.splitAnim.ms) {
+ sendSplitState(win, frame);
return;
}
const dir = st.splitAnim.dir;
@@ -280,12 +294,18 @@ function startSplitAnim(win, dir) {
const st = paneState(win);
if (!st?.secondaryView || win.isDestroyed()) return;
const current = splitOpenProgress(st);
- const now = Date.now();
- st.splitAnim = dir === 'out'
- ? { dir, t0: now - Math.round((1 - current) * SPLIT_ANIM_MS), ms: SPLIT_ANIM_MS }
- : { dir, t0: now - Math.round(current * SPLIT_ANIM_MS), ms: SPLIT_ANIM_MS };
- if (!st.animTimer) st.animTimer = setInterval(() => tickSplitAnim(win), 8);
- layoutViews(win);
+ const to = dir === 'out' ? 0 : 1;
+ if (st.animTimer) clearTimeout(st.animTimer);
+ st.secondaryLoading = false;
+ st.secondaryView.webContents.setBackgroundThrottling(false);
+ st.splitAnim = {
+ id: crypto.randomUUID(), dir, from: current, to, t0: performance.now(),
+ ms: Math.max(1, Math.round(Math.abs(to - current) * SPLIT_ANIM_MS)),
+ };
+ // The visible renderer supplies display-paced ticks. This single deadline
+ // only settles the transition if that renderer becomes hidden or stalls.
+ st.animTimer = setTimeout(() => tickSplitAnim(win), st.splitAnim.ms + 100);
+ sendSplitState(win, layoutViews(win));
}
function animateSplitClose(win) {
@@ -303,23 +323,26 @@ function applySplitScreenX(win, screenX) {
const bounds = win.getContentBounds();
if (!bounds?.width) return;
const st = paneState(win);
- if (!st) return;
+ if (!st?.secondaryView || st.secondaryLoading || st.splitAnim) return;
st.splitRatio = clampSplitRatio((Number(screenX) - bounds.x) / bounds.width);
layoutViews(win);
sendSplitState(win);
}
-function splitStatePayload(win) {
+function splitStatePayload(win, frame = splitFrame(win)) {
const st = paneState(win);
return {
split: !!(st?.secondaryView),
ratio: clampSplitRatio(st?.splitRatio ?? SPLIT_RATIO_DEFAULT),
- contentWidth: splitFrame(win).contentWidth,
+ contentWidth: frame.contentWidth,
+ progress: frame.progress,
+ animating: !!(st?.secondaryLoading || st?.splitAnim),
+ animationId: st?.splitAnim?.id || null,
};
}
-function sendSplitState(win) {
- sendToPanes(win, 'split-state-changed', splitStatePayload(win));
+function sendSplitState(win, frame) {
+ sendToPanes(win, 'split-state-changed', splitStatePayload(win, frame));
}
let leftButtonDownFn = null;
@@ -352,7 +375,7 @@ function stopSplitDrag(win, { notify = true } = {}) {
function startSplitDrag(win, screenX) {
const st = paneState(win);
- if (!st?.secondaryView || win.isDestroyed() || st.splitAnim) return;
+ if (!st?.secondaryView || win.isDestroyed() || st.secondaryLoading || st.splitAnim) return;
st.dragging = true;
applySplitScreenX(win, screenX);
if (st.dragTimer) return;
@@ -386,6 +409,7 @@ function destroySecondary(win) {
stopPaneActivityPolling(win);
const view = st.secondaryView;
st.secondaryView = null;
+ st.secondaryLoading = false;
st.secondaryWindowId = null;
st.secondaryPath = null;
st.secondaryBoardReady = false;
@@ -460,19 +484,29 @@ async function enterSplit(win, ratio) {
webPreferences: boardWebPreferences(secondaryId),
});
secondaryView.setBackgroundColor('#141519');
- st.layout.addChildView(secondaryView);
+ // Keep startup timers responsive while the pane is offscreen.
+ secondaryView.webContents.setBackgroundThrottling(false);
st.secondaryView = secondaryView;
+ st.secondaryLoading = true;
st.secondaryWindowId = secondaryId;
+ // Set offscreen bounds before attaching; loading must not expose a blank pane.
+ layoutViews(win);
+ st.layout.addChildView(secondaryView);
registerSender(secondaryView.webContents, win);
attachWebContentsSafety(win, secondaryView.webContents, { windowId: secondaryId, role: 'secondary' });
win.setMinimumSize(SPLIT_MIN_WIDTH, WINDOW_MIN_HEIGHT);
- startSplitAnim(win, 'in');
- await secondaryView.webContents.loadFile('index.html', {
- query: { wid: secondaryId, pane: 'secondary' },
- }).catch(err => {
+ sendSplitState(win, layoutViews(win));
+ try {
+ await secondaryView.webContents.loadFile('index.html', {
+ query: { wid: secondaryId, pane: 'secondary' },
+ });
+ } catch (err) {
+ if (win.isDestroyed() || st.secondaryView !== secondaryView) return { opened: false, reason: 'cancelled' };
console.warn('RefBoard split pane failed to load:', err?.message || err);
- });
- try { secondaryView.webContents.focus(); } catch { /* focus is best-effort */ }
+ if (st.secondaryView === secondaryView) destroySecondary(win);
+ return { opened: false, reason: 'load-failed' };
+ }
+ if (win.isDestroyed() || st.secondaryView !== secondaryView) return { opened: false, reason: 'cancelled' };
sendBlockedBoardPath(win);
sendSplitState(win);
startPaneActivityPolling(win);
@@ -485,6 +519,10 @@ function requestSplitExit(win) {
sendSplitState(win);
return { closed: true };
}
+ if (st.secondaryLoading) {
+ destroySecondary(win);
+ return { closed: true };
+ }
if (st.pendingClose === 'split-exit' || st.splitAnim?.dir === 'out') {
const wc = st.secondaryView?.webContents;
if (wc && !wc.isDestroyed() && !wc.isCrashed()) {
@@ -505,6 +543,7 @@ function requestSplitExit(win) {
function requestWindowClose(win) {
if (!win || win.isDestroyed()) return;
const st = paneState(win);
+ if (st?.secondaryLoading) destroySecondary(win);
const secondary = st?.secondaryView?.webContents;
if (secondary && !secondary.isDestroyed() && !secondary.isCrashed()) {
st.pendingClose = 'window';
@@ -1106,10 +1145,21 @@ function setupIpc() {
if (boardSaveSessionForTarget(target)) throw new Error('Board save in progress');
const reservation = { target };
boardSaveTargets.set(boardTargetKey(target), reservation);
+ const tempPath = `${target}.saving-${process.pid}-${crypto.randomUUID()}`;
+ let handle = null;
try {
- await fs.writeFile(target, data, 'utf8');
+ handle = await fs.open(tempPath, 'wx');
+ await handle.writeFile(data, 'utf8');
+ await handle.sync();
+ await handle.close();
+ handle = null;
+ await replaceBoardFile(target, tempPath);
refreshShellIcons(target);
return { saved: true, filePath: target };
+ } catch (err) {
+ await handle?.close().catch(() => {});
+ await fs.unlink(tempPath).catch(() => {});
+ throw err;
} finally { releaseBoardSaveTarget(reservation); }
});
@@ -1239,6 +1289,9 @@ function setupIpc() {
})).images;
compacted = true;
}
+ // Older releases left .bak behind after conversion or replacement.
+ // Retire it only once this save has committed and closed successfully.
+ await cleanupBoardBackup(session.target);
refreshShellIcons(session.target);
return {
saved: true, filePath: session.target,
@@ -1247,8 +1300,8 @@ function setupIpc() {
};
}
// A conversion or a brand-new file: swap the temp container in. The
- // previous board file, when there was one, is kept as .bak once; a
- // sidecar pair's store is removed, its images now live in the board.
+ // previous file is protected by .bak during the swap, then cleaned up;
+ // a sidecar pair's store is removed, its images now live in the board.
const tempPath = session.tempPath;
session.tempPath = null;
await replaceBoardFile(session.target, tempPath);
@@ -1624,6 +1677,7 @@ function setupIpc() {
} finally {
await box.handle.close().catch(() => {});
}
+ await cleanupBoardBackup(target);
refreshShellIcons(target);
return { written: true, filePath: target };
}
@@ -1736,6 +1790,14 @@ function setupIpc() {
if (target && !target.isDestroyed()) startSplitDrag(target, payload.screenX);
});
+ ipcMain.on('split-animation-frame', (event, animationId) => {
+ const target = windowForEvent(event);
+ const st = paneState(target);
+ if (!target || target.isDestroyed() || event.sender !== primaryWebContents(target)
+ || !st?.splitAnim || st.splitAnim.id !== animationId) return;
+ tickSplitAnim(target);
+ });
+
ipcMain.on('split-drag-move', (event, payload = {}) => {
const target = windowForEvent(event);
if (target && !target.isDestroyed()) applySplitScreenX(target, payload.screenX);
@@ -1786,6 +1848,10 @@ function setupIpc() {
if (isSecondarySender(target, event.sender)) {
st.secondaryPath = filePath;
st.secondaryBoardReady = !!payload?.ready;
+ if (st.secondaryLoading && payload?.startupComplete === true) {
+ startSplitAnim(target, 'in');
+ try { event.sender.focus(); } catch { /* focus is best-effort */ }
+ }
sendPaneActivity(target);
return;
}
@@ -2037,6 +2103,7 @@ async function createWindow(startupFilePath = null) {
layout,
primaryView,
secondaryView: null,
+ secondaryLoading: false,
primaryWindowId: windowId,
secondaryWindowId: null,
splitRatio: SPLIT_RATIO_DEFAULT,
diff --git a/package.json b/package.json
index 5b456a6..3747530 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
},
"scripts": {
"start": "electron .",
- "test": "node scripts/check-build-files.mjs && node scripts/test-build-files-guard.mjs && node scripts/test-group-model.mjs && node scripts/test-group-marquee-selection.mjs && node scripts/test-export-order.mjs && node scripts/test-shell-integration.mjs && node scripts/test-thumbnail-preview.mjs && node scripts/test-landing-layout.mjs && node scripts/test-landing-search.mjs && node scripts/test-board-rename.mjs && node scripts/test-toolbar-mode.mjs && node scripts/test-theme-system.mjs && node scripts/test-draw-panel-contract.mjs && node scripts/test-selection-tools-contract.mjs && node scripts/test-corner-rotate.mjs && node scripts/test-snap-engine.mjs && node scripts/test-note-roundtrip.mjs && node scripts/test-note-parsers.mjs && node scripts/test-image-decode-queue.mjs && node scripts/test-image-residency.mjs && node scripts/test-image-render-stability.mjs && node scripts/test-cropped-export.mjs && node scripts/test-clipboard-copy-order.mjs && node scripts/test-drag-out.mjs && node scripts/test-tags.mjs && node scripts/test-board-save-format.mjs && node scripts/test-board-save-as-contract.mjs && node scripts/test-board-file-replace.mjs && node scripts/test-board-preview-backfill.mjs && node scripts/test-board-open-stream.mjs && node scripts/test-board-io-perf-contract.mjs && node scripts/test-navigation-guards.mjs && node scripts/test-ipc-bridge-contract.mjs && node scripts/test-release-payload-contract.mjs && node scripts/test-multi-window.mjs && node scripts/test-split-view.mjs && node scripts/test-pin-window.mjs && node scripts/test-content-aware-fill.mjs && node scripts/test-content-aware-mask.mjs && node scripts/test-content-aware-sampling.mjs && node scripts/test-content-aware-patchmatch.mjs && node scripts/test-content-aware-blend.mjs && node scripts/test-content-aware-composite.mjs && node scripts/test-content-aware-regressions.mjs && node scripts/test-content-aware-scenes.mjs && node scripts/test-autosave-failure-contract.mjs && node scripts/test-image-source-contract.mjs && node scripts/test-recent-works.mjs && node scripts/test-pin-limit-contract.mjs && node scripts/test-changelog-format.mjs && node scripts/test-board-save-session.mjs && node scripts/test-atomic-json.mjs && node scripts/test-small-correctness-contract.mjs && node scripts/test-item-index.mjs && node scripts/test-view-persist-contract.mjs && node scripts/test-save-preview-contract.mjs && node scripts/test-board-sidecar.mjs && node scripts/test-board-container.mjs && node scripts/test-container-save-contract.mjs && node scripts/test-spatial-index.mjs && node scripts/test-spatial-index-contract.mjs && node scripts/test-board-history.mjs && node scripts/test-board-history-contract.mjs && node scripts/test-installer-retry.mjs && node scripts/test-board-save-concurrency.mjs && node scripts/test-clipboard-bridge.mjs",
+ "test": "node scripts/check-build-files.mjs && node scripts/test-build-files-guard.mjs && node scripts/test-group-model.mjs && node scripts/test-group-marquee-selection.mjs && node scripts/test-export-order.mjs && node scripts/test-shell-integration.mjs && node scripts/test-thumbnail-preview.mjs && node scripts/test-landing-layout.mjs && node scripts/test-landing-search.mjs && node scripts/test-board-rename.mjs && node scripts/test-toolbar-mode.mjs && node scripts/test-theme-system.mjs && node scripts/test-draw-panel-contract.mjs && node scripts/test-selection-tools-contract.mjs && node scripts/test-corner-rotate.mjs && node scripts/test-snap-engine.mjs && node scripts/test-note-roundtrip.mjs && node scripts/test-note-parsers.mjs && node scripts/test-image-decode-queue.mjs && node scripts/test-image-residency.mjs && node scripts/test-image-render-stability.mjs && node scripts/test-cropped-export.mjs && node scripts/test-clipboard-copy-order.mjs && node scripts/test-drag-out.mjs && node scripts/test-tags.mjs && node scripts/test-board-save-format.mjs && node scripts/test-board-save-as-contract.mjs && node scripts/test-board-file-replace.mjs && node scripts/test-board-preview-backfill.mjs && node scripts/test-board-open-stream.mjs && node scripts/test-board-io-perf-contract.mjs && node scripts/test-navigation-guards.mjs && node scripts/test-ipc-bridge-contract.mjs && node scripts/test-release-payload-contract.mjs && node scripts/test-multi-window.mjs && node scripts/test-split-view.mjs && node scripts/test-split-animation.mjs && node scripts/test-pin-window.mjs && node scripts/test-content-aware-fill.mjs && node scripts/test-content-aware-mask.mjs && node scripts/test-content-aware-sampling.mjs && node scripts/test-content-aware-patchmatch.mjs && node scripts/test-content-aware-blend.mjs && node scripts/test-content-aware-composite.mjs && node scripts/test-content-aware-regressions.mjs && node scripts/test-content-aware-scenes.mjs && node scripts/test-autosave-failure-contract.mjs && node scripts/test-image-source-contract.mjs && node scripts/test-recent-works.mjs && node scripts/test-pin-limit-contract.mjs && node scripts/test-changelog-format.mjs && node scripts/test-board-save-session.mjs && node scripts/test-atomic-json.mjs && node scripts/test-small-correctness-contract.mjs && node scripts/test-item-index.mjs && node scripts/test-view-persist-contract.mjs && node scripts/test-save-preview-contract.mjs && node scripts/test-board-sidecar.mjs && node scripts/test-board-container.mjs && node scripts/test-board-open-handles.mjs && node scripts/test-container-save-contract.mjs && node scripts/test-spatial-index.mjs && node scripts/test-spatial-index-contract.mjs && node scripts/test-board-history.mjs && node scripts/test-board-history-contract.mjs && node scripts/test-installer-retry.mjs && node scripts/test-board-save-concurrency.mjs && node scripts/test-clipboard-bridge.mjs",
"test:landing-smoke": "electron scripts/smoke-landing-layout.js",
"test:classic-landing-smoke": "electron scripts/smoke-classic-landing.cjs",
"test:image-zoom-smoke": "electron scripts/smoke-image-zoom.js",
diff --git a/preload.js b/preload.js
index 1e01320..1adbdc4 100644
--- a/preload.js
+++ b/preload.js
@@ -62,6 +62,7 @@ contextBridge.exposeInMainWorld('RefBoardAPI', {
getBoardWindowCount: () => ipcRenderer.invoke('get-board-window-count'),
splitEnter: (ratio) => ipcRenderer.invoke('split-enter', { ratio }),
splitExit: () => ipcRenderer.invoke('split-exit'),
+ splitAnimationFrame: (animationId) => ipcRenderer.send('split-animation-frame', animationId),
splitDragStart: (screenX) => ipcRenderer.send('split-drag-start', { screenX }),
splitDragMove: (screenX) => ipcRenderer.send('split-drag-move', { screenX }),
splitDragEnd: () => ipcRenderer.send('split-drag-end'),
diff --git a/scripts/board-container.js b/scripts/board-container.js
index 96879ad..7da21db 100644
--- a/scripts/board-container.js
+++ b/scripts/board-container.js
@@ -207,7 +207,7 @@ async function openContainer(filePath, { create = false, truncate = false, write
} catch (err) {
if (err.code !== 'ENOENT' || !create) throw err;
handle = await fs.open(target, 'w+');
- return initializeContainer(handle);
+ truncate = true;
}
try {
if (truncate) return await initializeContainer(handle);
diff --git a/scripts/board-file-replace.js b/scripts/board-file-replace.js
index 495975f..8ce7283 100644
--- a/scripts/board-file-replace.js
+++ b/scripts/board-file-replace.js
@@ -22,6 +22,22 @@ async function fileMtimeMs(filePath) {
}
}
+// Call only after a successful save. A backup is needed during replacement,
+// not as a permanent sibling of the board. Failure to tidy an obsolete copy
+// must not report the already-committed save as failed.
+async function cleanupBoardBackup(filePath) {
+ if (!filePath || typeof filePath !== 'string') return false;
+ const target = path.resolve(filePath);
+ if (await fileMtimeMs(target) == null) return false;
+ try {
+ await fs.unlink(boardBakPath(target));
+ return true;
+ } catch (err) {
+ if (err.code !== 'ENOENT') console.warn('Could not remove completed board backup:', err.message);
+ return false;
+ }
+}
+
async function replaceBoardFile(target, tempPath) {
const dest = path.resolve(String(target || ''));
const temp = path.resolve(String(tempPath || ''));
@@ -35,6 +51,9 @@ async function replaceBoardFile(target, tempPath) {
movedToBak = true;
}
await fs.rename(temp, dest);
+ // The caller has flushed and closed the complete new file. Keep .bak if
+ // the app stops before this point; remove it only after the swap succeeds.
+ await cleanupBoardBackup(dest);
return { replaced: true, bakPath: fsSync.existsSync(bakPath) ? bakPath : null };
} catch (err) {
if (movedToBak && !fsSync.existsSync(dest)) {
@@ -84,6 +103,7 @@ async function recoverBoardFileIfMissing(filePath) {
module.exports = {
boardBakPath,
+ cleanupBoardBackup,
replaceBoardFile,
recoverBoardFileIfMissing,
};
diff --git a/scripts/board-sidecar.js b/scripts/board-sidecar.js
index c08d5ec..2e30de4 100644
--- a/scripts/board-sidecar.js
+++ b/scripts/board-sidecar.js
@@ -172,7 +172,7 @@ async function openSidecarStore(storePath, { create = false, truncate = false }
} catch (err) {
if (err.code !== 'ENOENT' || !create) throw err;
handle = await fs.open(target, 'w+');
- return initializeStore(handle);
+ truncate = true;
}
try {
if (truncate) return await initializeStore(handle);
diff --git a/scripts/smoke-board-convert.mjs b/scripts/smoke-board-convert.mjs
index f76430f..e81202d 100644
--- a/scripts/smoke-board-convert.mjs
+++ b/scripts/smoke-board-convert.mjs
@@ -4,8 +4,8 @@
* Runs the real app twice over: once on a 2.1.0 sidecar pair (index plus
* .refboard.images store) and once on a legacy embedded JSON board. Each is
* opened, saved to the same path, and checked: the board is now a single
- * container file, the pair's store is gone, the previous file is kept as
- * .bak, every image reads back with its original bytes, and the Explorer
+ * container file, the pair's store and temporary .bak are gone,
+ * every image reads back with its original bytes, and the Explorer
* preview extractor finds the preview at the front of the new file.
*/
import assert from 'node:assert/strict';
@@ -117,7 +117,7 @@ async function checkConverted(filePath, label) {
} finally {
await box.handle.close();
}
- assert.ok(existsSync(`${filePath}.bak`), `${label}: the previous board file is kept as .bak`);
+ assert.equal(existsSync(`${filePath}.bak`), false, `${label}: successful conversion removes the temporary .bak`);
assert.equal(typeof extractPreviewBase64(filePath), 'string', `${label}: the Explorer extractor finds a preview at the front`);
}
@@ -136,8 +136,6 @@ try {
assert.equal(legacy.saved, true, 'the legacy board saved');
assert.equal(legacy.stats.appended, 2, 'a legacy board\'s images are sent once');
await checkConverted(legacyPath, 'legacy');
- const bak = await readFile(`${legacyPath}.bak`, 'utf8');
- assert.ok(bak.startsWith('{"preview"'), 'the .bak is the original embedded file');
console.log('board convert Electron smoke passed');
} finally {
diff --git a/scripts/smoke-split-view.mjs b/scripts/smoke-split-view.mjs
index 3be1aa0..5c0db48 100644
--- a/scripts/smoke-split-view.mjs
+++ b/scripts/smoke-split-view.mjs
@@ -17,9 +17,9 @@ import { removeProfileDir } from './smoke-profile-cleanup.mjs';
import { evaluate } from './smoke-cdp.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const electron = path.join(root, 'node_modules', 'electron', 'dist', process.platform === 'win32' ? 'electron.exe' : 'electron');
+const electron = process.env.REFBOARD_SMOKE_EXECUTABLE || path.join(root, 'node_modules', 'electron', 'dist', process.platform === 'win32' ? 'electron.exe' : 'electron');
const profile = await mkdtemp(path.join(os.tmpdir(), 'refboard-split-'));
-const child = spawn(electron, ['.', '--remote-debugging-port=0', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=CalculateNativeWinOcclusion', `--user-data-dir=${profile}`], {
+const child = spawn(electron, [...(process.env.REFBOARD_SMOKE_EXECUTABLE ? [] : ['.']), '--remote-debugging-port=0', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=CalculateNativeWinOcclusion', `--user-data-dir=${profile}`], {
cwd: root, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
@@ -44,6 +44,21 @@ const primaryExpression = `(async()=>{
const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms));
for(let attempt=0;attempt<300&&!window.RefBoard?.startupComplete;attempt++)await wait(50);
if(!window.RefBoard?.startupComplete)throw new Error('RefBoard startup did not complete');
+ document.querySelectorAll('.modal.show').forEach(el=>el.classList.remove('show'));
+ document.querySelector('#rwNewBoard').click();
+ for(let i=0;i<100&&!document.body.classList.contains('board-active');i++)await wait(50);
+ if(!document.body.classList.contains('board-active'))throw new Error('primary board did not open');
+ const image=document.createElement('canvas');image.width=80;image.height=60;
+ image.getContext('2d').fillRect(0,0,80,60);
+ const blob=await new Promise(resolve=>image.toBlob(resolve));
+ await window.RefBoard.addImages([new File([blob],'split.png',{type:'image/png'})]);
+ // Exercise the slide with a populated board, not only an empty canvas.
+ for(let i=0;i<120;i++)window.RefBoard.state.items.push(window.RefBoard.makeNoteForTest({
+ id:'split-note-'+i,x:(i%15)*100,y:Math.floor(i/15)*90,w:90,h:70,
+ html:'Split animation '+i+'
'}));
+ window.RefBoard.invalidate();
+ await wait(400);
+ const originalItems=JSON.stringify(window.RefBoard.state.items);
const toggle=document.querySelector('#splitToggle');
const minimap=document.querySelector('#minimapToggle');
if(!toggle)throw new Error('split toggle missing');
@@ -51,15 +66,80 @@ const primaryExpression = `(async()=>{
const shown=getComputedStyle(toggle).display!=='none';
if(!shown)throw new Error('split toggle must be visible on the primary pane');
if(typeof window.RefBoardAPI?.splitEnter!=='function')throw new Error('splitEnter bridge missing');
- const opened=await window.RefBoardAPI.splitEnter(0.5);
- if(!opened?.opened)throw new Error('split-enter failed: '+JSON.stringify(opened));
+ window.splitSmokeErrors=[];
+ window.addEventListener('error',e=>window.splitSmokeErrors.push(e.message));
+ window.splitSmokeMeasure={sets:[],samples:[]};
+ const board=document.querySelector('#board');
+ for(const key of ['width','height']){
+ const descriptor=Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype,key);
+ Object.defineProperty(board,key,{...descriptor,set(value){
+ window.splitSmokeMeasure.sets.push({key,value,at:performance.now()});
+ return descriptor.set.call(this,value);
+ }});
+ }
+ function sampleSlide(){
+ const rect=board.getBoundingClientRect();
+ window.splitSmokeMeasure.samples.push({at:performance.now(),
+ top:rect.top,height:rect.height,rasterHeight:board.height/(devicePixelRatio||1),
+ width:parseFloat(document.documentElement.style.getPropertyValue('--split-left'))||innerWidth});
+ requestAnimationFrame(sampleSlide);
+ }
+ requestAnimationFrame(sampleSlide);
+ window.splitSmokeFrames=[];
+ window.RefBoardAPI.onSplitStateChange(state=>{
+ const rect=board.getBoundingClientRect();
+ const clip=getComputedStyle(board).clipPath;
+ const inset=clip.startsWith('inset(')?clip.slice(6,-1).split(/\\s+/).map(parseFloat):[];
+ const right=inset.length>1?inset[1]:(inset[0]||0);
+ const left=inset.length>3?inset[3]:right;
+ window.splitSmokeFrames.push({...state,at:performance.now(),
+ boardWidth:rect.width,boardTop:rect.top,visibleBoardWidth:rect.width-right-left,
+ cssWidth:parseFloat(document.documentElement.style.getPropertyValue('--split-left'))||innerWidth});
+ });
+ toggle.click();
+ for(let i=0;i<300;i++){
+ const last=window.splitSmokeFrames.at(-1);
+ if(last?.split&&!last.animating)break;
+ await wait(50);
+ }
+ const opened=window.splitSmokeFrames.at(-1);
+ if(!opened?.split||opened.animating)throw new Error('split opening never completed: '+JSON.stringify(opened));
return {
pane: window.RefBoard.boardPane,
toggleVisible: shown,
ratio: opened.ratio,
+ width:innerWidth, frames:window.splitSmokeFrames,measure:window.splitSmokeMeasure,
+ itemsUnchanged:originalItems===JSON.stringify(window.RefBoard.state.items),
};
})()`;
+function checkMotion(label, frames, measure, fullWidth) {
+ const narrowWidth=Math.min(...frames.map(frame=>frame.contentWidth));
+ const moving=frames.filter(frame=>frame.contentWidth>narrowWidth+5&&frame.contentWidth=3, `${label} must have intermediate motion frames`);
+ const start=frames[0].at, end=frames.at(-1).at;
+ const samples=measure.samples.filter(sample=>sample.at>=start&&sample.at<=end
+ &&sample.width>narrowWidth+5&&sample.widthsample.width)).size;
+ // Compare to the display frames actually available on this machine. Do not
+ // require a particular refresh rate or millisecond budget from a loaded CI.
+ assert.ok(steps>=Math.min(12,Math.max(3,Math.floor(samples.length/2))),
+ `${label} must advance on display frames (${steps} positions in ${samples.length} frames)`);
+ const resets=measure.sets;
+ for(const key of ['width','height'])assert.ok(resets.filter(set=>set.key===key).length<=3,
+ `${label} must keep its canvas surface stable instead of clearing ${key} every frame`);
+ for(const sample of samples)assert.ok(Math.abs(sample.height-sample.rasterHeight)<=1,
+ `${label} must move the board without stretching its raster surface`);
+ const firstTop=frames[0].boardTop,lastTop=frames.at(-1).boardTop;
+ if(Math.abs(lastTop-firstTop)>2)assert.ok(moving.some(frame=>frame.boardTop>Math.min(firstTop,lastTop)+1
+ &&frame.boardTopframe.at-moving[i].at).sort((a,b)=>a-b);
+ const median=gaps.length?Math.round(gaps[Math.floor(gaps.length/2)]*10)/10:0;
+ console.log(`split ${label}: ${steps} visible positions / ${samples.length} display frames; `+
+ `${resets.length} canvas dimension writes; ${median} ms median layout interval`);
+}
+
const secondaryExpression = `(async()=>{
const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms));
for(let attempt=0;attempt<300&&!window.RefBoard?.startupComplete;attempt++)await wait(50);
@@ -92,6 +172,18 @@ try {
assert.equal(primary.pane, 'primary');
assert.equal(primary.toggleVisible, true);
assert.ok(primary.ratio >= 0.25 && primary.ratio <= 0.75, 'split ratio should stay clamped');
+ assert.equal(primary.itemsUnchanged, true, 'opening split must preserve board items');
+ const openingFrames = primary.frames.filter(frame => frame.split);
+ const finalWidth = openingFrames.at(-1).contentWidth;
+ assert.ok(Math.abs(openingFrames[0].contentWidth - primary.width) <= 1, 'opening starts with the full primary board width (allow native DPI rounding)');
+ const intermediate = openingFrames.filter(frame => frame.contentWidth < primary.width - 5 && frame.contentWidth > finalWidth + 5);
+ assert.ok(new Set(intermediate.map(frame => frame.contentWidth)).size >= 3, 'opening must render several intermediate positions instead of jumping');
+ for (let i = 1; i < openingFrames.length; i++) {
+ assert.ok(openingFrames[i].contentWidth <= openingFrames[i-1].contentWidth, 'opening advances monotonically');
+ assert.equal(openingFrames[i].cssWidth, openingFrames[i].contentWidth, 'left board and native pane use the same width');
+ assert.ok(Math.abs(openingFrames[i].visibleBoardWidth-openingFrames[i].contentWidth)<=1, 'the clipped board follows the divider');
+ }
+ checkMotion('opening', openingFrames, primary.measure, primary.width);
const secondary = await evaluate(port, secondaryExpression, { urlPattern: /[?&]pane=secondary(?:&|$)/ });
assert.equal(secondary.pane, 'secondary');
@@ -106,19 +198,61 @@ try {
assert.equal(secondary.landingShown, true);
const afterExit = await evaluate(port, `(async()=>{
+ window.splitSmokeFrames=[];
+ window.splitSmokeMeasure={sets:[],samples:[]};
const r=await window.RefBoardAPI.splitExit();
const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms));
for(let attempt=0;attempt<80;attempt++){
if(document.querySelector('#splitToggle')?.getAttribute('aria-pressed')==='false')break;
await wait(50);
}
+ // A second CSS titlebar transition used to stretch the freshly resized
+ // canvas after the horizontal slide had already finished.
+ await wait(450);
return {
pending: !!(r?.pending || r?.closed),
reason: r?.reason || null,
pressed: document.querySelector('#splitToggle')?.getAttribute('aria-pressed'),
+ frames:window.splitSmokeFrames,measure:window.splitSmokeMeasure,errors:window.splitSmokeErrors,width:innerWidth,
};
})()`, { urlPattern: /index\.html\?(?!.*pane=secondary)/i });
assert.ok(afterExit.pending, 'split-exit should close or wait for the right pane handshake');
+ assert.equal(afterExit.pressed, 'false', 'closing must complete');
+ assert.deepEqual(afterExit.errors, [], 'split transitions must not throw renderer errors');
+ const closingFrames = afterExit.frames;
+ assert.ok(closingFrames.some(frame => frame.contentWidth > finalWidth + 5 && frame.contentWidth < afterExit.width - 5), 'closing must also slide through intermediate positions');
+ for(const frame of closingFrames)assert.ok(Math.abs(frame.visibleBoardWidth-frame.contentWidth)<=1,
+ 'closing keeps the clipped canvas and divider aligned');
+ checkMotion('closing', closingFrames, afterExit.measure, afterExit.width);
+ const settledAt=closingFrames.at(-1).at;
+ const settledSamples=afterExit.measure.samples.filter(sample=>sample.at>=settledAt);
+ assert.ok(settledSamples.length>=2, 'closing must observe painted frames after the pane is removed');
+ for(const sample of settledSamples)assert.ok(Math.abs(sample.height-sample.rasterHeight)<=1,
+ 'closing must not stretch the canvas vertically after the horizontal motion ends');
+
+ const reopened = await evaluate(port, `(async()=>{
+ const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms));
+ window.splitSmokeFrames=[];
+ await window.RefBoardAPI.splitEnter(0.65);
+ for(let i=0;i<300;i++){
+ if(window.splitSmokeFrames.at(-1)?.split&&!window.splitSmokeFrames.at(-1)?.animating)break;
+ await wait(50);
+ }
+ const frame=window.splitSmokeFrames.at(-1);
+ // Drag using screen coordinates, as the real divider does.
+ window.RefBoardAPI.splitDragMove(window.screenX+innerWidth*0.4);
+ await wait(100);
+ const dragged=window.splitSmokeFrames.at(-1);
+ await window.RefBoardAPI.splitExit();
+ for(let i=0;i<100&&document.querySelector('#splitToggle').getAttribute('aria-pressed')!=='false';i++)await wait(50);
+ return {frame,dragged,width:innerWidth,errors:window.splitSmokeErrors,
+ closed:document.querySelector('#splitToggle').getAttribute('aria-pressed')==='false'};
+ })()`, { urlPattern: /index\.html\?(?!.*pane=secondary)/i });
+ assert.equal(reopened.frame.animating, false);
+ assert.ok(Math.abs(reopened.frame.contentWidth - Math.round(reopened.width * 0.65)) <= 1, 'reopening honors the remembered ratio');
+ assert.ok(Math.abs(reopened.dragged.ratio - 0.4) < 0.03, 'divider movement still resizes the panes');
+ assert.equal(reopened.closed, true);
+ assert.deepEqual(reopened.errors, []);
} catch (err) {
failed = true;
console.error(err);
@@ -128,4 +262,4 @@ try {
await removeProfileDir(profile);
}
if (failed) process.exit(1);
-console.log('split-view smoke ok');
+console.log('split-view smoke ok — intermediate opening/closing frames, synchronized layout, reopen and divider resize');
diff --git a/scripts/smoke-tags-menu.mjs b/scripts/smoke-tags-menu.mjs
index 23f9e55..e2af5c0 100644
--- a/scripts/smoke-tags-menu.mjs
+++ b/scripts/smoke-tags-menu.mjs
@@ -8,7 +8,7 @@
* feature. This starts clean and does one thing.
*
* Covers all three capabilities the submenu is meant to carry: adding a tag,
- * toggling one the selection partly has, and setting that tag's glow colour.
+ * applying one to a mixed selection, explicitly removing it, and setting that tag's glow colour.
*/
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
@@ -58,6 +58,7 @@ const smokeExpression = `(async()=>{
await wait(400);
document.querySelectorAll('.modal.show').forEach(el=>el.classList.remove('show'));
+ const errors=[];window.addEventListener('error',e=>errors.push(e.message));
const RB=window.RefBoard;
const mk=async fill=>{
const c=document.createElement('canvas');c.width=200;c.height=150;
@@ -93,7 +94,7 @@ const smokeExpression = `(async()=>{
};
const openTagsSub=async()=>{
if(!await openCtx())throw new Error('the context menu did not open');
- const row=[...document.querySelectorAll('#ctxmenu .mi')].find(r=>r.textContent.startsWith('Tags'));
+ const row=[...document.querySelectorAll('#ctxmenu .mi')].find(r=>r.textContent.startsWith('Labels'));
if(!row)throw new Error('no Tags entry — sel='+RB.state.sel.size
+' rows=['+[...document.querySelectorAll('#ctxmenu .mi')].map(r=>r.textContent.trim()).join(' | ')+']');
press(row);
@@ -102,20 +103,22 @@ const smokeExpression = `(async()=>{
};
const subShown=await openTagsSub();
- const rows=[...document.querySelectorAll('#ctxSub .mi')].map(r=>r.textContent.trim());
+ const rows=[...document.querySelectorAll('#ctxSub .mi, #ctxSub .mi-tag')].map(r=>r.textContent.trim());
const moodRow=[...document.querySelectorAll('#ctxSub .mi-tag')]
.find(r=>r.textContent.toLowerCase().includes('mood'));
- if(!moodRow)throw new Error('the submenu does not list the board tags');
- const partialMark=moodRow.querySelector('.k').textContent.trim();
+ if(!moodRow)throw new Error('the submenu does not list the board tags: '+document.querySelector('#ctxSub').outerHTML+' errors='+JSON.stringify(errors));
+ const partialMark=moodRow.querySelector('.tag-assignment-scope').textContent.trim();
+ press(moodRow.querySelector('.tag-assignment-name'));
+ const afterNameClick=added.map(it=>(it.tags||[]).some(t=>t.toLowerCase()==='mood'));
- press(moodRow);
+ press(moodRow.querySelector('.tag-apply'));
await wait(250);
const afterToggle=added.map(it=>(it.tags||[]).some(t=>t.toLowerCase()==='mood'));
- // Toggling again, now that all of them carry it, must take it off all of them.
+ // Only the explicit remove button takes it off all selected items.
await openTagsSub();
press([...document.querySelectorAll('#ctxSub .mi-tag')]
- .find(r=>r.textContent.toLowerCase().includes('mood')));
+ .find(r=>r.textContent.toLowerCase().includes('mood')).querySelector('.tag-remove'));
await wait(250);
const afterSecondToggle=added.map(it=>(it.tags||[]).some(t=>t.toLowerCase()==='mood'));
@@ -133,38 +136,48 @@ const smokeExpression = `(async()=>{
await wait(280);
const colors={...RB.state.tagColors};
- // 'Add tag…' opens the same panel the selection bar uses.
+ // 'Add label…' opens the same panel the selection bar uses.
await openTagsSub();
- press([...document.querySelectorAll('#ctxSub .mi')].find(r=>r.textContent.includes('Add tag')));
+ press([...document.querySelectorAll('#ctxSub .mi')].find(r=>r.textContent.includes('Add label')));
await wait(300);
const addOpensPop=document.querySelector('#tagPanel').classList.contains('open');
- return {subShown,rows,partialMark,afterToggle,afterSecondToggle,colorPopOpen,colors,addOpensPop};
+ // All submenus shared the same obsolete appearance-menu call.
+ const otherSubmenus=[];
+ for(const name of ['Alignment','Normalize','Blank canvas']){
+ await openCtx();
+ press([...document.querySelectorAll('#ctxmenu .mi')].find(row=>row.textContent.startsWith(name)));
+ otherSubmenus.push(document.querySelector('#ctxSub').classList.contains('show')&&document.querySelector('#ctxSub .mcat').textContent===name);
+ }
+ return {errors,otherSubmenus,subShown,rows,partialMark,afterNameClick,afterToggle,afterSecondToggle,colorPopOpen,colors,addOpensPop};
})()`;
try {
const r = await evaluate(await debuggerPort(), smokeExpression);
- assert.equal(r.subShown, true, 'right-click on a selection must offer a Tags submenu');
- assert.ok(r.rows.some(t => t.includes('Add tag')), `no "Add tag…" row: ${r.rows.join(' | ')}`);
+ assert.deepEqual(r.errors,[],'context menu interactions must not throw');
+ assert.deepEqual(r.otherSubmenus,[true,true,true],'related submenus must still open');
+ assert.equal(r.subShown, true, 'right-click on a selection must offer a Labels submenu');
+ assert.ok(r.rows.some(t => t.includes('Add label')), `no "Add label…" row: ${r.rows.join(' | ')}`);
assert.ok(r.rows.some(t => t.toLowerCase().includes('mood')), 'the board tags must be listed');
- assert.ok(r.rows.some(t => t.includes('Tag panel')), 'the submenu must reach the tag panel');
+ assert.ok(r.rows.some(t => t.includes('Label panel')), 'the submenu must reach the tag panel');
// Some-but-not-all has to look different from none, or one click looks inert.
- assert.equal(r.partialMark, '–', `a partly-applied tag must be marked, got "${r.partialMark}"`);
+ assert.deepEqual(r.afterNameClick, [true, false], 'clicking the label name must not change assignments');
+ assert.equal(r.partialMark, '1 of 2 items', `a partly-applied tag must be marked, got "${r.partialMark}"`);
assert.deepEqual(r.afterToggle, [true, true],
- 'toggling a partly-applied tag must bring the whole selection up to it, not strip it');
+ 'Apply to all must add the label to the rest of the selection');
assert.deepEqual(r.afterSecondToggle, [false, false],
- 'toggling again, once every item carries it, must remove it from all of them');
+ 'the explicit remove button must remove the label from all selected items');
assert.equal(r.colorPopOpen, true, 'the colour well in the submenu must open the palette');
assert.equal(Object.keys(r.colors).length, 1, `picking a swatch must assign a colour, got ${JSON.stringify(r.colors)}`);
assert.match(Object.values(r.colors)[0], /^#[0-9a-f]{6}$/, 'the colour must be stored as hex');
- assert.equal(r.addOpensPop, true, '"Add tag…" must open the same panel the selection bar uses');
+ assert.equal(r.addOpensPop, true, '"Add label…" must open the same panel the selection bar uses');
- console.log('tag context menu Electron smoke passed — add, toggle from partial, and colour, all from right-click');
+ console.log('tag context menu Electron smoke passed — add, apply to all, explicit removal, and colour, all from right-click');
} finally {
if (child.exitCode === null) child.kill();
await Promise.race([once(child, 'exit'), delay(3000)]).catch(() => {});
diff --git a/scripts/smoke-tags.mjs b/scripts/smoke-tags.mjs
index df40b2c..b9fe642 100644
--- a/scripts/smoke-tags.mjs
+++ b/scripts/smoke-tags.mjs
@@ -13,7 +13,7 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
-import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -21,13 +21,15 @@ import { removeProfileDir } from './smoke-profile-cleanup.mjs';
import { evaluate } from './smoke-cdp.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const electron = path.join(root, 'node_modules', 'electron', 'dist', process.platform === 'win32' ? 'electron.exe' : 'electron');
+const electron = process.env.REFBOARD_SMOKE_EXECUTABLE || path.join(root, 'node_modules', 'electron', 'dist', process.platform === 'win32' ? 'electron.exe' : 'electron');
const profile = await mkdtemp(path.join(os.tmpdir(), 'refboard-tags-'));
const workDir = await mkdtemp(path.join(os.tmpdir(), 'refboard-tags-board-'));
const boardPath = path.join(workDir, 'tagged.refboard');
+const otherBoardPath = path.join(workDir, 'other.refboard');
+await writeFile(otherBoardPath, JSON.stringify({ app: 'refboard', version: 3, items: [], images: [] }));
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
-const child = spawn(electron, ['.', '--remote-debugging-port=0', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=CalculateNativeWinOcclusion', `--user-data-dir=${profile}`], {
+const child = spawn(electron, [...(process.env.REFBOARD_SMOKE_EXECUTABLE ? [] : ['.']), '--remote-debugging-port=0', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=CalculateNativeWinOcclusion', `--user-data-dir=${profile}`], {
cwd: root, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
@@ -60,6 +62,8 @@ const smokeExpression = `(async()=>{
await wait(300);
document.querySelectorAll('.modal.show').forEach(el=>el.classList.remove('show'));
+ const runtimeErrors=[];
+ window.addEventListener('error',e=>runtimeErrors.push(e.message));
const RB=window.RefBoard;
const filePath=${JSON.stringify(boardPath)};
const png=async(fill)=>{
@@ -77,7 +81,9 @@ const smokeExpression = `(async()=>{
if(added.length!==3)throw new Error('expected three images');
// A note as well: notes are rebuilt field by field on load, images are not.
- const note=RB.state.items.find(it=>it.kind==='note');
+ const note=RB.makeNoteForTest({x:800,y:100,text:'A note',name:'Label test note'});
+ RB.state.items.push(note);
+ RB.invalidate();
const select=ids=>{ RB.state.sel=new Set(ids); RB.updateSelBarForTest(); };
/* What a mouse actually sends. A bare .click() skips pointerdown, which is
@@ -103,17 +109,19 @@ const smokeExpression = `(async()=>{
+' toast="'+document.querySelector('#toast').textContent+'"');
}
};
- const typeTags=async(text)=>{
+ const typeTags=async(text, useButton=false)=>{
await ensureTagPanelOpen();
const input=document.querySelector('#tagPopInput');
input.value=text;
- input.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));
+ input.dispatchEvent(new Event('input',{bubbles:true}));
+ if(useButton)press(document.querySelector('#tagAdd'));
+ else input.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));
await wait(160);
};
// Tag two images at once, then one on its own.
select([added[0].id,added[1].id]);
- await typeTags('Mood, lighting');
+ await typeTags('Mood, lighting',true);
select([added[2].id]);
await typeTags('mood');
@@ -142,6 +150,59 @@ const smokeExpression = `(async()=>{
const clearedItemTags=[...(added[2].tags||[])];
await typeTags('mood'); // put it back for the checks below
+ // Mixed selections use the same explicit actions as the right-click menu.
+ select([added[0].id,added[2].id]);
+ const rowFor=tag=>[...document.querySelectorAll('#tagPopChips .tag-assignment')].find(row=>row.dataset.tag.toLowerCase()===tag);
+ const mixedScope=rowFor('lighting').querySelector('.tag-assignment-scope').textContent;
+ press(rowFor('lighting').querySelector('.tag-assignment-name'));
+ const nameClickKeptTags=tagsOf(added[0].id).includes('lighting')&&!tagsOf(added[2].id).includes('lighting');
+ press(rowFor('lighting').querySelector('.tag-apply'));
+ const afterApplyAll=[tagsOf(added[0].id),tagsOf(added[2].id)];
+ press(rowFor('lighting').querySelector('.tag-remove'));
+ const afterRemove=[tagsOf(added[0].id),tagsOf(added[2].id)];
+ const undoButtonPresent=!!document.querySelector('#toast .toast-action');
+ press(document.querySelector('#toast .toast-action'));
+ await wait(180);
+ const afterRemoveUndo=[tagsOf(added[0].id),tagsOf(added[2].id)];
+ await RB.undoForTest(); // undo Apply to all, restore the original mixed selection
+ await ensureTagPanelOpen();
+
+ // Clear acts only on the selection, and restores the full label list on Undo.
+ press(document.querySelector('#tagSelClear'));
+ const bulkCleared=[tagsOf(added[0].id),tagsOf(added[2].id)];
+ const unselectedKeptTags=tagsOf(added[1].id);
+ const clearDisabled=document.querySelector('#tagSelClear').disabled;
+ press(document.querySelector('#toast .toast-action'));
+ await wait(180);
+ const bulkRestored=[tagsOf(added[0].id),tagsOf(added[2].id)];
+ await RB.redoForTest();
+ const bulkRedone=[tagsOf(added[0].id),tagsOf(added[2].id)];
+ await RB.undoForTest();
+ await ensureTagPanelOpen();
+
+ // A suggestion creates a label immediately; it appears without reopening.
+ const input=document.querySelector('#tagPopInput');
+ input.value='New label';input.dispatchEvent(new Event('input',{bubbles:true}));
+ const createSuggestion=document.querySelector('#tagPopSuggest .tag-suggestion');
+ const createText=createSuggestion.textContent;
+ press(createSuggestion);
+ const createdForBoth=[tagsOf(added[0].id),tagsOf(added[2].id)].every(tags=>tags.includes('New label'));
+ select([added[1].id]);
+ input.value='new';input.dispatchEvent(new Event('input',{bubbles:true}));
+ const existingSuggestion=[...document.querySelectorAll('#tagPopSuggest .tag-suggestion')].find(btn=>btn.firstElementChild.textContent==='New label');
+ const freshSuggestion=!!existingSuggestion;
+ press(existingSuggestion);
+ const reusedLabel=tagsOf(added[1].id).includes('New label');
+ await RB.undoForTest();await RB.undoForTest();
+ await ensureTagPanelOpen();
+
+ // Merely switching off display or resetting a filter never edits assignments.
+ const beforeFilters=added.map(it=>tagsOf(it.id));
+ select([]);
+ const noSelectionHidden=document.querySelector('#tagSelSection').classList.contains('empty');
+ const noSelectionAddDisabled=document.querySelector('#tagAdd').disabled;
+ select([added[2].id]);
+
// Filter to 'lighting': two images carry it, one does not.
// The toolbar button toggles, and tagging above may have left the panel
// open already, so ensure rather than press blindly.
@@ -179,6 +240,7 @@ const smokeExpression = `(async()=>{
press(document.querySelector('#tagClear'));
await wait(120);
const cleared=RB.tagStateForTest();
+ const afterFilters=added.map(it=>tagsOf(it.id));
// Give 'mood' a colour through the well on its panel row.
const moodDot=[...document.querySelectorAll('#tagPanelList .tag-row')]
@@ -281,7 +343,26 @@ const smokeExpression = `(async()=>{
const noteTagsBefore=note?tagsOf(note.id):null;
const saved=await RB.saveBoardFile({silent:true,filePath});
if(!saved)throw new Error('board save failed');
- await wait(400);
+ await wait(1000);
+ const db=await new Promise((resolve,reject)=>{
+ const request=indexedDB.open('refboard',4);
+ request.onsuccess=()=>resolve(request.result);
+ request.onerror=()=>reject(request.error);
+ });
+ const session=await new Promise((resolve,reject)=>{
+ const request=db.transaction('meta','readonly').objectStore('meta').get('board:main');
+ request.onsuccess=()=>resolve(request.result);
+ request.onerror=()=>reject(request.error);
+ });
+ db.close();
+ const sessionColors=session?.tagColors;
+ // Opening the current path is intentionally a no-op. Leave it first so this
+ // tests disk loading, including clearing colors when an older file has none.
+ const confirmOpen=setInterval(()=>document.querySelector('#confirmModal.show #confirmOk')?.click(),50);
+ try { await RB.openBoardFromPath(${JSON.stringify(otherBoardPath)}); }
+ finally { clearInterval(confirmOpen); }
+ if(RB.state.items.length!==0)throw new Error('did not leave the saved board');
+ const otherBoardColors={...RB.state.tagColors};
await RB.openBoardFromPath(filePath);
for(let i=0;i<200&&RB.state.items.length<3;i++)await wait(25);
await wait(600);
@@ -292,13 +373,57 @@ const smokeExpression = `(async()=>{
const reloadedBoardTags=RB.tagStateForTest().boardTags;
const reloadedColors={...RB.state.tagColors};
+ // Group selection edits its contents, while the group itself carries no labels.
+ const reopenedImages=RB.state.items.filter(it=>it.kind==='image');
+ select(reopenedImages.slice(0,2).map(it=>it.id));
+ input.blur();
+ document.querySelector('#board').dispatchEvent(new KeyboardEvent('keydown',{key:'g',ctrlKey:true,bubbles:true}));
+ const group=RB.state.items.find(it=>it.kind==='group'&&RB.state.sel.has(it.id));
+ if(!group)throw new Error('group did not form for label scope check');
+ await typeTags('Group label',true);
+ const groupLabels=reopenedImages.slice(0,2).every(it=>it.tags.includes('Group label'))&&!group.tags?.length;
+ const groupScope=document.querySelector('#tagSelTitle').textContent;
+ await RB.undoForTest();await RB.undoForTest();
+
+ // A later operation must retire a stale label Undo action.
+ select([reopenedImages[0].id]);
+ await typeTags('Temporary',true);
+ const staleUndo=document.querySelector('#toast .toast-action');
+ RB.pushUndoForTest();
+ reopenedImages[0].x+=12;
+ const movedX=reopenedImages[0].x;
+ const staleUndoRemoved=!document.querySelector('#toast .toast-action');
+ staleUndo.click();await wait(100);
+ const laterEditKept=reopenedImages[0].tags.includes('Temporary')&&reopenedImages[0].x===movedX;
+ await RB.undoForTest();await RB.undoForTest();
+
+ // At the label limit, preserve the typed label and explain why it was not added.
+ const capped=reopenedImages[0];
+ RB.pushUndoForTest();capped.tags=Array.from({length:24},(_,i)=>'Label '+i);
+ select([capped.id]);
+ await typeTags('Overflow label',true);
+ const limitPreservesInput=input.value==='Overflow label'&&capped.tags.length===24&&!capped.tags.includes('Overflow label');
+ const limitExplained=document.querySelector('#toast').textContent.includes('24 labels');
+ await RB.undoForTest();
+ input.value='';input.dispatchEvent(new Event('input',{bubbles:true}));
+
+ // Keyboard activation and dismissal work without a pointer.
+ input.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));
+ const escapeClosed=!document.querySelector('#tagPanel').classList.contains('open');
+ document.querySelector('#sTags').click();
+ const keyboardOpened=document.querySelector('#tagPanel').classList.contains('open')&&document.activeElement===input;
+
return {
+ runtimeErrors,groupLabels,groupScope,staleUndoRemoved,laterEditKept,limitPreservesInput,limitExplained,escapeClosed,keyboardOpened,
+ mixedScope,nameClickKeptTags,afterApplyAll,afterRemove,undoButtonPresent,afterRemoveUndo,
+ bulkCleared,unselectedKeptTags,clearDisabled,bulkRestored,bulkRedone,createText,createdForBoth,
+ freshSuggestion,reusedLabel,noSelectionHidden,noSelectionAddDisabled,beforeFilters,afterFilters,
afterTagging, afterDuplicate, selbarOffersTags, panelOpen, chipLabels,
stillOpenBeforeToggle, openAfterSecondPress, clearedItemTags,
colorPopOpen, chosen, colorsAfterPick, colorsAfterUndo, colorsBeforeSave, labelModes,
panelSurvivedColorPick, glowNear, glowMid, glowFar,
filtered, countText, anyMode, allMode, cleared, moreOpened, moreBadge,
- searchHits, noteTagsBefore, reloaded, reloadedBoardTags, reloadedColors,
+ searchHits, noteTagsBefore, reloaded, reloadedBoardTags, reloadedColors, sessionColors, otherBoardColors,
ids:{first:added[0].id,second:added[1].id,third:added[2].id},
};
})()`;
@@ -306,6 +431,16 @@ const smokeExpression = `(async()=>{
try {
const r = await evaluate(await debuggerPort(), smokeExpression);
+ assert.deepEqual(r.runtimeErrors,[],'label interactions must not throw renderer errors');
+ assert.equal(r.groupLabels,true,'group selection must label its contents');
+ assert.equal(r.groupScope,'Labels · 2 selected');
+ assert.equal(r.staleUndoRemoved,true,'a later operation dismisses the previous label Undo action');
+ assert.equal(r.laterEditKept,true,'even a stale Undo callback cannot undo a later edit');
+ assert.equal(r.limitPreservesInput,true);
+ assert.equal(r.limitExplained,true);
+ assert.equal(r.escapeClosed,true);
+ assert.equal(r.keyboardOpened,true);
+
/* ---- writing tags ---- */
assert.deepEqual(r.afterTagging.first, ['Mood', 'lighting'], 'both tags land on the first selected image');
assert.deepEqual(r.afterTagging.second, ['Mood', 'lighting'], 'a multi-selection is tagged as one');
@@ -316,9 +451,28 @@ try {
assert.equal(r.openAfterSecondPress, true, 'the tag button opens the panel and leaves it open');
assert.deepEqual(r.clearedItemTags, [], 'Clear tags must strip every tag from the selection');
+ assert.equal(r.mixedScope,'1 of 2 items');
+ assert.equal(r.nameClickKeptTags,true,'a label name is not a remove button');
+ assert.ok(r.afterApplyAll.every(tags=>tags.includes('lighting')),'Apply to all fills missing assignments');
+ assert.ok(r.afterRemove.every(tags=>!tags.includes('lighting')),'the remove button clears that label from the selection');
+ assert.equal(r.undoButtonPresent,true);
+ assert.deepEqual(r.afterRemoveUndo,r.afterApplyAll,'Undo restores a removed label');
+ assert.deepEqual(r.bulkCleared,[[],[]]);
+ assert.deepEqual(r.unselectedKeptTags,['Mood','lighting'],'bulk removal must not affect unselected items');
+ assert.equal(r.clearDisabled,true,'bulk removal disables when there are no labels');
+ assert.deepEqual(r.bulkRestored,[['Mood','lighting'],['mood']]);
+ assert.deepEqual(r.bulkRedone,[[],[]],'bulk removal supports redo');
+ assert.match(r.createText,/Create “New label”/);
+ assert.equal(r.createdForBoth,true);
+ assert.equal(r.freshSuggestion,true,'new labels become suggestions immediately');
+ assert.equal(r.reusedLabel,true,'choosing a suggestion applies the existing label');
+ assert.equal(r.noSelectionHidden,true);
+ assert.equal(r.noSelectionAddDisabled,true);
+ assert.deepEqual(r.afterFilters,r.beforeFilters,'filtering and Reset filters must preserve assignments');
+
/* ---- the filter ---- */
assert.equal(r.panelOpen, true, 'the tag button opens the filter panel');
- assert.equal(r.moreOpened, true, 'the Filter & labels disclosure must open when its summary is pressed');
+ assert.equal(r.moreOpened, true, 'the Filter board disclosure must open when its summary is pressed');
assert.ok(r.chipLabels.includes('lighting'), `panel is missing tags: ${r.chipLabels.join(', ')}`);
assert.deepEqual(r.filtered.filter, ['lighting'], 'clicking a chip filters by that tag');
@@ -397,6 +551,8 @@ try {
);
assert.deepEqual(r.reloadedColors, r.colorsBeforeSave,
'tag colours travel with the board, so they must survive save and reopen');
+ assert.deepEqual(r.otherBoardColors, {}, 'opening a board without colors must clear the previous board colors');
+ assert.deepEqual(r.sessionColors, r.colorsBeforeSave, 'crash recovery session data must include label colors');
console.log(
`tags Electron smoke passed — tagged through the panel, filtered to `
diff --git a/scripts/test-board-file-replace.mjs b/scripts/test-board-file-replace.mjs
index 6b28415..0f1a516 100644
--- a/scripts/test-board-file-replace.mjs
+++ b/scripts/test-board-file-replace.mjs
@@ -1,13 +1,17 @@
import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { existsSync } from 'node:fs';
+import fs from 'node:fs/promises';
import { mkdtemp, readFile, rename, rm, utimes, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
+import vm from 'node:vm';
const require = createRequire(import.meta.url);
const {
boardBakPath,
+ cleanupBoardBackup,
replaceBoardFile,
recoverBoardFileIfMissing,
} = require('./board-file-replace.js');
@@ -15,11 +19,20 @@ const {
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
const main = await readFile(new URL('../main.js', import.meta.url), 'utf8');
const preload = await readFile(new URL('../preload.js', import.meta.url), 'utf8');
+const replacementSource = await readFile(new URL('./board-file-replace.js', import.meta.url), 'utf8');
-// The index is written through the sidecar module, which swaps it in with the
-// same stable-.bak helper the whole-file save used.
-assert.match(main, /await replaceBoardFile\(session\.target, tempPath\);/, 'a new or converted board is swapped in with a stable .bak instead of deleting a UUID backup');
-assert.doesNotMatch(main, /unlink\(backupPath\)/, 'finish-board-save must not delete the previous board copy');
+function withFilesystemFaults(faults) {
+ const module = { exports: {} };
+ vm.runInNewContext(replacementSource, {
+ module,
+ require: id => id === 'fs/promises' ? { ...fs, ...faults } : require(id),
+ console: { warn() {} },
+ });
+ return module.exports;
+}
+
+// New and converted boards share the replacement and interrupted-save recovery path.
+assert.match(main, /await replaceBoardFile\(session\.target, tempPath\);/, 'new and converted boards must use the recoverable replacement helper');
assert.match(main, /recoverBoardFileIfMissing\(work\.path\)/, 'recent works must recover a missing board from .bak before listing it');
assert.match(main, /ipcMain\.handle\('read-board-file'[\s\S]*?recoverBoardFileIfMissing\(resolved\)/, 'opening a board file must recover .bak first');
assert.match(main, /ipcMain\.handle\('begin-board-open'[\s\S]*?recoverBoardFileIfMissing\(resolved\)/, 'streamed open must recover .bak first');
@@ -42,14 +55,50 @@ try {
const replaced = await replaceBoardFile(target, temp);
assert.equal(replaced.replaced, true);
assert.equal(await readFile(target, 'utf8'), 'updated');
- assert.equal(await readFile(bak, 'utf8'), 'original', 'successful replace must leave the previous board as .bak');
+ assert.equal(replaced.bakPath, null);
+ assert.equal(existsSync(bak), false, 'successful replacement must remove the temporary .bak');
assert.equal(existsSync(temp), false);
const temp2 = path.join(tempDir, 'board.refboard.saving-2');
+ await writeFile(bak, 'backup-left-by-an-older-release');
await writeFile(temp2, 'newest');
await replaceBoardFile(target, temp2);
assert.equal(await readFile(target, 'utf8'), 'newest');
- assert.equal(await readFile(bak, 'utf8'), 'updated', 'the next save must rotate .bak to the previous generation');
+ assert.equal(existsSync(bak), false, 'repeated successful saves must not leave .bak');
+
+ // A failed swap restores the old board and does not report success.
+ await assert.rejects(replaceBoardFile(target, `${target}.missing-temp`), { code: 'ENOENT' });
+ assert.equal(await readFile(target, 'utf8'), 'newest');
+ assert.equal(existsSync(bak), false, 'successful rollback restores the normal filename');
+
+ // If both installation and rollback fail, the only good copy stays recoverable.
+ await writeFile(temp, 'cannot-install');
+ const blockedSwap = withFilesystemFaults({
+ async rename(from, to) {
+ if (from === temp || from === bak) throw Object.assign(new Error('Locked file'), { code: 'EPERM' });
+ return fs.rename(from, to);
+ },
+ });
+ await assert.rejects(blockedSwap.replaceBoardFile(target, temp), { code: 'EPERM' });
+ assert.equal(existsSync(target), false);
+ assert.equal(await readFile(bak, 'utf8'), 'newest');
+ assert.equal(await cleanupBoardBackup(target), false, 'never clean up the only surviving board');
+ assert.equal((await recoverBoardFileIfMissing(target)).recovered, true);
+
+ // A locked backup after a committed save is a cleanup issue, not a failed save.
+ await writeFile(temp, 'committed-despite-cleanup-failure');
+ const blockedCleanup = withFilesystemFaults({
+ async unlink(filePath) {
+ if (filePath === bak) throw Object.assign(new Error('Backup locked'), { code: 'EACCES' });
+ return fs.unlink(filePath);
+ },
+ });
+ const committed = await blockedCleanup.replaceBoardFile(target, temp);
+ assert.equal(committed.replaced, true);
+ assert.equal(committed.bakPath, bak);
+ assert.equal(await readFile(target, 'utf8'), 'committed-despite-cleanup-failure');
+ assert.equal(await readFile(bak, 'utf8'), 'newest');
+ assert.equal(await cleanupBoardBackup(target), true, 'a later successful save can retry cleanup');
await rm(target, { force: true });
await writeFile(bak, 'from-bak');
@@ -89,6 +138,35 @@ try {
const present = await recoverBoardFileIfMissing(crashTarget);
assert.deepEqual(present, { exists: true, recovered: false });
+
+ // Exit the real replacement process between its renames, with no catch/finally.
+ // This covers actual interruption both before and after installing the new file.
+ for (const stage of ['backup', 'installed']) {
+ const stoppedTarget = path.join(tempDir, `stopped-${stage}.refboard`);
+ const stoppedTemp = `${stoppedTarget}.saving-test`;
+ await writeFile(stoppedTarget, 'before-crash');
+ await writeFile(stoppedTemp, 'after-crash');
+ const child = spawnSync(process.execPath, ['-e', `
+ const fs = require('fs/promises');
+ const [helper, target, temp, stage] = process.argv.slice(1);
+ const rename = fs.rename;
+ fs.rename = async (from, to) => {
+ await rename(from, to);
+ if (to === (stage === 'backup' ? target + '.bak' : target)) process.exit(77);
+ };
+ require(helper).replaceBoardFile(target, temp).catch(() => process.exit(1));
+ `, require.resolve('./board-file-replace.js'), stoppedTarget, stoppedTemp, stage], { windowsHide: true });
+ assert.equal(child.status, 77, child.stderr?.toString());
+ assert.equal(await readFile(`${stoppedTarget}.bak`, 'utf8'), 'before-crash');
+ const recovered = await recoverBoardFileIfMissing(stoppedTarget);
+ assert.equal(recovered.recovered, stage === 'backup');
+ assert.equal(await readFile(stoppedTarget, 'utf8'), stage === 'backup' ? 'before-crash' : 'after-crash');
+ if (stage === 'installed') {
+ assert.equal(existsSync(`${stoppedTarget}.bak`), true, 'opening alone preserves the interrupted-save backup');
+ await cleanupBoardBackup(stoppedTarget);
+ assert.equal(existsSync(`${stoppedTarget}.bak`), false);
+ }
+ }
} finally {
await rm(tempDir, { recursive: true, force: true });
}
diff --git a/scripts/test-board-open-handles.mjs b/scripts/test-board-open-handles.mjs
new file mode 100644
index 0000000..5b1e258
--- /dev/null
+++ b/scripts/test-board-open-handles.mjs
@@ -0,0 +1,84 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import vm from 'node:vm';
+
+// An initialization failure must release the new file handle immediately:
+// relying on garbage collection can lock boards on Windows or exhaust handles.
+// Inject a filesystem into each module's VM instead of changing global fs.
+const require = createRequire(import.meta.url);
+const formats = [
+ { file: './board-container.js', open: 'openContainer', size: 512 * 1024 },
+ { file: './board-sidecar.js', open: 'openSidecarStore', size: 8 },
+];
+let cases = 0;
+
+for (const format of formats) {
+ const source = await readFile(new URL(format.file, import.meta.url), 'utf8');
+ for (const mode of ['create-missing', 'truncate-existing', 'undersized-existing']) {
+ for (const failure of [null, 'truncate', 'write', 'sync']) {
+ for (const closeFails of failure ? [false, true] : [false]) {
+ const label = `${format.open}: ${mode}, ${failure || 'success'}, closeFails=${closeFails}`;
+ const expectedError = Object.assign(new Error(`Injected ${failure} failure`), { code: 'ENOSPC' });
+ const calls = { open: [], truncate: 0, write: 0, sync: 0, close: 0 };
+ const handle = {
+ async stat() { return { size: 1 }; },
+ async truncate() {
+ calls.truncate++;
+ if (failure === 'truncate') throw expectedError;
+ },
+ async write(_buffer, _offset, length) {
+ calls.write++;
+ if (failure === 'write') throw expectedError;
+ return { bytesWritten: length };
+ },
+ async sync() {
+ calls.sync++;
+ if (failure === 'sync') throw expectedError;
+ },
+ async close() {
+ calls.close++;
+ if (closeFails) throw new Error('Injected close failure');
+ },
+ };
+ const filesystem = {
+ async open(_target, flags) {
+ calls.open.push(flags);
+ if (mode === 'create-missing' && calls.open.length === 1) {
+ throw Object.assign(new Error('Missing fixture'), { code: 'ENOENT' });
+ }
+ return handle;
+ },
+ };
+ const module = { exports: {} };
+ vm.runInNewContext(source, {
+ module, Buffer,
+ require: id => id === 'fs/promises' ? filesystem : require(id),
+ }, { filename: format.file });
+ const options = mode === 'truncate-existing' ? { truncate: true } : { create: true };
+ const pending = module.exports[format.open]('handle-test.refboard', options);
+
+ if (failure) {
+ await assert.rejects(pending, error => error === expectedError, `${label}: preserve the initialization error`);
+ assert.equal(calls.close, 1, `${label}: close the failed handle exactly once`);
+ if (failure === 'truncate') assert.equal(calls.write, 0, `${label}: stop before writing`);
+ if (failure !== 'sync') assert.equal(calls.sync, 0, `${label}: stop before syncing`);
+ } else {
+ const opened = await pending;
+ assert.equal(opened.handle, handle, `${label}: return the caller's handle`);
+ assert.equal(opened.size, format.size, `${label}: initialize the format header`);
+ assert.equal(calls.sync, 1, `${label}: flush initialization`);
+ assert.equal(calls.close, 0, `${label}: successful handle remains caller-owned`);
+ await opened.handle.close();
+ assert.equal(calls.close, 1, `${label}: caller can close the handle`);
+ }
+ assert.equal(calls.truncate, 1, `${label}: initialization runs once`);
+ assert.deepEqual(calls.open, mode === 'create-missing' ? ['r+', 'w+']
+ : mode === 'truncate-existing' ? ['w+'] : ['r+'], `${label}: expected open path`);
+ cases++;
+ }
+ }
+ }
+}
+
+console.log(`board open handle lifecycle tests passed (${cases} cases)`);
diff --git a/scripts/test-board-preview-backfill.mjs b/scripts/test-board-preview-backfill.mjs
index 9482fcb..0827b69 100644
--- a/scripts/test-board-preview-backfill.mjs
+++ b/scripts/test-board-preview-backfill.mjs
@@ -96,12 +96,7 @@ try {
);
const bakPath = `${filePath}.bak`;
- assert.equal(existsSync(bakPath), true, 'preview backfill must keep the previous board as .bak');
- assert.equal(await readBoardPreview(bakPath), null, 'the first .bak should be the pre-preview save');
- assert.ok(
- (await readBoardImageBytes(bakPath, (await scanBoardFile(bakPath)).images[0])).equals(bytes),
- 'the kept .bak must still contain the original image bytes',
- );
+ assert.equal(existsSync(bakPath), false, 'successful preview backfill removes its temporary .bak');
// Second backfill should replace the preview without corrupting images.
const preview2 = Buffer.from('second-preview').toString('base64');
@@ -111,8 +106,7 @@ try {
(await readBoardImageBytes(filePath, (await scanBoardFile(filePath)).images[0])).equals(bytes),
'replacing an existing preview must keep image bytes intact',
);
- assert.equal(existsSync(bakPath), true, 'a second backfill must still leave a .bak');
- assert.equal(await readBoardPreview(bakPath), preview, 'the rotated .bak should keep the previous preview generation');
+ assert.equal(existsSync(bakPath), false, 'repeated preview updates must not leave .bak');
} finally {
await rm(tempDir, { recursive: true, force: true });
}
diff --git a/scripts/test-board-save-concurrency.mjs b/scripts/test-board-save-concurrency.mjs
index fe14406..b768fed 100644
--- a/scripts/test-board-save-concurrency.mjs
+++ b/scripts/test-board-save-concurrency.mjs
@@ -101,6 +101,8 @@ try {
const initial = await container.openContainer(target, { create: true });
await container.writeContainerIndex(initial, { items: [] }, null, []);
await initial.handle.close();
+ const backup = `${target}.bak`;
+ await fs.writeFile(backup, 'old-release-backup');
const opening = pause('open');
const pendingBegin = begin(target, [{ id: 'A', type: 'image/png' }]);
@@ -129,6 +131,7 @@ try {
const committing = pause('index');
const pendingFinish = finish(a);
await committing.entered;
+ assert.equal(await fs.readFile(backup, 'utf8'), 'old-release-backup', 'backup stays until the save commits');
await expectBusy();
await assert.rejects(call('save-board-file', { filePath: target, data: 'overwrite' }), /Board save in progress/);
await assert.rejects(call('write-board-preview', { filePath: target, preview: 'YQ==' }), /Board save in progress/);
@@ -136,7 +139,9 @@ try {
committing.release();
assert.equal((await pendingFinish).saved, true);
assert.deepEqual(await readSaved(), [{ id: 'A', bytes: 'AAAA' }]);
+ await assert.rejects(fs.stat(backup), { code: 'ENOENT' }, 'successful incremental save removes a leftover backup');
+ await fs.writeFile(backup, 'keep-on-failure');
const b = await begin();
await call('append-board-save-image', {
token: b.token, image: { id: 'B' }, data: Buffer.from('BBBB'),
@@ -148,6 +153,7 @@ try {
aborting.release();
assert.equal((await pendingAbort).aborted, true);
assert.deepEqual(await readSaved(), [{ id: 'A', bytes: 'AAAA' }], 'abort preserves the previous committed board');
+ assert.equal(await fs.readFile(backup, 'utf8'), 'keep-on-failure', 'aborting must keep existing recovery data');
// A failed commit must hold its reservation while rollback is still pending,
// and release it afterward so the user can retry.
@@ -160,6 +166,7 @@ try {
rollingBack.release();
await pendingFailure;
failIndexWrite = false;
+ assert.equal(await fs.readFile(backup, 'utf8'), 'keep-on-failure', 'a failed commit must not delete the backup');
assert.deepEqual(await readSaved(), [{ id: 'A', bytes: 'AAAA' }]);
const compacted = await begin(target, [{ id: 'A', type: 'image/png' }]);
@@ -172,15 +179,18 @@ try {
assert.equal((await pendingCompaction).compacted, true);
forceCompact = false;
assert.deepEqual(await readSaved(), [{ id: 'A', bytes: 'AAAA' }]);
+ await assert.rejects(fs.stat(backup), { code: 'ENOENT' }, 'successful compaction also cleans up the old backup');
// Preview backfills also mutate the container and must exclude saves in the
// opposite direction, including the initial asynchronous open.
const previewOpening = pause('open');
+ await fs.writeFile(backup, 'old-preview-backup');
const pendingPreview = call('write-board-preview', { filePath: target, preview: 'YQ==' });
await previewOpening.entered;
await expectBusy();
previewOpening.release();
assert.equal((await pendingPreview).written, true);
+ await assert.rejects(fs.stat(backup), { code: 'ENOENT' }, 'successful preview updates clean up old backups');
const retry = await begin();
await abort(retry);
@@ -190,6 +200,18 @@ try {
await fs.mkdir(path.dirname(missingDirTarget));
const recovered = await begin(missingDirTarget);
await abort(recovered);
+
+ // The fallback whole-file save also uses a flushed temporary replacement.
+ const rawTarget = path.join(dir, 'raw.refboard');
+ await fs.writeFile(rawTarget, 'original');
+ await fs.writeFile(`${rawTarget}.bak`, 'older');
+ await assert.rejects(call('save-board-file', { filePath: rawTarget, data: undefined }));
+ assert.equal(await fs.readFile(rawTarget, 'utf8'), 'original');
+ assert.equal(await fs.readFile(`${rawTarget}.bak`, 'utf8'), 'older');
+ assert.equal((await call('save-board-file', { filePath: rawTarget, data: 'updated' })).saved, true);
+ assert.equal(await fs.readFile(rawTarget, 'utf8'), 'updated');
+ await assert.rejects(fs.stat(`${rawTarget}.bak`), { code: 'ENOENT' });
+ assert.ok(!(await fs.readdir(dir)).some(name => name.startsWith('raw.refboard.saving-')));
console.log('board save concurrency passed — pending begin, case aliases, append, commit, abort, rollback, compaction, preview, and retry');
} finally {
for (const name of [...gates.keys()]) gates.delete(name);
diff --git a/scripts/test-board-sidecar.mjs b/scripts/test-board-sidecar.mjs
index e00f7bb..0b9bce0 100644
--- a/scripts/test-board-sidecar.mjs
+++ b/scripts/test-board-sidecar.mjs
@@ -66,9 +66,9 @@ try {
assert.equal(await sidecar.readSidecarIndex(legacyPath), null, 'a legacy board reads as null, not an error');
assert.equal(sidecar.isSidecarIndexHead(Buffer.from('{"preview":"...')), false);
- // A second write keeps the previous index as .bak and no temp files.
+ // A second successful write leaves neither a .bak nor temporary files.
await sidecar.writeSidecarIndex(indexPath, core, null, images);
- assert.ok(existsSync(`${indexPath}.bak`), 'rewriting the index keeps the previous one as .bak');
+ assert.equal(existsSync(`${indexPath}.bak`), false, 'rewriting the index removes its temporary .bak');
assert.equal(JSON.parse(await readFile(indexPath, 'utf8')).preview, undefined, 'a null preview omits the key');
/* --- reopening appends after the existing records --- */
diff --git a/scripts/test-container-save-contract.mjs b/scripts/test-container-save-contract.mjs
index 3f75501..b4ce298 100644
--- a/scripts/test-container-save-contract.mjs
+++ b/scripts/test-container-save-contract.mjs
@@ -49,7 +49,7 @@ const finish = handler('finish-board-save');
assert.match(finish, /const written = await writeContainerIndex\(box, session\.core, session\.preview, images\);/, 'the index is written at the tail, then the head stub');
assert.match(finish, /if \(session\.mode === 'append'\) \{[\s\S]*?shouldCompactContainer\(written\.size, garbage/, 'an in-place save considers compaction');
assert.match(finish, /rebuildContainer\(session\.target, \{\s*sourcePath: session\.target/, 'compaction copies the live records into a fresh file');
-assert.match(finish, /await replaceBoardFile\(session\.target, tempPath\);/, 'a conversion or a new file swaps the temp container in, keeping the previous file as .bak');
+assert.match(finish, /await replaceBoardFile\(session\.target, tempPath\);/, 'a conversion or new file uses the recoverable replacement helper');
assert.match(finish, /if \(session\.mode === 'convert-sidecar' && session\.sourceStorePath\) \{\s*await fs\.unlink\(session\.sourceStorePath\)/, 'a converted pair\'s store is removed once its images are in the board');
const discard = fn('discardBoardSaveSession');
diff --git a/scripts/test-draw-panel-contract.mjs b/scripts/test-draw-panel-contract.mjs
index 6a23e1f..ccada42 100644
--- a/scripts/test-draw-panel-contract.mjs
+++ b/scripts/test-draw-panel-contract.mjs
@@ -120,7 +120,7 @@ assert.match(
);
assert.match(
html,
- /function undo\(\)[\s\S]*?await settleDrawCommitForHistory\(\);[\s\S]*?captureBitmapAfterState\(entry\)/,
+ /function undo\([^)]*\)[\s\S]*?await settleDrawCommitForHistory\(\);[\s\S]*?captureBitmapAfterState\(entry\)/,
'undo should settle drawing before capturing the post-edit bitmap for redo',
);
assert.match(
diff --git a/scripts/test-installer-retry.mjs b/scripts/test-installer-retry.mjs
index d402371..2525626 100644
--- a/scripts/test-installer-retry.mjs
+++ b/scripts/test-installer-retry.mjs
@@ -14,13 +14,16 @@ const handlers = new Map();
const children = [];
let setupExists = true;
let spawnError = null;
+const quitTimers = [];
+let quitCount = 0;
const electron = {
- app: { whenReady: () => ({ then() {} }), on() {} },
+ app: { whenReady: () => ({ then() {} }), on() {}, quit: () => quitCount++ },
ipcMain: { handle: (name, handler) => handlers.set(name, handler), on() {} },
};
vm.runInNewContext(mainSource, {
__dirname: path.resolve('bootstrapper'),
process: { resourcesPath: path.resolve('bootstrapper/payload'), env: {} },
+ setTimeout: callback => quitTimers.push(callback),
require(name) {
if (name === 'electron') return electron;
if (name === 'path') return path;
@@ -28,6 +31,8 @@ vm.runInNewContext(mainSource, {
if (name === 'child_process') return { spawn() {
if (spawnError) throw spawnError;
const child = new EventEmitter();
+ child.unrefCount = 0;
+ child.unref = () => child.unrefCount++;
children.push(child);
return child;
} };
@@ -35,6 +40,7 @@ vm.runInNewContext(mainSource, {
},
});
const start = handlers.get('installer:start');
+const launch = handlers.get('installer:launch');
// The renderer runs with minimal DOM controls and instant animation frames.
function element() {
@@ -58,7 +64,7 @@ const root = element();
const body = element();
let frameTime = 0;
let launched = 0;
-const bridge = { start, launch: () => launched++ };
+const bridge = { start, launch: () => { launched++; return { launched: true }; } };
const ui = vm.createContext({
document: {
body, querySelector: selector => root.querySelector(selector),
@@ -116,7 +122,7 @@ children.at(-1).emit('exit', 0);
assert.equal((await recovered).ok, true);
// A rejected IPC promise must also offer retry instead of leaving the UI busy.
-vm.runInContext('installComplete = false', ui);
+vm.runInContext('installComplete = false; launching = false', ui);
bridge.start = () => Promise.reject(new Error('IPC disconnected'));
await install();
assert.equal(label(), 'Retry install');
@@ -125,4 +131,50 @@ bridge.start = () => Promise.resolve({ ok: true });
await install();
assert.equal(label(), 'Launch RefBoard');
-console.log('installer retry tests passed');
+// Launch failures stay in the completed installer and retry launching only.
+bridge.launch = () => Promise.reject(new Error('IPC disconnected'));
+await install();
+assert.equal(label(), 'Retry launch');
+assert.equal(button.disabled, false);
+assert.equal(body.classList.contains('is-complete'), true);
+bridge.launch = () => ({ launched: false, reason: 'app-not-found' });
+await install();
+assert.equal(label(), 'Retry launch');
+assert.match(root.querySelector('#installMeta').textContent, /could not be found/);
+assert.equal(button.disabled, false);
+
+// Exercise production launch IPC, including asynchronous errors from spawn.
+setupExists = false;
+assert.equal((await launch()).reason, 'app-not-found');
+setupExists = true;
+spawnError = new Error('spawn EPERM');
+assert.equal((await launch()).reason, 'spawn-failed');
+spawnError = null;
+const failedLaunch = launch();
+const failedChild = children.at(-1);
+assert.doesNotThrow(() => failedChild.emit('error', new Error('spawn EACCES')));
+assert.equal((await failedLaunch).reason, 'process-error');
+assert.equal(failedChild.unrefCount, 0);
+assert.equal(quitTimers.length, 0, 'failed launches must keep the installer open');
+
+bridge.launch = launch;
+const launchRetry = install();
+const launchedChild = children.at(-1);
+const childrenBeforeDuplicate = children.length;
+await install();
+const duplicateLaunch = launch();
+assert.equal(children.length, childrenBeforeDuplicate, 'repeated launch requests must share one process');
+assert.equal(button.disabled, true);
+assert.equal(label(), 'Launching\u2026');
+assert.equal(quitTimers.length, 0, 'wait for the spawn event before quitting');
+launchedChild.emit('spawn');
+await launchRetry;
+assert.equal((await duplicateLaunch).launched, true);
+assert.equal(launchedChild.unrefCount, 1);
+assert.equal(quitTimers.length, 1, 'one successful launch schedules one quit');
+assert.equal((await launch()).launched, true);
+assert.equal(children.length, childrenBeforeDuplicate, 'a completed launch must not spawn twice before quit');
+quitTimers[0]();
+assert.equal(quitCount, 1);
+
+console.log('installer install and launch retry tests passed');
diff --git a/scripts/test-small-correctness-contract.mjs b/scripts/test-small-correctness-contract.mjs
index 6bd748c..54e05b5 100644
--- a/scripts/test-small-correctness-contract.mjs
+++ b/scripts/test-small-correctness-contract.mjs
@@ -38,7 +38,7 @@ assert.match(html, /if \(k === 'enter'\) \{\s*const it = state\.sel\.size === 1
/* --- 2. history refuses a live gesture --- */
for (const name of ['undo', 'redo']) {
const body = fn(name);
- assert.match(body, /^function \w+\(\) \{\s*if \(mode\) return Promise\.resolve\(\);/, `${name} refuses while a gesture is live, before queueing`);
+ assert.match(body, /^function \w+\([^)]*\) \{\s*if \(mode\) return Promise\.resolve\(\);/, `${name} refuses while a gesture is live, before queueing`);
assert.match(body, /await settleDrawCommitForHistory\(\);\s*if \(mode\) return;/, `${name} re-checks after waiting its turn, in case a gesture began meanwhile`);
}
diff --git a/scripts/test-split-animation.mjs b/scripts/test-split-animation.mjs
new file mode 100644
index 0000000..1ceeb4a
--- /dev/null
+++ b/scripts/test-split-animation.mjs
@@ -0,0 +1,380 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import vm from 'node:vm';
+
+// Run the real layout/lifecycle functions with a controlled clock and native
+// views. This catches jumps that a final-state-only smoke cannot detect.
+const source = (await readFile(new URL('../main.js', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
+const names = ['clampSplitRatio', 'splitFrame', 'layoutViews', 'setPaneBounds', 'easeSplitMotion',
+ 'splitOpenProgress', 'stopSplitAnim', 'tickSplitAnim', 'startSplitAnim',
+ 'animateSplitClose', 'applySplitScreenX', 'splitStatePayload', 'sendSplitState',
+ 'enterSplit', 'requestSplitExit', 'requestWindowClose'];
+const functions = names.map(name => {
+ const match = source.match(new RegExp(`(?:async )?function ${name}\\([^]*?^}`, 'm'));
+ assert.ok(match, `production function ${name} exists`);
+ return match[0];
+}).join('\n');
+const sessionStart = source.indexOf(" ipcMain.on('board-session',");
+const sessionEnd = source.indexOf('\n });', sessionStart) + '\n });'.length;
+assert.ok(sessionStart > 0 && sessionEnd > sessionStart);
+const frameStart = source.indexOf(" ipcMain.on('split-animation-frame',");
+const frameEnd = source.indexOf('\n });', frameStart) + '\n });'.length;
+assert.ok(frameStart > 0 && frameEnd > frameStart);
+
+function harness() {
+ let now = 1000, failure = false, removed = 0, nextId = 0;
+ let resolveLoad, rejectLoad;
+ const timers = new Map(), frames = [], views = [], handlers = new Map();
+ const st = { primaryView: { updates: 0, getBounds() { return this.bounds || {}; },
+ setBounds(bounds) { this.bounds = bounds; this.updates++; } },
+ secondaryView: null, secondaryLoading: false, splitRatio: 0.5, splitAnim: null,
+ animTimer: 0, layout: { addChildView(view) {
+ assert.equal(view.bounds.x, 1200, 'the pane is offscreen before attachment');
+ } } };
+ const win = { isDestroyed: () => false, getContentSize: () => [1200, 800],
+ getContentBounds: () => ({ x: 100, width: 1200 }), setMinimumSize() {} };
+ const primary = { isDestroyed: () => false, isCrashed: () => false,
+ send: () => { st.primaryCloseRequested = true; } };
+ const context = vm.createContext({
+ Math, performance: { now: () => now }, console: { warn() {} },
+ SPLIT_RATIO_DEFAULT: 0.5, SPLIT_RATIO_MIN: 0.25, SPLIT_RATIO_MAX: 0.75,
+ SPLIT_GAP: 6, SPLIT_ANIM_MS: 400, TITLEBAR_H: 34,
+ SPLIT_MIN_WIDTH: 1000, WINDOW_MIN_HEIGHT: 480, MAX_BOARD_WINDOWS: 4,
+ paneState: () => st, boardPaneCount: () => 1,
+ crypto: { randomUUID: () => `split-${++nextId}` }, boardWebPreferences: () => ({}),
+ registerSender() {}, attachWebContentsSafety() {}, sendBlockedBoardPath() {},
+ startPaneActivityPolling() {}, stopSplitDrag() {}, sendPaneActivity() {},
+ windowForEvent: () => win,
+ primaryWebContents: () => primary,
+ isSecondarySender: (_win, sender) => sender === st.secondaryView?.webContents,
+ ipcMain: { on: (name, fn) => handlers.set(name, fn) },
+ setTimeout(fn) { const id = ++nextId; timers.set(id, fn); return id; },
+ clearTimeout: id => timers.delete(id),
+ sendToPanes: (_win, _channel, payload) => frames.push(payload),
+ destroySecondary() {
+ context.stopSplitAnim(win); st.secondaryView = null; st.secondaryLoading = false; removed++;
+ },
+ WebContentsView: class {
+ constructor() {
+ this.webContents = { focus() {}, setBackgroundThrottling(allowed) { this.throttled = allowed; }, isDestroyed: () => false, isCrashed: () => false,
+ send() {}, loadFile: () => failure ? Promise.reject(new Error('load failed'))
+ : new Promise((resolve, reject) => { resolveLoad = resolve; rejectLoad = reject; }) };
+ views.push(this);
+ }
+ getBounds() { return this.bounds || {}; }
+ setBounds(bounds) { this.bounds = bounds; }
+ setBackgroundColor() {}
+ },
+ });
+ vm.runInContext(functions + '\n' + source.slice(sessionStart, sessionEnd)
+ + '\n' + source.slice(frameStart, frameEnd), context);
+ return { st, win, context, frames, views, timers,
+ get removed() { return removed; },
+ time(ms) { now += ms; },
+ fail() { failure = true; },
+ resolveLoad() { resolveLoad(); },
+ rejectLoad() { rejectLoad(new Error('load cancelled')); },
+ ready(value = true, sender = st.secondaryView?.webContents) {
+ handlers.get('board-session')({ sender }, { startupComplete: value, ready: false });
+ },
+ frame(id = st.splitAnim?.id, sender = primary) {
+ handlers.get('split-animation-frame')({ sender }, id);
+ },
+ };
+}
+
+{
+ const h = harness(), { context: c, st, win } = h;
+ const opening = c.enterSplit(win, 0.5);
+ assert.equal(st.secondaryView.webContents.throttled, false, 'offscreen startup timers must run');
+ assert.equal(c.splitOpenProgress(st), 0);
+ assert.equal(c.splitFrame(win).contentWidth, 1200, 'loading does not shrink the primary board');
+ h.time(2000);
+ h.resolveLoad();
+ assert.equal((await opening).opened, true);
+ assert.equal(h.timers.size, 0, 'file load alone must not consume the slide animation');
+ h.ready(false);
+ assert.equal(h.timers.size, 0, 'early board-session reports must not start opening');
+ h.ready(true, {});
+ assert.equal(h.timers.size, 0, 'only the secondary renderer can report its readiness');
+ h.ready();
+ assert.equal(st.secondaryView.webContents.throttled, false, 'secondary keeps painting throughout the slide');
+ assert.equal(c.splitOpenProgress(st), 0, 'first opening frame starts at zero');
+ const started = st.splitAnim.t0;
+ h.ready();
+ assert.equal(st.splitAnim.t0, started, 'duplicate readiness cannot restart the slide');
+ const widths = [c.splitFrame(win).contentWidth];
+ for (let i = 0; i < 4; i++) {
+ h.time(100); h.frame();
+ const f = c.splitFrame(win);
+ widths.push(f.contentWidth);
+ assert.equal(st.secondaryView.bounds.x, f.secX);
+ assert.equal(h.frames.at(-1).contentWidth, f.contentWidth, 'native bounds and renderer share a frame');
+ assert.equal(st.primaryView.bounds.width, 1200, 'shared titlebar stays full width');
+ }
+ assert.deepEqual(widths, [1200, 1138, 900, 662, 600]);
+ assert.equal(st.secondaryView.webContents.throttled, true, 'normal throttling resumes after settling');
+ assert.equal(st.primaryView.updates, 1, 'unchanged native primary bounds are not applied every frame');
+ assert.equal(st.splitAnim, null);
+ assert.equal(h.timers.size, 0);
+ assert.equal(h.frames.at(-1).animating, false);
+ c.applySplitScreenX(win, 880);
+ assert.equal(st.splitRatio, 0.65, 'divider resizing works after the animation');
+ assert.equal(c.splitFrame(win).contentWidth, 780);
+ c.startSplitAnim(win, 'out');
+ h.time(200); h.frame();
+ assert.equal(c.splitFrame(win).contentWidth, 990, 'closing also has an intermediate frame');
+ h.time(200); h.frame();
+ assert.equal(st.secondaryView, null);
+ assert.equal(h.timers.size, 0);
+}
+
+// Reverse an opening halfway through without jumping to a different easing value.
+{
+ const h = harness(), { context: c, st, win } = h;
+ const opening = c.enterSplit(win, 0.75); h.resolveLoad(); await opening; h.ready();
+ h.time(100); c.tickSplitAnim(win);
+ const before = c.splitFrame(win).secX;
+ c.applySplitScreenX(win, 400);
+ assert.equal(st.splitRatio, 0.75, 'drag events cannot fight an active slide');
+ c.animateSplitClose(win);
+ assert.equal(c.splitFrame(win).secX, before, 'reversing keeps the exact current position');
+ h.time(400); h.frame();
+ assert.equal(st.secondaryView, null);
+}
+
+// Only the primary renderer and the current animation can advance native views.
+{
+ const h = harness(), { context: c, st, win } = h;
+ const opening = c.enterSplit(win, 0.5); h.resolveLoad(); await opening; h.ready();
+ const firstId = st.splitAnim.id;
+ h.time(100);
+ const count = h.frames.length;
+ h.frame('stale-id'); h.frame(firstId, st.secondaryView.webContents);
+ assert.equal(h.frames.length, count);
+ h.frame();
+ assert.ok(h.frames.at(-1).contentWidth < 1200);
+ c.animateSplitClose(win);
+ const reversedCount = h.frames.length;
+ h.frame(firstId);
+ assert.equal(h.frames.length, reversedCount, 'old callbacks cannot advance a reversed transition');
+ h.time(500);
+ const fallback = [...h.timers.values()][0];
+ fallback();
+ assert.equal(st.secondaryView, null, 'the deadline completes a slide if RAF stops');
+ assert.equal(h.timers.size, 0);
+}
+
+// Failure and cancellation cannot leave a blank pane or revive a removed one.
+{
+ const h = harness(); h.fail();
+ assert.equal((await h.context.enterSplit(h.win, 0.5)).reason, 'load-failed');
+ assert.equal(h.st.secondaryView, null);
+ assert.equal(h.timers.size, 0);
+}
+{
+ const h = harness();
+ const opening = h.context.enterSplit(h.win, 0.5);
+ assert.equal(h.context.requestSplitExit(h.win).closed, true);
+ h.resolveLoad();
+ assert.equal((await opening).reason, 'cancelled');
+ assert.equal(h.st.secondaryView, null);
+ assert.equal(h.timers.size, 0);
+}
+{
+ const h = harness();
+ const opening = h.context.enterSplit(h.win, 0.5);
+ h.context.requestWindowClose(h.win);
+ assert.equal(h.st.primaryCloseRequested, true, 'closing during startup reaches the primary save prompt');
+ h.rejectLoad();
+ assert.equal((await opening).reason, 'cancelled', 'an aborted load after closing is not reported as an open error');
+ assert.equal(h.st.secondaryView, null);
+}
+
+// Chromium can suspend RAF for an offscreen view. Only secondary startup may
+// fall back to a timer; normal board transitions still wait for painted frames.
+const html = (await readFile(new URL('../index.html', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
+const nextUiFrame = html.match(/function nextUiFrame\([^]*?^}/m)?.[0];
+assert.ok(nextUiFrame);
+for (const [pane, ready, fallback] of [['secondary', false, true], ['secondary', true, false], ['primary', false, false]]) {
+ const frames = [], timers = new Map();
+ const c = vm.createContext({ BOARD_PANE: pane, appStartupComplete: ready,
+ requestAnimationFrame: fn => frames.push(fn),
+ setTimeout: fn => { timers.set(1, fn); return 1; }, clearTimeout: id => timers.delete(id) });
+ vm.runInContext(nextUiFrame, c);
+ let resolved = false;
+ const waiting = c.nextUiFrame().then(() => { resolved = true; });
+ await Promise.resolve();
+ assert.equal(resolved, false);
+ assert.equal(timers.size, fallback ? 1 : 0);
+ if (fallback) timers.get(1)();
+ else { frames.shift()(); frames.shift()(); }
+ await waiting;
+ assert.equal(resolved, true);
+}
+
+console.log('split animation tests passed — ready gating, intermediate frames, resize, reversal, failure and cancellation');
+
+// The visible edge can move each frame, but reallocating the canvas each time
+// clears its pixels and stalls the slide. Exercise the actual renderer sizing
+// functions at high DPI so CSS sizing cannot silently stretch the backing.
+function rendererFunctions(names) {
+ return names.map(name => {
+ const match = html.match(new RegExp(`function ${name}\\([^]*?^}`, 'm'));
+ assert.ok(match, `renderer function ${name} exists`);
+ return match[0];
+ }).join('\n');
+}
+
+{
+ let backingWidth = 2400, backingHeight = 1532, widthWrites = 0, heightWrites = 0;
+ let redraws = 0;
+ const boardClasses = new Set(['board-active', 'titlebar-revealed']);
+ const canvas = {
+ get width() { return backingWidth; },
+ set width(value) { backingWidth = value; widthWrites++; },
+ get height() { return backingHeight; },
+ set height(value) { backingHeight = value; heightWrites++; },
+ style: { width: '1200px', height: '766px' },
+ };
+ const c = vm.createContext({
+ canvas, splitContentWidth: 1200, splitAnimating: true, splitActive: true,
+ splitDragging: false, BOARD_PANE: 'primary', innerWidth: 1200, innerHeight: 800,
+ devicePixelRatio: 2, chromeLeft: () => 0, titlebarH: () => 34,
+ document: {
+ documentElement: { classList: { contains: () => false } },
+ body: { classList: { contains: name => boardClasses.has(name) } },
+ },
+ invalidate: () => { redraws++; },
+ });
+ vm.runInContext(rendererFunctions(['boardLayoutSize', 'boardCanvasSize', 'resize']), c);
+ function assertRasterScale() {
+ assert.equal(canvas.width / parseFloat(canvas.style.width), 2, 'the raster stays at native DPI instead of stretching');
+ assert.equal(canvas.height / parseFloat(canvas.style.height), 2);
+ }
+ for (const width of [1200, 1150, 1000, 800, 600]) {
+ c.splitContentWidth = width;
+ c.resize();
+ assert.equal(c.boardLayoutSize().w, width, 'hit testing and layout retain the visible pane width');
+ assert.equal(canvas.width, 2400, 'opening preserves the full backing behind the clipped edge');
+ assertRasterScale();
+ }
+ assert.equal(widthWrites, 0);
+ assert.equal(heightWrites, 0);
+ assert.equal(redraws, 0, 'moving the clipped edge alone does not redraw the board');
+ c.splitAnimating = false;
+ c.resize();
+ assert.equal(canvas.width, 1200, 'settled split gets its exact visible backing size');
+ assert.equal(canvas.style.width, '600px');
+ assert.equal(widthWrites, 1);
+ assert.equal(heightWrites, 0, 'resizing only width never resets the unchanged height');
+ assertRasterScale();
+
+ c.splitAnimating = true;
+ c.resize();
+ assert.equal(canvas.width, 2400, 'closing prepares the complete area before it is revealed');
+ const preparedWrites = widthWrites;
+ for (const width of [600, 700, 900, 1100, 1200]) {
+ c.splitContentWidth = width;
+ c.resize();
+ assert.equal(widthWrites, preparedWrites, 'closing never reallocates between frames');
+ assert.equal(heightWrites, 0);
+ assertRasterScale();
+ }
+ c.splitAnimating = false;
+ c.splitActive = false;
+ c.splitContentWidth = 0;
+ c.resize();
+ assert.equal(canvas.width, 2400);
+ assert.equal(widthWrites, 2, 'there is only one backing resize at each necessary boundary');
+ assert.equal(redraws, 2);
+ assertRasterScale();
+
+ c.innerHeight = 900;
+ c.resize();
+ c.resize();
+ assert.equal(widthWrites, 2, 'height-only changes preserve width');
+ assert.equal(heightWrites, 1, 'unchanged dimensions are not assigned again');
+ assertRasterScale();
+ c.BOARD_PANE = 'secondary';
+ c.splitAnimating = true;
+ c.splitContentWidth = 500;
+ assert.equal(c.boardCanvasSize().w, 500, 'the secondary pane keeps its own normal canvas size');
+
+ c.BOARD_PANE = 'primary';
+ c.innerHeight = 800;
+ boardClasses.delete('titlebar-revealed');
+ c.resize();
+ assert.equal(c.boardCanvasSize().h, 800, 'auto-hidden titlebars keep the full vertical raster during the slide');
+ assert.equal(canvas.style.height, '800px');
+ assertRasterScale();
+ const verticalWrites = heightWrites;
+ for (const width of [500, 800, 1100, 1200]) {
+ c.splitContentWidth = width;
+ c.resize();
+ assert.equal(heightWrites, verticalWrites, 'moving past the titlebar never resizes or stretches pixels');
+ }
+ c.splitAnimating = false;
+ c.resize();
+ assert.equal(c.boardCanvasSize().h, 766, 'the settled split returns to its exact titlebar inset');
+ assertRasterScale();
+ c.splitAnimating = true;
+ boardClasses.add('titlebar-revealed');
+ assert.equal(c.boardCanvasSize().h, 766, 'an explicitly revealed titlebar retains its existing inset');
+ boardClasses.delete('board-active');
+ boardClasses.delete('titlebar-revealed');
+ assert.equal(c.boardCanvasSize().h, 766, 'the landing page retains its existing inset');
+}
+
+// Animation requests are display paced. Repeated state messages must neither
+// start parallel RAF loops nor leave old animation IDs running after reversal.
+{
+ let nextRaf = 1;
+ const callbacks = new Map(), sent = [], events = new Map();
+ const c = vm.createContext({
+ BOARD_PANE: 'primary', splitAnimationId: null, splitAnimationRaf: 0,
+ requestAnimationFrame(fn) { const id = nextRaf++; callbacks.set(id, fn); return id; },
+ cancelAnimationFrame: id => callbacks.delete(id),
+ window: {
+ RefBoardAPI: { splitAnimationFrame: id => sent.push(id) },
+ addEventListener: (name, fn) => events.set(name, fn),
+ },
+ });
+ const pagehide = html.match(/^window\.addEventListener\('pagehide', \(\) => syncSplitAnimationFrames\(null\)\);$/m)?.[0];
+ assert.ok(pagehide, 'renderer teardown cancels the animation frame loop');
+ vm.runInContext(rendererFunctions(['requestSplitAnimationFrame', 'syncSplitAnimationFrames']) + '\n' + pagehide, c);
+ function paintFrame() {
+ const pending = [...callbacks.values()];
+ callbacks.clear();
+ for (const callback of pending) callback();
+ }
+ c.syncSplitAnimationFrames('opening');
+ const firstRaf = c.splitAnimationRaf;
+ c.syncSplitAnimationFrames('opening');
+ assert.equal(c.splitAnimationRaf, firstRaf, 'duplicate IPC state cannot restart the frame clock');
+ assert.equal(callbacks.size, 1);
+ assert.deepEqual(sent, [], 'the first request waits until a paint opportunity');
+ paintFrame();
+ assert.deepEqual(sent, ['opening']);
+ assert.equal(callbacks.size, 1);
+ const oldRaf = c.splitAnimationRaf;
+ c.syncSplitAnimationFrames('closing');
+ assert.equal(callbacks.has(oldRaf), false, 'reversal cancels the previously queued frame');
+ assert.equal(callbacks.size, 1);
+ paintFrame();
+ assert.deepEqual(sent, ['opening', 'closing']);
+ c.syncSplitAnimationFrames(null);
+ assert.equal(callbacks.size, 0, 'settling cancels the loop');
+ paintFrame();
+ assert.deepEqual(sent, ['opening', 'closing']);
+ c.syncSplitAnimationFrames('reopening');
+ assert.equal(callbacks.size, 1);
+ events.get('pagehide')();
+ assert.equal(callbacks.size, 0, 'page teardown cancels the loop');
+ assert.equal(c.splitAnimationId, null);
+ c.BOARD_PANE = 'secondary';
+ c.syncSplitAnimationFrames('secondary');
+ assert.equal(callbacks.size, 0, 'only the primary renderer drives native layout');
+}
+
+console.log('split renderer tests passed — stable canvas, native pixel scale, RAF pacing and cancellation');
diff --git a/scripts/test-tags.mjs b/scripts/test-tags.mjs
index 65570b1..fdafcaf 100644
--- a/scripts/test-tags.mjs
+++ b/scripts/test-tags.mjs
@@ -199,33 +199,12 @@ assert.deepEqual(
);
assert.deepEqual(pruneTagColors({ mood: '#5aa2ff' }, []), {});
-/* ================= the datalist trap ================= */
-
-/* Rebuilding a while its popover is open crashes the renderer
- outright — an access violation, not an exception, so nothing is logged and
- the window simply disappears. renderTagSelection runs after every tag edit,
- which is exactly when the popover is open, so the suggestion rebuild has to
- stay out of it and happen once per open instead. Found the hard way. */
+/* The suggestions must stay ordinary DOM: updating an open native datalist
+ previously crashed Chromium. The interactive smoke covers live refresh. */
{
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
- const start = html.indexOf('function renderTagSelection()');
- assert.notEqual(start, -1, 'renderTagSelection should exist');
- let depth = 0;
- let body = '';
- for (let i = html.indexOf('{', start); i < html.length; i++) {
- if (html[i] === '{') depth++;
- else if (html[i] === '}' && --depth === 0) { body = html.slice(start, i + 1); break; }
- }
- assert.ok(body, 'renderTagSelection should have a complete body');
- assert.ok(
- !body.includes('tagPopSuggestEl'),
- 'renderTagSelection must not touch the suggestion datalist — rebuilding it while the '
- + 'popover is open crashes the renderer; refresh it when the panel opens instead',
- );
- assert.ok(
- /function setTagPanelOpen\([\s\S]{0,400}refreshTagSuggestions\(\)/.test(html),
- 'opening the panel must refresh the suggestions, or autocomplete never populates',
- );
+ assert.doesNotMatch(html, /]*id="tagPopSuggest"/, 'label suggestions must not recreate the native datalist crash');
+ assert.doesNotMatch(html, /list="tagPopSuggest"/, 'the input must not use a native suggestion popup');
}
-console.log('tags ok — values, board collection, filter modes, pruning and the datalist trap all hold');
+console.log('tags ok — values, board collection, filter modes, pruning and safe suggestions');