진짜 만들기 싫었지만 기존 코드에서는 img 삽입 방식을 커스텀하기가 어려워서 한땀한땀 다 가져다 붙였다.
const ToolBar = ({ editor, setLink, addImage }: { editor: Editor; setLink: () => void; addImage: () => void }) => {
return (
<div className="toolbar">
<div className="itemBox">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`toolbarBtn ${editor.isActive("bold") ? "is-active" : ""}`}
>
<BoldIcon className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`toolbarBtn ${editor.isActive("italic") ? "is-active" : ""}`}
>
<ItalicIcon className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={`toolbarBtn ${editor.isActive("strike") ? "is-active" : ""}`}
>
<StrikeIcon className="w-4 h-4" />
</button>
<button onClick={setLink} className={`toolbarBtn ${editor.isActive("link") ? "is-active" : ""}`}>
<LinkIcon className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`toolbarBtn ${editor.isActive("orderedList") ? "is-active" : ""}`}
>
<ListOrderedIcon className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`toolbarBtn ${editor.isActive("bulletList") ? "is-active" : ""}`}
>
<ListIcon className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`toolbarBtn ${editor.isActive("blockquote") ? "is-active" : ""}`}
>
<BlockQuoteIcon className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={`toolbarBtn ${editor.isActive("code") ? "is-active" : ""}`}
>
<CodeIcon className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={`toolbarBtn ${editor.isActive("codeBlock") ? "is-active" : ""}`}
>
<CodeBlockIcon className="w-4 h-4" />
</button>
<button onClick={addImage} className={`toolbarBtn ${editor.isActive("image") ? "is-active" : ""}`}>
<HardDriveUploadIcon className="w-4 h-4" />
</button>
</div>
</div>
);
};
img와 link를 넣다보니 코드가 굉장히 길어졌다.
이 부분의 핵심은 file 삽입과 삽입된 file의 미리보기 기능이다.
export default () => {
const [text, setText] = useState("helloWorld");
const fileInputRef = useRef<HTMLInputElement>(null);
const editor = useEditor({
editable: true,
extensions: [
Document,
Paragraph,
Text,
Blockquote,
Bold,
Italic,
Strike,
Code,
ListItem,
OrderedList,
BulletList,
CodeBlock,
Dropcursor,
Image,
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: "https",
protocols: ["http", "https"],
isAllowedUri: (url, ctx) => {
try {
// construct URL
const parsedUrl = url.includes(":") ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`);
// use default validation
if (!ctx.defaultValidate(parsedUrl.href)) {
return false;
}
// disallowed protocols
const disallowedProtocols = ["ftp", "file", "mailto"];
const protocol = parsedUrl.protocol.replace(":", "");
if (disallowedProtocols.includes(protocol)) {
return false;
}
// only allow protocols specified in ctx.protocols
const allowedProtocols = ctx.protocols.map((p) => (typeof p === "string" ? p : p.scheme));
if (!allowedProtocols.includes(protocol)) {
return false;
}
// disallowed domains
const disallowedDomains = ["example-phishing.com", "malicious-site.net"];
const domain = parsedUrl.hostname;
if (disallowedDomains.includes(domain)) {
return false;
}
// all checks have passed
return true;
} catch {
return false;
}
},
shouldAutoLink: (url) => {
try {
// construct URL
const parsedUrl = url.includes(":") ? new URL(url) : new URL(`https://${url}`);
// only auto-link if the domain is not in the disallowed list
const disallowedDomains = ["example-no-autolink.com", "another-no-autolink.com"];
const domain = parsedUrl.hostname;
return !disallowedDomains.includes(domain);
} catch {
return false;
}
},
}),
],
content: text,
});
const setLink = useCallback(() => {
const previousUrl = editor.getAttributes("link").href;
const url = window.prompt("URL", previousUrl);
// cancelled
if (url === null) {
return;
}
// empty
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
// update link
try {
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
} catch (e) {
alert(e.message);
}
}, [editor]);
const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const handleFileSelect = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && editor) {
// 파일 유효성 검사
// if (!file.type.startsWith("image/")) {
// alert("이미지 파일만 선택할 수 있습니다.");
// return;
// }
// 파일 크기 제한 (5MB)
if (file.size > 5 * 1024 * 1024) {
alert("파일 크기는 5MB 이하여야 합니다.");
return;
}
// 파일을 base64로 변환
const base64 = await convertFileToBase64(file);
// 에디터에 이미지 삽입
if (file.type.startsWith("image/")) {
editor.chain().focus().setImage({ src: base64 }).run();
} else {
const ext = file.name.split(".").pop()?.toLowerCase() || "";
let defaultImg = "/upload_default.png"; // 기본값
if (ext === "pdf") defaultImg = "/upload_default.png";
else if (["doc", "docx"].includes(ext)) defaultImg = "/upload_default.png";
else if (["xls", "xlsx"].includes(ext)) defaultImg = "/upload_default.png";
else if (["ppt", "pptx"].includes(ext)) defaultImg = "/upload_default.png";
editor.chain().focus().setImage({ src: defaultImg }).run();
}
// 파일 입력 초기화
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
},
[editor],
);
const addImage = useCallback(() => {
fileInputRef.current?.click(); // 숨겨진 input 클릭
}, []);
if (!editor) {
return null;
}
return (
<div className="chat-text-area">
<input
ref={fileInputRef}
type="file"
accept="image/*, .pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx"
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<div className="toolbar-container">
<ToolBar editor={editor} setLink={setLink} addImage={addImage} />
</div>
<div className="editor-container">
<EditorContent editor={editor} />
</div>
</div>
);
};
요즘들어 ai 의존도가 다시 높아졌다. 이 부분이 염려된다.