9.4 붙여넣기/드래그 삽입, 캡션/대체텍스트 o

This commit is contained in:
koreacomp5
2025-10-09 18:05:15 +09:00
parent b0b7b9f6fb
commit be9eb5f530
2 changed files with 97 additions and 3 deletions

View File

@@ -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<string> {
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<Blob> {
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<HTMLDivElement | null>(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 = `<figure style="margin:8px 0"><img src="${data.url}" alt="${alt.replace(/"/g, '&quot;')}" /><figcaption style="font-size:12px;opacity:.8">${caption.replace(/</g, "&lt;")}</figcaption></figure>`;
insertHtmlAtCursor(figure);
// onChange 동기화
const el = ref.current;
if (el) onChange(el.innerHTML);
} catch {
show("이미지 업로드 실패");
}
}
},
[insertHtmlAtCursor, onChange, show]
);
const onPaste = useCallback(
async (e: React.ClipboardEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
e.preventDefault();
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length > 0) await uploadAndInsert(files);
},
[uploadAndInsert]
);
return (
<div
ref={ref}
contentEditable
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
onPaste={onPaste}
onDrop={onDrop}
onDragOver={(e) => e.preventDefault()}
data-placeholder={placeholder}
style={{
minHeight: 160,

View File

@@ -65,7 +65,7 @@
9.1 에디터(Tiptap/Quill 중 택1) 통합 o
9.2 이미지/파일 업로드 서버 처리 및 검증 o
9.3 리사이즈/웹포맷 최적화 및 용량 제한 o
9.4 붙여넣기/드래그 삽입, 캡션/대체텍스트
9.4 붙여넣기/드래그 삽입, 캡션/대체텍스트 o
9.5 사진 중심 카테고리 프리셋(해상도/비율/워터마크 옵션)
[관리자(Admin)]