글쓰기

This commit is contained in:
mota
2025-11-02 11:33:44 +09:00
parent 9ff08d3e58
commit 9e02aa3a88
7 changed files with 141 additions and 10 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -44,7 +44,87 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
if (el.innerHTML !== value) el.innerHTML = value || ""; if (el.innerHTML !== value) el.innerHTML = value || "";
}, [value]);
// 이미지 리사이즈 및 삭제 기능
const setupImageHandlers = () => {
const figures = el.querySelectorAll<HTMLElement>("figure[data-resizable='true']");
figures.forEach((figure) => {
// 리사이즈 핸들 설정
const handle = figure.querySelector<HTMLElement>(".resize-handle");
const img = figure.querySelector<HTMLImageElement>("img");
if (handle && img) {
// 기존 이벤트 리스너 제거 (중복 방지)
const newHandle = handle.cloneNode(true) as HTMLElement;
handle.parentNode?.replaceChild(newHandle, handle);
let isResizing = false;
let startX = 0;
let startWidth = 0;
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
isResizing = true;
startX = e.clientX;
const rect = img.getBoundingClientRect();
startWidth = rect.width;
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const diffX = e.clientX - startX;
const scale = Math.max(0.1, Math.min(3, 1 + (diffX / startWidth)));
const newWidth = startWidth * scale;
img.style.width = `${newWidth}px`;
img.style.height = "auto";
// onChange 동기화
if (el) onChange(el.innerHTML);
};
const onMouseUp = () => {
isResizing = false;
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
newHandle.addEventListener("mousedown", onMouseDown);
}
// 삭제 버튼 설정
const deleteBtn = figure.querySelector<HTMLElement>(".delete-image-btn");
if (deleteBtn) {
// 기존 이벤트 리스너 제거 (중복 방지)
const newDeleteBtn = deleteBtn.cloneNode(true) as HTMLElement;
deleteBtn.parentNode?.replaceChild(newDeleteBtn, deleteBtn);
const onDeleteClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (figure.parentNode) {
figure.parentNode.removeChild(figure);
// onChange 동기화
if (el) onChange(el.innerHTML);
}
};
newDeleteBtn.addEventListener("click", onDeleteClick);
}
});
};
// 초기 설정 및 변경 감지
setupImageHandlers();
const observer = new MutationObserver(() => {
setupImageHandlers();
});
observer.observe(el, { childList: true, subtree: true });
return () => {
observer.disconnect();
};
}, [value, onChange]);
const insertHtmlAtCursor = useCallback((html: string) => { const insertHtmlAtCursor = useCallback((html: string) => {
// eslint-disable-next-line deprecation/deprecation // eslint-disable-next-line deprecation/deprecation
@@ -64,9 +144,7 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
const r = await fetch("/api/uploads", { method: "POST", body: fd }); const r = await fetch("/api/uploads", { method: "POST", body: fd });
const data = await r.json(); const data = await r.json();
if (!r.ok) throw new Error(JSON.stringify(data)); if (!r.ok) throw new Error(JSON.stringify(data));
const alt = window.prompt("이미지 대체텍스트(ALT)를 입력하세요.") || ""; const figure = `<figure style="margin:8px 0; position: relative; display: inline-block;" data-resizable="true"><img src="${data.url}" alt="" style="max-width: 100%; height: auto; display: block; cursor: pointer;" /><div class="resize-handle" style="position: absolute; bottom: 0; right: 0; width: 16px; height: 16px; background: #f94b37; cursor: se-resize; border-radius: 50% 0 0 0; opacity: 0.8;"></div><button class="delete-image-btn" style="position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; background: rgba(0, 0, 0, 0.6); color: white; border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; line-height: 1; opacity: 0.8;" title="이미지 삭제">×</button></figure>`;
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); insertHtmlAtCursor(figure);
// onChange 동기화 // onChange 동기화
const el = ref.current; const el = ref.current;
@@ -135,7 +213,7 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
const toolbar = useMemo(() => { const toolbar = useMemo(() => {
if (!withToolbar) return null; if (!withToolbar) return null;
return ( return (
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-nowrap items-center gap-1 overflow-x-auto">
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("bold")} aria-label="굵게">B</button> <button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("bold")} aria-label="굵게">B</button>
<button type="button" className="px-2 py-1 text-sm italic rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("italic")} aria-label="기울임">I</button> <button type="button" className="px-2 py-1 text-sm italic rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("italic")} aria-label="기울임">I</button>
<button type="button" className="px-2 py-1 text-sm line-through rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("strikeThrough")} aria-label="취소선">S</button> <button type="button" className="px-2 py-1 text-sm line-through rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("strikeThrough")} aria-label="취소선">S</button>
@@ -163,10 +241,58 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "BLOCKQUOTE")} aria-label="인용구"></button> <button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "BLOCKQUOTE")} aria-label="인용구"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertHorizontalRule")} aria-label="구분선"></button> <button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertHorizontalRule")} aria-label="구분선"></button>
<span className="mx-2 h-4 w-px bg-neutral-300" /> <span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyLeft")} aria-label="왼쪽 정렬"></button> <button
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyCenter")} aria-label="가운데 정렬"></button> type="button"
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyRight")} aria-label="오른쪽 정렬"></button> className="px-1 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100 flex items-center justify-center flex-shrink-0"
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyFull")} aria-label="양쪽 정렬"></button> onClick={() => exec("justifyLeft")}
aria-label="왼쪽 정렬"
style={{ width: "24px", height: "24px" }}
>
<span className="flex flex-col gap-0.5 items-start" style={{ width: "14px", height: "10px" }}>
<span className="block h-0.5 bg-current" style={{ width: "10px" }} />
<span className="block h-0.5 bg-current" style={{ width: "14px" }} />
<span className="block h-0.5 bg-current" style={{ width: "10px" }} />
</span>
</button>
<button
type="button"
className="px-1 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100 flex items-center justify-center flex-shrink-0"
onClick={() => exec("justifyCenter")}
aria-label="가운데 정렬"
style={{ width: "24px", height: "24px" }}
>
<span className="flex flex-col gap-0.5 items-center" style={{ width: "14px", height: "10px" }}>
<span className="block h-0.5 bg-current" style={{ width: "10px" }} />
<span className="block h-0.5 bg-current" style={{ width: "14px" }} />
<span className="block h-0.5 bg-current" style={{ width: "10px" }} />
</span>
</button>
<button
type="button"
className="px-1 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100 flex items-center justify-center flex-shrink-0"
onClick={() => exec("justifyRight")}
aria-label="오른쪽 정렬"
style={{ width: "24px", height: "24px" }}
>
<span className="flex flex-col gap-0.5 items-end" style={{ width: "14px", height: "10px" }}>
<span className="block h-0.5 bg-current" style={{ width: "10px" }} />
<span className="block h-0.5 bg-current" style={{ width: "14px" }} />
<span className="block h-0.5 bg-current" style={{ width: "10px" }} />
</span>
</button>
<button
type="button"
className="px-1 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100 flex items-center justify-center flex-shrink-0"
onClick={() => exec("justifyFull")}
aria-label="양쪽 정렬"
style={{ width: "24px", height: "24px" }}
>
<span className="flex flex-col gap-0.5 items-stretch" style={{ width: "14px", height: "10px" }}>
<span className="block h-0.5 bg-current" style={{ width: "100%" }} />
<span className="block h-0.5 bg-current" style={{ width: "100%" }} />
<span className="block h-0.5 bg-current" style={{ width: "100%" }} />
</span>
</button>
<span className="mx-2 h-4 w-px bg-neutral-300" /> <span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("indent")} aria-label="들여쓰기"></button> <button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("indent")} aria-label="들여쓰기"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("outdent")} aria-label="내어쓰기"></button> <button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("outdent")} aria-label="내어쓰기"></button>
@@ -196,8 +322,10 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
border: "1px solid #ddd", border: "1px solid #ddd",
borderRadius: 6, borderRadius: 6,
padding: 12, padding: 12,
overflow: "auto",
}} }}
suppressContentEditableWarning suppressContentEditableWarning
className="[&_figure]:my-2 [&_figure]:block [&_figure_img]:max-w-full [&_figure_img]:h-auto [&_figure_img]:rounded-lg [&_figure_img]:border [&_figure_img]:border-neutral-200 [&_figure_img]:shadow-sm [&_figcaption]:mt-1 [&_figcaption]:text-xs [&_figcaption]:text-neutral-600 [&_figcaption]:text-center [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_figure[data-resizable='true']]:hover_[.resize-handle]:opacity-100 [&_figure[data-resizable='true']_.resize-handle]:opacity-0 [&_figure[data-resizable='true']_.resize-handle]:transition-opacity [&_figure[data-resizable='true']]:hover_[.delete-image-btn]:opacity-100 [&_figure[data-resizable='true']_.delete-image-btn]:opacity-0 [&_figure[data-resizable='true']_.delete-image-btn]:transition-opacity"
/> />
</div> </div>
); );

View File

@@ -107,7 +107,10 @@ export default function NewPostPage() {
<span aria-hidden>🙂</span> <span aria-hidden>🙂</span>
<UploadButton <UploadButton
multiple multiple
onUploaded={(url) => setForm((f) => ({ ...f, content: `${f.content}\n![image](${url})` }))} onUploaded={(url) => {
const figure = `<figure style="margin:8px 0; position: relative; display: inline-block;" data-resizable="true"><img src="${url}" alt="" style="max-width: 100%; height: auto; display: block; cursor: pointer;" /><div class="resize-handle" style="position: absolute; bottom: 0; right: 0; width: 16px; height: 16px; background: #f94b37; cursor: se-resize; border-radius: 50% 0 0 0; opacity: 0.8;"></div><button class="delete-image-btn" style="position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; background: rgba(0, 0, 0, 0.6); color: white; border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; line-height: 1; opacity: 0.8;" title="이미지 삭제">×</button></figure>`;
setForm((f) => ({ ...f, content: `${f.content}\n${figure}` }));
}}
{...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})} {...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})}
/> />
</div> </div>