Merge pull request 'Mass guide editor changes' (#18) from guide_editor_changes into main
Template tests / tests (push) Successful in 1m39s
Template tests / tests (push) Successful in 1m39s
This commit was merged in pull request #18.
This commit is contained in:
+1
-1
@@ -279,7 +279,7 @@ class StepForgeApp {
|
||||
}
|
||||
send({ action: s.paused ? 'resume' : 'pause' });
|
||||
},
|
||||
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
|
||||
}, s.paused ? 'Start recording' : 'Stop recording');
|
||||
|
||||
this.captureStatus.append(
|
||||
el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`),
|
||||
|
||||
+70
-17
@@ -28,6 +28,7 @@ class AnnotationCanvas {
|
||||
this.selectedId = null;
|
||||
this.drag = null;
|
||||
this.cropRect = null;
|
||||
this.focusedView = null;
|
||||
|
||||
canvasEl.addEventListener('pointerdown', (e) => this.onDown(e));
|
||||
canvasEl.addEventListener('pointermove', (e) => this.onMove(e));
|
||||
@@ -55,6 +56,14 @@ class AnnotationCanvas {
|
||||
this.render();
|
||||
}
|
||||
|
||||
// The focused view crops and zooms the canvas itself to match what
|
||||
// exports produce. Annotation data stays in full-image-normalized
|
||||
// coordinates; only the on-screen viewport changes.
|
||||
setFocusedView(focusedView) {
|
||||
this.focusedView = focusedView || null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
setTool(tool) {
|
||||
this.tool = tool;
|
||||
this.cropRect = null;
|
||||
@@ -98,30 +107,64 @@ class AnnotationCanvas {
|
||||
}
|
||||
|
||||
// ---- coordinate helpers ----
|
||||
// The focused-view crop region, in coordinates normalized to the full
|
||||
// image (0..1). Identity rect when focused view is off, matching
|
||||
// core/raster.js applyFocusedView. panX/panY are 0..1 fractions of the
|
||||
// available pan range (0 = left/bottom edge, 1 = right/top edge), so the
|
||||
// slider's full range always pans the crop across the whole image
|
||||
// regardless of zoom. Y is inverted so panY increases upward.
|
||||
viewRect() {
|
||||
const fv = this.focusedView;
|
||||
if (!fv || !fv.enabled || !(fv.zoom > 1)) return { x: 0, y: 0, w: 1, h: 1 };
|
||||
const w = 1 / fv.zoom, h = 1 / fv.zoom;
|
||||
const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
|
||||
return { x: panX * (1 - w), y: (1 - panY) * (1 - h), w, h };
|
||||
}
|
||||
|
||||
toNorm(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const r = this.viewRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
x: r.x + ((e.clientX - rect.left) / rect.width) * r.w,
|
||||
y: r.y + ((e.clientY - rect.top) / rect.height) * r.h,
|
||||
};
|
||||
}
|
||||
|
||||
px(ann) {
|
||||
const r = this.viewRect();
|
||||
return {
|
||||
x: ann.x * this.canvas.width,
|
||||
y: ann.y * this.canvas.height,
|
||||
w: ann.w * this.canvas.width,
|
||||
h: ann.h * this.canvas.height,
|
||||
x: (ann.x - r.x) / r.w * this.canvas.width,
|
||||
y: (ann.y - r.y) / r.h * this.canvas.height,
|
||||
w: ann.w / r.w * this.canvas.width,
|
||||
h: ann.h / r.h * this.canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
// Converts a canvas-pixel point to image-pixel coordinates in the
|
||||
// full (uncropped) source image, for sampling `this.image` directly.
|
||||
toImagePx(x, y) {
|
||||
const r = this.viewRect();
|
||||
return {
|
||||
x: (x / this.canvas.width * r.w + r.x) * this.imgW,
|
||||
y: (y / this.canvas.height * r.h + r.y) * this.imgH,
|
||||
};
|
||||
}
|
||||
|
||||
toImageLen(len, axis) {
|
||||
const r = this.viewRect();
|
||||
return axis === 'y' ? len / this.canvas.height * r.h * this.imgH : len / this.canvas.width * r.w * this.imgW;
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
render() {
|
||||
const { ctx, canvas } = this;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!this.image) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height);
|
||||
const r = this.viewRect();
|
||||
ctx.drawImage(this.image,
|
||||
r.x * this.imgW, r.y * this.imgH, r.w * this.imgW, r.h * this.imgH,
|
||||
0, 0, canvas.width, canvas.height);
|
||||
|
||||
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
|
||||
for (const ann of ordered) this.drawAnnotation(ann);
|
||||
@@ -131,12 +174,17 @@ class AnnotationCanvas {
|
||||
if (this.cropRect) this.drawCropOverlay();
|
||||
}
|
||||
|
||||
// Annotation strokes/fonts are sized relative to the full image, then
|
||||
// magnified by the focused-view crop on export (core/raster.js) — divide
|
||||
// by the view rect so the canvas preview matches that magnification.
|
||||
strokePx(ann) {
|
||||
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000);
|
||||
const r = this.viewRect();
|
||||
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000 / r.w);
|
||||
}
|
||||
|
||||
fontPx(ann) {
|
||||
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height);
|
||||
const r = this.viewRect();
|
||||
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height / r.h);
|
||||
}
|
||||
|
||||
drawAnnotation(ann) {
|
||||
@@ -202,10 +250,13 @@ class AnnotationCanvas {
|
||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
const sw = w / zoom, sh = h / zoom;
|
||||
const center = this.toImagePx(x + w / 2, y + h / 2);
|
||||
const srcW = this.toImageLen(sw, 'x');
|
||||
const srcH = this.toImageLen(sh, 'y');
|
||||
ctx.drawImage(
|
||||
this.image,
|
||||
(x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale,
|
||||
sw / this.scale, sh / this.scale,
|
||||
center.x - srcW / 2, center.y - srcH / 2,
|
||||
srcW, srcH,
|
||||
x, y, w, h
|
||||
);
|
||||
ctx.restore();
|
||||
@@ -310,10 +361,11 @@ class AnnotationCanvas {
|
||||
drawCropOverlay() {
|
||||
const { ctx, canvas } = this;
|
||||
const r = this.cropRect;
|
||||
const x = Math.min(r.x0, r.x1) * canvas.width;
|
||||
const y = Math.min(r.y0, r.y1) * canvas.height;
|
||||
const w = Math.abs(r.x1 - r.x0) * canvas.width;
|
||||
const h = Math.abs(r.y1 - r.y0) * canvas.height;
|
||||
const view = this.viewRect();
|
||||
const x = (Math.min(r.x0, r.x1) - view.x) / view.w * canvas.width;
|
||||
const y = (Math.min(r.y0, r.y1) - view.y) / view.h * canvas.height;
|
||||
const w = Math.abs(r.x1 - r.x0) / view.w * canvas.width;
|
||||
const h = Math.abs(r.y1 - r.y0) / view.h * canvas.height;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.beginPath();
|
||||
@@ -486,8 +538,9 @@ class AnnotationCanvas {
|
||||
nudgeSelected(dx, dy) {
|
||||
const sel = this.selected();
|
||||
if (!sel) return false;
|
||||
sel.x += dx / this.canvas.width;
|
||||
sel.y += dy / this.canvas.height;
|
||||
const r = this.viewRect();
|
||||
sel.x += dx / this.canvas.width * r.w;
|
||||
sel.y += dy / this.canvas.height * r.h;
|
||||
this.changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -646,10 +646,11 @@ const SHORTCUTS = [
|
||||
['PageUp / PageDown', 'Previous / next step'],
|
||||
['Alt+↑ / Alt+↓', 'Move step up / down'],
|
||||
['Ctrl+Delete', 'Delete current step'],
|
||||
['Ctrl+Z / Ctrl+Shift+Z', 'Undo / redo (including step deletion)'],
|
||||
['Ctrl+V', 'Paste annotation, or clipboard image as new step'],
|
||||
]],
|
||||
['Canvas tools', [
|
||||
['S R O L A T', 'Select · Rect · Oval · Line · Arrow · Text'],
|
||||
['S R O L A T', 'Select · Rectangle · Oval · Line · Arrow · Text'],
|
||||
['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'],
|
||||
['Ctrl+C', 'Copy selected annotation'],
|
||||
['Delete', 'Delete selected annotation'],
|
||||
|
||||
+299
-63
@@ -8,6 +8,37 @@ const dialogs = window.StepForgeDialogs || {};
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 };
|
||||
|
||||
// Which style fields are meaningful for each annotation type, so the
|
||||
// annotation editor only shows controls that actually affect that type.
|
||||
const ANNOTATION_FIELDS = {
|
||||
rect: ['stroke', 'fill', 'strokeWidth'],
|
||||
oval: ['stroke', 'fill', 'strokeWidth'],
|
||||
line: ['stroke', 'strokeWidth'],
|
||||
arrow: ['stroke', 'strokeWidth'],
|
||||
text: ['text', 'stroke', 'fontSize'],
|
||||
tooltip: ['text', 'fill', 'stroke', 'strokeWidth', 'fontSize', 'textColor', 'tail'],
|
||||
number: ['value', 'stroke', 'textColor'],
|
||||
blur: ['radius'],
|
||||
highlight: [],
|
||||
magnify: ['zoom', 'stroke', 'strokeWidth'],
|
||||
cursor: [],
|
||||
};
|
||||
|
||||
// Display names for annotation types in the "Type" dropdown.
|
||||
const ANNOTATION_TYPE_LABELS = {
|
||||
rect: 'Rectangle',
|
||||
oval: 'Oval',
|
||||
line: 'Line',
|
||||
arrow: 'Arrow',
|
||||
text: 'Text',
|
||||
tooltip: 'Tooltip',
|
||||
number: 'Number',
|
||||
blur: 'Blur',
|
||||
highlight: 'Highlight',
|
||||
magnify: 'Magnify',
|
||||
cursor: 'Cursor',
|
||||
};
|
||||
|
||||
function blockText(block) {
|
||||
for (const key of ['code', 'text', 'body', 'value', 'content']) {
|
||||
const value = block && block[key];
|
||||
@@ -173,7 +204,7 @@ class GuideEditor {
|
||||
this.root.innerHTML = '';
|
||||
const toolButtons = [
|
||||
['select', 'Select'],
|
||||
['rect', 'Rect'],
|
||||
['rect', 'Rectangle'],
|
||||
['oval', 'Oval'],
|
||||
['line', 'Line'],
|
||||
['arrow', 'Arrow'],
|
||||
@@ -181,7 +212,7 @@ class GuideEditor {
|
||||
['tooltip', 'Tip'],
|
||||
['number', '#'],
|
||||
['blur', 'Blur'],
|
||||
['highlight', 'Hi'],
|
||||
['highlight', 'Highlight'],
|
||||
['magnify', 'Mag'],
|
||||
['cursor', 'Cursor'],
|
||||
['crop', 'Crop'],
|
||||
@@ -284,8 +315,7 @@ class GuideEditor {
|
||||
el('section', {},
|
||||
el('h3', {}, 'Guide'),
|
||||
this.dom.guideSummary = el('div.muted', {}),
|
||||
this.dom.linkedBtn = el('button', { type: 'button' }, 'Linked guide'),
|
||||
this.dom.saveNowBtn = el('button.primary', { type: 'button' }, 'Save now'),
|
||||
this.dom.saveNowBtn = el('button.primary', { type: 'button', title: 'Save changes. For guides linked to a shared archive, also writes the archive file.' }, 'Save now'),
|
||||
this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'),
|
||||
),
|
||||
),
|
||||
@@ -314,10 +344,32 @@ class GuideEditor {
|
||||
}
|
||||
|
||||
toolbarBtn(action, label, block = null) {
|
||||
return el('button', {
|
||||
const btn = el('button', {
|
||||
type: 'button',
|
||||
onClick: () => this.formatDescription(action, block),
|
||||
}, label);
|
||||
btn.dataset.action = action;
|
||||
if (block) btn.dataset.block = block;
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** Reflect the current selection's formatting state on the toolbar buttons. */
|
||||
updateToolbarState() {
|
||||
if (!this.dom.richToolbar) return;
|
||||
for (const btn of this.dom.richToolbar.children) {
|
||||
const { action, block } = btn.dataset;
|
||||
let active = false;
|
||||
try {
|
||||
if (action === 'formatBlock') {
|
||||
active = document.queryCommandValue('formatBlock').toLowerCase() === (block || 'blockquote');
|
||||
} else if (action === 'bold' || action === 'italic' || action === 'insertUnorderedList' || action === 'insertOrderedList') {
|
||||
active = document.queryCommandState(action);
|
||||
}
|
||||
} catch {
|
||||
active = false;
|
||||
}
|
||||
btn.classList.toggle('active', active);
|
||||
}
|
||||
}
|
||||
|
||||
bindShellEvents() {
|
||||
@@ -330,7 +382,6 @@ class GuideEditor {
|
||||
this.dom.deleteBtn.addEventListener('click', () => this.deleteSelectedStep());
|
||||
this.dom.saveNowBtn.addEventListener('click', () => this.saveAll());
|
||||
this.dom.snapshotBtn.addEventListener('click', () => this.createSnapshot());
|
||||
this.dom.linkedBtn.addEventListener('click', () => this.openLinkedGuide());
|
||||
this.dom.zoomFitBtn.addEventListener('click', () => this.setZoom('fit'));
|
||||
this.dom.zoom100Btn.addEventListener('click', () => this.setZoom(1));
|
||||
this.dom.zoom125Btn.addEventListener('click', () => this.setZoom(1.25));
|
||||
@@ -397,6 +448,7 @@ class GuideEditor {
|
||||
step.focusedView[field] = Number(node.value);
|
||||
this.pendingSave = true;
|
||||
this.saveStepDebounced();
|
||||
this.canvas.setFocusedView(step.focusedView);
|
||||
});
|
||||
bindFocusedSlider(this.dom.fvZoom, 'zoom');
|
||||
bindFocusedSlider(this.dom.fvPanX, 'panX');
|
||||
@@ -407,7 +459,14 @@ class GuideEditor {
|
||||
this.dom.addTableBlockBtn.addEventListener('click', () => this.addBlock('table'));
|
||||
|
||||
this.dom.descEditor.addEventListener('focus', () => {
|
||||
// Make Enter start a new paragraph (<p>) rather than a plain <div>,
|
||||
// so line breaks survive sanitization and show up in exports.
|
||||
document.execCommand('defaultParagraphSeparator', false, 'p');
|
||||
if (this.currentStep) this.pushCanvasHistory('description');
|
||||
this.updateToolbarState();
|
||||
});
|
||||
this.dom.descEditor.addEventListener('blur', () => {
|
||||
for (const btn of this.dom.richToolbar.children) btn.classList.remove('active');
|
||||
});
|
||||
this.dom.descEditor.addEventListener('input', () => {
|
||||
if (!this.currentStep) return;
|
||||
@@ -415,6 +474,12 @@ class GuideEditor {
|
||||
this.pendingSave = true;
|
||||
this.saveStepDebounced();
|
||||
this.emitMeta();
|
||||
this.updateToolbarState();
|
||||
});
|
||||
this.dom.descEditor.addEventListener('keyup', () => this.updateToolbarState());
|
||||
this.dom.descEditor.addEventListener('mouseup', () => this.updateToolbarState());
|
||||
document.addEventListener('selectionchange', () => {
|
||||
if (document.activeElement === this.dom.descEditor) this.updateToolbarState();
|
||||
});
|
||||
|
||||
this.dom.descEditor.addEventListener('paste', (e) => {
|
||||
@@ -438,7 +503,6 @@ class GuideEditor {
|
||||
this.renderCanvas();
|
||||
this.renderAnnotationPanel();
|
||||
this.renderBlocksPanel();
|
||||
this.renderGuidePanel();
|
||||
this.emitMeta();
|
||||
}
|
||||
|
||||
@@ -451,6 +515,7 @@ class GuideEditor {
|
||||
this.dom.fvPanX.value = fv.panX ?? 0.5;
|
||||
this.dom.fvPanY.value = fv.panY ?? 0.5;
|
||||
}
|
||||
this.canvas.setFocusedView(fv);
|
||||
}
|
||||
|
||||
// ---- text / code / table blocks ----------------------------------------
|
||||
@@ -624,6 +689,7 @@ class GuideEditor {
|
||||
}, header);
|
||||
|
||||
if (kind === 'text') {
|
||||
card.dataset.level = block.level || 'info';
|
||||
const position = makeSelect(block.position, [
|
||||
{ value: 'before-title', label: 'Before title' },
|
||||
{ value: 'after-title', label: 'After title' },
|
||||
@@ -646,7 +712,7 @@ class GuideEditor {
|
||||
body.dataset.blockField = 'body';
|
||||
body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, '');
|
||||
position.addEventListener('change', () => { block.position = position.value; save(); });
|
||||
level.addEventListener('change', () => { block.level = level.value; save(); });
|
||||
level.addEventListener('change', () => { block.level = level.value; card.dataset.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(
|
||||
@@ -716,6 +782,18 @@ class GuideEditor {
|
||||
this.selectStep(step.stepId);
|
||||
contextMenu(e.clientX, e.clientY, [
|
||||
{ label: 'Add substep', action: () => this.addSubstep(step.stepId) },
|
||||
{
|
||||
label: 'Make substep of…',
|
||||
submenu: () => {
|
||||
const subtreeIds = new Set([step.stepId, ...this.getStepDescendantIds(step.stepId)]);
|
||||
return this.steps
|
||||
.filter((s) => !subtreeIds.has(s.stepId))
|
||||
.map((s) => ({
|
||||
label: `${numbers.get(s.stepId)} ${s.title || 'Untitled step'}`,
|
||||
action: () => this.makeSubstepOf(step.stepId, s.stepId),
|
||||
}));
|
||||
},
|
||||
},
|
||||
{ label: 'Duplicate step', action: () => this.duplicateSelectedStep() },
|
||||
'sep',
|
||||
{ label: 'Move up', action: () => this.moveSelectedStep(-1) },
|
||||
@@ -795,13 +873,21 @@ class GuideEditor {
|
||||
if (!ids.length) return;
|
||||
const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' });
|
||||
if (!ok) return;
|
||||
if (this.pendingSave) await this.flushStep();
|
||||
const order = this.steps.map((s) => s.stepId);
|
||||
const entries = [];
|
||||
for (const stepId of ids) {
|
||||
const step = this.stepMap.get(stepId);
|
||||
if (step) entries.push(await this.snapshotStepForDeletion(step));
|
||||
}
|
||||
for (const stepId of ids) {
|
||||
await api.step.delete({ guideId: this.guideId, stepId });
|
||||
}
|
||||
if (entries.length) this.pushCanvasHistory({ type: 'delete-step', steps: entries, order });
|
||||
this.stepSelectMode = false;
|
||||
this.selectedSteps = new Set();
|
||||
await this.reload(null);
|
||||
this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted.`);
|
||||
this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted. Press Ctrl+Z to undo.`);
|
||||
}
|
||||
|
||||
syncStepFields() {
|
||||
@@ -852,6 +938,7 @@ class GuideEditor {
|
||||
if (token !== this.imageLoadToken) return;
|
||||
this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height);
|
||||
this.canvas.setAnnotations(step.annotations || []);
|
||||
this.canvas.setFocusedView(step.focusedView);
|
||||
this.canvas.setTool(this.currentTool);
|
||||
this.canvas.setZoom(this.currentZoom);
|
||||
};
|
||||
@@ -883,7 +970,7 @@ class GuideEditor {
|
||||
dataset: { annId: ann.id },
|
||||
style: { cursor: 'pointer', borderColor: selected ? 'var(--accent)' : '' },
|
||||
},
|
||||
el('div.row', {}, el('strong', {}, ann.type), el('span.muted', {}, ann.text || ann.value || '')),
|
||||
el('div.row', {}, el('strong', {}, ANNOTATION_TYPE_LABELS[ann.type] || ann.type), el('span.muted', {}, ann.text || ann.value || '')),
|
||||
el('div.muted', {}, `${ann.x.toFixed(3)}, ${ann.y.toFixed(3)} · ${ann.w.toFixed(3)} × ${ann.h.toFixed(3)}`)));
|
||||
}
|
||||
}
|
||||
@@ -898,7 +985,7 @@ class GuideEditor {
|
||||
const style = selected.style || {};
|
||||
const typeSelect = makeSelect(selected.type, [
|
||||
'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number', 'blur', 'highlight', 'magnify', 'cursor',
|
||||
].map((type) => ({ value: type, label: type })));
|
||||
].map((type) => ({ value: type, label: ANNOTATION_TYPE_LABELS[type] || type })));
|
||||
const textInput = el('input', { type: 'text', value: selected.text || '', placeholder: 'Annotation text' });
|
||||
const valueInput = el('input', { type: 'number', value: Number.isFinite(selected.value) ? selected.value : '', placeholder: 'Value' });
|
||||
const strokeInput = el('input', { type: 'color', value: style.stroke || '#E5484D' });
|
||||
@@ -932,31 +1019,38 @@ class GuideEditor {
|
||||
this.emitMeta();
|
||||
};
|
||||
|
||||
const annSection = el('div', { className: 'annotation-editor-inner' },
|
||||
labeledRow('Type', typeSelect),
|
||||
labeledRow('Text', textInput),
|
||||
labeledRow('Value', valueInput),
|
||||
labeledRow('Stroke', strokeInput),
|
||||
labeledRow('Fill', fillInput),
|
||||
labeledRow('Stroke width', strokeWidthInput),
|
||||
labeledRow('Font size', fontSizeInput),
|
||||
labeledRow('Text color', textColorInput),
|
||||
labeledRow('Zoom', zoomInput),
|
||||
labeledRow('Radius', radiusInput),
|
||||
labeledRow('Tail', tailInput),
|
||||
const fields = new Set(ANNOTATION_FIELDS[selected.type] || []);
|
||||
const strokeLabel = (selected.type === 'text' || selected.type === 'number') ? 'Color' : 'Stroke';
|
||||
const typeLabel = ANNOTATION_TYPE_LABELS[selected.type] || selected.type;
|
||||
|
||||
const rows = [labeledRow('Type', typeSelect)];
|
||||
if (fields.has('text')) rows.push(labeledRow('Text', textInput));
|
||||
if (fields.has('value')) rows.push(labeledRow('Value', valueInput));
|
||||
if (fields.has('stroke')) rows.push(labeledRow(strokeLabel, strokeInput));
|
||||
if (fields.has('fill')) rows.push(labeledRow('Fill', fillInput));
|
||||
if (fields.has('strokeWidth')) rows.push(labeledRow('Stroke width', strokeWidthInput));
|
||||
if (fields.has('fontSize')) rows.push(labeledRow('Font size', fontSizeInput));
|
||||
if (fields.has('textColor')) rows.push(labeledRow('Text color', textColorInput));
|
||||
if (fields.has('zoom')) rows.push(labeledRow('Zoom', zoomInput));
|
||||
if (fields.has('radius')) rows.push(labeledRow('Radius', radiusInput));
|
||||
if (fields.has('tail')) rows.push(labeledRow('Tail', tailInput));
|
||||
rows.push(
|
||||
el('div.muted', {}, `Copy this style to every other "${typeLabel}" annotation:`),
|
||||
el('div.row', {},
|
||||
el('button', {
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
this.canvas.deleteSelected();
|
||||
},
|
||||
}, 'Delete annotation'),
|
||||
),
|
||||
el('div.row', {},
|
||||
el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in this step', onClick: () => this.applyStyleAcross('step') }, 'Style → step'),
|
||||
el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in the whole guide', onClick: () => this.applyStyleAcross('guide') }, 'Style → guide'),
|
||||
title: `Overwrite the style of every "${typeLabel}" annotation on this step with the style shown above.`,
|
||||
onClick: () => this.applyStyleAcross('step'),
|
||||
}, 'This step'),
|
||||
el('button', {
|
||||
type: 'button',
|
||||
title: `Overwrite the style of every "${typeLabel}" annotation across all steps in this guide with the style shown above.`,
|
||||
onClick: () => this.applyStyleAcross('guide'),
|
||||
}, 'Entire guide'),
|
||||
),
|
||||
);
|
||||
|
||||
const annSection = el('div', { className: 'annotation-editor-inner' }, ...rows);
|
||||
this.dom.annotationEditor.append(annSection);
|
||||
|
||||
typeSelect.addEventListener('change', () => {
|
||||
@@ -1019,13 +1113,6 @@ class GuideEditor {
|
||||
});
|
||||
}
|
||||
|
||||
renderGuidePanel() {
|
||||
if (!this.guide) return;
|
||||
this.dom.linkedBtn.textContent = this.guide.linkedSource ? 'Linked guide' : 'Local guide';
|
||||
this.dom.linkedBtn.disabled = !this.guide.linkedSource;
|
||||
this.dom.snapshotBtn.textContent = 'Snapshot';
|
||||
}
|
||||
|
||||
defaultStyleForTool(tool) {
|
||||
switch (tool) {
|
||||
case 'highlight': return { fill: '#ffe066', stroke: '#ffbf00', strokeWidth: 1 };
|
||||
@@ -1067,7 +1154,8 @@ class GuideEditor {
|
||||
|
||||
pushCanvasHistory(recordOrLabel = 'change') {
|
||||
if (!this.currentStep) return;
|
||||
const record = recordOrLabel && typeof recordOrLabel === 'object' && recordOrLabel.step
|
||||
const record = recordOrLabel && typeof recordOrLabel === 'object'
|
||||
&& (recordOrLabel.step || recordOrLabel.type === 'delete-step')
|
||||
? recordOrLabel
|
||||
: { step: clone(this.currentStep) };
|
||||
this.canvasHistory.push(record);
|
||||
@@ -1108,27 +1196,39 @@ class GuideEditor {
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.currentStep) return;
|
||||
if (!this.canvasHistory.length) {
|
||||
this.onToast('Nothing to undo.');
|
||||
return;
|
||||
}
|
||||
const previous = this.canvasHistory.pop();
|
||||
if (previous.type === 'delete-step') {
|
||||
await this.restoreDeletedSteps(previous);
|
||||
this.canvasFuture.push(previous);
|
||||
this.renderAll();
|
||||
return;
|
||||
}
|
||||
if (!this.currentStep) return;
|
||||
const current = await this.snapshotCurrentStep(true);
|
||||
if (current) this.canvasFuture.push(current);
|
||||
const previous = this.canvasHistory.pop();
|
||||
await this.restoreHistoryRecord(previous);
|
||||
this.renderAll();
|
||||
}
|
||||
|
||||
async redo() {
|
||||
if (!this.currentStep) return;
|
||||
if (!this.canvasFuture.length) {
|
||||
this.onToast('Nothing to redo.');
|
||||
return;
|
||||
}
|
||||
const next = this.canvasFuture.pop();
|
||||
if (next.type === 'delete-step') {
|
||||
await this.deleteStepsAgain(next);
|
||||
this.canvasHistory.push(next);
|
||||
this.renderAll();
|
||||
return;
|
||||
}
|
||||
if (!this.currentStep) return;
|
||||
const current = await this.snapshotCurrentStep(true);
|
||||
if (current) this.canvasHistory.push(current);
|
||||
const next = this.canvasFuture.pop();
|
||||
await this.restoreHistoryRecord(next);
|
||||
this.renderAll();
|
||||
}
|
||||
@@ -1189,24 +1289,48 @@ class GuideEditor {
|
||||
async saveAll() {
|
||||
if (this.currentStep) await this.flushStep();
|
||||
if (this.guide) await this.flushGuide();
|
||||
if (this.guide && this.guide.linkedSource) {
|
||||
const result = await api.archive.saveLinked({ guideId: this.guideId, force: false });
|
||||
if (result.saved) {
|
||||
this.guide.linkedSource.lastSavedAt = new Date().toISOString();
|
||||
this.onToast('Saved and synced to linked archive.');
|
||||
} else {
|
||||
this.onToast('Saved locally, but the linked archive is locked by another session.', { error: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.onToast('Saved.');
|
||||
}
|
||||
|
||||
async createSnapshot() {
|
||||
if (!this.guideId) return;
|
||||
await api.snapshots.create({ guideId: this.guideId, label: 'manual' });
|
||||
const label = await dialogs.promptText({
|
||||
title: 'Create snapshot',
|
||||
label: 'Snapshot name',
|
||||
placeholder: 'manual',
|
||||
});
|
||||
if (label == null) return;
|
||||
if (this.currentStep) await this.flushStep();
|
||||
if (this.guide) await this.flushGuide();
|
||||
await api.snapshots.create({ guideId: this.guideId, label: label.trim() || 'manual' });
|
||||
this.onToast('Snapshot created.');
|
||||
}
|
||||
|
||||
async selectStep(stepId) {
|
||||
if (!this.stepMap.has(stepId)) return;
|
||||
// Persist any unsaved edits on the outgoing step before switching, so a
|
||||
// later guide-wide reload (e.g. applyStyleAcross('guide')) doesn't
|
||||
// discard them by re-fetching a stale on-disk copy.
|
||||
if (this.pendingSave) await this.flushStep();
|
||||
this.selectedStepId = stepId;
|
||||
this.selectedAnnotationId = null;
|
||||
this.canvas.select(null);
|
||||
this.syncStepFields();
|
||||
this.syncFocusedControls();
|
||||
this.renderStepList();
|
||||
this.renderCanvas();
|
||||
this.renderAnnotationPanel();
|
||||
this.renderBlocksPanel();
|
||||
this.emitMeta();
|
||||
}
|
||||
|
||||
@@ -1256,6 +1380,55 @@ class GuideEditor {
|
||||
this.onToast(parent ? 'Substep added.' : 'Step added.');
|
||||
}
|
||||
|
||||
/** All step ids whose ancestor chain leads back to `stepId`. */
|
||||
getStepDescendantIds(stepId) {
|
||||
const result = [];
|
||||
const queue = [stepId];
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
for (const s of this.steps) {
|
||||
if (s.parentStepId === current) {
|
||||
result.push(s.stepId);
|
||||
queue.push(s.stepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async makeSubstepOf(stepId, targetStepId) {
|
||||
const step = this.stepMap.get(stepId);
|
||||
const target = this.stepMap.get(targetStepId);
|
||||
if (!step || !target) return;
|
||||
const numbers = stepNumberMap(this.steps);
|
||||
const subtreeIds = new Set([stepId, ...this.getStepDescendantIds(stepId)]);
|
||||
if (subtreeIds.has(targetStepId)) {
|
||||
this.onToast('A step cannot be made a substep of itself or one of its own substeps.', { error: true });
|
||||
return;
|
||||
}
|
||||
if (step.parentStepId === targetStepId) {
|
||||
this.onToast(`“${step.title || 'Untitled step'}” is already a substep of step ${numbers.get(targetStepId)}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
step.parentStepId = targetStepId;
|
||||
await api.step.save({ guideId: this.guideId, step });
|
||||
|
||||
// Move the step (with its own substeps) to sit right after the target
|
||||
// step's existing substeps, so it becomes the target's last substep.
|
||||
const order = this.steps.map((s) => s.stepId);
|
||||
const remaining = order.filter((id) => !subtreeIds.has(id));
|
||||
const targetSubtree = new Set([targetStepId, ...this.getStepDescendantIds(targetStepId)]);
|
||||
let insertAt = remaining.indexOf(targetStepId) + 1;
|
||||
while (insertAt < remaining.length && targetSubtree.has(remaining[insertAt])) insertAt++;
|
||||
const movedBlock = order.filter((id) => subtreeIds.has(id));
|
||||
remaining.splice(insertAt, 0, ...movedBlock);
|
||||
await api.step.reorder({ guideId: this.guideId, order: remaining });
|
||||
|
||||
await this.reload(stepId);
|
||||
this.onToast(`“${step.title || 'Untitled step'}” is now a substep of step ${numbers.get(targetStepId)}.`);
|
||||
}
|
||||
|
||||
async duplicateSelectedStep() {
|
||||
const step = this.currentStep;
|
||||
if (!step) return;
|
||||
@@ -1282,12 +1455,16 @@ class GuideEditor {
|
||||
if (!step) return;
|
||||
const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' });
|
||||
if (!ok) return;
|
||||
if (this.pendingSave) await this.flushStep();
|
||||
const order = this.steps.map((s) => s.stepId);
|
||||
const entry = await this.snapshotStepForDeletion(step);
|
||||
await api.step.delete({ guideId: this.guideId, stepId: step.stepId });
|
||||
this.pushCanvasHistory({ type: 'delete-step', steps: [entry], order });
|
||||
const next = this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) + 1]
|
||||
|| this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1]
|
||||
|| null;
|
||||
await this.reload(next && next.stepId);
|
||||
this.onToast('Step deleted.');
|
||||
this.onToast('Step deleted. Press Ctrl+Z to undo.');
|
||||
}
|
||||
|
||||
async moveSelectedStep(delta) {
|
||||
@@ -1327,18 +1504,18 @@ class GuideEditor {
|
||||
async openCaptureMenu(event) {
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
const session = (await api.capture.state())?.active;
|
||||
contextMenu(rect.left, rect.bottom + 4, [
|
||||
const items = [
|
||||
{ label: 'Capture full screen', action: () => this.captureStep('fullscreen') },
|
||||
{ label: 'Capture window', action: () => this.captureStep('window') },
|
||||
{ label: 'Capture region…', action: () => this.captureStep('region') },
|
||||
'sep',
|
||||
{ label: 'Paste image as step', action: () => this.pasteClipboardStep() },
|
||||
{ label: 'Import images…', action: () => this.importImageSteps() },
|
||||
'sep',
|
||||
session
|
||||
? { label: 'Finish capture session', action: () => this.finishCaptureSession() }
|
||||
: { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() },
|
||||
]);
|
||||
];
|
||||
if (!session) {
|
||||
items.push('sep', { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() });
|
||||
}
|
||||
contextMenu(rect.left, rect.bottom + 4, items);
|
||||
}
|
||||
|
||||
async pasteClipboardStep() {
|
||||
@@ -1446,12 +1623,6 @@ class GuideEditor {
|
||||
this.emitMeta();
|
||||
}
|
||||
|
||||
async finishCaptureSession() {
|
||||
await api.capture.session({ action: 'finish', guideId: this.guideId });
|
||||
this.onToast('Capture session finished.');
|
||||
this.emitMeta();
|
||||
}
|
||||
|
||||
async openSettings() {
|
||||
const settings = await api.settings.all();
|
||||
const placeholders = await api.settings.globalPlaceholders();
|
||||
@@ -1598,8 +1769,12 @@ class GuideEditor {
|
||||
}
|
||||
|
||||
async currentStepImageToBase64(step = this.currentStep) {
|
||||
return this.stepImageToBase64(step, 'working');
|
||||
}
|
||||
|
||||
async stepImageToBase64(step, which = 'working') {
|
||||
if (!step || !step.image) return null;
|
||||
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' });
|
||||
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which });
|
||||
if (!file) return null;
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
@@ -1617,6 +1792,62 @@ class GuideEditor {
|
||||
});
|
||||
}
|
||||
|
||||
/** Snapshot a step (and its images, if any) before deletion so it can be undone. */
|
||||
async snapshotStepForDeletion(step) {
|
||||
const position = this.steps.findIndex((s) => s.stepId === step.stepId);
|
||||
const childIds = this.steps.filter((s) => s.parentStepId === step.stepId).map((s) => s.stepId);
|
||||
let images = null;
|
||||
if (step.image) {
|
||||
const [original, working] = await Promise.all([
|
||||
this.stepImageToBase64(step, 'original'),
|
||||
this.stepImageToBase64(step, 'working'),
|
||||
]);
|
||||
images = { original, working };
|
||||
}
|
||||
return { step: clone(step), position, childIds, images };
|
||||
}
|
||||
|
||||
/** Undo a 'delete-step' history record: recreate the deleted steps and their order. */
|
||||
async restoreDeletedSteps(record) {
|
||||
for (const entry of record.steps) {
|
||||
await api.step.restore({
|
||||
guideId: this.guideId,
|
||||
step: entry.step,
|
||||
originalBase64: entry.images?.original?.base64 || null,
|
||||
workingBase64: entry.images?.working?.base64 || null,
|
||||
position: entry.position,
|
||||
});
|
||||
}
|
||||
await api.step.reorder({ guideId: this.guideId, order: record.order });
|
||||
this.saveStepDebounced.cancel();
|
||||
this.pendingSave = false;
|
||||
await this.reload(record.steps[0].step.stepId);
|
||||
for (const entry of record.steps) {
|
||||
for (const childId of entry.childIds) {
|
||||
const child = this.stepMap.get(childId);
|
||||
if (child && child.parentStepId !== entry.step.stepId) {
|
||||
child.parentStepId = entry.step.stepId;
|
||||
await api.step.save({ guideId: this.guideId, step: child });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (record.steps.some((entry) => entry.childIds.length)) await this.reload(record.steps[0].step.stepId);
|
||||
this.onToast(`Restored ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`);
|
||||
}
|
||||
|
||||
/** Redo of an undone 'delete-step' record: delete those steps again. */
|
||||
async deleteStepsAgain(record) {
|
||||
for (const entry of record.steps) {
|
||||
await api.step.delete({ guideId: this.guideId, stepId: entry.step.stepId });
|
||||
}
|
||||
const deletedIds = new Set(record.steps.map((entry) => entry.step.stepId));
|
||||
const remaining = record.order.filter((id) => !deletedIds.has(id));
|
||||
this.saveStepDebounced.cancel();
|
||||
this.pendingSave = false;
|
||||
await this.reload(remaining[0] || null);
|
||||
this.onToast(`Deleted ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`);
|
||||
}
|
||||
|
||||
async onCanvasChange(annotations) {
|
||||
const step = this.currentStep;
|
||||
if (!step) return;
|
||||
@@ -1727,12 +1958,16 @@ class GuideEditor {
|
||||
case 'insertOrderedList':
|
||||
document.execCommand('insertOrderedList');
|
||||
break;
|
||||
case 'formatBlock':
|
||||
document.execCommand('formatBlock', false, block || 'blockquote');
|
||||
case 'formatBlock': {
|
||||
const want = block || 'blockquote';
|
||||
const current = document.queryCommandValue('formatBlock').toLowerCase();
|
||||
document.execCommand('formatBlock', false, current === want ? 'p' : want);
|
||||
break;
|
||||
}
|
||||
case 'createLink': {
|
||||
const url = window.prompt('Link URL');
|
||||
if (url) document.execCommand('createLink', false, url);
|
||||
const selectedText = window.getSelection().toString();
|
||||
const text = selectedText || 'Text';
|
||||
document.execCommand('insertText', false, `[${text}](Link)`);
|
||||
break;
|
||||
}
|
||||
case 'removeFormat':
|
||||
@@ -1746,6 +1981,7 @@ class GuideEditor {
|
||||
this.pendingSave = true;
|
||||
this.saveStepDebounced();
|
||||
}
|
||||
this.updateToolbarState();
|
||||
}
|
||||
|
||||
onDocumentKeyDown(e) {
|
||||
@@ -1832,7 +2068,7 @@ class GuideEditor {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Delete' && this.selectedAnnotationId) {
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedAnnotationId) {
|
||||
e.preventDefault();
|
||||
if (this.canvas.deleteSelected()) this.saveStepDebounced();
|
||||
return;
|
||||
|
||||
@@ -487,6 +487,11 @@ kbd {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rich-toolbar button { padding: 4px 8px; font-size: 12px; }
|
||||
.rich-toolbar button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.rich-editor {
|
||||
min-height: 110px;
|
||||
max-height: 220px;
|
||||
@@ -509,6 +514,12 @@ kbd {
|
||||
border-radius: 10px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.rich-editor blockquote {
|
||||
margin: 6px 0;
|
||||
padding: 2px 0 2px 12px;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.block-card,
|
||||
.annotation-editor-inner {
|
||||
@@ -518,12 +529,17 @@ kbd {
|
||||
}
|
||||
.block-card {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: var(--panel-solid);
|
||||
}
|
||||
.block-card[draggable="true"] { cursor: grab; }
|
||||
.block-card[draggable="true"]:active { cursor: grabbing; }
|
||||
.block-card[data-level="info"] { border-left-color: #3b82f6; }
|
||||
.block-card[data-level="success"] { border-left-color: #10b981; }
|
||||
.block-card[data-level="warn"] { border-left-color: #f59e0b; }
|
||||
.block-card[data-level="error"] { border-left-color: #ef4444; }
|
||||
.annotation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -731,6 +747,25 @@ fieldset legend {
|
||||
}
|
||||
.ctx-menu .mi:hover { background: var(--panel-2); }
|
||||
.ctx-menu .mi.danger { color: var(--danger); }
|
||||
.ctx-menu .mi.disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
.ctx-menu .mi.disabled:hover { background: none; }
|
||||
.ctx-menu .mi.has-submenu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.ctx-menu .mi .submenu-arrow {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ctx-submenu {
|
||||
max-height: min(360px, calc(100vh - 24px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ctx-menu hr {
|
||||
margin: 6px 0;
|
||||
border: 0;
|
||||
|
||||
+44
-4
@@ -119,15 +119,55 @@ function promptDialog(title, { value = '', label = 'Name' } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Context menu at (x, y); items: [{label, danger, action}] or 'sep'. */
|
||||
/**
|
||||
* Context menu at (x, y).
|
||||
* items: [{label, danger, action}] | [{label, submenu}] | 'sep'.
|
||||
* `submenu` is an array (or a function returning one) of the same item
|
||||
* shapes, shown in a scrollable panel beside the item on hover.
|
||||
*/
|
||||
function contextMenu(x, y, items) {
|
||||
document.querySelectorAll('.ctx-menu').forEach((n) => n.remove());
|
||||
const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } });
|
||||
let openSubmenu = null;
|
||||
const closeSubmenu = () => {
|
||||
if (openSubmenu) { openSubmenu.remove(); openSubmenu = null; }
|
||||
};
|
||||
const closeAll = () => { closeSubmenu(); menu.remove(); };
|
||||
for (const item of items) {
|
||||
if (item === 'sep') { menu.append(el('hr')); continue; }
|
||||
if (item === 'sep') { menu.append(el('hr', { onMouseEnter: closeSubmenu })); continue; }
|
||||
if (item.submenu) {
|
||||
const mi = el('div.mi.has-submenu', {
|
||||
onMouseEnter: () => {
|
||||
closeSubmenu();
|
||||
const subItems = typeof item.submenu === 'function' ? item.submenu() : item.submenu;
|
||||
const sub = el('div.ctx-menu.ctx-submenu');
|
||||
if (!subItems.length) {
|
||||
sub.append(el('div.mi.disabled', {}, 'Nothing else to choose'));
|
||||
}
|
||||
for (const subItem of subItems) {
|
||||
if (subItem === 'sep') { sub.append(el('hr')); continue; }
|
||||
sub.append(el('div.mi', {
|
||||
className: `mi${subItem.danger ? ' danger' : ''}`,
|
||||
onClick: () => { closeAll(); subItem.action(); },
|
||||
}, subItem.label));
|
||||
}
|
||||
document.body.append(sub);
|
||||
const miRect = mi.getBoundingClientRect();
|
||||
sub.style.left = `${miRect.right + 2}px`;
|
||||
sub.style.top = `${miRect.top}px`;
|
||||
const subRect = sub.getBoundingClientRect();
|
||||
if (subRect.right > innerWidth) sub.style.left = `${Math.max(6, miRect.left - subRect.width - 2)}px`;
|
||||
if (subRect.bottom > innerHeight) sub.style.top = `${Math.max(6, innerHeight - subRect.height - 6)}px`;
|
||||
openSubmenu = sub;
|
||||
},
|
||||
}, el('span', {}, item.label), el('span.submenu-arrow', {}, '›'));
|
||||
menu.append(mi);
|
||||
continue;
|
||||
}
|
||||
menu.append(el('div.mi', {
|
||||
className: `mi${item.danger ? ' danger' : ''}`,
|
||||
onClick: () => { menu.remove(); item.action(); },
|
||||
onMouseEnter: closeSubmenu,
|
||||
onClick: () => { closeAll(); item.action(); },
|
||||
}, item.label));
|
||||
}
|
||||
document.body.append(menu);
|
||||
@@ -135,7 +175,7 @@ function contextMenu(x, y, items) {
|
||||
if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`;
|
||||
if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`;
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', () => menu.remove(), { once: true });
|
||||
document.addEventListener('click', () => closeAll(), { once: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user