카탈로그 / 027 Studio / 프로젝트 029
PROJECT 029 ● 개발 진행 중 · 4/7단계

보고서 슬라이드 에디터
A4 캔버스 → PPT · PDF

미리캔버스/Canva 스타일의 웹 에디터. 텍스트·이미지·표를 A4 가로 캔버스에 자유 배치하고, 화면 그대로 .pptx(편집·전달용)와 .pdf(인쇄용)로 내보내는 것이 목표. 모든 것은 단 하나의 JSON 데이터 모델에서 흘러나온다.

√2:1
A4 가로 297×210mm
0~1
비율 좌표계
3종
배치 객체 (텍스트·이미지·표)
1,437
소스 16개 파일
01 — OVERVIEW

프로젝트 개요

보고서는 A4 가로 양식. 인쇄(PDF)와 PPT 편집·전달 두 용도를 모두 전제로, 화면·PPT·PDF 세 출력의 위치가 1mm도 틀어지지 않는 것이 핵심 요구사항이다.

슬라이드 규격A4 가로 297×210mm = 11.69×8.27inch, 종횡비 √2:1
안전 여백상하좌우 10mm — 캔버스에 점선 가이드 상시 표시
기술 스택React 18 + Vite · react-moveable · zustand
내보내기 (예정)PPT: pptxgenjs 커스텀 레이아웃 A4L · PDF: html2canvas + jsPDF
이미지 처리브라우저 메모리 base64 (업로드 서버 불필요)
폰트 기본값맑은 고딕 — 어느 PC에서 열어도 깨지지 않는 범용 폰트
02 — FEATURES

구현된 기능

모든 객체는 자유 이동 · 8방향 리사이즈 · 회전 · 삭제 · 레이어 순서 변경을 지원한다.

✏️ 텍스트 블럭

더블클릭 인라인 편집(여러 줄). 글꼴·크기(pt)·색상·굵기·정렬·줄간격 편집. 방향키 미세이동, Delete 삭제.

🖼️ 이미지 블럭

파일 선택(다중) 또는 캔버스 드래그앤드롭 업로드. 원본 비율 자동 배치, 한 페이지 3~4개 이상 가능.

📊 표 블럭

행/열 추가·삭제, 더블클릭 셀 편집, 테두리·글자색·정렬 설정. 좌측 패널에서 셀 그리드로 일괄 입력도 가능.

🧲 마그네틱 스냅

중앙·가장자리·여백선 스냅 + 간격 스냅: 이미지 A–B 간격과 같아지는 지점에 C가 자석처럼 붙고, 간격이 mm로 표시. 3~4개 균등 배치가 드래그만으로 완성.

📐 비율 고정 리사이즈

Shift/Ctrl를 누른 채 핸들을 끌면 가로세로 비율 유지. 드래그 중 전환 가능, 창 포커스 아웃 시 자동 해제.

🗂️ 좌우 작업 패널

좌측: 모든 요소의 텍스트를 폼으로 일괄 입력(반복 보고서 효율). 우측: 선택 객체의 위치·크기를 mm 단위로 정밀 편집 + 타입별 스타일.

03 — ARCHITECTURE

단일 데이터 모델 아키텍처

화면 렌더링·PPT 내보내기·PDF 내보내기가 모두 하나의 JSON 모델만 읽는다. 좌표는 픽셀이 아닌 슬라이드 대비 비율(0~1)로 저장하므로, 어떤 해상도·어떤 출력에서도 배치가 동일하다.

📦 단일 데이터 모델 (zustand) pages[] → objects[]
x·y·w·h = 비율(0~1) · fontSize = pt
🖥️ 화면 렌더링비율 × 캔버스 px
📑 PPT (pptxgenjs)비율 × 11.69 / 8.27 inch
🖨️ PDF (jsPDF)비율 × 297 / 210 mm
// 객체 하나의 실제 모양 — 이것이 전부다
{
  id: "a1b2c3", type: "text",
  x: 0.1, y: 0.2, w: 0.4, h: 0.15,   // 슬라이드 대비 비율
  rotation: 0,
  content: "분기 실적 요약",
  style: { fontFamily: "맑은 고딕", fontSize: 18 /* pt */,
           color: "#222222", bold: false, align: "left", lineHeight: 1.4 }
}
x_inch = x × 11.69 y_inch = y × 8.27 x_mm = x × 297 화면px = 비율 × 캔버스px 글자px = pt × (캔버스폭 ÷ 841.68pt)
04 — SOURCE CODE

전체 소스코드

16개 파일 · 1,437줄. 파일명을 클릭하면 코드가 펼쳐진다. 경로: claude code project/보고서_슬라이드_에디터

package.json프로젝트 정의 · 의존성22줄
{
  "name": "report-slide-editor",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-moveable": "^0.56.0",
    "zustand": "^4.5.5"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.4",
    "vite": "^5.4.11"
  }
}
vite.config.jsVite 설정7줄
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});
index.html엔트리 HTML13줄
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>보고서 슬라이드 에디터</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
src/main.jsxReact 마운트11줄
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
src/constants.jsA4 규격 · 여백 · 단위 변환 상수28줄
// ─── 슬라이드 규격: A4 가로 (297mm × 210mm) ───────────────────────
// 모든 객체 좌표는 슬라이드 대비 비율(0~1)로 저장하고,
// 내보내기 시 inch로 변환한다. x_inch = x * SLIDE_W_IN
export const SLIDE_W_MM = 297;
export const SLIDE_H_MM = 210;
export const SLIDE_W_IN = 11.69;
export const SLIDE_H_IN = 8.27;
export const SLIDE_RATIO = SLIDE_W_MM / SLIDE_H_MM; // √2 ≈ 1.4142

