프레임 픽(Frame Pick) 프로젝트는 유튜브 영상 썸네일 생성을 위한 웹 기반 편집기로, Fabric.js 라이브러리를 통해 캔버스 기반의 복잡한 편집 기능을 제공합니다. 사용자의 편의를 위해 Undo(실행 취소)와 Redo(다시 실행) 기능은 필수적이었지만, 특히 객체 드래그(이동, 크기 조절, 회전)와 같은 연속적인 제스처를 히스토리에 어떻게 기록할지가 큰 도전 과제였습니다.

초기 접근: 모든 변경사항 기록의 문제점

가장 먼저 떠오른 방법은 Fabric.js 캔버스에서 발생하는 모든 변경 이벤트를 감지하여 현재 캔버스 상태를 히스토리에 저장하는 것이었습니다. Fabric.js는 객체가 수정될 때 object:modified 이벤트를 발생시키는데, 이 이벤트를 활용하여 canvas.toDatalessJSON() 메서드로 캔버스의 모든 객체 데이터를 JSON 형태로 직렬화하여 스냅샷을 찍는 방식입니다. 이 스냅샷들을 스택에 쌓아 Undo/Redo 기능을 구현하려 했습니다.

하지만 이 방식은 드래그 제스처에서 치명적인 문제를 드러냈습니다. 객체를 마우스로 드래그하여 이동시키는 경우, 마우스가 움직이는 동안 object:moving 이벤트가 수없이 발생하고, 드래그가 끝날 때 object:moved 이벤트가 발생합니다. object:modified 이벤트는 object:moved를 포함하여 모든 변형(이동, 크기 조절, 회전)이 완료된 후에 트리거됩니다. 따라서 한 번의 드래그 작업이 수십, 수백 개의 object:modified 이벤트를 유발하며, 이는 히스토리에 수많은 중간 상태를 기록하게 됩니다. 결과적으로 사용자가 Shift+Z나 Ctrl+Z를 눌렀을 때, 객체가 드래그된 궤적을 따라 매우 미세하게 움직이는 현상이 발생하여 Undo/Redo 기능이 사실상 무용지물이 되는 문제가 발생했습니다.

드래그 제스처 통합을 위한 전략

이 문제를 해결하기 위해, 연속적인 드래그 제스처는 시작과 끝을 하나의 논리적인 변경 스텝으로 병합해야 한다고 판단했습니다. 즉, 객체를 드래그하는 동안 발생하는 수많은 중간 object:moving 이벤트는 무시하고, 드래그가 시작되기 전의 상태와 드래그가 완전히 끝난 후의 최종 상태만을 비교하여 단 하나의 히스토리 스텝으로 기록하는 전략을 세웠습니다. README.md에 명시된 "드래그 제스처는 modified 1회로 병합"이 바로 이 부분을 지칭합니다.

이를 위해 다음과 같은 이벤트 핸들링 로직을 설계했습니다.

  1. 드래그 시작 감지: Fabric.js의 object:moving, object:scaling, object:rotating 이벤트는 객체 변형이 시작되거나 진행 중일 때 발생합니다. 이 이벤트들 중 하나가 처음 발생했을 때, isDragging과 같은 플래그를 true로 설정하고, 현재 캔버스의 상태를 initialStateSnapshot 변수에 저장합니다. 이때 canvas.toDatalessJSON()을 사용하여 객체의 주요 속성들만 스냅샷으로 찍어 불필요한 데이터를 줄였습니다. 이 스냅샷은 드래그 시작 전의 '이전 상태'를 나타냅니다.
  2. 드래그 종료 및 병합: object:modified 이벤트는 객체의 변형이 완료되었을 때 발생합니다. 이 이벤트가 발생했을 때, 만약 isDragging 플래그가 true라면, 이는 드래그 제스처가 끝났음을 의미합니다. 현재 캔버스 상태(finalStateSnapshot)를 다시 찍고, initialStateSnapshotfinalStateSnapshot을 비교합니다. 실제로 변경이 발생했다면 (예: 객체 위치가 달라졌다면), 이 두 스냅샷을 기반으로 하나의 Undo/Redo 히스토리 스텝을 생성합니다. 이때 히스토리 스텝에는 labelcommand_type을 함께 기록하여 어떤 종류의 변경이었는지 명확히 표시했습니다. 히스토리 스텝 기록 후에는 isDragging 플래그를 false로 재설정합니다.
  3. 개별 변경 처리: 만약 object:modified 이벤트가 발생했지만 isDragging 플래그가 false인 경우, 이는 드래그가 아닌 다른 종류의 단일 변경(예: 색상 변경, 텍스트 내용 수정 등)으로 간주하고, 해당 변경에 대한 개별 히스토리 스텝을 생성하여 기록합니다.

