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:
@@ -5,6 +5,6 @@
|
|||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
[ariaCurrentWhenActive]="'page'"
|
[ariaCurrentWhenActive]="'page'"
|
||||||
class="editor-nav-link"
|
class="editor-nav-link"
|
||||||
>{{ section.label | translate }}@if (issueCount(section.id); as count) {<span class="editor-nav-badge" [attr.aria-label]="count + ' ' + ('builder.title' | translate)">{{ count }}</span>}</a>
|
>{{ section.label | translate }}@if (issueCount(section.id); as count) {<span class="editor-nav-badge" [attr.aria-label]="count + ' ' + ('builder.title' | translate)">{{ count }}</span>} @else if (isModified(section.id)) {<span class="editor-nav-dot" [attr.aria-label]="'builder.unsavedChanges' | translate"></span>}</a>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -58,6 +58,20 @@
|
|||||||
color: var(--error-color, #ef4444);
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.editor-nav-link {
|
.editor-nav-link {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
|||||||
export class ProjectEditorNavComponent {
|
export class ProjectEditorNavComponent {
|
||||||
private readonly facade = inject(ProjectEditorFacade);
|
private readonly facade = inject(ProjectEditorFacade);
|
||||||
private readonly issuesBySection = this.facade.issuesBySection;
|
private readonly issuesBySection = this.facade.issuesBySection;
|
||||||
|
private readonly modifiedSections = this.facade.modifiedSections;
|
||||||
|
|
||||||
/** Count of blocking issues in a section, for the nav badge. */
|
/** Count of blocking issues in a section, for the nav badge. */
|
||||||
issueCount(sectionId: ProjectEditorSectionId): number {
|
issueCount(sectionId: ProjectEditorSectionId): number {
|
||||||
return this.issuesBySection().get(sectionId) ?? 0;
|
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 }> = [
|
readonly sections: Array<{ id: ProjectEditorSectionId; label: string }> = [
|
||||||
{ id: 'general', label: 'builder.general' },
|
{ id: 'general', label: 'builder.general' },
|
||||||
{ id: 'branding', label: 'builder.branding' },
|
{ id: 'branding', label: 'builder.branding' },
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="project-editor-save-bar-actions">
|
<div class="project-editor-save-bar-actions">
|
||||||
|
<app-button variant="ghost" size="sm" [disabled]="!canUndo()" (click)="undo()">{{ 'builder.undo' | translate }}</app-button>
|
||||||
|
<app-button variant="ghost" size="sm" [disabled]="!canRedo()" (click)="redo()">{{ 'builder.redo' | translate }}</app-button>
|
||||||
<app-button variant="danger" size="sm" (click)="resetDraft()">{{ 'builder.resetDraft' | translate }}</app-button>
|
<app-button variant="danger" size="sm" (click)="resetDraft()">{{ 'builder.resetDraft' | translate }}</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="save()">{{ 'builder.save' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="save()">{{ 'builder.save' | translate }}</app-button>
|
||||||
<app-button variant="primary" size="sm" [disabled]="hasBlockingIssues()" (click)="publish()">{{ 'builder.publish' | translate }}</app-button>
|
<app-button variant="primary" size="sm" [disabled]="hasBlockingIssues()" (click)="publish()">{{ 'builder.publish' | translate }}</app-button>
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export class ProjectEditorSaveBarComponent {
|
|||||||
readonly status = this.facade.status;
|
readonly status = this.facade.status;
|
||||||
readonly issues = this.facade.validationIssues;
|
readonly issues = this.facade.validationIssues;
|
||||||
readonly hasBlockingIssues = this.facade.hasBlockingIssues;
|
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 lastSavedAt = this.facade.lastSavedAt;
|
||||||
readonly draftRestored = this.facade.draftRestored;
|
readonly draftRestored = this.facade.draftRestored;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { ProjectEditorState } from '../models/project-editor.model';
|
|||||||
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
|
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
|
||||||
import { ProjectValidator, ProjectValidationIssue } from '../services/project-validator.service';
|
import { ProjectValidator, ProjectValidationIssue } from '../services/project-validator.service';
|
||||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
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 { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
|
||||||
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
||||||
|
|
||||||
@@ -22,6 +26,12 @@ export class ProjectEditorFacade {
|
|||||||
private readonly runtime = inject(PlatformRuntimeService);
|
private readonly runtime = inject(PlatformRuntimeService);
|
||||||
private readonly validator = inject(ProjectValidator);
|
private readonly validator = inject(ProjectValidator);
|
||||||
private readonly draftStorage = inject(ProjectEditorDraftStorageService);
|
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>({
|
private readonly state = signal<ProjectEditorState>({
|
||||||
bootstrap: null,
|
bootstrap: null,
|
||||||
@@ -85,6 +95,34 @@ export class ProjectEditorFacade {
|
|||||||
}
|
}
|
||||||
return JSON.stringify(current) !== JSON.stringify(this.state().lastSavedBootstrap);
|
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 {
|
loadBootstrap(): void {
|
||||||
this.configService.loadBootstrap(true).pipe(take(1)).subscribe({
|
this.configService.loadBootstrap(true).pipe(take(1)).subscribe({
|
||||||
@@ -103,6 +141,7 @@ export class ProjectEditorFacade {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
draftRestored: restoredFromDraft,
|
draftRestored: restoredFromDraft,
|
||||||
}));
|
}));
|
||||||
|
this.clearHistory();
|
||||||
},
|
},
|
||||||
error: () => this.state.update(current => ({ ...current, bootstrap: null, importError: 'builder.importError' })),
|
error: () => this.state.update(current => ({ ...current, bootstrap: null, importError: 'builder.importError' })),
|
||||||
});
|
});
|
||||||
@@ -114,9 +153,80 @@ export class ProjectEditorFacade {
|
|||||||
return;
|
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));
|
const next = this.normalize(updater(JSON.parse(JSON.stringify(current)) as BootstrapConfig));
|
||||||
this.state.update(state => ({ ...state, bootstrap: next, draftRestored: false }));
|
this.state.update(state => ({ ...state, bootstrap: next, draftRestored: false }));
|
||||||
this.draftStorage.save(next);
|
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`. */
|
/** 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);
|
const reset = this.normalize(JSON.parse(JSON.stringify(original)) as BootstrapConfig);
|
||||||
this.draftStorage.clear();
|
this.draftStorage.clear();
|
||||||
this.state.update(state => ({ ...state, bootstrap: reset, lastSavedAt: null, draftRestored: false }));
|
this.state.update(state => ({ ...state, bootstrap: reset, lastSavedAt: null, draftRestored: false }));
|
||||||
|
this.clearHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(): boolean {
|
publish(): boolean {
|
||||||
@@ -223,6 +334,7 @@ export class ProjectEditorFacade {
|
|||||||
lastSavedAt: savedAt,
|
lastSavedAt: savedAt,
|
||||||
lastPublishedAt: savedAt,
|
lastPublishedAt: savedAt,
|
||||||
}));
|
}));
|
||||||
|
this.clearHistory();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,4 +85,29 @@ export class ProjectEditorPageComponent {
|
|||||||
event.returnValue = '';
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/app/features/project-editor/schema/history.util.spec.ts
Normal file
60
src/app/features/project-editor/schema/history.util.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { commit, emptyHistory, redo, undo } from './history.util';
|
||||||
|
|
||||||
|
describe('history reducer', () => {
|
||||||
|
it('starts empty', () => {
|
||||||
|
const history = emptyHistory<number>();
|
||||||
|
expect(history.past).toEqual([]);
|
||||||
|
expect(history.future).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commit records an undo point and clears the redo stack', () => {
|
||||||
|
let history = emptyHistory<number>();
|
||||||
|
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<number>();
|
||||||
|
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<number>(), 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<number>(), 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/app/features/project-editor/schema/history.util.ts
Normal file
52
src/app/features/project-editor/schema/history.util.ts
Normal file
@@ -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<T> {
|
||||||
|
readonly past: readonly T[];
|
||||||
|
readonly future: readonly T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryStep<T> {
|
||||||
|
readonly history: History<T>;
|
||||||
|
readonly value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyHistory<T>(): History<T> {
|
||||||
|
return { past: [], future: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a snapshot as a new undo point and drops the redo stack. Caps depth at `limit`. */
|
||||||
|
export function commit<T>(history: History<T>, snapshot: T, limit = HISTORY_LIMIT): History<T> {
|
||||||
|
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<T>(history: History<T>, current: T): HistoryStep<T> | 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<T>(history: History<T>, current: T): HistoryStep<T> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -535,6 +535,8 @@ export const en: Translations = {
|
|||||||
validationInvalidCss: 'A static page contains invalid CSS.',
|
validationInvalidCss: 'A static page contains invalid CSS.',
|
||||||
validationDuplicateRoutes: 'Two or more pages share the same route.',
|
validationDuplicateRoutes: 'Two or more pages share the same route.',
|
||||||
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
|
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
|
||||||
|
undo: 'Undo',
|
||||||
|
redo: 'Redo',
|
||||||
statusDraft: 'Draft',
|
statusDraft: 'Draft',
|
||||||
statusPublished: 'Published',
|
statusPublished: 'Published',
|
||||||
unsavedChanges: 'Unsaved changes',
|
unsavedChanges: 'Unsaved changes',
|
||||||
|
|||||||
@@ -535,6 +535,8 @@ export const hy: Translations = {
|
|||||||
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
||||||
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
||||||
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
||||||
|
undo: 'Հետարկել',
|
||||||
|
redo: 'Կրկնել',
|
||||||
statusDraft: 'Սևագիր',
|
statusDraft: 'Սևագիր',
|
||||||
statusPublished: 'Հրապարակված',
|
statusPublished: 'Հրապարակված',
|
||||||
unsavedChanges: 'Չպահված փոփոխություններ',
|
unsavedChanges: 'Չպահված փոփոխություններ',
|
||||||
|
|||||||
@@ -535,6 +535,8 @@ export const ru: Translations = {
|
|||||||
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
||||||
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
||||||
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
||||||
|
undo: 'Отменить',
|
||||||
|
redo: 'Повторить',
|
||||||
statusDraft: 'Черновик',
|
statusDraft: 'Черновик',
|
||||||
statusPublished: 'Опубликовано',
|
statusPublished: 'Опубликовано',
|
||||||
unsavedChanges: 'Есть несохранённые изменения',
|
unsavedChanges: 'Есть несохранённые изменения',
|
||||||
|
|||||||
@@ -533,6 +533,8 @@ export interface Translations {
|
|||||||
validationInvalidCss: string;
|
validationInvalidCss: string;
|
||||||
validationDuplicateRoutes: string;
|
validationDuplicateRoutes: string;
|
||||||
validationInvalidWidgetConfig: string;
|
validationInvalidWidgetConfig: string;
|
||||||
|
undo: string;
|
||||||
|
redo: string;
|
||||||
statusDraft: string;
|
statusDraft: string;
|
||||||
statusPublished: string;
|
statusPublished: string;
|
||||||
unsavedChanges: string;
|
unsavedChanges: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user