Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+114
-44
@@ -23,6 +23,7 @@ const { encodePng } = require('../core/png');
|
||||
*/
|
||||
|
||||
const CLICK_DEBOUNCE_MS = 700;
|
||||
const CLICK_CAPTURE_CACHE_MS = 75;
|
||||
const CLICK_CAPTURE_HIDE_DELAY_MS = 25;
|
||||
|
||||
function hasBinary(name) {
|
||||
@@ -43,6 +44,9 @@ class CaptureService {
|
||||
this.session = null; // { guideId, paused, count, intervalSec }
|
||||
this.intervalTimer = null;
|
||||
this.clickWatcher = null;
|
||||
this.captureCacheTimer = null;
|
||||
this.captureCache = null;
|
||||
this.captureCacheRunning = false;
|
||||
this.lastClickCapture = 0;
|
||||
this.shooting = false;
|
||||
}
|
||||
@@ -89,12 +93,22 @@ class CaptureService {
|
||||
if (!process.env.STEPFORGE_SCREENSHOT) {
|
||||
this.createSessionTray();
|
||||
const win = this.getWindow();
|
||||
const startClickCache = () => {
|
||||
if (this.settings.get('capture.captureOutsideClicks') !== false && this.clickCaptureAvailable()) {
|
||||
this.startClickCaptureCache();
|
||||
}
|
||||
};
|
||||
if (win && !win.isDestroyed() && win.isVisible()) {
|
||||
this.hiddenForSession = true;
|
||||
setTimeout(() => {
|
||||
// Re-check: the session may have been finished within the delay.
|
||||
if (this.session && this.hiddenForSession && !win.isDestroyed()) win.hide();
|
||||
if (this.session && this.hiddenForSession && !win.isDestroyed()) {
|
||||
win.hide();
|
||||
startClickCache();
|
||||
}
|
||||
}, 1200); // let the user read the "session started" toast first
|
||||
} else {
|
||||
startClickCache();
|
||||
}
|
||||
try {
|
||||
new Notification({
|
||||
@@ -203,6 +217,7 @@ class CaptureService {
|
||||
this.intervalTimer = null;
|
||||
}
|
||||
this.stopClickWatcher();
|
||||
this.stopClickCaptureCache();
|
||||
this.destroySessionTray();
|
||||
this.session = null;
|
||||
if (this.hiddenForSession) {
|
||||
@@ -238,20 +253,26 @@ class CaptureService {
|
||||
this.shooting = true;
|
||||
try {
|
||||
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
||||
const result = await this.shoot({
|
||||
guideId: this.session.guideId,
|
||||
mode: mode === 'region' ? 'fullscreen' : mode,
|
||||
delayMs: 0,
|
||||
hideWindowDelayMs: trigger === 'click' ? CLICK_CAPTURE_HIDE_DELAY_MS : null,
|
||||
refocus: false, // don't steal focus from the app the user is documenting
|
||||
});
|
||||
if (result.ok) {
|
||||
const grabMode = mode === 'region' ? 'fullscreen' : mode;
|
||||
const cached = trigger === 'click' && this.captureCache && this.captureCache.mode === grabMode
|
||||
? this.captureCache
|
||||
: null;
|
||||
const finalResult = cached
|
||||
? this.storeFrameAsStep(this.session.guideId, grabMode, cached)
|
||||
: await this.shoot({
|
||||
guideId: this.session.guideId,
|
||||
mode: grabMode,
|
||||
delayMs: 0,
|
||||
hideWindowDelayMs: trigger === 'click' ? CLICK_CAPTURE_HIDE_DELAY_MS : null,
|
||||
refocus: false, // don't steal focus from the app the user is documenting
|
||||
});
|
||||
if (finalResult.ok) {
|
||||
this.session.count += 1;
|
||||
this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger });
|
||||
this.notify('capture:added', { guideId: this.session.guideId, step: finalResult.step, trigger });
|
||||
this.notify('capture:state', this.state());
|
||||
if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter
|
||||
}
|
||||
return result;
|
||||
return finalResult;
|
||||
} finally {
|
||||
this.shooting = false;
|
||||
}
|
||||
@@ -263,6 +284,40 @@ class CaptureService {
|
||||
|
||||
// ---- click-triggered capture --------------------------------------------
|
||||
|
||||
startClickCaptureCache() {
|
||||
if (this.captureCacheRunning) return;
|
||||
this.captureCacheRunning = true;
|
||||
const refresh = async () => {
|
||||
if (!this.session || this.session.paused || !this.captureCacheRunning) return;
|
||||
try {
|
||||
if (!this.shooting) {
|
||||
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
||||
const grabMode = mode === 'region' ? 'fullscreen' : mode;
|
||||
const frame = await this.captureCurrentFrame(grabMode);
|
||||
if (this.captureCacheRunning && this.session && !this.session.paused) {
|
||||
this.captureCache = frame;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache misses are fine; click capture falls back to a fresh shot.
|
||||
} finally {
|
||||
if (this.session && !this.session.paused && this.captureCacheRunning) {
|
||||
this.captureCacheTimer = setTimeout(refresh, CLICK_CAPTURE_CACHE_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.captureCacheTimer = setTimeout(refresh, 0);
|
||||
}
|
||||
|
||||
stopClickCaptureCache() {
|
||||
if (this.captureCacheTimer) {
|
||||
clearTimeout(this.captureCacheTimer);
|
||||
this.captureCacheTimer = null;
|
||||
}
|
||||
this.captureCacheRunning = false;
|
||||
this.captureCache = null;
|
||||
}
|
||||
|
||||
startClickWatcher() {
|
||||
this.stopClickWatcher();
|
||||
try {
|
||||
@@ -320,6 +375,49 @@ while ($true) {
|
||||
this.sessionCapture('click').catch(() => {});
|
||||
}
|
||||
|
||||
async captureCurrentFrame(mode) {
|
||||
const grabbed = await this.grab(mode);
|
||||
return {
|
||||
mode,
|
||||
png: grabbed.image.toPNG(),
|
||||
size: grabbed.image.getSize(),
|
||||
display: grabbed.display,
|
||||
cursor: grabbed.cursor,
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
storeFrameAsStep(guideId, mode, frame) {
|
||||
if (!frame) return { ok: false, reason: 'no capture frame available' };
|
||||
const annotations = [];
|
||||
if (mode !== 'window' && this.settings.get('capture.clickMarker')) {
|
||||
const fx = (frame.cursor.x - frame.display.bounds.x) / frame.display.bounds.width;
|
||||
const fy = (frame.cursor.y - frame.display.bounds.y) / frame.display.bounds.height;
|
||||
if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) {
|
||||
const d = 0.035;
|
||||
annotations.push({
|
||||
type: 'oval',
|
||||
x: fx - d / 2, y: fy - (d * frame.size.width / frame.size.height) / 2,
|
||||
w: d, h: d * frame.size.width / frame.size.height,
|
||||
style: {
|
||||
stroke: this.settings.get('capture.clickMarkerColor') || '#E5484D',
|
||||
strokeWidth: 4, fill: 'transparent',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const step = this.store.addStep(guideId, {
|
||||
title: this.autoTitle(mode),
|
||||
annotations,
|
||||
focusedView: {
|
||||
enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')),
|
||||
zoom: 1, panX: 0.5, panY: 0.5,
|
||||
},
|
||||
}, frame.png, frame.size);
|
||||
return { ok: true, step };
|
||||
}
|
||||
|
||||
autoTitle(mode) {
|
||||
const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]';
|
||||
const now = new Date();
|
||||
@@ -404,46 +502,18 @@ while ($true) {
|
||||
}) {
|
||||
const delay = delayMs == null ? this.settings.get('capture.delayMs') || 0 : delayMs;
|
||||
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
let grabbed;
|
||||
let frame;
|
||||
try {
|
||||
grabbed = hideWindow
|
||||
? await this.withWindowHidden(() => this.grab(mode), {
|
||||
frame = hideWindow
|
||||
? await this.withWindowHidden(() => this.captureCurrentFrame(mode), {
|
||||
refocus,
|
||||
pauseMs: hideWindowDelayMs == null ? 350 : hideWindowDelayMs,
|
||||
})
|
||||
: await this.grab(mode);
|
||||
: await this.captureCurrentFrame(mode);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
const { image, display, cursor } = grabbed;
|
||||
const size = image.getSize();
|
||||
const annotations = [];
|
||||
if (mode !== 'window' && this.settings.get('capture.clickMarker')) {
|
||||
const fx = (cursor.x - display.bounds.x) / display.bounds.width;
|
||||
const fy = (cursor.y - display.bounds.y) / display.bounds.height;
|
||||
if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) {
|
||||
const d = 0.035;
|
||||
annotations.push({
|
||||
type: 'oval',
|
||||
x: fx - d / 2, y: fy - (d * size.width / size.height) / 2,
|
||||
w: d, h: d * size.width / size.height,
|
||||
style: {
|
||||
stroke: this.settings.get('capture.clickMarkerColor') || '#E5484D',
|
||||
strokeWidth: 4, fill: 'transparent',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const step = this.store.addStep(guideId, {
|
||||
title: this.autoTitle(mode),
|
||||
annotations,
|
||||
focusedView: {
|
||||
enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')),
|
||||
zoom: 1, panX: 0.5, panY: 0.5,
|
||||
},
|
||||
}, image.toPNG(), size);
|
||||
return { ok: true, step };
|
||||
return this.storeFrameAsStep(guideId, mode, frame);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,3 +48,50 @@ test('click-triggered session capture uses the low-latency hide pause', async ()
|
||||
refocus: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('click-triggered session capture prefers the cached frame when ready', async () => {
|
||||
const service = makeService();
|
||||
service.settings.get = (key) => {
|
||||
if (key === 'capture.mode') return 'fullscreen';
|
||||
if (key === 'capture.delayMs') return 0;
|
||||
if (key === 'capture.clickMarker') return true;
|
||||
if (key === 'capture.clickMarkerColor') return '#E5484D';
|
||||
if (key === 'editor.focusedViewDefaultForNewSteps') return false;
|
||||
return null;
|
||||
};
|
||||
service.session = { guideId: 'guide-2', paused: false, count: 0, intervalSec: 0 };
|
||||
service.captureCache = {
|
||||
mode: 'fullscreen',
|
||||
png: Buffer.from('cached-png'),
|
||||
size: { width: 120, height: 80 },
|
||||
display: { bounds: { x: 10, y: 20, width: 120, height: 80 } },
|
||||
cursor: { x: 70, y: 40 },
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
|
||||
let shootCalled = false;
|
||||
service.shoot = async () => {
|
||||
shootCalled = true;
|
||||
throw new Error('fresh shot should not run when cache is ready');
|
||||
};
|
||||
|
||||
const added = [];
|
||||
service.store.addStep = (guideId, fields, png, size) => {
|
||||
added.push({ guideId, fields, png, size });
|
||||
return { stepId: 'step-2', ...fields };
|
||||
};
|
||||
service.notify = (channel, payload) => {
|
||||
added.push({ channel, payload });
|
||||
};
|
||||
|
||||
const result = await service.sessionCapture('click');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(shootCalled, false);
|
||||
assert.equal(service.session.count, 1);
|
||||
assert.equal(added[0].guideId, 'guide-2');
|
||||
assert.deepEqual(added[0].png, Buffer.from('cached-png'));
|
||||
assert.deepEqual(added[0].size, { width: 120, height: 80 });
|
||||
assert.equal(added[0].fields.annotations.length, 1);
|
||||
assert.equal(added[0].fields.annotations[0].type, 'oval');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user