이러한 방식을 통해 EditorSessionContext.tsx 내에서 Undo/Redo 스택을 관리하며, 최대 20스텝까지의 히스토리를 효율적으로 유지할 수 있었습니다. 특히 toDatalessJSON을 사용하여 스냅샷 크기를 최적화하고, ['clipPath']와 같이 불필요한 속성은 제외하여 성능 저하를 최소화했습니다.

구현 세부 사항 및 고려사항

구현 과정에서 몇 가지 추가적인 고려사항이 있었습니다. Fabric.js의 object:modified 이벤트는 선택된 객체가 없는 상태에서 캔버스 자체의 변경(예: 배경색 변경)에도 발생할 수 있으므로, 어떤 객체가 변경되었는지 (e.target)를 확인하여 히스토리 스텝의 label을 보다 구체적으로 지정했습니다. 예를 들어, e.target.type을 활용하여 '텍스트 수정', '이미지 이동', '도형 크기 조절' 등으로 구분할 수 있었습니다.

또한, Undo/Redo 시 단순히 캔버스 상태만 복원하는 것을 넘어, 이전 스텝에서 선택되어 있던 객체들을 다시 선택 상태로 만드는 것이 사용자 경험에 중요합니다. README.md에서 언급된 "undo/redo 후 선택(lay" 부분이 이를 의미합니다. 이를 위해 히스토리 스텝에 activeObject 또는 activeSelection의 ID 목록을 함께 저장하고, Undo/Redo 시 해당 ID들을 기반으로 객체를 찾아 다시 활성화하는 로직을 추가했습니다.