// 인쇄용 안전 여백 (상하좌우 10mm) — 비율로 환산
export const MARGIN_MM = 10;
export const MARGIN_X = MARGIN_MM / SLIDE_W_MM; // ≈ 0.0337
export const MARGIN_Y = MARGIN_MM / SLIDE_H_MM; // ≈ 0.0476

// 폰트 크기는 pt로 저장 (PPT 내보내기 시 그대로 사용)
// 화면 렌더링 시: px = pt × (캔버스폭px ÷ (11.69in × 72pt))
export const SLIDE_W_PT = SLIDE_W_IN * 72; // 841.68pt

export const FONT_FAMILIES = [
  '맑은 고딕',
  '나눔고딕',
  '바탕',
  '돋움',
  'Arial',
  'Georgia',
  'Courier New',
];
src/store.js단일 데이터 모델 (zustand) — 핵심193줄
import { create } from 'zustand';
import { SLIDE_RATIO } from './constants';

const uid = () =>
  typeof crypto !== 'undefined' && crypto.randomUUID
    ? crypto.randomUUID()
    : Math.random().toString(36).slice(2, 10);

export const DEFAULT_TEXT_STYLE = {
  fontFamily: '맑은 고딕',
  fontSize: 18, // pt
  color: '#222222',
  bold: false,
  align: 'left',
  lineHeight: 1.4,
};

export const DEFAULT_TABLE_STYLE = {
  fontFamily: '맑은 고딕',
  fontSize: 12, // pt
  color: '#222222',
  borderColor: '#8a94a3',
  align: 'left',
};

// ─── 단일 데이터 모델 ────────────────────────────────────────────
// pages: [{ id, objects: [{ id, type, x, y, w, h, rotation, style, content }] }]
// x/y/w/h는 슬라이드 대비 비율(0~1). objects 배열 순서 = 레이어 순서(뒤가 위).
// 화면 렌더링 / PPT / PDF 내보내기 모두 이 모델 하나만 읽는다.

const firstPageId = uid();

function mapObjects(state, id, fn) {
  return state.pages.map((p) =>
    p.id === state.currentPageId
      ? { ...p, objects: p.objects.map((o) => (o.id === id ? fn(o) : o)) }
      : p
  );
}

export const useStore = create((set, get) => ({
  pages: [{ id: firstPageId, objects: [] }],
  currentPageId: firstPageId,
  selectedId: null,

  select: (id) => set({ selectedId: id }),

  addObject: (obj) =>
    set((s) => ({
      pages: s.pages.map((p) =>
        p.id === s.currentPageId ? { ...p, objects: [...p.objects, obj] } : p
      ),
      selectedId: obj.id,
    })),

  addText: () =>
    get().addObject({
      id: uid(),
      type: 'text',
      x: 0.35,
      y: 0.42,
      w: 0.3,
      h: 0.12,
      rotation: 0,
      content: '텍스트를 입력하세요',
      style: { ...DEFAULT_TEXT_STYLE },
    }),

  // 이미지: base64 dataURL 저장, 원본 비율에 맞춰 기본 크기 계산
  addImage: (src, natW, natH) => {
    let w = 0.35;
    let h = w * (natH / natW) * SLIDE_RATIO;
    if (h > 0.7) {
      w = (w * 0.7) / h;
      h = 0.7;
    }
    get().addObject({
      id: uid(),
      type: 'image',
      x: 0.5 - w / 2,
      y: 0.5 - h / 2,
      w,
      h,
      rotation: 0,
      src,
    });
  },

  addTable: () =>
    get().addObject({
      id: uid(),
      type: 'table',
      x: 0.3,
      y: 0.35,
      w: 0.4,
      h: 0.28,
      rotation: 0,
      rows: [
        ['', '', ''],
        ['', '', ''],
        ['', '', ''],
      ],
      style: { ...DEFAULT_TABLE_STYLE },
    }),

  setCell: (id, r, c, text) =>
    set((s) => ({
      pages: mapObjects(s, id, (o) => ({
        ...o,
        rows: o.rows.map((row, ri) =>
          ri === r ? row.map((cell, ci) => (ci === c ? text : cell)) : row
        ),
      })),
    })),

  addRow: (id) =>
    set((s) => ({
      pages: mapObjects(s, id, (o) => ({
        ...o,
        rows: [...o.rows, o.rows[0].map(() => '')],
      })),
    })),

  delRow: (id) =>
    set((s) => ({
      pages: mapObjects(s, id, (o) =>
        o.rows.length > 1 ? { ...o, rows: o.rows.slice(0, -1) } : o
      ),
    })),

  addCol: (id) =>
    set((s) => ({
      pages: mapObjects(s, id, (o) => ({
        ...o,
        rows: o.rows.map((row) => [...row, '']),
      })),
    })),

  delCol: (id) =>
    set((s) => ({
      pages: mapObjects(s, id, (o) =>
        o.rows[0].length > 1 ? { ...o, rows: o.rows.map((row) => row.slice(0, -1)) } : o
      ),
    })),

  updateObject: (id, patch) =>
    set((s) => ({ pages: mapObjects(s, id, (o) => ({ ...o, ...patch })) })),

  updateStyle: (id, patch) =>
    set((s) => ({
      pages: mapObjects(s, id, (o) => ({ ...o, style: { ...o.style, ...patch } })),
    })),

  removeObject: (id) =>
    set((s) => ({
      pages: s.pages.map((p) => ({
        ...p,
        objects: p.objects.filter((o) => o.id !== id),
      })),
      selectedId: s.selectedId === id ? null : s.selectedId,
    })),

  nudge: (id, dx, dy) =>
    set((s) => ({
      pages: mapObjects(s, id, (o) => ({ ...o, x: o.x + dx, y: o.y + dy })),
    })),

  // 레이어 순서: 배열 순서가 z-order (뒤에 있을수록 위)
  moveLayer: (id, dir) =>
    set((s) => ({
      pages: s.pages.map((p) => {
        if (p.id !== s.currentPageId) return p;
        const i = p.objects.findIndex((o) => o.id === id);
        const j = i + dir;
        if (i < 0 || j < 0 || j >= p.objects.length) return p;
        const objects = [...p.objects];
        [objects[i], objects[j]] = [objects[j], objects[i]];
        return { ...p, objects };
      }),
    })),
}));

