Merge branch 'subwork'
This commit is contained in:
@@ -44,7 +44,87 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
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) => {
|
||||
// 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 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, '"')}" /><figcaption style="font-size:12px;opacity:.8">${caption.replace(/</g, "<")}</figcaption></figure>`;
|
||||
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>`;
|
||||
insertHtmlAtCursor(figure);
|
||||
// onChange 동기화
|
||||
const el = ref.current;
|
||||
@@ -135,7 +213,7 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
const toolbar = useMemo(() => {
|
||||
if (!withToolbar) return null;
|
||||
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 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>
|
||||
@@ -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("insertHorizontalRule")} aria-label="구분선">—</button>
|
||||
<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 type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyCenter")} aria-label="가운데 정렬">⟷</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>
|
||||
<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>
|
||||
<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("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" />
|
||||
<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>
|
||||
@@ -196,8 +322,10 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
overflow: "auto",
|
||||
}}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -107,7 +107,10 @@ export default function NewPostPage() {
|
||||
<span aria-hidden>🙂</span>
|
||||
<UploadButton
|
||||
multiple
|
||||
onUploaded={(url) => setForm((f) => ({ ...f, content: `${f.content}\n` }))}
|
||||
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) : {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user