diff --git a/src/app/features/project-editor/components/project-editor-nav.component.html b/src/app/features/project-editor/components/project-editor-nav.component.html
index 73380bb..ddfedda 100644
--- a/src/app/features/project-editor/components/project-editor-nav.component.html
+++ b/src/app/features/project-editor/components/project-editor-nav.component.html
@@ -5,6 +5,6 @@
routerLinkActive="active"
[ariaCurrentWhenActive]="'page'"
class="editor-nav-link"
- >{{ section.label | translate }}@if (issueCount(section.id); as count) {{{ count }}}
+ >{{ section.label | translate }}@if (issueCount(section.id); as count) {{{ count }}} @else if (isModified(section.id)) {}
}
diff --git a/src/app/features/project-editor/components/project-editor-nav.component.scss b/src/app/features/project-editor/components/project-editor-nav.component.scss
index 4a8bf54..400405b 100644
--- a/src/app/features/project-editor/components/project-editor-nav.component.scss
+++ b/src/app/features/project-editor/components/project-editor-nav.component.scss
@@ -58,6 +58,20 @@
color: var(--error-color, #ef4444);
}
+.editor-nav-dot {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ margin-left: 6px;
+ border-radius: 999px;
+ background: var(--warning-color, #f59e0b);
+ vertical-align: middle;
+}
+
+.editor-nav-link.active .editor-nav-dot {
+ background: #fff;
+}
+
@media (prefers-reduced-motion: reduce) {
.editor-nav-link {
transition: none;
diff --git a/src/app/features/project-editor/components/project-editor-nav.component.ts b/src/app/features/project-editor/components/project-editor-nav.component.ts
index 1fa6c09..ac5d6f1 100644
--- a/src/app/features/project-editor/components/project-editor-nav.component.ts
+++ b/src/app/features/project-editor/components/project-editor-nav.component.ts
@@ -16,12 +16,18 @@ import { ProjectEditorFacade } from '../facade/project-editor.facade';
export class ProjectEditorNavComponent {
private readonly facade = inject(ProjectEditorFacade);
private readonly issuesBySection = this.facade.issuesBySection;
+ private readonly modifiedSections = this.facade.modifiedSections;
/** Count of blocking issues in a section, for the nav badge. */
issueCount(sectionId: ProjectEditorSectionId): number {
return this.issuesBySection().get(sectionId) ?? 0;
}
+ /** Whether a section has unsaved modified fields, for the nav dot. */
+ isModified(sectionId: ProjectEditorSectionId): boolean {
+ return this.modifiedSections().has(sectionId);
+ }
+
readonly sections: Array<{ id: ProjectEditorSectionId; label: string }> = [
{ id: 'general', label: 'builder.general' },
{ id: 'branding', label: 'builder.branding' },
diff --git a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html
index 6278f49..cd89441 100644
--- a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html
+++ b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html
@@ -21,6 +21,8 @@
}
+
{{ 'builder.undo' | translate }}
+
{{ 'builder.redo' | translate }}
{{ 'builder.resetDraft' | translate }}
{{ 'builder.save' | translate }}
{{ 'builder.publish' | translate }}
diff --git a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts
index 5033adf..25b0b2d 100644
--- a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts
+++ b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts
@@ -19,6 +19,16 @@ export class ProjectEditorSaveBarComponent {
readonly status = this.facade.status;
readonly issues = this.facade.validationIssues;
readonly hasBlockingIssues = this.facade.hasBlockingIssues;
+ readonly canUndo = this.facade.canUndo;
+ readonly canRedo = this.facade.canRedo;
+
+ undo(): void {
+ this.facade.undo();
+ }
+
+ redo(): void {
+ this.facade.redo();
+ }
readonly lastSavedAt = this.facade.lastSavedAt;
readonly draftRestored = this.facade.draftRestored;
diff --git a/src/app/features/project-editor/facade/project-editor.facade.ts b/src/app/features/project-editor/facade/project-editor.facade.ts
index f3fdac3..5e7cb8d 100644
--- a/src/app/features/project-editor/facade/project-editor.facade.ts
+++ b/src/app/features/project-editor/facade/project-editor.facade.ts
@@ -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
>(emptyHistory());
+ /** Snapshot captured at the start of an edit burst; committed to history once edits settle. */
+ private pendingBaseline: BootstrapConfig | null = null;
+ private historyTimer: ReturnType | null = null;
private readonly state = signal({
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>(() => {
+ const current = this.bootstrap();
+ const original = this.state().originalBootstrap;
+ const set = new Set();
+ 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>(() => {
+ const sections = new Set();
+ 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;
}
diff --git a/src/app/features/project-editor/pages/project-editor-page.component.ts b/src/app/features/project-editor/pages/project-editor-page.component.ts
index ae8232c..e3069a6 100644
--- a/src/app/features/project-editor/pages/project-editor-page.component.ts
+++ b/src/app/features/project-editor/pages/project-editor-page.component.ts
@@ -85,4 +85,29 @@ export class ProjectEditorPageComponent {
event.returnValue = '';
}
}
+
+ /**
+ * Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redoes. Skipped while a
+ * text control has focus so native per-field text undo is preserved; the
+ * save-bar buttons cover that case.
+ */
+ @HostListener('document:keydown', ['$event'])
+ handleUndoRedoShortcut(event: KeyboardEvent): void {
+ if (!event.ctrlKey && !event.metaKey) {
+ return;
+ }
+ const target = event.target as HTMLElement | null;
+ const tag = target?.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) {
+ return;
+ }
+ const key = event.key.toLowerCase();
+ if (key === 'z' && !event.shiftKey) {
+ event.preventDefault();
+ this.facade.undo();
+ } else if (key === 'y' || (key === 'z' && event.shiftKey)) {
+ event.preventDefault();
+ this.facade.redo();
+ }
+ }
}
diff --git a/src/app/features/project-editor/schema/history.util.spec.ts b/src/app/features/project-editor/schema/history.util.spec.ts
new file mode 100644
index 0000000..48d616f
--- /dev/null
+++ b/src/app/features/project-editor/schema/history.util.spec.ts
@@ -0,0 +1,60 @@
+import { commit, emptyHistory, redo, undo } from './history.util';
+
+describe('history reducer', () => {
+ it('starts empty', () => {
+ const history = emptyHistory();
+ expect(history.past).toEqual([]);
+ expect(history.future).toEqual([]);
+ });
+
+ it('commit records an undo point and clears the redo stack', () => {
+ let history = emptyHistory();
+ history = commit(history, 1);
+ history = commit({ ...history, future: [9] }, 2);
+ expect(history.past).toEqual([1, 2]);
+ expect(history.future).toEqual([]);
+ });
+
+ it('caps history depth at the limit', () => {
+ let history = emptyHistory();
+ for (let i = 1; i <= 5; i++) {
+ history = commit(history, i, 3);
+ }
+ expect(history.past).toEqual([3, 4, 5]);
+ });
+
+ it('undo steps back and moves current onto the redo stack', () => {
+ const history = { past: [1, 2], future: [] as number[] };
+ const step = undo(history, 3);
+ expect(step).not.toBeNull();
+ expect(step!.value).toBe(2);
+ expect(step!.history.past).toEqual([1]);
+ expect(step!.history.future).toEqual([3]);
+ });
+
+ it('undo returns null when there is nothing to undo', () => {
+ expect(undo(emptyHistory(), 1)).toBeNull();
+ });
+
+ it('redo steps forward and moves current back onto the undo stack', () => {
+ const history = { past: [1], future: [3] };
+ const step = redo(history, 2);
+ expect(step).not.toBeNull();
+ expect(step!.value).toBe(3);
+ expect(step!.history.past).toEqual([1, 2]);
+ expect(step!.history.future).toEqual([]);
+ });
+
+ it('redo returns null when there is nothing to redo', () => {
+ expect(redo(emptyHistory(), 1)).toBeNull();
+ });
+
+ it('undo then redo round-trips to the same value', () => {
+ const start = { past: [10, 20], future: [] as number[] };
+ const undone = undo(start, 30)!;
+ const redone = redo(undone.history, undone.value)!;
+ expect(redone.value).toBe(30);
+ expect(redone.history.past).toEqual([10, 20]);
+ expect(redone.history.future).toEqual([]);
+ });
+});
diff --git a/src/app/features/project-editor/schema/history.util.ts b/src/app/features/project-editor/schema/history.util.ts
new file mode 100644
index 0000000..0c51de6
--- /dev/null
+++ b/src/app/features/project-editor/schema/history.util.ts
@@ -0,0 +1,52 @@
+/**
+ * Pure undo/redo history reducer over immutable snapshots. Framework-agnostic
+ * so it can be unit-tested in isolation; the facade wraps it with debounced
+ * commits and Angular signals. `past` is oldest-to-newest; `future` is the
+ * redo stack, newest-undone first.
+ */
+
+export const HISTORY_LIMIT = 50;
+
+export interface History {
+ readonly past: readonly T[];
+ readonly future: readonly T[];
+}
+
+export interface HistoryStep {
+ readonly history: History;
+ readonly value: T;
+}
+
+export function emptyHistory(): History {
+ return { past: [], future: [] };
+}
+
+/** Records a snapshot as a new undo point and drops the redo stack. Caps depth at `limit`. */
+export function commit(history: History, snapshot: T, limit = HISTORY_LIMIT): History {
+ const past = [...history.past, snapshot].slice(-limit);
+ return { past, future: [] };
+}
+
+/** Steps back one snapshot, pushing `current` onto the redo stack. Null when nothing to undo. */
+export function undo(history: History, current: T): HistoryStep | null {
+ if (history.past.length === 0) {
+ return null;
+ }
+ const value = history.past[history.past.length - 1];
+ return {
+ history: { past: history.past.slice(0, -1), future: [current, ...history.future] },
+ value,
+ };
+}
+
+/** Steps forward one snapshot, pushing `current` back onto the undo stack. Null when nothing to redo. */
+export function redo(history: History, current: T): HistoryStep | null {
+ if (history.future.length === 0) {
+ return null;
+ }
+ const value = history.future[0];
+ return {
+ history: { past: [...history.past, current], future: history.future.slice(1) },
+ value,
+ };
+}
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
index adfdc43..d051767 100644
--- a/src/app/i18n/en.ts
+++ b/src/app/i18n/en.ts
@@ -535,6 +535,8 @@ export const en: Translations = {
validationInvalidCss: 'A static page contains invalid CSS.',
validationDuplicateRoutes: 'Two or more pages share the same route.',
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
+ undo: 'Undo',
+ redo: 'Redo',
statusDraft: 'Draft',
statusPublished: 'Published',
unsavedChanges: 'Unsaved changes',
diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts
index 686c433..0b104d5 100644
--- a/src/app/i18n/hy.ts
+++ b/src/app/i18n/hy.ts
@@ -535,6 +535,8 @@ export const hy: Translations = {
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
+ undo: 'Հետարկել',
+ redo: 'Կրկնել',
statusDraft: 'Սևագիր',
statusPublished: 'Հրապարակված',
unsavedChanges: 'Չպահված փոփոխություններ',
diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts
index 2771d9f..99d8ef3 100644
--- a/src/app/i18n/ru.ts
+++ b/src/app/i18n/ru.ts
@@ -535,6 +535,8 @@ export const ru: Translations = {
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
+ undo: 'Отменить',
+ redo: 'Повторить',
statusDraft: 'Черновик',
statusPublished: 'Опубликовано',
unsavedChanges: 'Есть несохранённые изменения',
diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts
index 0a01842..b8e6756 100644
--- a/src/app/i18n/translations.ts
+++ b/src/app/i18n/translations.ts
@@ -533,6 +533,8 @@ export interface Translations {
validationInvalidCss: string;
validationDuplicateRoutes: string;
validationInvalidWidgetConfig: string;
+ undo: string;
+ redo: string;
statusDraft: string;
statusPublished: string;
unsavedChanges: string;