feat(project-editor): session undo/redo and modified-field tracking
Milestone 4 of the Configuration Engine sprint. - Add schema/history.util: pure undo/redo reducer (commit/undo/redo, depth cap) with full spec coverage. - Facade: debounced snapshot history (~300ms coalesce so a typing burst = one undo step); undo()/redo() route through the draft-save path so autosave never desyncs; canUndo/canRedo; history cleared on load/publish/resetDraft. modifiedFields (schema-diff vs original) + modifiedSections computeds. - save-bar: Undo/Redo buttons. Page: Ctrl/Cmd+Z / Shift+Z / Y shortcuts (skipped while a text field is focused so native text undo is preserved); beforeunload guard already present. - nav: amber modified-field dot per section (when no blocking badge). - i18n: builder.undo / builder.redo in interface + en/ru/hy. Gate: tsc --noEmit, npm test (33/33), arch:check, build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,10 @@ import { ProjectEditorState } from '../models/project-editor.model';
|
||||
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
|
||||
import { ProjectValidator, ProjectValidationIssue } from '../services/project-validator.service';
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
import { EditorSchemaService } from '../schema/editor-schema.service';
|
||||
import { History, commit as commitHistory, emptyHistory, redo as redoHistory, undo as undoHistory } from '../schema/history.util';
|
||||
|
||||
const HISTORY_DEBOUNCE_MS = 300;
|
||||
import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
|
||||
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
||||
|
||||
@@ -22,6 +26,12 @@ export class ProjectEditorFacade {
|
||||
private readonly runtime = inject(PlatformRuntimeService);
|
||||
private readonly validator = inject(ProjectValidator);
|
||||
private readonly draftStorage = inject(ProjectEditorDraftStorageService);
|
||||
private readonly schema = inject(EditorSchemaService);
|
||||
|
||||
private readonly history = signal<History<BootstrapConfig>>(emptyHistory());
|
||||
/** Snapshot captured at the start of an edit burst; committed to history once edits settle. */
|
||||
private pendingBaseline: BootstrapConfig | null = null;
|
||||
private historyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly state = signal<ProjectEditorState>({
|
||||
bootstrap: null,
|
||||
@@ -85,6 +95,34 @@ export class ProjectEditorFacade {
|
||||
}
|
||||
return JSON.stringify(current) !== JSON.stringify(this.state().lastSavedBootstrap);
|
||||
});
|
||||
readonly canUndo = computed(() => this.history().past.length > 0);
|
||||
readonly canRedo = computed(() => this.history().future.length > 0);
|
||||
/** Schema field keys whose current value differs from the originally loaded/published config. */
|
||||
readonly modifiedFields = computed<Set<string>>(() => {
|
||||
const current = this.bootstrap();
|
||||
const original = this.state().originalBootstrap;
|
||||
const set = new Set<string>();
|
||||
if (!current || !original) {
|
||||
return set;
|
||||
}
|
||||
for (const field of this.schema.all()) {
|
||||
if (JSON.stringify(this.schema.getByPath(current, field.key)) !== JSON.stringify(this.schema.getByPath(original, field.key))) {
|
||||
set.add(field.key);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
});
|
||||
/** Editor sections that contain at least one modified field, for the nav modified indicator. */
|
||||
readonly modifiedSections = computed<Set<ProjectEditorSectionId>>(() => {
|
||||
const sections = new Set<ProjectEditorSectionId>();
|
||||
for (const key of this.modifiedFields()) {
|
||||
const field = this.schema.getField(key);
|
||||
if (field) {
|
||||
sections.add(field.section);
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
});
|
||||
|
||||
loadBootstrap(): void {
|
||||
this.configService.loadBootstrap(true).pipe(take(1)).subscribe({
|
||||
@@ -103,6 +141,7 @@ export class ProjectEditorFacade {
|
||||
status: 'draft',
|
||||
draftRestored: restoredFromDraft,
|
||||
}));
|
||||
this.clearHistory();
|
||||
},
|
||||
error: () => this.state.update(current => ({ ...current, bootstrap: null, importError: 'builder.importError' })),
|
||||
});
|
||||
@@ -114,9 +153,80 @@ export class ProjectEditorFacade {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the pre-burst snapshot so a run of rapid edits collapses into one
|
||||
// undo step. The baseline is cleared when the debounce fires (or on undo).
|
||||
if (this.pendingBaseline === null) {
|
||||
this.pendingBaseline = current;
|
||||
}
|
||||
const next = this.normalize(updater(JSON.parse(JSON.stringify(current)) as BootstrapConfig));
|
||||
this.state.update(state => ({ ...state, bootstrap: next, draftRestored: false }));
|
||||
this.draftStorage.save(next);
|
||||
this.scheduleHistoryCommit();
|
||||
}
|
||||
|
||||
private scheduleHistoryCommit(): void {
|
||||
if (this.historyTimer !== null) {
|
||||
clearTimeout(this.historyTimer);
|
||||
}
|
||||
this.historyTimer = setTimeout(() => this.flushHistory(), HISTORY_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** Commits any pending edit burst to the undo stack immediately. */
|
||||
private flushHistory(): void {
|
||||
if (this.historyTimer !== null) {
|
||||
clearTimeout(this.historyTimer);
|
||||
this.historyTimer = null;
|
||||
}
|
||||
if (this.pendingBaseline === null) {
|
||||
return;
|
||||
}
|
||||
const baseline = this.pendingBaseline;
|
||||
this.pendingBaseline = null;
|
||||
this.history.update(history => commitHistory(history, baseline));
|
||||
}
|
||||
|
||||
private clearHistory(): void {
|
||||
if (this.historyTimer !== null) {
|
||||
clearTimeout(this.historyTimer);
|
||||
this.historyTimer = null;
|
||||
}
|
||||
this.pendingBaseline = null;
|
||||
this.history.set(emptyHistory());
|
||||
}
|
||||
|
||||
private applyHistoryState(bootstrap: BootstrapConfig): void {
|
||||
const restored = this.normalize(JSON.parse(JSON.stringify(bootstrap)) as BootstrapConfig);
|
||||
this.state.update(state => ({ ...state, bootstrap: restored, draftRestored: false }));
|
||||
this.draftStorage.save(restored);
|
||||
}
|
||||
|
||||
/** Reverts the last edit burst. Flushes any in-flight burst first. */
|
||||
undo(): void {
|
||||
this.flushHistory();
|
||||
const current = this.state().bootstrap;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const step = undoHistory(this.history(), current);
|
||||
if (!step) {
|
||||
return;
|
||||
}
|
||||
this.history.set(step.history);
|
||||
this.applyHistoryState(step.value);
|
||||
}
|
||||
|
||||
/** Re-applies the most recently undone burst. */
|
||||
redo(): void {
|
||||
const current = this.state().bootstrap;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const step = redoHistory(this.history(), current);
|
||||
if (!step) {
|
||||
return;
|
||||
}
|
||||
this.history.set(step.history);
|
||||
this.applyHistoryState(step.value);
|
||||
}
|
||||
|
||||
/** i18n message key of the first issue mapped to a field, or null. Reactive: reads `issuesByField`. */
|
||||
@@ -206,6 +316,7 @@ export class ProjectEditorFacade {
|
||||
const reset = this.normalize(JSON.parse(JSON.stringify(original)) as BootstrapConfig);
|
||||
this.draftStorage.clear();
|
||||
this.state.update(state => ({ ...state, bootstrap: reset, lastSavedAt: null, draftRestored: false }));
|
||||
this.clearHistory();
|
||||
}
|
||||
|
||||
publish(): boolean {
|
||||
@@ -223,6 +334,7 @@ export class ProjectEditorFacade {
|
||||
lastSavedAt: savedAt,
|
||||
lastPublishedAt: savedAt,
|
||||
}));
|
||||
this.clearHistory();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user