D&K Dreams

개발

블로그 제작 #1 - 에디터

2026년 8월 25일

블로그 제작 #1 - 에디터

에디터 만들기

전에 만들었었던 블로그들도 왠만하면 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>`;
}

예제는 내가 좋아하는 유민상 아저씨!

댓글 0

아직 댓글이 없습니다.