3d0b753205
Per request: clicks of the same button closer together than capture.clickDebounceMs (default 200ms) now collapse into a single step, so accidental fast/double clicks don't each become a step. It is a leading-edge debounce measured from the last *accepted* click, so a run of fast clicks can't push the next deliberate click out — two clicks spaced beyond the window (e.g. the reported 400-500ms apart) always register. Replaces the prior 8ms duplicate-delivery suppression (subsumed by the window). Configurable; 0 captures every click. Tests (the point of this change is that it can't silently regress): - 13 behavioral unit tests in capture.test.js that drive real onOsClick calls with controlled timestamps and assert which clicks survive — the reported 400/450/500ms cases, sub-window collapse, the 200ms boundary, per-button independence, configurability, debounce=0, last-accepted (not last-dropped) reference, session reset, and a full onOsClick -> queue -> store integration check. No keyword/comment assertions. - A fourth end-to-end self-test scenario (burst of 40ms clicks collapses to 1; three 300ms-apart clicks each register => 4 total). The marker/drain scenarios set debounce to 0 so they keep stressing the frame pipeline. 147 unit tests + all repo checks pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
119 lines
3.8 KiB
JavaScript
119 lines
3.8 KiB
JavaScript
'use strict';
|
|
|
|
const path = require('node:path');
|
|
const { writeJsonSync, readJsonIfExists, deepClone } = require('./util');
|
|
|
|
const DEFAULT_SETTINGS = {
|
|
schemaVersion: 1,
|
|
appearance: 'system', // system | light | dark
|
|
language: 'en',
|
|
spellcheck: true,
|
|
capture: {
|
|
delayMs: 0,
|
|
mode: 'fullscreen', // fullscreen | window | region
|
|
includeCursor: true,
|
|
clickMarker: true,
|
|
clickMarkerColor: '#E5484D',
|
|
hotkeyCapture: 'CommandOrControl+Shift+1',
|
|
hotkeyPauseResume: 'CommandOrControl+Shift+2',
|
|
captureOutsideClicks: true,
|
|
confirmSimpleCapture: false,
|
|
// Leading-edge click debounce (ms): clicks of the same button closer
|
|
// together than this collapse into one step, so accidental fast/double
|
|
// clicks don't each become a step. Clicks spaced further apart always
|
|
// register. Set to 0 to capture every click.
|
|
clickDebounceMs: 200,
|
|
autoIntervalSec: 5, // session fallback when click capture is unavailable
|
|
// Strict click timing: a step never uses a frame whose grab started
|
|
// after the click. Turn off only if captures are too slow to keep a
|
|
// pre-click frame buffered (re-enables the legacy slack heuristics).
|
|
strictClickFrames: true,
|
|
// Off-main-process frame recorder (hidden worker window sampling a
|
|
// desktop media stream). Falls back to the in-process loop when false
|
|
// or when streams cannot start on this desktop.
|
|
streamCapture: true,
|
|
frameSampleMs: 50, // stream backend sampling cadence (finer = fresher frames)
|
|
// Target the screen this many ms *before* each click. The hook fires on
|
|
// button-down but the UI/cursor often start reacting within a frame, and
|
|
// stream pixels lag slightly; a small lead keeps the saved screenshot
|
|
// clear of the click's onset. Raise it if screenshots still feel late.
|
|
clickLeadMs: 120,
|
|
// After the window hides at recording start, wait this long before the
|
|
// user is likely to click so the buffer holds frames of the now-visible
|
|
// screen rather than the just-dismissed app window.
|
|
postHideSettleMs: 150,
|
|
},
|
|
editor: {
|
|
focusedViewDefaultForNewSteps: false,
|
|
autoTitleTemplate: '[[Mode]] capture [[Time]]',
|
|
},
|
|
exports: {
|
|
previewStepCount: 3,
|
|
openFolderAfterExport: true,
|
|
lastOutputDirs: {}, // format -> dir
|
|
},
|
|
library: {
|
|
sortBy: 'updatedAt',
|
|
},
|
|
backups: {
|
|
automatic: true,
|
|
keepLast: 10,
|
|
everyNSaves: 25,
|
|
},
|
|
};
|
|
|
|
class Settings {
|
|
constructor(settingsDir) {
|
|
this.file = path.join(settingsDir, 'app-settings.json');
|
|
this.globalPlaceholdersFile = path.join(settingsDir, 'placeholders.json');
|
|
this.data = this.load();
|
|
}
|
|
|
|
load() {
|
|
const stored = readJsonIfExists(this.file, {});
|
|
return mergeDeep(deepClone(DEFAULT_SETTINGS), stored);
|
|
}
|
|
|
|
save() {
|
|
writeJsonSync(this.file, this.data);
|
|
return this.data;
|
|
}
|
|
|
|
get(keyPath) {
|
|
return keyPath.split('.').reduce((o, k) => (o == null ? undefined : o[k]), this.data);
|
|
}
|
|
|
|
set(keyPath, value) {
|
|
const keys = keyPath.split('.');
|
|
let obj = this.data;
|
|
for (const k of keys.slice(0, -1)) {
|
|
if (typeof obj[k] !== 'object' || obj[k] === null) obj[k] = {};
|
|
obj = obj[k];
|
|
}
|
|
obj[keys[keys.length - 1]] = value;
|
|
return this.save();
|
|
}
|
|
|
|
getGlobalPlaceholders() {
|
|
return readJsonIfExists(this.globalPlaceholdersFile, {});
|
|
}
|
|
|
|
setGlobalPlaceholders(values) {
|
|
writeJsonSync(this.globalPlaceholdersFile, values);
|
|
return values;
|
|
}
|
|
}
|
|
|
|
function mergeDeep(base, extra) {
|
|
for (const [k, v] of Object.entries(extra || {})) {
|
|
if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
|
|
mergeDeep(base[k], v);
|
|
} else {
|
|
base[k] = v;
|
|
}
|
|
}
|
|
return base;
|
|
}
|
|
|
|
module.exports = { Settings, DEFAULT_SETTINGS };
|