// src/contexts/EditorSessionContext.tsx (개념적 예시)
import { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
import { fabric } from 'fabric';

interface HistoryEntry {
  type: string;
  label: string;
  before: any; // Fabric.js JSON state
  after: any;  // Fabric.js JSON state
  selectedObjectIds?: string[]; // Optional: IDs of selected objects
}

const MAX_HISTORY_STEPS = 20;

export const EditorSessionContext = createContext<any>(null);

export const EditorSessionProvider = ({ children }: { children: React.ReactNode }) => {
  const canvas = useCanvasContext(); // Assuming a context for Fabric.js canvas instance
  const [history, setHistory] = useState<HistoryEntry[]>([]);
  const [historyPointer, setHistoryPointer] = useState(-1);
  const isDraggingRef = useRef(false);
  const initialStateSnapshotRef = useRef<any>(null);

  const getCurrentSelectionIds = useCallback(() => {
    if (!canvas || !canvas.getActiveObject()) return [];
    const activeObject = canvas.getActiveObject();
    if (activeObject && activeObject.type === 'activeSelection') {
      return (activeObject as fabric.ActiveSelection)._objects.map(obj => obj.id);
    }
    return [activeObject.id];
  }, [canvas]);

  const addHistoryEntry = useCallback((entry: Omit<HistoryEntry, 'selectedObjectIds'>) => {
    setHistory(prevHistory => {
      const newHistory = prevHistory.slice(0, historyPointer + 1);
      const selectedObjectIds = getCurrentSelectionIds();
      const newEntry = { ...entry, selectedObjectIds };
      if (newHistory.length >= MAX_HISTORY_STEPS) {
        newHistory.shift(); // Remove oldest entry
      }
      return [...newHistory, newEntry];
    });
    setHistoryPointer(prevPointer => prevPointer + 1);
  }, [historyPointer, getCurrentSelectionIds]);

  useEffect(() => {
    if (!canvas) return;

    const onObjectMoving = () => {
      if (!isDraggingRef.current) {
        isDraggingRef.current = true;
        initialStateSnapshotRef.current = canvas.toDatalessJSON(['clipPath']);
      }
    };

    const onObjectModified = (options: fabric.IEvent) => {
      if (isDraggingRef.current) {
        // Drag/Scale/Rotate operation ended
        const finalStateSnapshot = canvas.toDatalessJSON(['clipPath']);
        // TODO: Compare initialStateSnapshotRef.current and finalStateSnapshot to ensure actual change
        addHistoryEntry({
          type: 'objectModified',
          label: `${options.target?.type || '객체'} ${options.transform?.action || '변형'}`,
          before: initialStateSnapshotRef.current,
          after: finalStateSnapshot
        });
        isDraggingRef.current = false;
        initialStateSnapshotRef.current = null;
      } else {
        // Other direct modifications (e.g., property change from UI)
        // This part needs more sophisticated 'before' and 'after' for specific property changes
        // For simplicity, here we'll just capture a full canvas state
        // A real implementation would capture only the relevant object's state or a diff
        addHistoryEntry({
          type: 'propertyChange',
          label: `${options.target?.type || '객체'} 속성 변경`,
          before: null, // More complex to get specific before state here without a dedicated change listener
          after: canvas.toDatalessJSON(['clipPath'])
        });
      }
    };

    canvas.on('object:moving', onObjectMoving);
    canvas.on('object:scaling', onObjectMoving); // Also treat scaling as a drag-like operation
    canvas.on('object:rotating', onObjectMoving); // And rotating
    canvas.on('object:modified', onObjectModified);

    return () => {
      canvas.off('object:moving', onObjectMoving);
      canvas.off('object:scaling', onObjectMoving);
      canvas.off('object:rotating', onObjectMoving);
      canvas.off('object:modified', onObjectModified);
    };
  }, [canvas, addHistoryEntry]);

  // ... undo/redo functions that apply state and restore selection
  // value for context provider
  return <EditorSessionContext.Provider value={{ /* ... */ }}>{children}</EditorSessionContext.Provider>;
};

남은 문제 및 개선점

현재 구현은 드래그 제스처를 효과적으로 병합하고 있지만, canvas.toDatalessJSON()을 사용하여 전체 캔버스 스냅샷을 찍는 방식은 객체 수가 매우 많아지거나 캔버스가 복잡해질 경우 성능 병목이 될 가능성이 있습니다. Fabric.js 캔버스의 toDatalessJSON은 비교적 최적화되어 있지만, 더 많은 객체를 처리할 때는 변경된 특정 객체만을 직렬화하거나, 변경 전후의 JSON 'diff'를 생성하여 저장하는 방식을 고려해볼 수 있습니다. 하지만 이는 구현 복잡도가 크게 증가하므로, 현재는 썸네일 편집기의 스케일과 성능 요구사항을 고려하여 전체 스냅샷 방식을 유지하고 있습니다.

또한, 특정 속성 변경(예: 텍스트 색상 변경)에 대한 before 상태를 정확히 캡처하는 로직은 아직 더 정교하게 다듬을 필요가 있습니다. 현재는 object:modified에서 전체 캔버스 스냅샷을 찍지만, 이상적으로는 변경된 객체의 해당 속성만 이전 상태와 이후 상태를 저장하는 것이 효율적입니다. 이 부분은 command_typelabel을 더욱 세분화하여 각 변경 유형에 맞는 최적화된 스냅샷 전략을 적용하는 방향으로 개선될 수 있습니다.