export function useCurrentPage() {
  return useStore((s) => s.pages.find((p) => p.id === s.currentPageId));
}

export function useSelectedObject() {
  return useStore((s) => {
    const page = s.pages.find((p) => p.id === s.currentPageId);
    return page?.objects.find((o) => o.id === s.selectedId) ?? null;
  });
}
src/imageUtils.js이미지 업로드/교체 (base64)25줄
import { useStore } from './store';

// 파일 → base64 dataURL → 원본 크기 측정 후 스토어에 추가
export function addImageFiles(files) {
  for (const file of files) {
    if (!file.type.startsWith('image/')) continue;
    const reader = new FileReader();
    reader.onload = () => {
      const img = new Image();
      img.onload = () =>
        useStore.getState().addImage(reader.result, img.naturalWidth, img.naturalHeight);
      img.src = reader.result;
    };
    reader.readAsDataURL(file);
  }
}

// 선택된 이미지 객체의 src 교체
export function replaceImage(id, file) {
  if (!file || !file.type.startsWith('image/')) return;
  const reader = new FileReader();
  reader.onload = () => useStore.getState().updateObject(id, { src: reader.result });
  reader.readAsDataURL(file);
}
src/App.jsx레이아웃 + 키보드 단축키69줄
import { useEffect } from 'react';
import { useStore } from './store';
import Toolbar from './components/Toolbar';
import EditorCanvas from './components/EditorCanvas';
import LeftPanel from './components/LeftPanel';
import RightPanel from './components/RightPanel';

