Refine admin tooling and clean up legacy code
This commit is contained in:
parent
0fc1dc756e
commit
13f66a0bbe
@ -16,12 +16,12 @@
|
||||
### 2. **📏 Унифицированы размеры сайдбара и предпросмотра**
|
||||
**Проблема:** Разные размеры панелей создавали визуальную несогласованность.
|
||||
|
||||
**Исправление в `/src/components/admin/builder/BuilderLayout.tsx`:**
|
||||
**Исправление в макете билдера (`/src/app/admin/builder/[id]/page.tsx`):**
|
||||
- **Сайдбар:** `w-[360px]` (фиксированный)
|
||||
- **Предпросмотр:** `w-[360px]` (было `w-96` = 384px)
|
||||
- **Предпросмотр:** `w-[360px]`
|
||||
- **Оба:** `shrink-0` - не сжимаются
|
||||
|
||||
**Результат:** ✅ Одинаковые размеры боковых панелей - 360px
|
||||
**Результат:** ✅ Одинаковые размеры боковых панелей — 360px
|
||||
|
||||
### 3. **🎯 Предпросмотр больше не сжимается**
|
||||
**Проблема:** Предпросмотр мог сжиматься и терять пропорции.
|
||||
@ -33,23 +33,15 @@
|
||||
|
||||
**Результат:** ✅ Предпросмотр сохраняет размеры как заложено изначально
|
||||
|
||||
### 4. **⏪ Реализована современная система Undo/Redo**
|
||||
### 4. **⏪ Реализована рабочая система Undo/Redo**
|
||||
**Проблема:** Старые кнопки были заглушками и не работали.
|
||||
|
||||
**Исправление - Command Pattern вместо Memento:**
|
||||
- Создан `/src/lib/admin/builder/undoRedo.ts` - Command-based система
|
||||
- Создан `/src/lib/admin/builder/useBuilderUndoRedo.ts` - React интеграция
|
||||
- Обновлен `BuilderTopBar.tsx` с рабочими кнопками Undo/Redo
|
||||
**Исправление:**
|
||||
- Добавлен `BuilderUndoRedoProvider` на базе снепшотов состояния (`/src/lib/admin/builder/useSimpleUndoRedo.ts`)
|
||||
- Горячие клавиши Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z и Ctrl/Cmd+Y
|
||||
- Автоматическое сохранение ключевых изменений состояния
|
||||
|
||||
**Архитектурные принципы:**
|
||||
- ✅ **Command Pattern** - granular операции вместо снимков состояния
|
||||
- ✅ **Linear time history** - каждая операция имеет timestamp
|
||||
- ✅ **Session-scoped** - история привязана к сессии редактирования
|
||||
- ✅ **Keyboard shortcuts** - Ctrl+Z, Ctrl+Y, Ctrl+Shift+Z
|
||||
- ✅ **Conflict handling** - проверка возможности выполнения команд
|
||||
- ✅ **Memory management** - ограничение истории (100 операций)
|
||||
|
||||
**Результат:** 🔧 Основа готова, требует подключения к действиям редактора
|
||||
**Результат:** 🔧 Кнопки и горячие клавиши Undo/Redo работают и управляют историей изменений
|
||||
|
||||
## 🚀 Текущий статус:
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TextInput } from '@/components/ui/TextInput/TextInput';
|
||||
import {
|
||||
@ -49,6 +50,7 @@ export default function AdminCatalogPage() {
|
||||
const [funnels, setFunnels] = useState<FunnelListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Фильтры и поиск
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@ -68,7 +70,7 @@ export default function AdminCatalogPage() {
|
||||
// const [selectedFunnels, setSelectedFunnels] = useState<Set<string>>(new Set());
|
||||
|
||||
// Загрузка данных
|
||||
const loadFunnels = async (page: number = 1) => {
|
||||
const loadFunnels = useCallback(async (page: number = 1) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@ -89,20 +91,24 @@ export default function AdminCatalogPage() {
|
||||
|
||||
const data = await response.json();
|
||||
setFunnels(data.funnels);
|
||||
setPagination(data.pagination);
|
||||
setPagination({
|
||||
current: data.pagination.current,
|
||||
total: data.pagination.total,
|
||||
count: data.pagination.count,
|
||||
totalItems: data.pagination.totalItems
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, statusFilter, sortBy, sortOrder]);
|
||||
|
||||
// Эффекты
|
||||
useEffect(() => {
|
||||
loadFunnels();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery, statusFilter, sortBy, sortOrder]); // loadFunnels создается заново при каждом рендере
|
||||
loadFunnels(1);
|
||||
}, [loadFunnels]);
|
||||
|
||||
// Создание новой воронки
|
||||
const handleCreateFunnel = async () => {
|
||||
@ -159,7 +165,7 @@ export default function AdminCatalogPage() {
|
||||
const createdFunnel = await response.json();
|
||||
|
||||
// Переходим к редактированию новой воронки
|
||||
window.location.href = `/admin/builder/${createdFunnel._id}`;
|
||||
router.push(`/admin/builder/${createdFunnel._id}`);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create funnel');
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BuilderLayoutProps {
|
||||
className?: string;
|
||||
topBar?: ReactNode;
|
||||
sidebar?: ReactNode;
|
||||
canvas?: ReactNode;
|
||||
preview?: ReactNode;
|
||||
showPreview?: boolean;
|
||||
onTogglePreview?: () => void;
|
||||
}
|
||||
|
||||
export function BuilderLayout({
|
||||
className,
|
||||
topBar,
|
||||
sidebar,
|
||||
canvas,
|
||||
preview,
|
||||
showPreview = true,
|
||||
onTogglePreview
|
||||
}: BuilderLayoutProps) {
|
||||
return (
|
||||
<div className={cn("flex h-screen flex-col bg-muted/20", className)}>
|
||||
{topBar && <header className="border-b border-border/60 bg-background/80 backdrop-blur-sm">{topBar}</header>}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebar && (
|
||||
<aside className="w-[360px] shrink-0 overflow-y-auto border-r border-border/60 bg-background/95">
|
||||
{sidebar}
|
||||
</aside>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="relative h-full w-full overflow-auto bg-slate-100 dark:bg-slate-900">
|
||||
{canvas}
|
||||
</div>
|
||||
</div>
|
||||
{showPreview && preview && (
|
||||
<div className="w-[360px] shrink-0 border-l border-border/60 bg-background/95">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">Предпросмотр</h3>
|
||||
{onTogglePreview && (
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Скрыть
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">{preview}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showPreview && onTogglePreview && (
|
||||
<div className="flex w-12 shrink-0 items-center justify-center border-l border-border/60 bg-background/95">
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Показать предпросмотр"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -25,18 +25,16 @@ interface BuilderTopBarProps {
|
||||
saving?: boolean;
|
||||
funnelInfo?: FunnelInfo;
|
||||
onLoadError?: (error: string) => void;
|
||||
onSaveSuccess?: () => void; // Коллбек для сброса isDirty после успешного сохранения
|
||||
}
|
||||
|
||||
export function BuilderTopBar({
|
||||
onNew,
|
||||
export function BuilderTopBar({
|
||||
onNew,
|
||||
onSave,
|
||||
onPublish,
|
||||
onBackToCatalog,
|
||||
saving,
|
||||
funnelInfo,
|
||||
onLoadError,
|
||||
onSaveSuccess
|
||||
onLoadError
|
||||
}: BuilderTopBarProps) {
|
||||
const dispatch = useBuilderDispatch();
|
||||
const state = useBuilderState();
|
||||
@ -84,8 +82,8 @@ export function BuilderTopBar({
|
||||
const handleSave = async () => {
|
||||
if (onSave && !saving) {
|
||||
const success = await onSave(state);
|
||||
if (success && onSaveSuccess) {
|
||||
onSaveSuccess(); // Сбрасываем isDirty после успешного сохранения
|
||||
if (success) {
|
||||
undoRedo.resetDirty();
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -94,7 +92,10 @@ export function BuilderTopBar({
|
||||
if (onPublish && !publishing && !saving) {
|
||||
setPublishing(true);
|
||||
try {
|
||||
await onPublish(state);
|
||||
const success = await onPublish(state);
|
||||
if (success) {
|
||||
undoRedo.resetDirty();
|
||||
}
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
|
||||
@ -1,331 +0,0 @@
|
||||
/**
|
||||
* Command-based Undo/Redo System for Builder
|
||||
*
|
||||
* Based on modern best practices from research on collaborative editing systems.
|
||||
* Uses Command pattern instead of Memento pattern for granular state changes.
|
||||
*/
|
||||
|
||||
// Command-based undo/redo system types
|
||||
import type { BuilderAction } from './context';
|
||||
|
||||
export interface UndoRedoCommand {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
description: string;
|
||||
|
||||
// Forward operation
|
||||
execute(): void;
|
||||
|
||||
// Reverse operation
|
||||
undo(): void;
|
||||
|
||||
// Optional: check if command can be applied
|
||||
canExecute?(): boolean;
|
||||
canUndo?(): boolean;
|
||||
}
|
||||
|
||||
export interface UndoRedoState {
|
||||
// Linear time-based history (not state-based)
|
||||
past: UndoRedoCommand[];
|
||||
future: UndoRedoCommand[];
|
||||
|
||||
// Current position in history
|
||||
currentIndex: number;
|
||||
|
||||
// Session tracking for cleanup
|
||||
sessionId: string;
|
||||
maxHistorySize: number;
|
||||
}
|
||||
|
||||
export interface UndoRedoActions {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
execute(command: UndoRedoCommand): void;
|
||||
clear(): void;
|
||||
|
||||
// History introspection
|
||||
getHistory(): { past: UndoRedoCommand[]; future: UndoRedoCommand[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new undo/redo manager instance
|
||||
*/
|
||||
export function createUndoRedoManager(
|
||||
sessionId: string,
|
||||
maxHistorySize: number = 100
|
||||
): UndoRedoActions {
|
||||
const state: UndoRedoState = {
|
||||
past: [],
|
||||
future: [],
|
||||
currentIndex: 0,
|
||||
sessionId,
|
||||
maxHistorySize,
|
||||
};
|
||||
|
||||
const canUndo = () => state.past.length > 0;
|
||||
const canRedo = () => state.future.length > 0;
|
||||
|
||||
const execute = (command: UndoRedoCommand) => {
|
||||
// Check if command can be executed
|
||||
if (command.canExecute && !command.canExecute()) {
|
||||
console.warn('Command cannot be executed:', command.description);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute the command
|
||||
command.execute();
|
||||
|
||||
// Add to history
|
||||
state.past.push(command);
|
||||
state.future = []; // Clear future when new command is executed
|
||||
state.currentIndex = state.past.length;
|
||||
|
||||
// Trim history if too large
|
||||
if (state.past.length > state.maxHistorySize) {
|
||||
state.past = state.past.slice(-state.maxHistorySize);
|
||||
state.currentIndex = state.past.length;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to execute command:', command.description, error);
|
||||
}
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
if (!canUndo()) return;
|
||||
|
||||
const command = state.past[state.past.length - 1];
|
||||
|
||||
// Check if command can be undone
|
||||
if (command.canUndo && !command.canUndo()) {
|
||||
console.warn('Command cannot be undone:', command.description);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Undo the command
|
||||
command.undo();
|
||||
|
||||
// Move command from past to future
|
||||
state.past.pop();
|
||||
state.future.unshift(command);
|
||||
state.currentIndex = state.past.length;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to undo command:', command.description, error);
|
||||
}
|
||||
};
|
||||
|
||||
const redo = () => {
|
||||
if (!canRedo()) return;
|
||||
|
||||
const command = state.future[0];
|
||||
|
||||
// Check if command can be re-executed
|
||||
if (command.canExecute && !command.canExecute()) {
|
||||
console.warn('Command cannot be re-executed:', command.description);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-execute the command
|
||||
command.execute();
|
||||
|
||||
// Move command from future to past
|
||||
state.future.shift();
|
||||
state.past.push(command);
|
||||
state.currentIndex = state.past.length;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to redo command:', command.description, error);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
state.past = [];
|
||||
state.future = [];
|
||||
state.currentIndex = 0;
|
||||
};
|
||||
|
||||
const getHistory = () => ({
|
||||
past: [...state.past],
|
||||
future: [...state.future],
|
||||
});
|
||||
|
||||
return {
|
||||
get canUndo() { return canUndo(); },
|
||||
get canRedo() { return canRedo(); },
|
||||
undo,
|
||||
redo,
|
||||
execute,
|
||||
clear,
|
||||
getHistory,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for common builder commands
|
||||
*/
|
||||
export class BuilderCommands {
|
||||
constructor(private dispatch: (action: BuilderAction) => void) {}
|
||||
|
||||
/**
|
||||
* Update screen property command
|
||||
*/
|
||||
updateScreen(
|
||||
screenId: string,
|
||||
property: string,
|
||||
newValue: unknown,
|
||||
oldValue: unknown
|
||||
): UndoRedoCommand {
|
||||
return {
|
||||
id: `update-screen-${screenId}-${property}-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
type: 'update-screen',
|
||||
description: `Update ${property} in screen ${screenId}`,
|
||||
|
||||
execute: () => {
|
||||
this.dispatch({
|
||||
type: 'UPDATE_SCREEN',
|
||||
payload: { screenId, updates: { [property]: newValue } }
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
this.dispatch({
|
||||
type: 'UPDATE_SCREEN',
|
||||
payload: { screenId, updates: { [property]: oldValue } }
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add screen command
|
||||
*/
|
||||
addScreen(screen: Record<string, unknown>, position: number): UndoRedoCommand {
|
||||
return {
|
||||
id: `add-screen-${screen.id}-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
type: 'add-screen',
|
||||
description: `Add screen ${screen.id}`,
|
||||
|
||||
execute: () => {
|
||||
this.dispatch({
|
||||
type: 'ADD_SCREEN',
|
||||
payload: { screen, position }
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
this.dispatch({
|
||||
type: 'REMOVE_SCREEN',
|
||||
payload: { screenId: screen.id }
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove screen command
|
||||
*/
|
||||
removeScreen(screenId: string, screenData: Record<string, unknown>, position: number): UndoRedoCommand {
|
||||
return {
|
||||
id: `remove-screen-${screenId}-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
type: 'remove-screen',
|
||||
description: `Remove screen ${screenId}`,
|
||||
|
||||
execute: () => {
|
||||
this.dispatch({
|
||||
type: 'REMOVE_SCREEN',
|
||||
payload: { screenId }
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
this.dispatch({
|
||||
type: 'ADD_SCREEN',
|
||||
payload: { screen: screenData, position }
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move screen command
|
||||
*/
|
||||
moveScreen(screenId: string, fromPosition: number, toPosition: number): UndoRedoCommand {
|
||||
return {
|
||||
id: `move-screen-${screenId}-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
type: 'move-screen',
|
||||
description: `Move screen ${screenId} from ${fromPosition} to ${toPosition}`,
|
||||
|
||||
execute: () => {
|
||||
this.dispatch({
|
||||
type: 'MOVE_SCREEN',
|
||||
payload: { screenId, fromPosition, toPosition }
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
this.dispatch({
|
||||
type: 'MOVE_SCREEN',
|
||||
payload: { screenId, fromPosition: toPosition, toPosition: fromPosition }
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch multiple commands into one
|
||||
*/
|
||||
batch(commands: UndoRedoCommand[], description: string): UndoRedoCommand {
|
||||
return {
|
||||
id: `batch-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
type: 'batch',
|
||||
description,
|
||||
|
||||
execute: () => {
|
||||
commands.forEach(cmd => cmd.execute());
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
// Undo in reverse order
|
||||
commands.slice().reverse().forEach(cmd => cmd.undo());
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for using undo/redo in components
|
||||
*/
|
||||
export function useUndoRedo(dispatch: (action: BuilderAction) => void, sessionId?: string) {
|
||||
const manager = createUndoRedoManager(sessionId || `session-${Date.now()}`);
|
||||
const commands = new BuilderCommands(dispatch);
|
||||
|
||||
return {
|
||||
// Core actions
|
||||
canUndo: manager.canUndo,
|
||||
canRedo: manager.canRedo,
|
||||
undo: manager.undo,
|
||||
redo: manager.redo,
|
||||
clear: manager.clear,
|
||||
|
||||
// Command execution
|
||||
execute: manager.execute,
|
||||
|
||||
// Command factories
|
||||
commands,
|
||||
|
||||
// History introspection
|
||||
getHistory: manager.getHistory,
|
||||
};
|
||||
}
|
||||
@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Integration hook for undo/redo functionality in the builder
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useBuilderDispatch, type BuilderAction } from './context';
|
||||
import { useUndoRedo, type UndoRedoCommand } from './undoRedo';
|
||||
|
||||
export interface BuilderUndoRedoActions {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
|
||||
// High-level command shortcuts
|
||||
updateScreenProperty(screenId: string, property: string, newValue: unknown, oldValue: unknown): void;
|
||||
addScreen(screen: Record<string, unknown>, position: number): void;
|
||||
removeScreen(screenId: string, screenData: Record<string, unknown>, position: number): void;
|
||||
moveScreen(screenId: string, fromPosition: number, toPosition: number): void;
|
||||
|
||||
// Batch operations
|
||||
batchCommands(commands: UndoRedoCommand[], description: string): void;
|
||||
|
||||
// History management
|
||||
clearHistory(): void;
|
||||
getHistoryInfo(): { pastCount: number; futureCount: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook for builder undo/redo functionality
|
||||
*/
|
||||
export function useBuilderUndoRedo(sessionId?: string): BuilderUndoRedoActions {
|
||||
const dispatch = useBuilderDispatch();
|
||||
const undoRedo = useUndoRedo(dispatch, sessionId);
|
||||
|
||||
const actions = useMemo(() => ({
|
||||
canUndo: undoRedo.canUndo,
|
||||
canRedo: undoRedo.canRedo,
|
||||
undo: undoRedo.undo,
|
||||
redo: undoRedo.redo,
|
||||
|
||||
updateScreenProperty: (screenId: string, property: string, newValue: unknown, oldValue: unknown) => {
|
||||
const command = undoRedo.commands.updateScreen(screenId, property, newValue, oldValue);
|
||||
undoRedo.execute(command);
|
||||
},
|
||||
|
||||
addScreen: (screen: Record<string, unknown>, position: number) => {
|
||||
const command = undoRedo.commands.addScreen(screen, position);
|
||||
undoRedo.execute(command);
|
||||
},
|
||||
|
||||
removeScreen: (screenId: string, screenData: Record<string, unknown>, position: number) => {
|
||||
const command = undoRedo.commands.removeScreen(screenId, screenData, position);
|
||||
undoRedo.execute(command);
|
||||
},
|
||||
|
||||
moveScreen: (screenId: string, fromPosition: number, toPosition: number) => {
|
||||
const command = undoRedo.commands.moveScreen(screenId, fromPosition, toPosition);
|
||||
undoRedo.execute(command);
|
||||
},
|
||||
|
||||
batchCommands: (commands: UndoRedoCommand[], description: string) => {
|
||||
const batchCommand = undoRedo.commands.batch(commands, description);
|
||||
undoRedo.execute(batchCommand);
|
||||
},
|
||||
|
||||
clearHistory: () => {
|
||||
undoRedo.clear();
|
||||
},
|
||||
|
||||
getHistoryInfo: () => {
|
||||
const history = undoRedo.getHistory();
|
||||
return {
|
||||
pastCount: history.past.length,
|
||||
futureCount: history.future.length,
|
||||
};
|
||||
},
|
||||
}), [undoRedo]);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for keyboard shortcuts (Ctrl+Z, Ctrl+Y, Ctrl+Shift+Z)
|
||||
*/
|
||||
export function useBuilderUndoRedoShortcuts(undoRedo: BuilderUndoRedoActions) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Only handle shortcuts when not in input elements
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement ||
|
||||
(event.target as HTMLElement)?.contentEditable === 'true'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (isCtrlOrCmd && event.key === 'z') {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Ctrl+Shift+Z or Cmd+Shift+Z for redo
|
||||
if (undoRedo.canRedo) {
|
||||
undoRedo.redo();
|
||||
}
|
||||
} else {
|
||||
// Ctrl+Z or Cmd+Z for undo
|
||||
if (undoRedo.canUndo) {
|
||||
undoRedo.undo();
|
||||
}
|
||||
}
|
||||
} else if (isCtrlOrCmd && event.key === 'y') {
|
||||
// Ctrl+Y for redo (Windows standard)
|
||||
event.preventDefault();
|
||||
if (undoRedo.canRedo) {
|
||||
undoRedo.redo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [undoRedo]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-level hook that combines undo/redo with keyboard shortcuts
|
||||
*/
|
||||
export function useBuilderUndoRedoWithShortcuts(sessionId?: string): BuilderUndoRedoActions {
|
||||
const undoRedo = useBuilderUndoRedo(sessionId);
|
||||
|
||||
// Enable keyboard shortcuts
|
||||
useBuilderUndoRedoShortcuts(undoRedo);
|
||||
|
||||
return undoRedo;
|
||||
}
|
||||
@ -6,6 +6,14 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { BuilderState } from './context';
|
||||
|
||||
function cloneState<T>(value: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
interface UndoRedoHook {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
@ -28,7 +36,7 @@ export function useSimpleUndoRedo(
|
||||
|
||||
const store = useCallback(() => {
|
||||
// Deep clone the state to prevent mutations
|
||||
const stateSnapshot = JSON.parse(JSON.stringify(currentState));
|
||||
const stateSnapshot = cloneState(currentState);
|
||||
|
||||
setUndoStack(prev => {
|
||||
const newStack = [...prev, stateSnapshot];
|
||||
@ -49,9 +57,9 @@ export function useSimpleUndoRedo(
|
||||
if (!canUndo) return;
|
||||
|
||||
const lastState = undoStack[undoStack.length - 1];
|
||||
|
||||
|
||||
// Move current state to redo stack
|
||||
const currentSnapshot = JSON.parse(JSON.stringify(currentState));
|
||||
const currentSnapshot = cloneState(currentState);
|
||||
setRedoStack(prev => [...prev, currentSnapshot]);
|
||||
|
||||
// Remove last state from undo stack
|
||||
@ -65,9 +73,9 @@ export function useSimpleUndoRedo(
|
||||
if (!canRedo) return;
|
||||
|
||||
const lastRedoState = redoStack[redoStack.length - 1];
|
||||
|
||||
|
||||
// Move current state to undo stack
|
||||
const currentSnapshot = JSON.parse(JSON.stringify(currentState));
|
||||
const currentSnapshot = cloneState(currentState);
|
||||
setUndoStack(prev => [...prev, currentSnapshot]);
|
||||
|
||||
// Remove last state from redo stack
|
||||
|
||||
@ -1,208 +0,0 @@
|
||||
import { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import type { BuilderState } from '@/lib/admin/builder/context';
|
||||
|
||||
interface UndoRedoState {
|
||||
past: BuilderState[];
|
||||
present: BuilderState;
|
||||
future: BuilderState[];
|
||||
}
|
||||
|
||||
interface UndoRedoHook {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
addState: (state: BuilderState) => void;
|
||||
clearHistory: () => void;
|
||||
reset: (initialState: BuilderState) => void;
|
||||
markAsBaseline: () => void;
|
||||
hasUnsavedChanges: () => boolean;
|
||||
present: BuilderState;
|
||||
}
|
||||
|
||||
const MAX_HISTORY_SIZE = 50; // Максимальное количество шагов в истории
|
||||
|
||||
export function useUndoRedo(initialState: BuilderState): UndoRedoHook {
|
||||
const [undoRedoState, setUndoRedoState] = useState<UndoRedoState>({
|
||||
past: [],
|
||||
present: initialState,
|
||||
future: []
|
||||
});
|
||||
|
||||
// Ref для отслеживания базовых точек (сохранено в БД)
|
||||
const baselineIndices = useRef<Set<number>>(new Set());
|
||||
const currentIndex = useRef(0);
|
||||
|
||||
const canUndo = undoRedoState.past.length > 0;
|
||||
const canRedo = undoRedoState.future.length > 0;
|
||||
|
||||
// Добавить новое состояние в историю
|
||||
const addState = useCallback((newState: BuilderState) => {
|
||||
// Проверяем, изменилось ли состояние реально
|
||||
if (JSON.stringify(newState) === JSON.stringify(undoRedoState.present)) {
|
||||
return; // Не добавляем дубликаты
|
||||
}
|
||||
|
||||
setUndoRedoState(prevState => {
|
||||
const newPast = [...prevState.past, prevState.present];
|
||||
|
||||
// Ограничиваем размер истории
|
||||
const trimmedPast = newPast.length > MAX_HISTORY_SIZE
|
||||
? newPast.slice(newPast.length - MAX_HISTORY_SIZE)
|
||||
: newPast;
|
||||
|
||||
// Обновляем индекс и очищаем future
|
||||
currentIndex.current = trimmedPast.length;
|
||||
|
||||
return {
|
||||
past: trimmedPast,
|
||||
present: { ...newState, isDirty: true },
|
||||
future: [] // Очищаем future при добавлении нового состояния
|
||||
};
|
||||
});
|
||||
}, [undoRedoState.present]);
|
||||
|
||||
// Отменить последнее действие
|
||||
const undo = useCallback(() => {
|
||||
if (!canUndo) return;
|
||||
|
||||
setUndoRedoState(prevState => {
|
||||
const previous = prevState.past[prevState.past.length - 1];
|
||||
const newPast = prevState.past.slice(0, prevState.past.length - 1);
|
||||
|
||||
currentIndex.current = newPast.length;
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: { ...previous, isDirty: true },
|
||||
future: [prevState.present, ...prevState.future]
|
||||
};
|
||||
});
|
||||
}, [canUndo]);
|
||||
|
||||
// Повторить отмененное действие
|
||||
const redo = useCallback(() => {
|
||||
if (!canRedo) return;
|
||||
|
||||
setUndoRedoState(prevState => {
|
||||
const next = prevState.future[0];
|
||||
const newFuture = prevState.future.slice(1);
|
||||
|
||||
currentIndex.current = prevState.past.length + 1;
|
||||
|
||||
return {
|
||||
past: [...prevState.past, prevState.present],
|
||||
present: { ...next, isDirty: true },
|
||||
future: newFuture
|
||||
};
|
||||
});
|
||||
}, [canRedo]);
|
||||
|
||||
// Очистить историю
|
||||
const clearHistory = useCallback(() => {
|
||||
setUndoRedoState(prevState => ({
|
||||
past: [],
|
||||
present: prevState.present,
|
||||
future: []
|
||||
}));
|
||||
baselineIndices.current.clear();
|
||||
currentIndex.current = 0;
|
||||
}, []);
|
||||
|
||||
// Сброс к начальному состоянию
|
||||
const reset = useCallback((newInitialState: BuilderState) => {
|
||||
setUndoRedoState({
|
||||
past: [],
|
||||
present: newInitialState,
|
||||
future: []
|
||||
});
|
||||
baselineIndices.current.clear();
|
||||
currentIndex.current = 0;
|
||||
}, []);
|
||||
|
||||
// Отмечаем текущую позицию как базовую при сохранении
|
||||
const markAsBaseline = useCallback(() => {
|
||||
baselineIndices.current.add(currentIndex.current);
|
||||
}, []);
|
||||
|
||||
// Проверяем, есть ли несохраненные изменения после последней базовой точки
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
const lastBaselineIndex = Math.max(...Array.from(baselineIndices.current), -1);
|
||||
return currentIndex.current > lastBaselineIndex;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
canUndo,
|
||||
canRedo,
|
||||
undo,
|
||||
redo,
|
||||
addState,
|
||||
clearHistory,
|
||||
reset,
|
||||
// Дополнительные методы для работы с базовыми точками
|
||||
markAsBaseline,
|
||||
hasUnsavedChanges,
|
||||
// Текущее состояние
|
||||
present: undoRedoState.present
|
||||
};
|
||||
}
|
||||
|
||||
// Хук для интеграции с BuilderContext
|
||||
export function useBuilderUndoRedo(
|
||||
builderState: BuilderState,
|
||||
dispatch: (action: { type: string; payload?: BuilderState }) => void
|
||||
) {
|
||||
const {
|
||||
canUndo,
|
||||
canRedo,
|
||||
undo: undoState,
|
||||
redo: redoState,
|
||||
addState,
|
||||
markAsBaseline,
|
||||
hasUnsavedChanges,
|
||||
present
|
||||
} = useUndoRedo(builderState);
|
||||
|
||||
// Синхронизируем состояние билдера с undo/redo
|
||||
useEffect(() => {
|
||||
if (present && present !== builderState) {
|
||||
dispatch({ type: 'reset', payload: present });
|
||||
}
|
||||
}, [present, builderState, dispatch]);
|
||||
|
||||
// Обертки для undo/redo с диспатчем
|
||||
const undo = useCallback(() => {
|
||||
undoState();
|
||||
}, [undoState]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
redoState();
|
||||
}, [redoState]);
|
||||
|
||||
// Сохранение состояния в историю при изменениях
|
||||
const saveStateToHistory = useCallback(() => {
|
||||
if (builderState.isDirty) {
|
||||
addState({ ...builderState, isDirty: false });
|
||||
}
|
||||
}, [builderState, addState]);
|
||||
|
||||
// Отметка базовой точки при сохранении в БД
|
||||
const markSaved = useCallback(() => {
|
||||
markAsBaseline();
|
||||
// Также сбрасываем флаг isDirty
|
||||
dispatch({
|
||||
type: 'reset',
|
||||
payload: { ...builderState, isDirty: false }
|
||||
});
|
||||
}, [markAsBaseline, builderState, dispatch]);
|
||||
|
||||
return {
|
||||
canUndo,
|
||||
canRedo,
|
||||
undo,
|
||||
redo,
|
||||
saveStateToHistory,
|
||||
markSaved,
|
||||
hasUnsavedChanges: hasUnsavedChanges()
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user