840 lines
28 KiB
Python
840 lines
28 KiB
Python
"""
|
|
Command pattern implementation for undo/redo functionality
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from typing import Dict, Any, List, Optional
|
|
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
|
|
|
|
|
def _normalize_asset_path(image_path: str, asset_manager) -> str:
|
|
"""
|
|
Convert absolute path to relative for asset manager.
|
|
|
|
Args:
|
|
image_path: Image path (absolute or relative)
|
|
asset_manager: AssetManager instance
|
|
|
|
Returns:
|
|
Relative path suitable for asset manager
|
|
"""
|
|
if not asset_manager or not image_path:
|
|
return image_path
|
|
|
|
import os
|
|
if os.path.isabs(image_path):
|
|
return os.path.relpath(image_path, asset_manager.project_folder)
|
|
return image_path
|
|
|
|
|
|
class Command(ABC):
|
|
"""Abstract base class for all commands"""
|
|
|
|
@abstractmethod
|
|
def execute(self):
|
|
"""Execute the command"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def undo(self):
|
|
"""Undo the command"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def redo(self):
|
|
"""Redo the command (default implementation calls execute)"""
|
|
self.execute()
|
|
|
|
@abstractmethod
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize command to dictionary for saving"""
|
|
pass
|
|
|
|
@staticmethod
|
|
@abstractmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'Command':
|
|
"""Deserialize command from dictionary"""
|
|
pass
|
|
|
|
|
|
class AddElementCommand(Command):
|
|
"""Command for adding an element to a page"""
|
|
|
|
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
|
self.page_layout = page_layout
|
|
self.element = element
|
|
self.executed = False
|
|
self.asset_manager = asset_manager
|
|
|
|
# Acquire reference to asset when command is created
|
|
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
|
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
|
self.asset_manager.acquire_reference(rel_path)
|
|
|
|
def execute(self):
|
|
"""Add the element to the page"""
|
|
if not self.executed:
|
|
self.page_layout.add_element(self.element)
|
|
self.executed = True
|
|
|
|
def undo(self):
|
|
"""Remove the element from the page"""
|
|
if self.executed:
|
|
self.page_layout.remove_element(self.element)
|
|
self.executed = False
|
|
|
|
def redo(self):
|
|
"""Re-add the element"""
|
|
self.execute()
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
return {
|
|
"type": "add_element",
|
|
"element": self.element.serialize(),
|
|
"executed": self.executed
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand':
|
|
"""Deserialize from dictionary"""
|
|
# Reconstruct element from serialized data
|
|
elem_data = data["element"]
|
|
elem_type = elem_data.get("type")
|
|
|
|
if elem_type == "image":
|
|
element = ImageData()
|
|
elif elem_type == "placeholder":
|
|
element = PlaceholderData()
|
|
elif elem_type == "textbox":
|
|
element = TextBoxData()
|
|
else:
|
|
raise ValueError(f"Unknown element type: {elem_type}")
|
|
|
|
element.deserialize(elem_data)
|
|
|
|
# Note: We need to find the correct page_layout
|
|
# This will be handled by the CommandHistory deserializer
|
|
cmd = AddElementCommand(None, element)
|
|
cmd.executed = data.get("executed", False)
|
|
return cmd
|
|
|
|
|
|
class DeleteElementCommand(Command):
|
|
"""Command for deleting an element from a page"""
|
|
|
|
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
|
self.page_layout = page_layout
|
|
self.element = element
|
|
self.executed = False
|
|
self.asset_manager = asset_manager
|
|
|
|
# Acquire reference to asset to keep it alive while in undo history
|
|
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
|
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
|
self.asset_manager.acquire_reference(rel_path)
|
|
|
|
def execute(self):
|
|
"""Remove the element from the page"""
|
|
if not self.executed:
|
|
self.page_layout.remove_element(self.element)
|
|
self.executed = True
|
|
|
|
def undo(self):
|
|
"""Re-add the element to the page"""
|
|
if self.executed:
|
|
self.page_layout.add_element(self.element)
|
|
self.executed = False
|
|
|
|
def redo(self):
|
|
"""Re-remove the element"""
|
|
self.execute()
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
return {
|
|
"type": "delete_element",
|
|
"element": self.element.serialize(),
|
|
"executed": self.executed
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand':
|
|
"""Deserialize from dictionary"""
|
|
elem_data = data["element"]
|
|
elem_type = elem_data.get("type")
|
|
|
|
if elem_type == "image":
|
|
element = ImageData()
|
|
elif elem_type == "placeholder":
|
|
element = PlaceholderData()
|
|
elif elem_type == "textbox":
|
|
element = TextBoxData()
|
|
else:
|
|
raise ValueError(f"Unknown element type: {elem_type}")
|
|
|
|
element.deserialize(elem_data)
|
|
|
|
cmd = DeleteElementCommand(None, element)
|
|
cmd.executed = data.get("executed", False)
|
|
return cmd
|
|
|
|
|
|
class MoveElementCommand(Command):
|
|
"""Command for moving an element"""
|
|
|
|
def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
|
|
self.element = element
|
|
self.old_position = old_position
|
|
self.new_position = new_position
|
|
|
|
def execute(self):
|
|
"""Move element to new position"""
|
|
self.element.position = self.new_position
|
|
|
|
def undo(self):
|
|
"""Move element back to old position"""
|
|
self.element.position = self.old_position
|
|
|
|
def redo(self):
|
|
"""Move element to new position again"""
|
|
self.execute()
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
return {
|
|
"type": "move_element",
|
|
"element": self.element.serialize(),
|
|
"old_position": self.old_position,
|
|
"new_position": self.new_position
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand':
|
|
"""Deserialize from dictionary"""
|
|
elem_data = data["element"]
|
|
elem_type = elem_data.get("type")
|
|
|
|
if elem_type == "image":
|
|
element = ImageData()
|
|
elif elem_type == "placeholder":
|
|
element = PlaceholderData()
|
|
elif elem_type == "textbox":
|
|
element = TextBoxData()
|
|
else:
|
|
raise ValueError(f"Unknown element type: {elem_type}")
|
|
|
|
element.deserialize(elem_data)
|
|
|
|
return MoveElementCommand(
|
|
element,
|
|
tuple(data["old_position"]),
|
|
tuple(data["new_position"])
|
|
)
|
|
|
|
|
|
class ResizeElementCommand(Command):
|
|
"""Command for resizing an element"""
|
|
|
|
def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple,
|
|
new_position: tuple, new_size: tuple):
|
|
self.element = element
|
|
self.old_position = old_position
|
|
self.old_size = old_size
|
|
self.new_position = new_position
|
|
self.new_size = new_size
|
|
|
|
def execute(self):
|
|
"""Resize element to new size"""
|
|
self.element.position = self.new_position
|
|
self.element.size = self.new_size
|
|
|
|
def undo(self):
|
|
"""Resize element back to old size"""
|
|
self.element.position = self.old_position
|
|
self.element.size = self.old_size
|
|
|
|
def redo(self):
|
|
"""Resize element to new size again"""
|
|
self.execute()
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
return {
|
|
"type": "resize_element",
|
|
"element": self.element.serialize(),
|
|
"old_position": self.old_position,
|
|
"old_size": self.old_size,
|
|
"new_position": self.new_position,
|
|
"new_size": self.new_size
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand':
|
|
"""Deserialize from dictionary"""
|
|
elem_data = data["element"]
|
|
elem_type = elem_data.get("type")
|
|
|
|
if elem_type == "image":
|
|
element = ImageData()
|
|
elif elem_type == "placeholder":
|
|
element = PlaceholderData()
|
|
elif elem_type == "textbox":
|
|
element = TextBoxData()
|
|
else:
|
|
raise ValueError(f"Unknown element type: {elem_type}")
|
|
|
|
element.deserialize(elem_data)
|
|
|
|
return ResizeElementCommand(
|
|
element,
|
|
tuple(data["old_position"]),
|
|
tuple(data["old_size"]),
|
|
tuple(data["new_position"]),
|
|
tuple(data["new_size"])
|
|
)
|
|
|
|
|
|
class RotateElementCommand(Command):
|
|
"""Command for rotating an element"""
|
|
|
|
def __init__(self, element: BaseLayoutElement, old_rotation: float, new_rotation: float):
|
|
self.element = element
|
|
self.old_rotation = old_rotation
|
|
self.new_rotation = new_rotation
|
|
|
|
# Store old position, size, and PIL rotation state
|
|
self.old_position = element.position
|
|
self.old_size = element.size
|
|
|
|
# For ImageData, store the old PIL rotation state
|
|
if hasattr(element, 'pil_rotation_90'):
|
|
self.old_pil_rotation = element.pil_rotation_90
|
|
else:
|
|
self.old_pil_rotation = None
|
|
|
|
def execute(self):
|
|
"""Rotate element by physically rotating the PIL image data"""
|
|
from pyPhotoAlbum.models import ImageData
|
|
|
|
# Calculate rotation delta
|
|
delta = (self.new_rotation - self.old_rotation) % 360
|
|
|
|
# For ImageData, rotate the actual PIL image
|
|
if isinstance(self.element, ImageData):
|
|
# Update PIL rotation counter
|
|
if delta == 90:
|
|
self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 1) % 4
|
|
elif delta == 270:
|
|
self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 3) % 4
|
|
elif delta == 180:
|
|
self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 2) % 4
|
|
|
|
# For 90° or 270° rotations, swap dimensions
|
|
if delta == 90 or delta == 270:
|
|
w, h = self.element.size
|
|
x, y = self.element.position
|
|
|
|
# Swap dimensions
|
|
self.element.size = (h, w)
|
|
|
|
# Adjust position to keep center in same place
|
|
center_x = x + w / 2
|
|
center_y = y + h / 2
|
|
self.element.position = (center_x - h / 2, center_y - w / 2)
|
|
|
|
# Clear the texture so it will be reloaded with the new rotation
|
|
if hasattr(self.element, '_texture_id'):
|
|
del self.element._texture_id
|
|
if hasattr(self.element, '_async_load_requested'):
|
|
self.element._async_load_requested = False
|
|
|
|
# Keep visual rotation at 0
|
|
self.element.rotation = 0
|
|
else:
|
|
# For non-image elements, use old visual rotation
|
|
if delta == 90 or delta == 270:
|
|
w, h = self.element.size
|
|
x, y = self.element.position
|
|
self.element.size = (h, w)
|
|
center_x = x + w / 2
|
|
center_y = y + h / 2
|
|
self.element.position = (center_x - h / 2, center_y - w / 2)
|
|
self.element.rotation = 0
|
|
else:
|
|
self.element.rotation = self.new_rotation
|
|
|
|
def undo(self):
|
|
"""Restore element back to old state"""
|
|
from pyPhotoAlbum.models import ImageData
|
|
|
|
# Restore original rotation, position, and size
|
|
self.element.rotation = self.old_rotation
|
|
self.element.position = self.old_position
|
|
self.element.size = self.old_size
|
|
|
|
# For ImageData, restore PIL rotation and clear texture
|
|
if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
|
|
self.element.pil_rotation_90 = self.old_pil_rotation
|
|
if hasattr(self.element, '_texture_id'):
|
|
self.element._texture_id = None
|
|
self.element._async_load_requested = False
|
|
|
|
def redo(self):
|
|
"""Rotate element to new angle again"""
|
|
self.execute()
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
return {
|
|
"type": "rotate_element",
|
|
"element": self.element.serialize(),
|
|
"old_rotation": self.old_rotation,
|
|
"new_rotation": self.new_rotation
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand':
|
|
"""Deserialize from dictionary"""
|
|
elem_data = data["element"]
|
|
elem_type = elem_data.get("type")
|
|
|
|
if elem_type == "image":
|
|
element = ImageData()
|
|
elif elem_type == "placeholder":
|
|
element = PlaceholderData()
|
|
elif elem_type == "textbox":
|
|
element = TextBoxData()
|
|
else:
|
|
raise ValueError(f"Unknown element type: {elem_type}")
|
|
|
|
element.deserialize(elem_data)
|
|
|
|
return RotateElementCommand(
|
|
element,
|
|
data["old_rotation"],
|
|
data["new_rotation"]
|
|
)
|
|
|
|
|
|
class AdjustImageCropCommand(Command):
|
|
"""Command for adjusting image crop/pan within frame"""
|
|
|
|
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
|
|
self.element = element
|
|
self.old_crop_info = old_crop_info
|
|
self.new_crop_info = new_crop_info
|
|
|
|
def execute(self):
|
|
"""Apply new crop info"""
|
|
self.element.crop_info = self.new_crop_info
|
|
|
|
def undo(self):
|
|
"""Restore old crop info"""
|
|
self.element.crop_info = self.old_crop_info
|
|
|
|
def redo(self):
|
|
"""Apply new crop info again"""
|
|
self.execute()
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
return {
|
|
"type": "adjust_image_crop",
|
|
"element": self.element.serialize(),
|
|
"old_crop_info": self.old_crop_info,
|
|
"new_crop_info": self.new_crop_info
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand':
|
|
"""Deserialize from dictionary"""
|
|
elem_data = data["element"]
|
|
element = ImageData()
|
|
element.deserialize(elem_data)
|
|
|
|
return AdjustImageCropCommand(
|
|
element,
|
|
tuple(data["old_crop_info"]),
|
|
tuple(data["new_crop_info"])
|
|
)
|
|
|
|
|
|
class AlignElementsCommand(Command):
|
|
"""Command for aligning multiple elements"""
|
|
|
|
def __init__(self, changes: List[tuple]):
|
|
"""
|
|
Args:
|
|
changes: List of (element, old_position) tuples
|
|
"""
|
|
self.changes = changes
|
|
|
|
def execute(self):
|
|
"""Positions have already been set by AlignmentManager"""
|
|
pass
|
|
|
|
def undo(self):
|
|
"""Restore old positions"""
|
|
for element, old_position in self.changes:
|
|
element.position = old_position
|
|
|
|
def redo(self):
|
|
"""Re-apply alignment (positions are stored in current state)"""
|
|
# Store current positions and restore them
|
|
new_positions = [(elem, elem.position) for elem, _ in self.changes]
|
|
for element, old_position in self.changes:
|
|
element.position = old_position
|
|
# Then re-apply new positions
|
|
for element, new_position in new_positions:
|
|
element.position = new_position
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
return {
|
|
"type": "align_elements",
|
|
"changes": [
|
|
{
|
|
"element": elem.serialize(),
|
|
"old_position": old_pos
|
|
}
|
|
for elem, old_pos in self.changes
|
|
]
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand':
|
|
"""Deserialize from dictionary"""
|
|
changes = []
|
|
for change_data in data.get("changes", []):
|
|
elem_data = change_data["element"]
|
|
elem_type = elem_data.get("type")
|
|
|
|
if elem_type == "image":
|
|
element = ImageData()
|
|
elif elem_type == "placeholder":
|
|
element = PlaceholderData()
|
|
elif elem_type == "textbox":
|
|
element = TextBoxData()
|
|
else:
|
|
continue
|
|
|
|
element.deserialize(elem_data)
|
|
old_position = tuple(change_data["old_position"])
|
|
changes.append((element, old_position))
|
|
|
|
return AlignElementsCommand(changes)
|
|
|
|
|
|
class ResizeElementsCommand(Command):
|
|
"""Command for resizing multiple elements"""
|
|
|
|
def __init__(self, changes: List[tuple]):
|
|
"""
|
|
Args:
|
|
changes: List of (element, old_position, old_size) tuples
|
|
"""
|
|
self.changes = changes
|
|
self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
|
|
|
|
def execute(self):
|
|
"""Sizes have already been set by AlignmentManager"""
|
|
pass
|
|
|
|
def undo(self):
|
|
"""Restore old positions and sizes"""
|
|
for element, old_position, old_size in self.changes:
|
|
element.position = old_position
|
|
element.size = old_size
|
|
|
|
def redo(self):
|
|
"""Re-apply new sizes"""
|
|
for element, new_position, new_size in self.new_states:
|
|
element.position = new_position
|
|
element.size = new_size
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
return {
|
|
"type": "resize_elements",
|
|
"changes": [
|
|
{
|
|
"element": elem.serialize(),
|
|
"old_position": old_pos,
|
|
"old_size": old_size
|
|
}
|
|
for elem, old_pos, old_size in self.changes
|
|
]
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand':
|
|
"""Deserialize from dictionary"""
|
|
changes = []
|
|
for change_data in data.get("changes", []):
|
|
elem_data = change_data["element"]
|
|
elem_type = elem_data.get("type")
|
|
|
|
if elem_type == "image":
|
|
element = ImageData()
|
|
elif elem_type == "placeholder":
|
|
element = PlaceholderData()
|
|
elif elem_type == "textbox":
|
|
element = TextBoxData()
|
|
else:
|
|
continue
|
|
|
|
element.deserialize(elem_data)
|
|
old_position = tuple(change_data["old_position"])
|
|
old_size = tuple(change_data["old_size"])
|
|
changes.append((element, old_position, old_size))
|
|
|
|
return ResizeElementsCommand(changes)
|
|
|
|
|
|
class ChangeZOrderCommand(Command):
|
|
"""Command for changing element z-order (list position)"""
|
|
|
|
def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):
|
|
self.page_layout = page_layout
|
|
self.element = element
|
|
self.old_index = old_index
|
|
self.new_index = new_index
|
|
|
|
def execute(self):
|
|
"""Move element to new position in list"""
|
|
elements = self.page_layout.elements
|
|
if self.element in elements:
|
|
elements.remove(self.element)
|
|
elements.insert(self.new_index, self.element)
|
|
|
|
def undo(self):
|
|
"""Move element back to old position in list"""
|
|
elements = self.page_layout.elements
|
|
if self.element in elements:
|
|
elements.remove(self.element)
|
|
elements.insert(self.old_index, self.element)
|
|
|
|
def redo(self):
|
|
"""Move element to new position again"""
|
|
self.execute()
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
return {
|
|
"type": "change_zorder",
|
|
"element": self.element.serialize(),
|
|
"old_index": self.old_index,
|
|
"new_index": self.new_index
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand':
|
|
"""Deserialize from dictionary"""
|
|
elem_data = data["element"]
|
|
elem_type = elem_data.get("type")
|
|
|
|
if elem_type == "image":
|
|
element = ImageData()
|
|
elif elem_type == "placeholder":
|
|
element = PlaceholderData()
|
|
elif elem_type == "textbox":
|
|
element = TextBoxData()
|
|
else:
|
|
raise ValueError(f"Unknown element type: {elem_type}")
|
|
|
|
element.deserialize(elem_data)
|
|
|
|
return ChangeZOrderCommand(
|
|
None, # page_layout will be set by CommandHistory
|
|
element,
|
|
data["old_index"],
|
|
data["new_index"]
|
|
)
|
|
|
|
|
|
class StateChangeCommand(Command):
|
|
"""
|
|
Generic command for operations that change state.
|
|
|
|
This command captures before/after snapshots of state and can restore them.
|
|
Used by the @undoable_operation decorator.
|
|
"""
|
|
|
|
def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):
|
|
"""
|
|
Args:
|
|
description: Human-readable description of the operation
|
|
restore_func: Function to restore state: restore_func(state)
|
|
before_state: State before the operation
|
|
after_state: State after the operation (captured during execute)
|
|
"""
|
|
self.description = description
|
|
self.restore_func = restore_func
|
|
self.before_state = before_state
|
|
self.after_state = after_state
|
|
|
|
def execute(self):
|
|
"""State is already applied, just store after_state if not set"""
|
|
# After state is captured by decorator after operation runs
|
|
pass
|
|
|
|
def undo(self):
|
|
"""Restore to before state"""
|
|
self.restore_func(self.before_state)
|
|
|
|
def redo(self):
|
|
"""Restore to after state"""
|
|
self.restore_func(self.after_state)
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize to dictionary"""
|
|
# For now, state change commands are not serialized
|
|
# This could be enhanced later if needed
|
|
return {
|
|
"type": "state_change",
|
|
"description": self.description
|
|
}
|
|
|
|
@staticmethod
|
|
def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand':
|
|
"""Deserialize from dictionary"""
|
|
# Not implemented - would need to serialize state
|
|
raise NotImplementedError("StateChangeCommand deserialization not yet supported")
|
|
|
|
|
|
class CommandHistory:
|
|
"""Manages undo/redo command history"""
|
|
|
|
def __init__(self, max_history: int = 100, asset_manager=None):
|
|
self.undo_stack: List[Command] = []
|
|
self.redo_stack: List[Command] = []
|
|
self.max_history = max_history
|
|
self.asset_manager = asset_manager
|
|
|
|
def execute(self, command: Command):
|
|
"""Execute a command and add it to history"""
|
|
command.execute()
|
|
|
|
# When clearing redo stack, release asset references
|
|
for cmd in self.redo_stack:
|
|
self._release_command_assets(cmd)
|
|
self.redo_stack.clear()
|
|
|
|
self.undo_stack.append(command)
|
|
|
|
# Limit history size - release assets from old commands
|
|
if len(self.undo_stack) > self.max_history:
|
|
old_cmd = self.undo_stack.pop(0)
|
|
self._release_command_assets(old_cmd)
|
|
|
|
def _release_command_assets(self, command: Command):
|
|
"""Release asset references held by a command"""
|
|
if not self.asset_manager:
|
|
return
|
|
|
|
# Release asset references for commands that hold them
|
|
if isinstance(command, (AddElementCommand, DeleteElementCommand)):
|
|
if isinstance(command.element, ImageData) and command.element.image_path:
|
|
# Convert absolute path to relative for asset manager
|
|
import os
|
|
asset_path = command.element.image_path
|
|
if os.path.isabs(asset_path):
|
|
asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)
|
|
self.asset_manager.release_reference(asset_path)
|
|
|
|
def undo(self) -> bool:
|
|
"""Undo the last command"""
|
|
if not self.can_undo():
|
|
return False
|
|
|
|
command = self.undo_stack.pop()
|
|
command.undo()
|
|
self.redo_stack.append(command)
|
|
return True
|
|
|
|
def redo(self) -> bool:
|
|
"""Redo the last undone command"""
|
|
if not self.can_redo():
|
|
return False
|
|
|
|
command = self.redo_stack.pop()
|
|
command.redo()
|
|
self.undo_stack.append(command)
|
|
return True
|
|
|
|
def can_undo(self) -> bool:
|
|
"""Check if undo is available"""
|
|
return len(self.undo_stack) > 0
|
|
|
|
def can_redo(self) -> bool:
|
|
"""Check if redo is available"""
|
|
return len(self.redo_stack) > 0
|
|
|
|
def clear(self):
|
|
"""Clear all history and release asset references"""
|
|
# Release all asset references
|
|
for cmd in self.undo_stack:
|
|
self._release_command_assets(cmd)
|
|
for cmd in self.redo_stack:
|
|
self._release_command_assets(cmd)
|
|
|
|
self.undo_stack.clear()
|
|
self.redo_stack.clear()
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize history to dictionary"""
|
|
return {
|
|
"undo_stack": [cmd.serialize() for cmd in self.undo_stack],
|
|
"redo_stack": [cmd.serialize() for cmd in self.redo_stack],
|
|
"max_history": self.max_history
|
|
}
|
|
|
|
def deserialize(self, data: Dict[str, Any], project):
|
|
"""Deserialize history from dictionary"""
|
|
self.max_history = data.get("max_history", 100)
|
|
|
|
# Deserialize undo stack
|
|
self.undo_stack = []
|
|
for cmd_data in data.get("undo_stack", []):
|
|
cmd = self._deserialize_command(cmd_data, project)
|
|
if cmd:
|
|
self.undo_stack.append(cmd)
|
|
|
|
# Deserialize redo stack
|
|
self.redo_stack = []
|
|
for cmd_data in data.get("redo_stack", []):
|
|
cmd = self._deserialize_command(cmd_data, project)
|
|
if cmd:
|
|
self.redo_stack.append(cmd)
|
|
|
|
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
|
|
"""Deserialize a single command"""
|
|
cmd_type = data.get("type")
|
|
|
|
try:
|
|
if cmd_type == "add_element":
|
|
return AddElementCommand.deserialize(data, project)
|
|
elif cmd_type == "delete_element":
|
|
return DeleteElementCommand.deserialize(data, project)
|
|
elif cmd_type == "move_element":
|
|
return MoveElementCommand.deserialize(data, project)
|
|
elif cmd_type == "resize_element":
|
|
return ResizeElementCommand.deserialize(data, project)
|
|
elif cmd_type == "rotate_element":
|
|
return RotateElementCommand.deserialize(data, project)
|
|
elif cmd_type == "align_elements":
|
|
return AlignElementsCommand.deserialize(data, project)
|
|
elif cmd_type == "resize_elements":
|
|
return ResizeElementsCommand.deserialize(data, project)
|
|
elif cmd_type == "change_zorder":
|
|
return ChangeZOrderCommand.deserialize(data, project)
|
|
elif cmd_type == "adjust_image_crop":
|
|
return AdjustImageCropCommand.deserialize(data, project)
|
|
else:
|
|
print(f"Warning: Unknown command type: {cmd_type}")
|
|
return None
|
|
except Exception as e:
|
|
print(f"Error deserializing command: {e}")
|
|
return None
|