export default function App() {
  // 방향키 미세 이동 / Delete 삭제 (텍스트 편집 중에는 무시)
  useEffect(() => {
    const onKey = (e) => {
      const el = document.activeElement;
      if (
        el &&
        (el.isContentEditable ||
          el.tagName === 'INPUT' ||
          el.tagName === 'SELECT' ||
          el.tagName === 'TEXTAREA')
      ) {
        return;
      }
      const { selectedId, nudge, removeObject } = useStore.getState();
      if (!selectedId) return;

      if (e.key === 'Delete' || e.key === 'Backspace') {
        e.preventDefault();
        removeObject(selectedId);
        return;
      }
      const step = e.shiftKey ? 0.01 : 0.002;
      const moves = {
        ArrowLeft: [-step, 0],
        ArrowRight: [step, 0],
        ArrowUp: [0, -step],
        ArrowDown: [0, step],
      };
      if (moves[e.key]) {
        e.preventDefault();
        nudge(selectedId, ...moves[e.key]);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  return (
    <div className="app">
      <header className="header">
        <h1>보고서 슬라이드 에디터</h1>
        <span className="header-meta">A4 가로 297×210mm · 여백 가이드 10mm</span>
        <div className="header-actions">
          <button className="btn" disabled title="5단계에서 추가">
            PPT 내보내기
          </button>
          <button className="btn" disabled title="6단계에서 추가">
            PDF 내보내기
          </button>
        </div>
      </header>
      <Toolbar />
      <div className="main">
        <LeftPanel />
        <EditorCanvas />
        <RightPanel />
      </div>
    </div>
  );
}
src/components/EditorCanvas.jsx캔버스 + Moveable(드래그/리사이즈/회전/스냅)212줄
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import Moveable from 'react-moveable';
import { SLIDE_RATIO, MARGIN_X, MARGIN_Y } from '../constants';
import { useStore, useCurrentPage } from '../store';
import { addImageFiles } from '../imageUtils';
import TextBlock from './TextBlock';
import ImageBlock from './ImageBlock';
import TableBlock from './TableBlock';

export default function EditorCanvas() {
  const wrapRef = useRef(null);
  const pageRef = useRef(null);
  const moveableRef = useRef(null);

  const page = useCurrentPage();
  const selectedId = useStore((s) => s.selectedId);
  const select = useStore((s) => s.select);
  const updateObject = useStore((s) => s.updateObject);

  const [size, setSize] = useState({ w: 900, h: 900 / SLIDE_RATIO });
  const [editingId, setEditingId] = useState(null);
  const [targetEl, setTargetEl] = useState(null);
  const [guideEls, setGuideEls] = useState([]);
  const [keepRatio, setKeepRatio] = useState(false);

  // Shift/Ctrl을 누르고 있는 동안 비율 고정 리사이즈
  useEffect(() => {
    const down = (e) => {
      if (e.key === 'Shift' || e.key === 'Control') setKeepRatio(true);
    };
    const up = (e) => {
      if (e.key === 'Shift' || e.key === 'Control') setKeepRatio(false);
    };
    window.addEventListener('keydown', down);
    window.addEventListener('keyup', up);
    window.addEventListener('blur', () => setKeepRatio(false));
    return () => {
      window.removeEventListener('keydown', down);
      window.removeEventListener('keyup', up);
    };
  }, []);

  // 캔버스를 화면에 맞춰 A4 가로 비율(√2:1) 그대로 스케일
  useLayoutEffect(() => {
    const el = wrapRef.current;
    const ro = new ResizeObserver(() => {
      const pad = 56;
      const aw = el.clientWidth - pad;
      const ah = el.clientHeight - pad;
      const w = Math.max(320, Math.min(aw, ah * SLIDE_RATIO));
      setSize({ w, h: w / SLIDE_RATIO });
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // 선택된 객체의 DOM을 Moveable 타깃으로 연결
  useEffect(() => {
    if (!selectedId || editingId === selectedId) {
      setTargetEl(null);
    } else {
      setTargetEl(pageRef.current?.querySelector(`[data-obj-id="${selectedId}"]`) ?? null);
    }
    // 스냅 가이드: 나머지 객체들
    const els = Array.from(pageRef.current?.querySelectorAll('.obj') ?? []).filter(
      (el) => el.dataset.objId !== selectedId
    );
    setGuideEls(els);
  }, [selectedId, editingId, page.objects, size]);

  const W = size.w;
  const H = size.h;

  const onObjMouseDown = (e, id) => {
    if (editingId === id) return;
    e.stopPropagation();
    if (editingId) setEditingId(null);
    if (selectedId !== id) {
      const native = e.nativeEvent;
      flushSync(() => select(id));
      // 선택 직후 같은 제스처로 바로 드래그 가능하게
      requestAnimationFrame(() => {
        try {
          moveableRef.current?.dragStart(native);
        } catch {
          /* 다음 클릭부터 드래그 */
        }
      });
    }
  };

  const startEdit = (id) => {
    setEditingId(id);
  };

  const endEdit = (id, text) => {
    updateObject(id, { content: text });
    setEditingId(null);
  };

  return (
    <div className="canvas-wrap" ref={wrapRef}>
      <div
        className="page"
        ref={pageRef}
        style={{ width: W, height: H }}
        onMouseDown={(e) => {
          if (e.target === e.currentTarget) {
            select(null);
            setEditingId(null);
          }
        }}
        onDragOver={(e) => e.preventDefault()}
        onDrop={(e) => {
          e.preventDefault();
          addImageFiles(e.dataTransfer.files);
        }}
      >
        {/* 인쇄 안전 여백 가이드 (10mm) */}
        <div
          className="margin-guide"
          style={{
            left: MARGIN_X * W,
            top: MARGIN_Y * H,
            width: (1 - 2 * MARGIN_X) * W,
            height: (1 - 2 * MARGIN_Y) * H,
          }}
        />

        {page.objects.map((obj) => {
          if (obj.type === 'text') {
            return (
              <TextBlock
                key={obj.id}
                obj={obj}
                size={size}
                editing={editingId === obj.id}
                onMouseDown={onObjMouseDown}
                onStartEdit={startEdit}
                onEndEdit={endEdit}
              />
            );
          }
          if (obj.type === 'image') {
            return <ImageBlock key={obj.id} obj={obj} size={size} onMouseDown={onObjMouseDown} />;
          }
          if (obj.type === 'table') {
            return (
              <TableBlock
                key={obj.id}
                obj={obj}
                size={size}
                editing={editingId === obj.id}
                onMouseDown={onObjMouseDown}
                onStartEdit={startEdit}
              />
            );
          }
          return null;
        })}

        <Moveable
          ref={moveableRef}
          target={targetEl}
          origin={false}
          draggable
          resizable
          rotatable
          keepRatio={keepRatio}
          throttleDrag={0}
          throttleResize={0}
          throttleRotate={0}
          renderDirections={['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']}
          snappable
          snapThreshold={8}
          snapGap={true}
          isDisplaySnapDigit={true}
          isDisplayInnerSnapDigit={true}
          snapDigit={0}
          snapDistFormat={(v) => `${Math.round((v * 297) / W)}mm`}
          snapDirections={{ top: true, left: true, bottom: true, right: true, center: true, middle: true }}
          elementSnapDirections={{ top: true, left: true, bottom: true, right: true, center: true, middle: true }}
          elementGuidelines={guideEls}
          verticalGuidelines={[0, MARGIN_X * W, W / 2, (1 - MARGIN_X) * W, W]}
          horizontalGuidelines={[0, MARGIN_Y * H, H / 2, (1 - MARGIN_Y) * H, H]}
          onDrag={(e) => {
            flushSync(() => {
              updateObject(selectedId, { x: e.left / W, y: e.top / H });
            });
          }}
          onResize={(e) => {
            flushSync(() => {
              updateObject(selectedId, {
                w: e.width / W,
                h: e.height / H,
                x: e.drag.left / W,
                y: e.drag.top / H,
              });
            });
          }}
          onRotate={(e) => {
            flushSync(() => {
              updateObject(selectedId, { rotation: e.rotation });
            });
          }}
        />
      </div>
    </div>
  );
}
src/components/TextBlock.jsx텍스트 블럭 (더블클릭 인라인 편집)70줄
import { useEffect, useRef } from 'react';
import { SLIDE_W_PT } from '../constants';

function EditableContent({ content, onCommit }) {
  const ref = useRef(null);

  useEffect(() => {
    const el = ref.current;
    el.focus();
    const range = document.createRange();
    range.selectNodeContents(el);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  }, []);

  return (
    <div
      ref={ref}
      className="text-edit"
      contentEditable
      suppressContentEditableWarning
      onBlur={() => onCommit(ref.current.innerText)}
      onKeyDown={(e) => {
        if (e.key === 'Escape') ref.current.blur();
        e.stopPropagation();
      }}
      onMouseDown={(e) => e.stopPropagation()}
    >
      {content}
    </div>
  );
}

export default function TextBlock({ obj, size, editing, onMouseDown, onStartEdit, onEndEdit }) {
  const { x, y, w, h, rotation, style, content } = obj;
  const W = size.w;
  const H = size.h;
  // pt → 화면 px (캔버스 크기에 비례)
  const fontPx = style.fontSize * (W / SLIDE_W_PT);

  return (
    <div
      className="obj text-obj"
      data-obj-id={obj.id}
      style={{
        left: x * W,
        top: y * H,
        width: w * W,
        height: h * H,
        transform: `rotate(${rotation}deg)`,
        fontFamily: `'${style.fontFamily}', sans-serif`,
        fontSize: fontPx,
        color: style.color,
        fontWeight: style.bold ? 700 : 400,
        textAlign: style.align,
        lineHeight: style.lineHeight,
      }}
      onMouseDown={(e) => onMouseDown(e, obj.id)}
      onDoubleClick={() => onStartEdit(obj.id)}
    >
      {editing ? (
        <EditableContent content={content} onCommit={(text) => onEndEdit(obj.id, text)} />
      ) : (
        <div className="text-view">{content}</div>
      )}
    </div>
  );
}
src/components/ImageBlock.jsx이미지 블럭23줄
export default function ImageBlock({ obj, size, onMouseDown }) {
  const { x, y, w, h, rotation, src } = obj;
  const W = size.w;
  const H = size.h;

  return (
    <div
      className="obj image-obj"
      data-obj-id={obj.id}
      style={{
        left: x * W,
        top: y * H,
        width: w * W,
        height: h * H,
        transform: `rotate(${rotation}deg)`,
      }}
      onMouseDown={(e) => onMouseDown(e, obj.id)}
    >
      <img src={src} draggable={false} alt="" />
    </div>
  );
}
src/components/TableBlock.jsx표 블럭 (셀 편집)59줄
import { SLIDE_W_PT } from '../constants';
import { useStore } from '../store';

export default function TableBlock({ obj, size, editing, onMouseDown, onStartEdit }) {
  const setCell = useStore((s) => s.setCell);
  const { x, y, w, h, rotation, rows, style } = obj;
  const W = size.w;
  const H = size.h;
  const fontPx = style.fontSize * (W / SLIDE_W_PT);

  return (
    <div
      className={`obj table-obj ${editing ? 'editing' : ''}`}
      data-obj-id={obj.id}
      style={{
        left: x * W,
        top: y * H,
        width: w * W,
        height: h * H,
        transform: `rotate(${rotation}deg)`,
      }}
      onMouseDown={(e) => onMouseDown(e, obj.id)}
      onDoubleClick={() => onStartEdit(obj.id)}
    >
      <table
        style={{
          fontFamily: `'${style.fontFamily}', sans-serif`,
          fontSize: fontPx,
          color: style.color,
          textAlign: style.align,
          '--border-color': style.borderColor,
        }}
      >
        <tbody>
          {rows.map((row, r) => (
            <tr key={r}>
              {row.map((cell, c) => (
                <td
                  key={c}
                  contentEditable={editing}
                  suppressContentEditableWarning
                  onBlur={
                    editing
                      ? (e) => setCell(obj.id, r, c, e.currentTarget.innerText)
                      : undefined
                  }
                  onMouseDown={editing ? (e) => e.stopPropagation() : undefined}
                >
                  {cell}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
src/components/Toolbar.jsx상단 툴바 (객체 추가)43줄
import { useRef } from 'react';
import { useStore } from '../store';
import { addImageFiles } from '../imageUtils';

export default function Toolbar() {
  const addText = useStore((s) => s.addText);
  const addTable = useStore((s) => s.addTable);
  const fileRef = useRef(null);

  return (
    <div className="toolbar">
      <div className="toolbar-group">
        <button className="btn primary" onClick={addText}>
          + 텍스트
        </button>
        <button className="btn primary" onClick={() => fileRef.current.click()}>
          + 이미지
        </button>
        <button className="btn primary" onClick={addTable}>
          + 표
        </button>
        <input
          ref={fileRef}
          type="file"
          accept="image/*"
          multiple
          hidden
          onChange={(e) => {
            addImageFiles(e.target.files);
            e.target.value = '';
          }}
        />
      </div>

      <div className="toolbar-sep" />

      <span className="toolbar-hint">
        클릭 선택 · 더블클릭 편집(텍스트/표) · Shift/Ctrl+크기조절 = 비율 유지 · 방향키 미세이동 · Delete 삭제
      </span>
    </div>
  );
}
src/components/LeftPanel.jsx좌측 내용 일괄 입력 폼68줄
import { useStore, useCurrentPage } from '../store';

const TYPE_LABEL = { text: '텍스트', image: '이미지', table: '표' };

// 페이지 내 모든 요소의 텍스트를 폼으로 일괄 입력 — 반복 입력 효율용.
// 캔버스와 같은 단일 데이터 모델을 읽고 쓰므로 즉시 동기화된다.
export default function LeftPanel() {
  const page = useCurrentPage();
  const selectedId = useStore((s) => s.selectedId);
  const select = useStore((s) => s.select);
  const updateObject = useStore((s) => s.updateObject);
  const setCell = useStore((s) => s.setCell);

  return (
    <aside className="panel panel-left">
      <div className="panel-title">내용 입력</div>
      {page.objects.length === 0 && (
        <p className="panel-empty">
          객체를 추가하면 여기서 텍스트를
          <br />
          빠르게 입력할 수 있습니다.
        </p>
      )}
      {page.objects.map((o, i) => (
        <div
          key={o.id}
          className={`item-card ${selectedId === o.id ? 'selected' : ''}`}
          onClick={() => select(o.id)}
        >
          <div className="item-label">
            {i + 1}. {TYPE_LABEL[o.type]}
          </div>

          {o.type === 'text' && (
            <textarea
              rows={2}
              value={o.content}
              onChange={(e) => updateObject(o.id, { content: e.target.value })}
              onFocus={() => select(o.id)}
            />
          )}

          {o.type === 'table' && (
            <div
              className="cell-grid"
              style={{ gridTemplateColumns: `repeat(${o.rows[0].length}, 1fr)` }}
            >
              {o.rows.map((row, r) =>
                row.map((cell, c) => (
                  <input
                    key={`${r}-${c}`}
                    value={cell}
                    placeholder={`${r + 1}·${c + 1}`}
                    onChange={(e) => setCell(o.id, r, c, e.target.value)}
                    onFocus={() => select(o.id)}
                  />
                ))
              )}
            </div>
          )}

          {o.type === 'image' && <img className="thumb" src={o.src} alt="" />}
        </div>
      ))}
    </aside>
  );
}
src/components/RightPanel.jsx우측 상세 설정 (mm 단위)183줄
import { useRef } from 'react';
import { FONT_FAMILIES, SLIDE_W_MM, SLIDE_H_MM } from '../constants';
import { useStore, useSelectedObject } from '../store';
import { replaceImage } from '../imageUtils';

function NumField({ label, value, onChange, step = 1, min, max }) {
  return (
    <label className="field">
      <span>{label}</span>
      <input
        type="number"
        step={step}
        min={min}
        max={max}
        value={value}
        onChange={(e) => onChange(Number(e.target.value) || 0)}
      />
    </label>
  );
}

const round1 = (v) => Math.round(v * 10) / 10;

// 선택된 객체의 위치·크기·회전(mm/도) + 타입별 스타일 상세 설정
export default function RightPanel() {
  const selected = useSelectedObject();
  const updateObject = useStore((s) => s.updateObject);
  const updateStyle = useStore((s) => s.updateStyle);
  const removeObject = useStore((s) => s.removeObject);
  const moveLayer = useStore((s) => s.moveLayer);
  const addRow = useStore((s) => s.addRow);
  const delRow = useStore((s) => s.delRow);
  const addCol = useStore((s) => s.addCol);
  const delCol = useStore((s) => s.delCol);
  const fileRef = useRef(null);

  if (!selected) {
    return (
      <aside className="panel panel-right">
        <div className="panel-title">상세 설정</div>
        <p className="panel-empty">
          객체를 선택하면 위치·크기·
          <br />
          스타일을 편집할 수 있습니다.
        </p>
      </aside>
    );
  }

  const id = selected.id;
  const patch = (p) => updateObject(id, p);
  const patchStyle = (p) => updateStyle(id, p);
  const style = selected.style;

  return (
    <aside className="panel panel-right">
      <div className="panel-title">
        상세 설정 — {{ text: '텍스트', image: '이미지', table: '표' }[selected.type]}
      </div>

      <div className="panel-section">
        <div className="section-title">위치 / 크기 (mm)</div>
        <div className="field-row">
          <NumField label="X" value={round1(selected.x * SLIDE_W_MM)} onChange={(v) => patch({ x: v / SLIDE_W_MM })} />
          <NumField label="Y" value={round1(selected.y * SLIDE_H_MM)} onChange={(v) => patch({ y: v / SLIDE_H_MM })} />
        </div>
        <div className="field-row">
          <NumField label="너비" value={round1(selected.w * SLIDE_W_MM)} onChange={(v) => patch({ w: Math.max(1, v) / SLIDE_W_MM })} />
          <NumField label="높이" value={round1(selected.h * SLIDE_H_MM)} onChange={(v) => patch({ h: Math.max(1, v) / SLIDE_H_MM })} />
        </div>
        <div className="field-row">
          <NumField label="회전°" value={Math.round(selected.rotation)} onChange={(v) => patch({ rotation: v })} step={5} />
        </div>
      </div>

      {selected.type === 'text' && (
        <div className="panel-section">
          <div className="section-title">텍스트 스타일</div>
          <label className="field wide">
            <span>글꼴</span>
            <select value={style.fontFamily} onChange={(e) => patchStyle({ fontFamily: e.target.value })}>
              {FONT_FAMILIES.map((f) => (
                <option key={f} value={f}>{f}</option>
              ))}
            </select>
          </label>
          <div className="field-row">
            <NumField label="크기pt" value={style.fontSize} onChange={(v) => patchStyle({ fontSize: Math.max(6, v) })} min={6} max={120} />
            <label className="field">
              <span>색상</span>
              <input type="color" value={style.color} onChange={(e) => patchStyle({ color: e.target.value })} />
            </label>
          </div>
          <div className="btn-row">
            <button className={`btn icon ${style.bold ? 'active' : ''}`} onClick={() => patchStyle({ bold: !style.bold })}>
              <b>B</b>
            </button>
            {['left', 'center', 'right'].map((a) => (
              <button
                key={a}
                className={`btn icon ${style.align === a ? 'active' : ''}`}
                onClick={() => patchStyle({ align: a })}
              >
                {{ left: '⇤', center: '↔', right: '⇥' }[a]}
              </button>
            ))}
            <select value={style.lineHeight} onChange={(e) => patchStyle({ lineHeight: Number(e.target.value) })}>
              {[1, 1.2, 1.4, 1.6, 2].map((lh) => (
                <option key={lh} value={lh}>줄간격 {lh}</option>
              ))}
            </select>
          </div>
        </div>
      )}

      {selected.type === 'image' && (
        <div className="panel-section">
          <div className="section-title">이미지</div>
          <button className="btn wide" onClick={() => fileRef.current.click()}>
            이미지 교체
          </button>
          <input
            ref={fileRef}
            type="file"
            accept="image/*"
            hidden
            onChange={(e) => {
              replaceImage(id, e.target.files[0]);
              e.target.value = '';
            }}
          />
        </div>
      )}

      {selected.type === 'table' && (
        <div className="panel-section">
          <div className="section-title">
            표 ({selected.rows.length}행 × {selected.rows[0].length}열)
          </div>
          <div className="btn-row">
            <button className="btn" onClick={() => addRow(id)}>행 +</button>
            <button className="btn" onClick={() => delRow(id)}>행 −</button>
            <button className="btn" onClick={() => addCol(id)}>열 +</button>
            <button className="btn" onClick={() => delCol(id)}>열 −</button>
          </div>
          <div className="field-row">
            <NumField label="크기pt" value={style.fontSize} onChange={(v) => patchStyle({ fontSize: Math.max(6, v) })} min={6} max={72} />
            <label className="field">
              <span>글자색</span>
              <input type="color" value={style.color} onChange={(e) => patchStyle({ color: e.target.value })} />
            </label>
          </div>
          <div className="field-row">
            <label className="field">
              <span>테두리</span>
              <input type="color" value={style.borderColor} onChange={(e) => patchStyle({ borderColor: e.target.value })} />
            </label>
            <label className="field">
              <span>정렬</span>
              <select value={style.align} onChange={(e) => patchStyle({ align: e.target.value })}>
                <option value="left">왼쪽</option>
                <option value="center">가운데</option>
                <option value="right">오른쪽</option>
              </select>
            </label>
          </div>
        </div>
      )}

      <div className="panel-section">
        <div className="section-title">레이어</div>
        <div className="btn-row">
          <button className="btn" onClick={() => moveLayer(id, +1)}>앞으로 ▲</button>
          <button className="btn" onClick={() => moveLayer(id, -1)}>뒤로 ▼</button>
        </div>
        <button className="btn danger wide" onClick={() => removeObject(id)}>
          객체 삭제
        </button>
      </div>
    </aside>
  );
}
src/styles.css전체 스타일411줄
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body,
#root {
  height: 100%;
}

body {
  font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
  background: #2b2f36;
  color: #e5e7eb;
  overflow: hidden;
}

.app {
  display: flex;
  flex-direction: column;
  height: 100%;
}

/* ─── 헤더 ─── */
.header {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  background: #1f2329;
  border-bottom: 1px solid #3a3f47;
}

.header h1 {
  font-size: 15px;
  font-weight: 700;
}

.header-meta {
  font-size: 12px;
  color: #9ca3af;
}

.header-actions {
  margin-left: auto;
  display: flex;
  gap: 8px;
}

/* ─── 툴바 ─── */
.toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 16px;
  background: #262a31;
  border-bottom: 1px solid #3a3f47;
  min-height: 46px;
}

.toolbar-group {
  display: flex;
  align-items: center;
  gap: 6px;
}

.toolbar-sep {
  width: 1px;
  height: 22px;
  background: #3a3f47;
}

.toolbar-hint {
  font-size: 12px;
  color: #9ca3af;
}

.toolbar select,
.toolbar input[type='number'] {
  background: #1f2329;
  color: #e5e7eb;
  border: 1px solid #3a3f47;
  border-radius: 6px;
  padding: 5px 8px;
  font-size: 13px;
}

.toolbar input[type='color'] {
  width: 32px;
  height: 30px;
  padding: 2px;
  background: #1f2329;
  border: 1px solid #3a3f47;
  border-radius: 6px;
  cursor: pointer;
}

/* ─── 버튼 ─── */
.btn {
  background: #343941;
  color: #e5e7eb;
  border: 1px solid #454b54;
  border-radius: 6px;
  padding: 6px 12px;
  font-size: 13px;
  cursor: pointer;
}

.btn:hover:not(:disabled) {
  background: #3f454e;
}

.btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.btn.primary {
  background: #2563eb;
  border-color: #2563eb;
}

.btn.primary:hover {
  background: #1d4ed8;
}

.btn.danger {
  background: #7f1d1d;
  border-color: #991b1b;
}

.btn.icon {
  padding: 6px 10px;
}

.btn.active {
  background: #2563eb;
  border-color: #2563eb;
}

/* ─── 메인 3단 레이아웃 ─── */
.main {
  flex: 1;
  display: flex;
  overflow: hidden;
}

/* ─── 좌우 패널 ─── */
.panel {
  width: 264px;
  flex-shrink: 0;
  background: #22262c;
  overflow-y: auto;
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.panel-left {
  border-right: 1px solid #3a3f47;
}

.panel-right {
  border-left: 1px solid #3a3f47;
}

.panel-title {
  font-size: 13px;
  font-weight: 700;
  color: #cbd5e1;
  padding-bottom: 6px;
  border-bottom: 1px solid #3a3f47;
}

.panel-empty {
  font-size: 12px;
  color: #6b7280;
  line-height: 1.6;
}

.panel-section {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-bottom: 10px;
  border-bottom: 1px solid #31363d;
}

.section-title {
  font-size: 12px;
  font-weight: 600;
  color: #9ca3af;
}

/* 좌측 패널 아이템 카드 */
.item-card {
  background: #1c2025;
  border: 1px solid #31363d;
  border-radius: 8px;
  padding: 8px;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.item-card.selected {
  border-color: #2563eb;
}

.item-label {
  font-size: 12px;
  font-weight: 600;
  color: #9ca3af;
}

.item-card textarea {
  width: 100%;
  resize: vertical;
  background: #14171b;
  color: #e5e7eb;
  border: 1px solid #3a3f47;
  border-radius: 6px;
  padding: 6px 8px;
  font-size: 13px;
  font-family: inherit;
}

.cell-grid {
  display: grid;
  gap: 3px;
}

.cell-grid input {
  width: 100%;
  min-width: 0;
  background: #14171b;
  color: #e5e7eb;
  border: 1px solid #3a3f47;
  border-radius: 4px;
  padding: 4px 5px;
  font-size: 12px;
}

.thumb {
  width: 100%;
  max-height: 80px;
  object-fit: contain;
  background: #14171b;
  border-radius: 6px;
}

/* 우측 패널 필드 */
.field {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 1;
  min-width: 0;
}

.field span {
  font-size: 12px;
  color: #9ca3af;
  white-space: nowrap;
}

.field input[type='number'],
.field select {
  width: 100%;
  min-width: 0;
  background: #14171b;
  color: #e5e7eb;
  border: 1px solid #3a3f47;
  border-radius: 6px;
  padding: 5px 6px;
  font-size: 13px;
}

.field input[type='color'] {
  width: 100%;
  height: 30px;
  padding: 2px;
  background: #14171b;
  border: 1px solid #3a3f47;
  border-radius: 6px;
  cursor: pointer;
}

.field.wide {
  width: 100%;
}

.field-row {
  display: flex;
  gap: 8px;
}

.btn-row {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
  align-items: center;
}

.btn-row select {
  background: #14171b;
  color: #e5e7eb;
  border: 1px solid #3a3f47;
  border-radius: 6px;
  padding: 5px 6px;
  font-size: 12px;
}

.btn.wide {
  width: 100%;
}

/* ─── 캔버스 ─── */
.canvas-wrap {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  position: relative;
}

.page {
  position: relative;
  background: #ffffff;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
  overflow: hidden;
}

.margin-guide {
  position: absolute;
  border: 1px dashed #b8c4d4;
  pointer-events: none;
}

/* ─── 배치 객체 ─── */
.obj {
  position: absolute;
  user-select: none;
  cursor: move;
}

.text-obj {
  color: #222;
}

.text-view {
  width: 100%;
  height: 100%;
  white-space: pre-wrap;
  word-break: break-word;
  overflow: hidden;
}

.text-edit {
  width: 100%;
  height: 100%;
  white-space: pre-wrap;
  word-break: break-word;
  outline: 2px solid #2563eb;
  cursor: text;
  user-select: text;
}

/* ─── 이미지 블럭 ─── */
.image-obj img {
  width: 100%;
  height: 100%;
  object-fit: fill;
  display: block;
}

/* ─── 표 블럭 ─── */
.table-obj table {
  width: 100%;
  height: 100%;
  border-collapse: collapse;
  table-layout: fixed;
}

.table-obj td {
  border: 1px solid var(--border-color, #8a94a3);
  padding: 2px 6px;
  vertical-align: middle;
  overflow: hidden;
  word-break: break-word;
}

.table-obj.editing {
  outline: 2px solid #2563eb;
}

.table-obj.editing td {
  cursor: text;
  user-select: text;
}

/* Moveable 스냅 가이드라인 색 보정 */
.moveable-line.moveable-guideline {
  background: #f43f5e !important;
}
05 — ROADMAP

진행 로드맵

각 단계가 실제로 동작하는 상태로 만들고 확인받으며 진행한다.

1. A4 가로 캔버스 + 단일 데이터 모델 골격완료 — √2:1 자동 스케일 캔버스, 10mm 여백 가이드, zustand 비율 좌표 모델
2. 텍스트 블럭완료 — 추가/이동/리사이즈/회전/인라인 편집/스타일/방향키/레이어
3. 이미지 블럭완료 — base64 업로드, 드래그앤드롭, 간격 스냅, 비율 고정 리사이즈
4. 표 블럭 + 좌우 작업 패널완료 — 행/열 편집, 셀 입력, 내용 일괄 입력 폼, mm 상세 설정
5
PPT 내보내기 (pptxgenjs)defineLayout A4L(11.69×8.27) · addText/addImage/addTable · rotate 반영
6
PDF 내보내기 (html2canvas + jsPDF)페이지 DOM 캡처 → A4 가로 297×210mm 삽입, 화면과 1:1 비율 일치
7
다중 페이지 + 마무리페이지 추가/삭제/순서 변경 UI (데이터 모델은 이미 pages[] 지원)