diff --git a/src/app/components/Editor.tsx b/src/app/components/Editor.tsx index 2439750..72a5146 100644 --- a/src/app/components/Editor.tsx +++ b/src/app/components/Editor.tsx @@ -1,18 +1,112 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useCallback } from "react"; +import { useToast } from "@/app/components/ui/ToastProvider"; -export function Editor({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) { +type Props = { value: string; onChange: (v: string) => void; placeholder?: string }; + +async function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +async function resizeImageToBlob(dataUrl: string, opts: { maxWidth: number; maxHeight: number; quality: number }): Promise { + const img = document.createElement("img"); + img.decoding = "async"; + img.loading = "eager"; + img.src = dataUrl; + await new Promise((res, rej) => { + img.onload = () => res(null); + img.onerror = rej; + }); + const w = img.naturalWidth || 1; + const h = img.naturalHeight || 1; + const scale = Math.min(1, opts.maxWidth / w || 1, opts.maxHeight / h || 1); + const targetW = Math.max(1, Math.round(w * scale)); + const targetH = Math.max(1, Math.round(h * scale)); + const canvas = document.createElement("canvas"); + canvas.width = targetW; + canvas.height = targetH; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas unsupported"); + ctx.drawImage(img, 0, 0, targetW, targetH); + return await new Promise((resolve) => canvas.toBlob((b) => resolve(b as Blob), "image/webp", opts.quality)); +} + +export function Editor({ value, onChange, placeholder }: Props) { const ref = useRef(null); + const { show } = useToast(); + useEffect(() => { const el = ref.current; if (!el) return; if (el.innerHTML !== value) el.innerHTML = value || ""; }, [value]); + + const insertHtmlAtCursor = useCallback((html: string) => { + // eslint-disable-next-line deprecation/deprecation + document.execCommand("insertHTML", false, html); + }, []); + + const uploadAndInsert = useCallback( + async (files: File[]) => { + const images = files.filter((f) => f.type.startsWith("image/")); + if (images.length === 0) return; + for (const f of images) { + try { + const dataUrl = await readFileAsDataUrl(f); + const blob = await resizeImageToBlob(dataUrl, { maxWidth: 1600, maxHeight: 1600, quality: 0.9 }); + const fd = new FormData(); + fd.append("file", new File([blob], `${Date.now()}.webp`, { type: "image/webp" })); + const r = await fetch("/api/uploads", { method: "POST", body: fd }); + const data = await r.json(); + if (!r.ok) throw new Error(JSON.stringify(data)); + const alt = window.prompt("이미지 대체텍스트(ALT)를 입력하세요.") || ""; + const caption = window.prompt("이미지 캡션(선택)") || ""; + const figure = `
${alt.replace(/
${caption.replace(/
`; + insertHtmlAtCursor(figure); + // onChange 동기화 + const el = ref.current; + if (el) onChange(el.innerHTML); + } catch { + show("이미지 업로드 실패"); + } + } + }, + [insertHtmlAtCursor, onChange, show] + ); + + const onPaste = useCallback( + async (e: React.ClipboardEvent) => { + const files = Array.from(e.clipboardData?.files ?? []); + if (files.some((f) => f.type.startsWith("image/"))) { + e.preventDefault(); + await uploadAndInsert(files); + } + }, + [uploadAndInsert] + ); + + const onDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + const files = Array.from(e.dataTransfer?.files ?? []); + if (files.length > 0) await uploadAndInsert(files); + }, + [uploadAndInsert] + ); + return (
onChange((e.target as HTMLDivElement).innerHTML)} + onPaste={onPaste} + onDrop={onDrop} + onDragOver={(e) => e.preventDefault()} data-placeholder={placeholder} style={{ minHeight: 160, diff --git a/todolist.txt b/todolist.txt index f0f84e6..05e0a7a 100644 --- a/todolist.txt +++ b/todolist.txt @@ -65,7 +65,7 @@ 9.1 에디터(Tiptap/Quill 중 택1) 통합 o 9.2 이미지/파일 업로드 서버 처리 및 검증 o 9.3 리사이즈/웹포맷 최적화 및 용량 제한 o -9.4 붙여넣기/드래그 삽입, 캡션/대체텍스트 +9.4 붙여넣기/드래그 삽입, 캡션/대체텍스트 o 9.5 사진 중심 카테고리 프리셋(해상도/비율/워터마크 옵션) [관리자(Admin)]