미리캔버스/Canva 스타일의 웹 에디터. 텍스트·이미지·표를 A4 가로 캔버스에 자유 배치하고, 화면 그대로 .pptx(편집·전달용)와 .pdf(인쇄용)로 내보내는 것이 목표. 모든 것은 단 하나의 JSON 데이터 모델에서 흘러나온다.
보고서는 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에서 열어도 깨지지 않는 범용 폰트 |
모든 객체는 자유 이동 · 8방향 리사이즈 · 회전 · 삭제 · 레이어 순서 변경을 지원한다.
더블클릭 인라인 편집(여러 줄). 글꼴·크기(pt)·색상·굵기·정렬·줄간격 편집. 방향키 미세이동, Delete 삭제.
파일 선택(다중) 또는 캔버스 드래그앤드롭 업로드. 원본 비율 자동 배치, 한 페이지 3~4개 이상 가능.
행/열 추가·삭제, 더블클릭 셀 편집, 테두리·글자색·정렬 설정. 좌측 패널에서 셀 그리드로 일괄 입력도 가능.
중앙·가장자리·여백선 스냅 + 간격 스냅: 이미지 A–B 간격과 같아지는 지점에 C가 자석처럼 붙고, 간격이 mm로 표시. 3~4개 균등 배치가 드래그만으로 완성.
Shift/Ctrl를 누른 채 핸들을 끌면 가로세로 비율 유지. 드래그 중 전환 가능, 창 포커스 아웃 시 자동 해제.
좌측: 모든 요소의 텍스트를 폼으로 일괄 입력(반복 보고서 효율). 우측: 선택 객체의 위치·크기를 mm 단위로 정밀 편집 + 타입별 스타일.
화면 렌더링·PPT 내보내기·PDF 내보내기가 모두 하나의 JSON 모델만 읽는다. 좌표는 픽셀이 아닌 슬라이드 대비 비율(0~1)로 저장하므로, 어떤 해상도·어떤 출력에서도 배치가 동일하다.
// 객체 하나의 실제 모양 — 이것이 전부다
{
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)
16개 파일 · 1,437줄. 파일명을 클릭하면 코드가 펼쳐진다.
경로: claude code project/보고서_슬라이드_에디터
{
"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"
}
}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});
<!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>
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>
);
// ─── 슬라이드 규격: 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',
];
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;
});
}
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);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
* {
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;
}
각 단계가 실제로 동작하는 상태로 만들고 확인받으며 진행한다.