블로그 제작 #1 - 에디터
2026년 8월 25일

에디터 만들기
전에 만들었었던 블로그들도 왠만하면 MD로 그냥 글을 작성해서 업로드하거나 이런 방식을 좋아하지 않아서 에디터를 꾸역꾸역 만들어서 넣었었는데, 막상 사용하려고 들면 글 작성하는 것 자체가 불편해지는 상황이 많아져서 결국 블로그를 없애거나 방치하게 되더라
그래서 이번에는 에디터에 최대한 내가 편하게 글을 작성할 수 있게 이런 저런 기능들을 넣으려고 노력했다
코드의 전부를 넣을 순 없겠지만 우선 내 블로그 에디터의 기본적인 틀은 이렇다.
// write/page.tsx
import type { Metadata } from 'next';
import WriteForm from '@/components/write/write-form';
import { listAllCategories } from '@/lib/posts';
export const metadata: Metadata = {
title: '글 작성',
};
export default async function WritePage() {
const categories = await listAllCategories();
return <WriteForm categories={categories} />;
}글을 작성할 때 카테고리별로 묶어주어야 나중에 찾거나 수정하거나 할 때 훨씬 편할 것 같아서 카테고리를 불러올 수 있게 listAllCategories를 호출하여 async/await가 붙었고, 현재까지 저장되어 있는 카테고리들을 글작성하는 에디터 상단(툴바)에 불러와 지정해서 사용할 수 있게 했다.

새로 추가할 때는 그냥 input 창에 입력하면 된다
// components/write/write-form.tsx
import { useActionState, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { createPostAction, saveDraftAction } from "@/lib/actions/posts";
export default function WriteForm({
draft,
categories,
}: {
draft?: WriteDraft;
categories: string[];
}) {
const router = useRouter();
const [error, formAction, pending] = useActionState(createPostAction, null);
const [category, setCategory] = useState(draft?.category ?? "");
const [title, setTitle] = useState(draft?.title ?? "");
const [tags, setTags] = useState(draft?.tags.map((tag) => tag.name) ?? []);
const [draftId, setDraftId] = useState(draft?.id ?? "");
const [status, setStatus] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const { body, textareaRef, insertAtCursor, onChange, onKeyDown } =
useWriteBody(draft?.body ?? "");
const { thumbnail, dragging, pickFile, clearThumbnail, onPaste } =
useWriteImages(draft?.thumbnail ?? "", insertAtCursor, setStatus);
const preview = useWritePreview(body);
// 작성 중인 글 임시 저장
async function saveDraft() {
const form = formRef.current;
// ...
}
// Ctrl + s 단축키로 임시 저장
useEffect(() => {
const onSave = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowercase() === "s") {
event.preventDefault();
void saveDraft();
}
}
window.addEventListener("keydown", onSave);
return () => window.removeEventListener("keydown", onSave);
});
return (
<form ref={formRef} action={formAction}>
{/* ... */ }
);
}// lib/actions/posts.ts
export async function saveDraftAction(formData: FormData) {
const session = await auth();
// ...
if (id) {
const existing = await prisma.post.findUnique({ where: { id } });
await prisma.post.update({
where: { id },
data: { ...data, tags: { set: [] } },
});
const post = await prisma.post.update({
where: { id },
data: { tags: await connectTags(tags) },
});
if (existing?.publishedAt) {
revalidatePath("/");
revalidatePath(`/category/${encodeURIComponent(existing.category)}`);
revalidatePath(`/post/${encodeURIComponent(existing.slug)}`);
revalidatePath(`/feed.xml`);
}
return { id: post.id, unpublished: !!existing?.publishedAt };
}
const post = await prisma.post.create({
data: {
...data,
slug: await uniqueSlug(title),
tags: await connectTags(tags),
}
});
return { id: post.id, unpublished: false };
}그리고 이번 에디터를 만들면서 가장 핵심적으로 꼭 넣어야 했던 기능이 바로 임시 저장 이다
별 거 아닌 기능이지만 글을 작성하다가 중간에 다른 일이 생기거나 PC에 이상이 생겨서 다운되면 글 작성할 마음이 싹 사라져 버려서 결국은 게을러 지길래 무조건 시작하자마자 이 기능부터 넣어야겠다고 생각했고 흔히 한글이나 엑셀같은 문서 작업을 할 때 사용하는 Ctrl + s 단축키로 기능을 넣었다.
Markdown 에디터
이전에는 Quill 에디터, CKEditor, Toast UI Editor 등등 이미 널리 알려진 WYSIWYG(What You See Is What You Get) 에디터를 사용해왔었는데, 이게 사용자 커스텀 하기가 참 힘들고 이런 것에 시간을 들이는 것도 좀 별로라 생각해서, 글을 작성할 때는 그냥 쭉~ 글을 작성하는데에만 집중할 수 있도록 Markdown 에디터로 방향을 잡았고, 그 와중에 이미지를 넣거나 유튜브 링크를 넣거나 하는 작업도 편하게 할 수 있도록 단축키, 단축어도 추가했다.
예를 들면 이런 거다
/이미지, /image 라고 타이핑 후 엔터르 치면 자동으로 이미지 파일 찾기 창이 열리는 기능이라던가
export function consumeImageCommand(
current: string,
start: number,
end: number,
) {
const untilCursor = current.slice(0, start);
const line = untilCursor.split("\n").at(-1)?.trim() ?? "";
if (line !== "/image" && line !== "/이미지") {
return null;
}
const lineStart = untilCursor.lastIndexOf("\n") + 1;
return {
next: current.slice(0, lineStart) + current.slice(end),
start: lineStart,
end: lineStart,
};
}클립보드에서 이미지를 가져와서 넣는다던가 하는 기능이다
function onPaste(event: ClipboardEvent<HTMLTextAreaElement>) {
const files = [...event.clipboardData.files].filter((file) =>
file.type.startsWith("image/"),
);
if (files.length === 0) return;
event.preventDefault();
void files.reduce(
(chain, file) => chain.then(() => uploadFile(file)),
Promise.resolve(),
);
}실제로 글을 지금 작성하며 사용하고 있는데 매우매우 편리하다!
전부터 고생하더라도 구현했어야 했었던 기능인데 왜 안 했을까... ㅜㅜ
유튜브 영상 재생
유튜브 영상도 가끔은 블로그 본문에 넣어야 할 것 같은데 그냥 넣으면 링크 로만 생성되어서 iframe을 이용해 넣는 것으로 했다
export function youtubeEmbed(id: string) {
return `<iframe class="markdown-video" src="https://www.youtube-nocookie.com/embed/${id}" title="YouTube video" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy" referrerpolicy="strict-origin-when-cross-origin"></iframe>`;
}예제는 내가 좋아하는 유민상 아저씨!