Compare commits
5
Commits
1b16531321
...
ce9bd41e72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce9bd41e72 | ||
|
|
20d69619c2 | ||
|
|
2274544ed8 | ||
|
|
05b7713efb | ||
|
|
50fb9927ca |
+126
-16
@@ -461,6 +461,7 @@ class GuideEditor {
|
|||||||
this.onToast('Select a step first.', { error: true });
|
this.onToast('Select a step first.', { error: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.syncBlockEditors(step);
|
||||||
const id = `blk-${Date.now().toString(36)}`;
|
const id = `blk-${Date.now().toString(36)}`;
|
||||||
if (kind === 'text') {
|
if (kind === 'text') {
|
||||||
step.textBlocks = step.textBlocks || [];
|
step.textBlocks = step.textBlocks || [];
|
||||||
@@ -477,6 +478,45 @@ class GuideEditor {
|
|||||||
this.renderBlocksPanel();
|
this.renderBlocksPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncBlockEditors(step = this.currentStep) {
|
||||||
|
if (!step || !this.dom?.blocksList) return;
|
||||||
|
const findBlock = (kind, id) => {
|
||||||
|
const list = kind === 'text'
|
||||||
|
? step.textBlocks || []
|
||||||
|
: kind === 'code'
|
||||||
|
? step.codeBlocks || []
|
||||||
|
: step.tableBlocks || [];
|
||||||
|
return list.find((block) => block.id === id) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const card of this.dom.blocksList.querySelectorAll('.block-card[data-block-id]')) {
|
||||||
|
const kind = card.dataset.blockKind;
|
||||||
|
const block = findBlock(kind, card.dataset.blockId);
|
||||||
|
if (!block) continue;
|
||||||
|
if (kind === 'text') {
|
||||||
|
const position = card.querySelector('select[data-block-field="position"]');
|
||||||
|
const level = card.querySelector('select[data-block-field="level"]');
|
||||||
|
const title = card.querySelector('input[data-block-field="title"]');
|
||||||
|
const body = card.querySelector('textarea[data-block-field="body"]');
|
||||||
|
if (position) block.position = position.value;
|
||||||
|
if (level) block.level = level.value;
|
||||||
|
if (title) block.title = title.value;
|
||||||
|
if (body) block.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`;
|
||||||
|
} else if (kind === 'code') {
|
||||||
|
const lang = card.querySelector('input[data-block-field="language"]');
|
||||||
|
const code = card.querySelector('textarea[data-block-field="code"]');
|
||||||
|
if (lang) block.language = lang.value;
|
||||||
|
if (code) block.code = code.value;
|
||||||
|
} else if (kind === 'table') {
|
||||||
|
const grid = card.querySelector('textarea[data-block-field="rows"]');
|
||||||
|
if (grid) {
|
||||||
|
block.rows = grid.value.split('\n').filter((line) => line.trim() !== '')
|
||||||
|
.map((line) => line.split('|').map((cell) => cell.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderBlocksPanel() {
|
renderBlocksPanel() {
|
||||||
clearNode(this.dom.blocksList);
|
clearNode(this.dom.blocksList);
|
||||||
const step = this.currentStep;
|
const step = this.currentStep;
|
||||||
@@ -485,16 +525,46 @@ class GuideEditor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const save = () => {
|
const save = () => {
|
||||||
|
this.syncBlockEditors(step);
|
||||||
this.pendingSave = true;
|
this.pendingSave = true;
|
||||||
this.saveStepDebounced();
|
this.saveStepDebounced();
|
||||||
};
|
};
|
||||||
|
const findBlockCard = (kind, id) => {
|
||||||
|
for (const card of this.dom.blocksList.querySelectorAll('.block-card[data-block-id]')) {
|
||||||
|
if (card.dataset.blockKind === kind && card.dataset.blockId === id) return card;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const refreshBlockCard = (entry, index, total) => {
|
||||||
|
const card = findBlockCard(entry.kind, entry.block.id);
|
||||||
|
if (!card) return false;
|
||||||
|
const orderLabel = card.querySelector('[data-block-order]');
|
||||||
|
const moveUpBtn = card.querySelector('[data-block-move="up"]');
|
||||||
|
const moveDownBtn = card.querySelector('[data-block-move="down"]');
|
||||||
|
if (orderLabel) {
|
||||||
|
orderLabel.textContent = `#${Number.isFinite(entry.block.order) ? entry.block.order : index + 1}`;
|
||||||
|
}
|
||||||
|
if (moveUpBtn) moveUpBtn.disabled = index === 0;
|
||||||
|
if (moveDownBtn) moveDownBtn.disabled = index === total - 1;
|
||||||
|
this.dom.blocksList.append(card);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const reflowBlockCards = () => {
|
||||||
|
const blocksNow = orderedStepBlocks(step);
|
||||||
|
for (const [index, entry] of blocksNow.entries()) {
|
||||||
|
if (!refreshBlockCard(entry, index, blocksNow.length)) {
|
||||||
|
this.renderBlocksPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
const moveBlock = (source, target) => {
|
const moveBlock = (source, target) => {
|
||||||
if (!source || !target || source.kind === target.kind && source.block.id === target.block.id) return;
|
if (!source || !target || source.kind === target.kind && source.block.id === target.block.id) return;
|
||||||
const swap = source.block.order;
|
const swap = source.block.order;
|
||||||
source.block.order = target.block.order;
|
source.block.order = target.block.order;
|
||||||
target.block.order = swap;
|
target.block.order = swap;
|
||||||
save();
|
save();
|
||||||
this.renderBlocksPanel();
|
reflowBlockCards();
|
||||||
};
|
};
|
||||||
const removeBtn = (onRemove) => el('button.icon.danger', {
|
const removeBtn = (onRemove) => el('button.icon.danger', {
|
||||||
type: 'button', title: 'Remove block',
|
type: 'button', title: 'Remove block',
|
||||||
@@ -506,15 +576,25 @@ class GuideEditor {
|
|||||||
const { kind, block } = entry;
|
const { kind, block } = entry;
|
||||||
const canMoveUp = index > 0;
|
const canMoveUp = index > 0;
|
||||||
const canMoveDown = index < blocks.length - 1;
|
const canMoveDown = index < blocks.length - 1;
|
||||||
const moveUp = () => moveBlock(entry, blocks[index - 1]);
|
const moveUp = () => {
|
||||||
const moveDown = () => moveBlock(entry, blocks[index + 1]);
|
const currentBlocks = orderedStepBlocks(step);
|
||||||
|
const currentIndex = currentBlocks.findIndex((item) => item.kind === kind && item.block.id === block.id);
|
||||||
|
if (currentIndex > 0) moveBlock(currentBlocks[currentIndex], currentBlocks[currentIndex - 1]);
|
||||||
|
};
|
||||||
|
const moveDown = () => {
|
||||||
|
const currentBlocks = orderedStepBlocks(step);
|
||||||
|
const currentIndex = currentBlocks.findIndex((item) => item.kind === kind && item.block.id === block.id);
|
||||||
|
if (currentIndex >= 0 && currentIndex < currentBlocks.length - 1) {
|
||||||
|
moveBlock(currentBlocks[currentIndex], currentBlocks[currentIndex + 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const header = el('div.row', {},
|
const header = el('div.row', {},
|
||||||
el('strong', {}, blockLabel(kind)),
|
el('strong', {}, blockLabel(kind)),
|
||||||
el('span.muted', {}, `#${Number.isFinite(block.order) ? block.order : index + 1}`),
|
el('span.muted', { dataset: { blockOrder: 'true' } }, `#${Number.isFinite(block.order) ? block.order : index + 1}`),
|
||||||
el('span.spacer'),
|
el('span.spacer'),
|
||||||
el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp }, '↑'),
|
el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp, dataset: { blockMove: 'up' } }, '↑'),
|
||||||
el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown }, '↓'),
|
el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown, dataset: { blockMove: 'down' } }, '↓'),
|
||||||
removeBtn(() => {
|
removeBtn(() => {
|
||||||
if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block);
|
if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block);
|
||||||
else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block);
|
else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block);
|
||||||
@@ -524,6 +604,7 @@ class GuideEditor {
|
|||||||
|
|
||||||
const card = el('div.block-card', {
|
const card = el('div.block-card', {
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
dataset: { blockId: block.id, blockKind: kind },
|
||||||
onDragStart: (e) => {
|
onDragStart: (e) => {
|
||||||
this.draggedBlock = entry;
|
this.draggedBlock = entry;
|
||||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -551,14 +632,18 @@ class GuideEditor {
|
|||||||
{ value: 'before-description', label: 'Before description' },
|
{ value: 'before-description', label: 'Before description' },
|
||||||
{ value: 'after-description', label: 'After description' },
|
{ value: 'after-description', label: 'After description' },
|
||||||
]);
|
]);
|
||||||
|
position.dataset.blockField = 'position';
|
||||||
const level = makeSelect(block.level, [
|
const level = makeSelect(block.level, [
|
||||||
{ value: 'info', label: 'Note' },
|
{ value: 'info', label: 'Note' },
|
||||||
{ value: 'warn', label: 'Warning' },
|
{ value: 'warn', label: 'Warning' },
|
||||||
{ value: 'error', label: 'Important' },
|
{ value: 'error', label: 'Important' },
|
||||||
{ value: 'success', label: 'Tip' },
|
{ value: 'success', label: 'Tip' },
|
||||||
]);
|
]);
|
||||||
|
level.dataset.blockField = 'level';
|
||||||
const title = el('input', { type: 'text', value: block.title || '', placeholder: 'Block title' });
|
const title = el('input', { type: 'text', value: block.title || '', placeholder: 'Block title' });
|
||||||
|
title.dataset.blockField = 'title';
|
||||||
const body = el('textarea', { rows: 2, placeholder: 'Block text' });
|
const body = el('textarea', { rows: 2, placeholder: 'Block text' });
|
||||||
|
body.dataset.blockField = 'body';
|
||||||
body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, '');
|
body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, '');
|
||||||
position.addEventListener('change', () => { block.position = position.value; save(); });
|
position.addEventListener('change', () => { block.position = position.value; save(); });
|
||||||
level.addEventListener('change', () => { block.level = level.value; save(); });
|
level.addEventListener('change', () => { block.level = level.value; save(); });
|
||||||
@@ -571,7 +656,9 @@ class GuideEditor {
|
|||||||
);
|
);
|
||||||
} else if (kind === 'code') {
|
} else if (kind === 'code') {
|
||||||
const lang = el('input', { type: 'text', value: block.language || '', placeholder: 'Language (e.g. bash)' });
|
const lang = el('input', { type: 'text', value: block.language || '', placeholder: 'Language (e.g. bash)' });
|
||||||
|
lang.dataset.blockField = 'language';
|
||||||
const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false });
|
const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false });
|
||||||
|
code.dataset.blockField = 'code';
|
||||||
code.value = blockText(block);
|
code.value = blockText(block);
|
||||||
code.style.fontFamily = 'monospace';
|
code.style.fontFamily = 'monospace';
|
||||||
lang.addEventListener('input', () => { block.language = lang.value; save(); });
|
lang.addEventListener('input', () => { block.language = lang.value; save(); });
|
||||||
@@ -579,6 +666,7 @@ class GuideEditor {
|
|||||||
card.append(lang, code);
|
card.append(lang, code);
|
||||||
} else if (kind === 'table') {
|
} else if (kind === 'table') {
|
||||||
const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false });
|
const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false });
|
||||||
|
grid.dataset.blockField = 'rows';
|
||||||
grid.value = (block.rows || []).map((r) => r.join(' | ')).join('\n');
|
grid.value = (block.rows || []).map((r) => r.join(' | ')).join('\n');
|
||||||
grid.addEventListener('input', () => {
|
grid.addEventListener('input', () => {
|
||||||
block.rows = grid.value.split('\n').filter((l) => l.trim() !== '')
|
block.rows = grid.value.split('\n').filter((l) => l.trim() !== '')
|
||||||
@@ -1013,9 +1101,7 @@ class GuideEditor {
|
|||||||
size: record.image.size,
|
size: record.image.size,
|
||||||
step,
|
step,
|
||||||
});
|
});
|
||||||
this.stepMap.set(saved.stepId, saved);
|
this.commitSavedStep(saved);
|
||||||
const idx = this.steps.findIndex((s) => s.stepId === saved.stepId);
|
|
||||||
if (idx >= 0) this.steps[idx] = saved;
|
|
||||||
} else {
|
} else {
|
||||||
await this.flushStep(step);
|
await this.flushStep(step);
|
||||||
}
|
}
|
||||||
@@ -1051,14 +1137,11 @@ class GuideEditor {
|
|||||||
if (!step) return;
|
if (!step) return;
|
||||||
this.pendingSave = false;
|
this.pendingSave = false;
|
||||||
const saved = await api.step.save({ guideId: this.guideId, step });
|
const saved = await api.step.save({ guideId: this.guideId, step });
|
||||||
this.stepMap.set(saved.stepId, saved);
|
const committed = this.commitSavedStep(saved);
|
||||||
// Keep the steps array in sync — it holds the objects the list renders.
|
if (this.selectedStepId === committed.stepId) {
|
||||||
const idx = this.steps.findIndex((s) => s.stepId === saved.stepId);
|
|
||||||
if (idx >= 0) this.steps[idx] = saved;
|
|
||||||
if (this.selectedStepId === saved.stepId) {
|
|
||||||
this.renderStepList();
|
this.renderStepList();
|
||||||
this.syncStepFields();
|
this.syncStepFields();
|
||||||
this.canvas.setAnnotations(saved.annotations || []);
|
this.canvas.setAnnotations(committed.annotations || []);
|
||||||
// Rebuilding the annotation editor while the user is typing in one of
|
// Rebuilding the annotation editor while the user is typing in one of
|
||||||
// its inputs would steal focus, so skip it in that case.
|
// its inputs would steal focus, so skip it in that case.
|
||||||
if (!this.dom.annotationEditor.contains(document.activeElement)) {
|
if (!this.dom.annotationEditor.contains(document.activeElement)) {
|
||||||
@@ -1066,7 +1149,34 @@ class GuideEditor {
|
|||||||
}
|
}
|
||||||
this.emitMeta();
|
this.emitMeta();
|
||||||
}
|
}
|
||||||
return saved;
|
return committed;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitSavedStep(saved) {
|
||||||
|
if (!saved || !saved.stepId) return saved;
|
||||||
|
const existing = this.stepMap.get(saved.stepId);
|
||||||
|
if (!existing || existing === saved) {
|
||||||
|
this.stepMap.set(saved.stepId, saved);
|
||||||
|
const idx = this.steps.findIndex((s) => s.stepId === saved.stepId);
|
||||||
|
if (idx >= 0) this.steps[idx] = saved;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the live block arrays so block-card closures keep pointing at the
|
||||||
|
// same objects after the backend returns a fresh saved step.
|
||||||
|
const preservedBlocks = {
|
||||||
|
textBlocks: existing.textBlocks,
|
||||||
|
codeBlocks: existing.codeBlocks,
|
||||||
|
tableBlocks: existing.tableBlocks,
|
||||||
|
};
|
||||||
|
for (const key of Object.keys(existing)) {
|
||||||
|
if (!(key in saved)) delete existing[key];
|
||||||
|
}
|
||||||
|
Object.assign(existing, saved, preservedBlocks);
|
||||||
|
this.stepMap.set(existing.stepId, existing);
|
||||||
|
const idx = this.steps.findIndex((s) => s.stepId === existing.stepId);
|
||||||
|
if (idx >= 0) this.steps[idx] = existing;
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
async flushGuide() {
|
async flushGuide() {
|
||||||
|
|||||||
Reference in New Issue
Block a user