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;