Fix guide editor issues 4-10
Template tests / tests (pull_request) Has been cancelled
Template tests / tests (push) Has been cancelled

This commit is contained in:
Iisyourdad
2026-06-12 11:07:57 -05:00
parent d966ac762d
commit f88ff0259e
22 changed files with 598 additions and 174 deletions
+11 -6
View File
@@ -184,6 +184,7 @@ class CaptureService {
// the user explicitly presses "Start recording" in the capture bar, so
// New Capture never makes the window vanish out from under them.
this.session = { guideId, paused: true, count: 0, intervalSec: interval };
this.sessionNotificationShown = false;
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
this.applyInterval();
this.notify('capture:state', this.state());
@@ -196,12 +197,6 @@ class CaptureService {
// up — that's what `togglePause` uses to decide whether to tuck the
// app away once the user actually starts recording.
this.hiddenForSession = Boolean(win && !win.isDestroyed() && win.isVisible());
try {
new Notification({
title: 'StepForge is ready to capture',
body: 'Click "Start recording" in the red capture bar when youre ready. The window tucks away and the red tray icon takes over.',
}).show();
} catch { /* notifications unavailable on this desktop */ }
}
}
@@ -387,6 +382,15 @@ class CaptureService {
await new Promise((r) => setTimeout(r, Number.isFinite(settleMs) ? settleMs : 150));
}
// Window hidden and buffer primed — clicks now count.
if (!process.env.STEPFORGE_SCREENSHOT && !this.sessionNotificationShown) {
try {
new Notification({
title: 'StepForge is recording',
body: 'Use the red tray icon to pause or finish capture.',
}).show();
this.sessionNotificationShown = true;
} catch { /* notifications unavailable on this desktop */ }
}
this.warmingUp = false;
};
run().catch(() => { this.warmingUp = false; });
@@ -403,6 +407,7 @@ class CaptureService {
this.stopClickFrameBackend();
this.destroySessionTray();
this.session = null;
this.sessionNotificationShown = false;
if (this.hiddenForSession) {
this.hiddenForSession = false;
this.showWindow();
+3 -2
View File
@@ -424,8 +424,8 @@ function setupIpc() {
const p = store.stepImagePath(guideId, stepId, which || 'working');
return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null;
});
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size }) =>
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size));
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) =>
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null));
h('step:resetWorkingImage', ({ guideId, stepId }) => {
const p = store.stepImagePath(guideId, stepId, 'original');
const img = nativeImage.createFromPath(p);
@@ -579,6 +579,7 @@ function setupIpc() {
markdown: '../exporters/markdown',
'html-simple': '../exporters/html',
'html-rich': '../exporters/html',
confluence: '../exporters/confluence',
pdf: '../exporters/pdf',
gif: '../exporters/gif',
'image-bundle': '../exporters/image-bundle',
+9 -5
View File
@@ -611,6 +611,7 @@ class StepForgeApp {
const moveItems = folderItems.length ? ['sep', ...folderItems] : [];
contextMenu(event.clientX, event.clientY, [
{ label: 'Open guide', action: () => this.openGuideAndArmCapture(guide.guideId) },
{ label: 'Rename guide…', action: () => this.renameGuide(guide) },
{ label: guide.favorite ? 'Unfavorite' : 'Favorite', action: () => this.toggleFavorite(guide) },
{ label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) },
{ label: 'Export', action: () => this.openGuideExport(guide.guideId) },
@@ -843,15 +844,18 @@ class StepForgeApp {
}
}
async renameGuide() {
const guide = this.editorMeta?.guide;
async renameGuide(guide = this.editorMeta?.guide) {
if (!guide) return;
const title = await dialogs.promptText({ title: 'Rename guide', label: 'Title', value: guide.title });
if (title == null || !title.trim()) return;
guide.title = title.trim();
await api.guide.save({ guide });
await this.editor.reload(this.editor.selectedStepId);
const fullGuide = (await api.guide.get({ guideId: guide.guideId })).guide;
fullGuide.title = title.trim();
await api.guide.save({ guide: fullGuide });
if (this.state.view === 'editor' && this.editor.guideId === fullGuide.guideId) {
await this.editor.reload(this.editor.selectedStepId);
}
await this.refreshLibrary();
if (this.state.view === 'editor') this.renderTopbar();
}
async importArchive(mode = 'copy') {
+187 -80
View File
@@ -6,6 +6,35 @@ const api = window.stepforge;
const dialogs = window.StepForgeDialogs || {};
const clone = (value) => JSON.parse(JSON.stringify(value));
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 };
function blockText(block) {
for (const key of ['code', 'text', 'body', 'value', 'content']) {
const value = block && block[key];
if (value != null && value !== '') return String(value);
}
return '';
}
function orderedStepBlocks(step) {
const blocks = [];
for (const tb of step.textBlocks || []) blocks.push({ kind: 'text', block: tb });
for (const cb of step.codeBlocks || []) blocks.push({ kind: 'code', block: cb });
for (const tbl of step.tableBlocks || []) blocks.push({ kind: 'table', block: tbl });
return blocks.sort((a, b) => (
(Number.isFinite(a.block.order) ? a.block.order : 0) - (Number.isFinite(b.block.order) ? b.block.order : 0)
|| BLOCK_KIND_ORDER[a.kind] - BLOCK_KIND_ORDER[b.kind]
|| String(a.block.id || '').localeCompare(String(b.block.id || ''))
));
}
function nextBlockOrder(step) {
return orderedStepBlocks(step).reduce((max, entry) => Math.max(max, Number.isFinite(entry.block.order) ? entry.block.order : 0), 0) + 1;
}
function blockLabel(kind) {
return kind === 'text' ? 'Text block' : kind === 'code' ? 'Code block' : 'Table';
}
function stepNumberMap(steps) {
const numbers = new Map();
@@ -57,6 +86,7 @@ class GuideEditor {
this.canvasHistory = [];
this.canvasFuture = [];
this.beforeCanvasSnapshot = null;
this.draggedBlock = null;
this.stepLoadToken = 0;
this.imageLoadToken = 0;
this.shellMounted = false;
@@ -265,7 +295,7 @@ class GuideEditor {
// canvas interactions need to snapshot the current step before the drag
// mutates it, so undo can restore the pre-edit annotations.
this.dom.canvas.addEventListener('pointerdown', () => {
if (this.currentStep) this.beforeCanvasSnapshot = clone(this.currentStep);
if (this.currentStep) this.beforeCanvasSnapshot = { step: clone(this.currentStep) };
}, true);
this.canvas = new AnnotationCanvas(this.dom.canvas, {
@@ -434,13 +464,13 @@ class GuideEditor {
const id = `blk-${Date.now().toString(36)}`;
if (kind === 'text') {
step.textBlocks = step.textBlocks || [];
step.textBlocks.push({ id, position: 'after-description', level: 'info', title: '', descriptionHtml: '' });
step.textBlocks.push({ id, order: nextBlockOrder(step), position: 'after-description', level: 'info', title: '', descriptionHtml: '' });
} else if (kind === 'code') {
step.codeBlocks = step.codeBlocks || [];
step.codeBlocks.push({ id, language: '', code: '' });
step.codeBlocks.push({ id, order: nextBlockOrder(step), language: '', code: '' });
} else if (kind === 'table') {
step.tableBlocks = step.tableBlocks || [];
step.tableBlocks.push({ id, rows: [['Column A', 'Column B'], ['', '']] });
step.tableBlocks.push({ id, order: nextBlockOrder(step), rows: [['Column A', 'Column B'], ['', '']] });
}
this.pendingSave = true;
this.saveStepDebounced();
@@ -458,73 +488,114 @@ class GuideEditor {
this.pendingSave = true;
this.saveStepDebounced();
};
const moveBlock = (source, target) => {
if (!source || !target || source.kind === target.kind && source.block.id === target.block.id) return;
const swap = source.block.order;
source.block.order = target.block.order;
target.block.order = swap;
save();
this.renderBlocksPanel();
};
const removeBtn = (onRemove) => el('button.icon.danger', {
type: 'button', title: 'Remove block',
onClick: () => { onRemove(); save(); this.renderBlocksPanel(); },
}, '✕');
for (const tb of step.textBlocks || []) {
const position = makeSelect(tb.position, [
{ value: 'before-title', label: 'Before title' },
{ value: 'after-title', label: 'After title' },
{ value: 'before-image', label: 'Before image' },
{ value: 'after-image', label: 'After image' },
{ value: 'before-description', label: 'Before description' },
{ value: 'after-description', label: 'After description' },
]);
const level = makeSelect(tb.level, [
{ value: 'info', label: 'Note' },
{ value: 'warn', label: 'Warning' },
{ value: 'error', label: 'Important' },
{ value: 'success', label: 'Tip' },
]);
const title = el('input', { type: 'text', value: tb.title || '', placeholder: 'Block title' });
const body = el('textarea', { rows: 2, placeholder: 'Block text' });
body.value = (tb.descriptionHtml || '').replace(/<[^>]+>/g, '');
position.addEventListener('change', () => { tb.position = position.value; save(); });
level.addEventListener('change', () => { tb.level = level.value; save(); });
title.addEventListener('input', () => { tb.title = title.value; save(); });
body.addEventListener('input', () => { tb.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); });
this.dom.blocksList.append(el('div.block-card', {},
el('div.row', {}, el('strong', {}, 'Text block'), el('span.spacer'),
removeBtn(() => { step.textBlocks = step.textBlocks.filter((b) => b !== tb); })),
el('div.row', {}, level, position),
title, body,
));
const blocks = orderedStepBlocks(step);
for (const [index, entry] of blocks.entries()) {
const { kind, block } = entry;
const canMoveUp = index > 0;
const canMoveDown = index < blocks.length - 1;
const moveUp = () => moveBlock(entry, blocks[index - 1]);
const moveDown = () => moveBlock(entry, blocks[index + 1]);
const header = el('div.row', {},
el('strong', {}, blockLabel(kind)),
el('span.muted', {}, `#${Number.isFinite(block.order) ? block.order : index + 1}`),
el('span.spacer'),
el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp }, '↑'),
el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown }, '↓'),
removeBtn(() => {
if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block);
else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block);
else step.tableBlocks = (step.tableBlocks || []).filter((b) => b !== block);
}),
);
const card = el('div.block-card', {
draggable: true,
onDragStart: (e) => {
this.draggedBlock = entry;
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
},
onDragOver: (e) => {
if (this.draggedBlock) e.preventDefault();
},
onDrop: (e) => {
e.preventDefault();
if (!this.draggedBlock) return;
moveBlock(this.draggedBlock, entry);
this.draggedBlock = null;
},
onDragEnd: () => {
this.draggedBlock = null;
},
}, header);
if (kind === 'text') {
const position = makeSelect(block.position, [
{ value: 'before-title', label: 'Before title' },
{ value: 'after-title', label: 'After title' },
{ value: 'before-image', label: 'Before image' },
{ value: 'after-image', label: 'After image' },
{ value: 'before-description', label: 'Before description' },
{ value: 'after-description', label: 'After description' },
]);
const level = makeSelect(block.level, [
{ value: 'info', label: 'Note' },
{ value: 'warn', label: 'Warning' },
{ value: 'error', label: 'Important' },
{ value: 'success', label: 'Tip' },
]);
const title = el('input', { type: 'text', value: block.title || '', placeholder: 'Block title' });
const body = el('textarea', { rows: 2, placeholder: 'Block text' });
body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, '');
position.addEventListener('change', () => { block.position = position.value; save(); });
level.addEventListener('change', () => { block.level = level.value; save(); });
title.addEventListener('input', () => { block.title = title.value; save(); });
body.addEventListener('input', () => { block.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); });
card.append(
el('div.row', {}, level, position),
title,
body,
);
} else if (kind === 'code') {
const lang = el('input', { type: 'text', value: block.language || '', placeholder: 'Language (e.g. bash)' });
const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false });
code.value = blockText(block);
code.style.fontFamily = 'monospace';
lang.addEventListener('input', () => { block.language = lang.value; save(); });
code.addEventListener('input', () => { block.code = code.value; save(); });
card.append(lang, code);
} else if (kind === 'table') {
const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false });
grid.value = (block.rows || []).map((r) => r.join(' | ')).join('\n');
grid.addEventListener('input', () => {
block.rows = grid.value.split('\n').filter((l) => l.trim() !== '')
.map((line) => line.split('|').map((c) => c.trim()));
save();
});
card.append(
el('div.muted', {}, 'First line is the header row.'),
grid,
);
}
this.dom.blocksList.append(card);
}
for (const cb of step.codeBlocks || []) {
const lang = el('input', { type: 'text', value: cb.language || '', placeholder: 'Language (e.g. bash)' });
const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false });
code.value = cb.code || '';
code.style.fontFamily = 'monospace';
lang.addEventListener('input', () => { cb.language = lang.value; save(); });
code.addEventListener('input', () => { cb.code = code.value; save(); });
this.dom.blocksList.append(el('div.block-card', {},
el('div.row', {}, el('strong', {}, 'Code block'), el('span.spacer'),
removeBtn(() => { step.codeBlocks = step.codeBlocks.filter((b) => b !== cb); })),
lang, code,
));
}
for (const tbl of step.tableBlocks || []) {
const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false });
grid.value = (tbl.rows || []).map((r) => r.join(' | ')).join('\n');
grid.addEventListener('input', () => {
tbl.rows = grid.value.split('\n').filter((l) => l.trim() !== '')
.map((line) => line.split('|').map((c) => c.trim()));
save();
});
this.dom.blocksList.append(el('div.block-card', {},
el('div.row', {}, el('strong', {}, 'Table'), el('span.spacer'),
removeBtn(() => { step.tableBlocks = step.tableBlocks.filter((b) => b !== tbl); })),
el('div.muted', {}, 'First line is the header row.'),
grid,
));
}
if (!(step.textBlocks || []).length && !(step.codeBlocks || []).length && !(step.tableBlocks || []).length) {
this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks render in every export.'));
if (!blocks.length) {
this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks can be reordered with drag handles or arrows.'));
}
}
@@ -906,27 +977,60 @@ class GuideEditor {
if (mode === 1.5) this.dom.zoom150Btn.classList.add('active');
}
pushCanvasHistory(label = 'change') {
pushCanvasHistory(recordOrLabel = 'change') {
if (!this.currentStep) return;
this.canvasHistory.push(clone(this.currentStep));
const record = recordOrLabel && typeof recordOrLabel === 'object' && recordOrLabel.step
? recordOrLabel
: { step: clone(this.currentStep) };
this.canvasHistory.push(record);
if (this.canvasHistory.length > 40) this.canvasHistory.shift();
this.canvasFuture.length = 0;
this.beforeCanvasSnapshot = null;
}
async snapshotCurrentStep(includeImage = false) {
if (!this.currentStep) return null;
const record = { step: clone(this.currentStep) };
if (includeImage && this.currentStep.image) {
const image = await this.currentStepImageToBase64(this.currentStep);
if (image) record.image = image;
}
return record;
}
async restoreHistoryRecord(record) {
if (!record || !record.step) return;
const step = clone(record.step);
this.selectedStepId = step.stepId;
this.beforeCanvasSnapshot = null;
this.saveStepDebounced.cancel();
this.pendingSave = false;
if (record.image && step.image) {
const saved = await api.step.setWorkingImage({
guideId: this.guideId,
stepId: step.stepId,
pngBase64: record.image.base64,
size: record.image.size,
step,
});
this.stepMap.set(saved.stepId, saved);
const idx = this.steps.findIndex((s) => s.stepId === saved.stepId);
if (idx >= 0) this.steps[idx] = saved;
} else {
await this.flushStep(step);
}
}
async undo() {
if (!this.currentStep) return;
if (!this.canvasHistory.length) {
this.onToast('Nothing to undo.');
return;
}
this.canvasFuture.push(clone(this.currentStep));
const current = await this.snapshotCurrentStep(true);
if (current) this.canvasFuture.push(current);
const previous = this.canvasHistory.pop();
this.stepMap.set(previous.stepId, previous);
const prevIdx = this.steps.findIndex((s) => s.stepId === previous.stepId);
if (prevIdx >= 0) this.steps[prevIdx] = previous;
this.selectedStepId = previous.stepId;
await this.flushStep(previous);
await this.restoreHistoryRecord(previous);
this.renderAll();
}
@@ -936,13 +1040,10 @@ class GuideEditor {
this.onToast('Nothing to redo.');
return;
}
this.canvasHistory.push(clone(this.currentStep));
const current = await this.snapshotCurrentStep(true);
if (current) this.canvasHistory.push(current);
const next = this.canvasFuture.pop();
this.stepMap.set(next.stepId, next);
const nextIdx = this.steps.findIndex((s) => s.stepId === next.stepId);
if (nextIdx >= 0) this.steps[nextIdx] = next;
this.selectedStepId = next.stepId;
await this.flushStep(next);
await this.restoreHistoryRecord(next);
this.renderAll();
}
@@ -1388,8 +1489,7 @@ class GuideEditor {
}
}
async currentStepImageToBase64() {
const step = this.currentStep;
async currentStepImageToBase64(step = this.currentStep) {
if (!step || !step.image) return null;
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' });
if (!file) return null;
@@ -1437,6 +1537,13 @@ class GuideEditor {
if (!step || !step.image) return;
const ok = await confirmDialog('Crop the working image to the selected area?');
if (!ok) return;
this.saveStepDebounced.cancel();
const snapshot = this.beforeCanvasSnapshot || await this.snapshotCurrentStep(true);
if (snapshot) {
if (!snapshot.image) snapshot.image = await this.currentStepImageToBase64(step);
this.pushCanvasHistory(snapshot);
}
this.beforeCanvasSnapshot = null;
const src = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' });
if (!src) return;
const img = await loadImage(src);
+13
View File
@@ -430,7 +430,18 @@ kbd {
pointer-events: none;
}
.pane-props section {
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 14px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
}
.pane-props section + section { margin-top: 16px; }
.pane-props section:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.pane-props input[type="text"],
.pane-props input[type="number"],
.pane-props input[type="color"],
@@ -511,6 +522,8 @@ kbd {
padding: 10px;
background: var(--panel-solid);
}
.block-card[draggable="true"] { cursor: grab; }
.block-card[draggable="true"]:active { cursor: grabbing; }
.annotation-list {
display: flex;
flex-direction: column;