Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+109
-39
@@ -23,6 +23,7 @@ const { encodePng } = require('../core/png');
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const CLICK_DEBOUNCE_MS = 700;
|
const CLICK_DEBOUNCE_MS = 700;
|
||||||
|
const CLICK_CAPTURE_CACHE_MS = 75;
|
||||||
const CLICK_CAPTURE_HIDE_DELAY_MS = 25;
|
const CLICK_CAPTURE_HIDE_DELAY_MS = 25;
|
||||||
|
|
||||||
function hasBinary(name) {
|
function hasBinary(name) {
|
||||||
@@ -43,6 +44,9 @@ class CaptureService {
|
|||||||
this.session = null; // { guideId, paused, count, intervalSec }
|
this.session = null; // { guideId, paused, count, intervalSec }
|
||||||
this.intervalTimer = null;
|
this.intervalTimer = null;
|
||||||
this.clickWatcher = null;
|
this.clickWatcher = null;
|
||||||
|
this.captureCacheTimer = null;
|
||||||
|
this.captureCache = null;
|
||||||
|
this.captureCacheRunning = false;
|
||||||
this.lastClickCapture = 0;
|
this.lastClickCapture = 0;
|
||||||
this.shooting = false;
|
this.shooting = false;
|
||||||
}
|
}
|
||||||
@@ -89,12 +93,22 @@ class CaptureService {
|
|||||||
if (!process.env.STEPFORGE_SCREENSHOT) {
|
if (!process.env.STEPFORGE_SCREENSHOT) {
|
||||||
this.createSessionTray();
|
this.createSessionTray();
|
||||||
const win = this.getWindow();
|
const win = this.getWindow();
|
||||||
|
const startClickCache = () => {
|
||||||
|
if (this.settings.get('capture.captureOutsideClicks') !== false && this.clickCaptureAvailable()) {
|
||||||
|
this.startClickCaptureCache();
|
||||||
|
}
|
||||||
|
};
|
||||||
if (win && !win.isDestroyed() && win.isVisible()) {
|
if (win && !win.isDestroyed() && win.isVisible()) {
|
||||||
this.hiddenForSession = true;
|
this.hiddenForSession = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Re-check: the session may have been finished within the delay.
|
// 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
|
}, 1200); // let the user read the "session started" toast first
|
||||||
|
} else {
|
||||||
|
startClickCache();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
new Notification({
|
new Notification({
|
||||||
@@ -203,6 +217,7 @@ class CaptureService {
|
|||||||
this.intervalTimer = null;
|
this.intervalTimer = null;
|
||||||
}
|
}
|
||||||
this.stopClickWatcher();
|
this.stopClickWatcher();
|
||||||
|
this.stopClickCaptureCache();
|
||||||
this.destroySessionTray();
|
this.destroySessionTray();
|
||||||
this.session = null;
|
this.session = null;
|
||||||
if (this.hiddenForSession) {
|
if (this.hiddenForSession) {
|
||||||
@@ -238,20 +253,26 @@ class CaptureService {
|
|||||||
this.shooting = true;
|
this.shooting = true;
|
||||||
try {
|
try {
|
||||||
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
||||||
const result = await this.shoot({
|
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,
|
guideId: this.session.guideId,
|
||||||
mode: mode === 'region' ? 'fullscreen' : mode,
|
mode: grabMode,
|
||||||
delayMs: 0,
|
delayMs: 0,
|
||||||
hideWindowDelayMs: trigger === 'click' ? CLICK_CAPTURE_HIDE_DELAY_MS : null,
|
hideWindowDelayMs: trigger === 'click' ? CLICK_CAPTURE_HIDE_DELAY_MS : null,
|
||||||
refocus: false, // don't steal focus from the app the user is documenting
|
refocus: false, // don't steal focus from the app the user is documenting
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (finalResult.ok) {
|
||||||
this.session.count += 1;
|
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());
|
this.notify('capture:state', this.state());
|
||||||
if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter
|
if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter
|
||||||
}
|
}
|
||||||
return result;
|
return finalResult;
|
||||||
} finally {
|
} finally {
|
||||||
this.shooting = false;
|
this.shooting = false;
|
||||||
}
|
}
|
||||||
@@ -263,6 +284,40 @@ class CaptureService {
|
|||||||
|
|
||||||
// ---- click-triggered capture --------------------------------------------
|
// ---- 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() {
|
startClickWatcher() {
|
||||||
this.stopClickWatcher();
|
this.stopClickWatcher();
|
||||||
try {
|
try {
|
||||||
@@ -320,6 +375,49 @@ while ($true) {
|
|||||||
this.sessionCapture('click').catch(() => {});
|
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) {
|
autoTitle(mode) {
|
||||||
const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]';
|
const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -404,46 +502,18 @@ while ($true) {
|
|||||||
}) {
|
}) {
|
||||||
const delay = delayMs == null ? this.settings.get('capture.delayMs') || 0 : delayMs;
|
const delay = delayMs == null ? this.settings.get('capture.delayMs') || 0 : delayMs;
|
||||||
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
let grabbed;
|
let frame;
|
||||||
try {
|
try {
|
||||||
grabbed = hideWindow
|
frame = hideWindow
|
||||||
? await this.withWindowHidden(() => this.grab(mode), {
|
? await this.withWindowHidden(() => this.captureCurrentFrame(mode), {
|
||||||
refocus,
|
refocus,
|
||||||
pauseMs: hideWindowDelayMs == null ? 350 : hideWindowDelayMs,
|
pauseMs: hideWindowDelayMs == null ? 350 : hideWindowDelayMs,
|
||||||
})
|
})
|
||||||
: await this.grab(mode);
|
: await this.captureCurrentFrame(mode);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, reason: err.message };
|
return { ok: false, reason: err.message };
|
||||||
}
|
}
|
||||||
const { image, display, cursor } = grabbed;
|
return this.storeFrameAsStep(guideId, mode, frame);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,3 +48,50 @@ test('click-triggered session capture uses the low-latency hide pause', async ()
|
|||||||
refocus: false,
|
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