프레임 픽(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회로 병합"이 바로 이 부분을 지칭합니다.
이를 위해 다음과 같은 이벤트 핸들링 로직을 설계했습니다.
- 드래그 시작 감지: Fabric.js의
object:moving,object:scaling,object:rotating이벤트는 객체 변형이 시작되거나 진행 중일 때 발생합니다. 이 이벤트들 중 하나가 처음 발생했을 때,isDragging과 같은 플래그를true로 설정하고, 현재 캔버스의 상태를initialStateSnapshot변수에 저장합니다. 이때canvas.toDatalessJSON()을 사용하여 객체의 주요 속성들만 스냅샷으로 찍어 불필요한 데이터를 줄였습니다. 이 스냅샷은 드래그 시작 전의 '이전 상태'를 나타냅니다. - 드래그 종료 및 병합:
object:modified이벤트는 객체의 변형이 완료되었을 때 발생합니다. 이 이벤트가 발생했을 때, 만약isDragging플래그가true라면, 이는 드래그 제스처가 끝났음을 의미합니다. 현재 캔버스 상태(finalStateSnapshot)를 다시 찍고,initialStateSnapshot과finalStateSnapshot을 비교합니다. 실제로 변경이 발생했다면 (예: 객체 위치가 달라졌다면), 이 두 스냅샷을 기반으로 하나의 Undo/Redo 히스토리 스텝을 생성합니다. 이때 히스토리 스텝에는label과command_type을 함께 기록하여 어떤 종류의 변경이었는지 명확히 표시했습니다. 히스토리 스텝 기록 후에는isDragging플래그를false로 재설정합니다. - 개별 변경 처리: 만약
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_type과 label을 더욱 세분화하여 각 변경 유형에 맞는 최적화된 스냅샷 전략을 적용하는 방향으로 개선될 수 있습니다.