[{"data":1,"prerenderedAt":18},["ShallowReactive",2],{"$fPXKGMaEB0qz1trxTI2b7RcNnmbCm8n_5xNhGCp4VpwM":3},{"uuid":4,"title":5,"summary":6,"content":7,"createdAt":8,"tags":9},"9b5a3166-0883-4793-8304-b694ae1dabfa","Fabric.js 기반 캔버스 편집기에서 드래그 제스처와 통합된 Undo\u002FRedo 히스토리 구현","Fabric.js를 활용한 캔버스 편집기에서 객체 드래그와 같은 연속적인 제스처를 Undo\u002FRedo 히스토리에 효과적으로 통합하는 방법에 대한 시행착오와 해결 과정을 공유합니다. 모든 중간 상태를 기록하는 방식의 비효율성을 개선하고, 최종 변경 사항만 단일 히스토리 스텝으로 기록하는 방법을 모색했습니다.","프레임 픽(Frame Pick) 프로젝트는 유튜브 영상 썸네일 생성을 위한 웹 기반 편집기로, Fabric.js 라이브러리를 통해 캔버스 기반의 복잡한 편집 기능을 제공합니다. 사용자의 편의를 위해 Undo(실행 취소)와 Redo(다시 실행) 기능은 필수적이었지만, 특히 객체 드래그(이동, 크기 조절, 회전)와 같은 연속적인 제스처를 히스토리에 어떻게 기록할지가 큰 도전 과제였습니다.\n\n## 초기 접근: 모든 변경사항 기록의 문제점\n\n가장 먼저 떠오른 방법은 Fabric.js 캔버스에서 발생하는 모든 변경 이벤트를 감지하여 현재 캔버스 상태를 히스토리에 저장하는 것이었습니다. Fabric.js는 객체가 수정될 때 `object:modified` 이벤트를 발생시키는데, 이 이벤트를 활용하여 `canvas.toDatalessJSON()` 메서드로 캔버스의 모든 객체 데이터를 JSON 형태로 직렬화하여 스냅샷을 찍는 방식입니다. 이 스냅샷들을 스택에 쌓아 Undo\u002FRedo 기능을 구현하려 했습니다.\n\n하지만 이 방식은 드래그 제스처에서 치명적인 문제를 드러냈습니다. 객체를 마우스로 드래그하여 이동시키는 경우, 마우스가 움직이는 동안 `object:moving` 이벤트가 수없이 발생하고, 드래그가 끝날 때 `object:moved` 이벤트가 발생합니다. `object:modified` 이벤트는 `object:moved`를 포함하여 모든 변형(이동, 크기 조절, 회전)이 완료된 후에 트리거됩니다. 따라서 한 번의 드래그 작업이 수십, 수백 개의 `object:modified` 이벤트를 유발하며, 이는 히스토리에 수많은 중간 상태를 기록하게 됩니다. 결과적으로 사용자가 Shift+Z나 Ctrl+Z를 눌렀을 때, 객체가 드래그된 궤적을 따라 매우 미세하게 움직이는 현상이 발생하여 Undo\u002FRedo 기능이 사실상 무용지물이 되는 문제가 발생했습니다.\n\n## 드래그 제스처 통합을 위한 전략\n\n이 문제를 해결하기 위해, 연속적인 드래그 제스처는 시작과 끝을 하나의 논리적인 변경 스텝으로 병합해야 한다고 판단했습니다. 즉, 객체를 드래그하는 동안 발생하는 수많은 중간 `object:moving` 이벤트는 무시하고, 드래그가 시작되기 전의 상태와 드래그가 완전히 끝난 후의 최종 상태만을 비교하여 단 하나의 히스토리 스텝으로 기록하는 전략을 세웠습니다. `README.md`에 명시된 \"드래그 제스처는 `modified` 1회로 병합\"이 바로 이 부분을 지칭합니다.\n\n이를 위해 다음과 같은 이벤트 핸들링 로직을 설계했습니다.\n\n1.  **드래그 시작 감지**: Fabric.js의 `object:moving`, `object:scaling`, `object:rotating` 이벤트는 객체 변형이 시작되거나 진행 중일 때 발생합니다. 이 이벤트들 중 하나가 처음 발생했을 때, `isDragging`과 같은 플래그를 `true`로 설정하고, 현재 캔버스의 상태를 `initialStateSnapshot` 변수에 저장합니다. 이때 `canvas.toDatalessJSON()`을 사용하여 객체의 주요 속성들만 스냅샷으로 찍어 불필요한 데이터를 줄였습니다. 이 스냅샷은 드래그 시작 전의 '이전 상태'를 나타냅니다.\n2.  **드래그 종료 및 병합**: `object:modified` 이벤트는 객체의 변형이 완료되었을 때 발생합니다. 이 이벤트가 발생했을 때, 만약 `isDragging` 플래그가 `true`라면, 이는 드래그 제스처가 끝났음을 의미합니다. 현재 캔버스 상태(`finalStateSnapshot`)를 다시 찍고, `initialStateSnapshot`과 `finalStateSnapshot`을 비교합니다. 실제로 변경이 발생했다면 (예: 객체 위치가 달라졌다면), 이 두 스냅샷을 기반으로 하나의 Undo\u002FRedo 히스토리 스텝을 생성합니다. 이때 히스토리 스텝에는 `label`과 `command_type`을 함께 기록하여 어떤 종류의 변경이었는지 명확히 표시했습니다. 히스토리 스텝 기록 후에는 `isDragging` 플래그를 `false`로 재설정합니다.\n3.  **개별 변경 처리**: 만약 `object:modified` 이벤트가 발생했지만 `isDragging` 플래그가 `false`인 경우, 이는 드래그가 아닌 다른 종류의 단일 변경(예: 색상 변경, 텍스트 내용 수정 등)으로 간주하고, 해당 변경에 대한 개별 히스토리 스텝을 생성하여 기록합니다.\n\n이러한 방식을 통해 `EditorSessionContext.tsx` 내에서 Undo\u002FRedo 스택을 관리하며, 최대 20스텝까지의 히스토리를 효율적으로 유지할 수 있었습니다. 특히 `toDatalessJSON`을 사용하여 스냅샷 크기를 최적화하고, `['clipPath']`와 같이 불필요한 속성은 제외하여 성능 저하를 최소화했습니다.\n\n## 구현 세부 사항 및 고려사항\n\n구현 과정에서 몇 가지 추가적인 고려사항이 있었습니다. Fabric.js의 `object:modified` 이벤트는 선택된 객체가 없는 상태에서 캔버스 자체의 변경(예: 배경색 변경)에도 발생할 수 있으므로, 어떤 객체가 변경되었는지 (`e.target`)를 확인하여 히스토리 스텝의 `label`을 보다 구체적으로 지정했습니다. 예를 들어, `e.target.type`을 활용하여 '텍스트 수정', '이미지 이동', '도형 크기 조절' 등으로 구분할 수 있었습니다.\n\n또한, Undo\u002FRedo 시 단순히 캔버스 상태만 복원하는 것을 넘어, 이전 스텝에서 선택되어 있던 객체들을 다시 선택 상태로 만드는 것이 사용자 경험에 중요합니다. `README.md`에서 언급된 \"undo\u002Fredo 후 선택(lay\" 부분이 이를 의미합니다. 이를 위해 히스토리 스텝에 `activeObject` 또는 `activeSelection`의 ID 목록을 함께 저장하고, Undo\u002FRedo 시 해당 ID들을 기반으로 객체를 찾아 다시 활성화하는 로직을 추가했습니다.\n\n```typescript\n\u002F\u002F src\u002Fcontexts\u002FEditorSessionContext.tsx (개념적 예시)\nimport { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';\nimport { fabric } from 'fabric';\n\ninterface HistoryEntry {\n  type: string;\n  label: string;\n  before: any; \u002F\u002F Fabric.js JSON state\n  after: any;  \u002F\u002F Fabric.js JSON state\n  selectedObjectIds?: string[]; \u002F\u002F Optional: IDs of selected objects\n}\n\nconst MAX_HISTORY_STEPS = 20;\n\nexport const EditorSessionContext = createContext\u003Cany>(null);\n\nexport const EditorSessionProvider = ({ children }: { children: React.ReactNode }) => {\n  const canvas = useCanvasContext(); \u002F\u002F Assuming a context for Fabric.js canvas instance\n  const [history, setHistory] = useState\u003CHistoryEntry[]>([]);\n  const [historyPointer, setHistoryPointer] = useState(-1);\n  const isDraggingRef = useRef(false);\n  const initialStateSnapshotRef = useRef\u003Cany>(null);\n\n  const getCurrentSelectionIds = useCallback(() => {\n    if (!canvas || !canvas.getActiveObject()) return [];\n    const activeObject = canvas.getActiveObject();\n    if (activeObject && activeObject.type === 'activeSelection') {\n      return (activeObject as fabric.ActiveSelection)._objects.map(obj => obj.id);\n    }\n    return [activeObject.id];\n  }, [canvas]);\n\n  const addHistoryEntry = useCallback((entry: Omit\u003CHistoryEntry, 'selectedObjectIds'>) => {\n    setHistory(prevHistory => {\n      const newHistory = prevHistory.slice(0, historyPointer + 1);\n      const selectedObjectIds = getCurrentSelectionIds();\n      const newEntry = { ...entry, selectedObjectIds };\n      if (newHistory.length >= MAX_HISTORY_STEPS) {\n        newHistory.shift(); \u002F\u002F Remove oldest entry\n      }\n      return [...newHistory, newEntry];\n    });\n    setHistoryPointer(prevPointer => prevPointer + 1);\n  }, [historyPointer, getCurrentSelectionIds]);\n\n  useEffect(() => {\n    if (!canvas) return;\n\n    const onObjectMoving = () => {\n      if (!isDraggingRef.current) {\n        isDraggingRef.current = true;\n        initialStateSnapshotRef.current = canvas.toDatalessJSON(['clipPath']);\n      }\n    };\n\n    const onObjectModified = (options: fabric.IEvent) => {\n      if (isDraggingRef.current) {\n        \u002F\u002F Drag\u002FScale\u002FRotate operation ended\n        const finalStateSnapshot = canvas.toDatalessJSON(['clipPath']);\n        \u002F\u002F TODO: Compare initialStateSnapshotRef.current and finalStateSnapshot to ensure actual change\n        addHistoryEntry({\n          type: 'objectModified',\n          label: `${options.target?.type || '객체'} ${options.transform?.action || '변형'}`,\n          before: initialStateSnapshotRef.current,\n          after: finalStateSnapshot\n        });\n        isDraggingRef.current = false;\n        initialStateSnapshotRef.current = null;\n      } else {\n        \u002F\u002F Other direct modifications (e.g., property change from UI)\n        \u002F\u002F This part needs more sophisticated 'before' and 'after' for specific property changes\n        \u002F\u002F For simplicity, here we'll just capture a full canvas state\n        \u002F\u002F A real implementation would capture only the relevant object's state or a diff\n        addHistoryEntry({\n          type: 'propertyChange',\n          label: `${options.target?.type || '객체'} 속성 변경`,\n          before: null, \u002F\u002F More complex to get specific before state here without a dedicated change listener\n          after: canvas.toDatalessJSON(['clipPath'])\n        });\n      }\n    };\n\n    canvas.on('object:moving', onObjectMoving);\n    canvas.on('object:scaling', onObjectMoving); \u002F\u002F Also treat scaling as a drag-like operation\n    canvas.on('object:rotating', onObjectMoving); \u002F\u002F And rotating\n    canvas.on('object:modified', onObjectModified);\n\n    return () => {\n      canvas.off('object:moving', onObjectMoving);\n      canvas.off('object:scaling', onObjectMoving);\n      canvas.off('object:rotating', onObjectMoving);\n      canvas.off('object:modified', onObjectModified);\n    };\n  }, [canvas, addHistoryEntry]);\n\n  \u002F\u002F ... undo\u002Fredo functions that apply state and restore selection\n  \u002F\u002F value for context provider\n  return \u003CEditorSessionContext.Provider value={{ \u002F* ... *\u002F }}>{children}\u003C\u002FEditorSessionContext.Provider>;\n};\n```\n\n## 남은 문제 및 개선점\n\n현재 구현은 드래그 제스처를 효과적으로 병합하고 있지만, `canvas.toDatalessJSON()`을 사용하여 전체 캔버스 스냅샷을 찍는 방식은 객체 수가 매우 많아지거나 캔버스가 복잡해질 경우 성능 병목이 될 가능성이 있습니다. Fabric.js 캔버스의 `toDatalessJSON`은 비교적 최적화되어 있지만, 더 많은 객체를 처리할 때는 변경된 특정 객체만을 직렬화하거나, 변경 전후의 JSON 'diff'를 생성하여 저장하는 방식을 고려해볼 수 있습니다. 하지만 이는 구현 복잡도가 크게 증가하므로, 현재는 썸네일 편집기의 스케일과 성능 요구사항을 고려하여 전체 스냅샷 방식을 유지하고 있습니다.\n\n또한, 특정 속성 변경(예: 텍스트 색상 변경)에 대한 `before` 상태를 정확히 캡처하는 로직은 아직 더 정교하게 다듬을 필요가 있습니다. 현재는 `object:modified`에서 전체 캔버스 스냅샷을 찍지만, 이상적으로는 변경된 객체의 해당 속성만 이전 상태와 이후 상태를 저장하는 것이 효율적입니다. 이 부분은 `command_type`과 `label`을 더욱 세분화하여 각 변경 유형에 맞는 최적화된 스냅샷 전략을 적용하는 방향으로 개선될 수 있습니다.","2026-08-18T14:00:51.178+09:00",[10,11,12,13,14,15,16,17],"Canvas Editor","Drag Gesture","Fabric-js","Next-js","TypeScript","Undo-Redo","Web Development","개발기",1788152657225]