RichTextEditor

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
BIUSH1H2⟨/⟩🖼

에디터 제목

이 영역은 볼드, 이탤릭, 밑줄을 모두 지원합니다.

이미지·표·코드 블록까지 한 곳에서 자연스럽게 작성할 수 있어요.

RichTextEditor

간단한 게시글 작성을 위한 WYSIWYG 에디터 컴포넌트입니다.
Antigraffiti(dompurify)를 통해 XSS 공격을 방지하도록 처리된 HTML을 반환합니다.
기본적으로 Tailwind CSS 기반의 타이포그래피 스타일(p, h3, blockquote, list 등)이 적용되어 있으며,tagStyles props를 통해 태그별 스타일을 커스터마이징할 수 있습니다.

기본 에디터

Toolbar(Bold, Italic, Link)와 ContentEditable 영역을 제공합니다.

Source Code
          const [html, setHtml] = useState('');

          <RichTextEditor
            onChange={setHtml}
          />

          {/* 결과 출력 (Sanitized HTML) */}
          <div>{html}</div>
        

Sanitized Output

입력된 내용이 없습니다.

* 이 컴포넌트는 게시판 등록용 에디터입니다. (Antigraffiti 적용됨)

Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

RichTextEditortypescript
'use client';

import { useCallback, useEffect, useRef, useState } from "react";
import { sanitize } from "@/lib/antigraffiti";
import { cn } from "@/lib/cn";
import { Modal } from "@/components/Modal";
import { Button } from "@/components/Button";
import styles from "./RichTextEditor.module.css";
import {
  AlignCenterIcon,
  AlignJustifyIcon,
  AlignLeftIcon,
  AlignRightIcon,
  AttachFileIcon,
  BoldIcon,
  CodeBlockIcon,
  CodeIcon,
  EditModeIcon,
  HelpKeyboardIcon,
  HorizontalRuleIcon,
  HtmlIcon,
  ImageIcon,
  IndentDecreaseIcon,
  IndentIncreaseIcon,
  ItalicIcon,
  LinkIcon,
  ListBulletIcon,
  ListOrderedIcon,
  QuoteIcon,
  StrikethroughIcon,
  TableIcon,
  TitleH1Icon,
  TitleH2Icon,
  TitleH3Icon,
  UnderlineIcon,
} from "./ToolbarIcons";
import {
  MAX_FILE_SIZE_BYTES,
  MAX_IMAGE_SIZE_BYTES,
  RTE_ASSET_DRAG_TYPE,
  buildAssetHtml,
  buildFilePlaceholderHtml,
  buildImagePlaceholderHtml,
  createLocalId,
  escapeHtml,
  inferFileKind,
  insertHTMLAtCursor,
  markPlaceholderError,
  moveCaretFromPoint,
  removePlaceholder,
  replacePlaceholderHtml,
  toEditorAssetFromUpload,
} from "./editorUtils";
import type { AssetKind, EditorMode, ListedAsset } from "./types";
import { useAlertContext } from "@/components/alert";
import { useAuth } from "@/components/providers/AuthProvider";
import { deleteMediaAsset } from "@/lib/api/deleteMediaAsset";
import { cleanupUploadedAssets, deleteAssetFile, uploadAssetFile } from "./upload";
import { AssetsPanel } from "./AssetsPanel";
import { MediaLibraryPanel } from "./MediaLibraryPanel";
import type { MediaAsset } from "@/types/media";

import { useEditor, EditorContent } from '@tiptap/react';
import { StarterKit } from '@tiptap/starter-kit';
import { Image } from '@tiptap/extension-image';
import { Link } from '@tiptap/extension-link';
import { Underline } from '@tiptap/extension-underline';
import { TextAlign } from '@tiptap/extension-text-align';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { BubbleMenu } from '@tiptap/react/menus';
import { Tooltip } from '@/components/Tooltip';
import { DropdownMenu } from '@/components/DropdownMenu';
import { common, createLowlight } from 'lowlight';
import 'highlight.js/styles/atom-one-dark.css';

const lowlight = createLowlight(common);

interface RichTextEditorProps {
  initialValue?: string;
  onChange?: (html: string) => void;
  className?: string;
  onAssetsCleanupReady?: (cleanup: () => Promise<void>) => void;
  disableAssetFeatures?: boolean;
  /** false면 읽기 전용(저장 중 등). 기본 true */
  editable?: boolean;
  /**
   * 설정 시 관리자에게만 첨부 목록 모달에 미디어 라이브러리 탭 추가 (GET /media?domain=…).
   * 비관리자는 「이번 글 첨부」만 동일하게 사용합니다.
   * 블로그: "blog", 쇼케이스: "showcase" 등 업로드 domain과 맞출 것.
   */
  mediaLibraryDomain?: string;
  /** 업로드 미디어를 태깅할 domain (기본 "blog"). 커뮤니티는 "board" 등으로 분리 */
  uploadDomain?: string;
  /**
   * true면 업로드를 status="temp"로 올린다. 글 저장 시 백엔드가 본문에 쓰인 것만 used로 승격,
   * 미사용분은 cron이 정리(temp→used 승격 패턴). 호출부가 저장 시 승격을 구현할 때만 사용.
   */
  uploadAsTemp?: boolean;
  /** 편집 영역 최소 높이(px 또는 CSS 값). 미지정 시 내용 높이(빈 글은 1줄) */
  minHeight?: number | string;
}

export function RichTextEditor({
  initialValue = "",
  onChange,
  className,
  onAssetsCleanupReady,
  disableAssetFeatures = false,
  editable = true,
  mediaLibraryDomain,
  uploadDomain,
  uploadAsTemp = false,
  minHeight,
}: RichTextEditorProps) {
  const { showToast } = useAlertContext();
  const { user } = useAuth();
  const showMediaLibraryTab = Boolean(mediaLibraryDomain && user?.role === "admin");
  const lastEmittedHtmlRef = useRef<string | null>(null);
  const rootRef = useRef<HTMLDivElement>(null);
  const htmlTextareaRef = useRef<HTMLTextAreaElement>(null);
  const imageInputRef = useRef<HTMLInputElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const uploadedAssetIdsRef = useRef<Set<number>>(new Set());
  const deletingAssetIdsRef = useRef<Set<number>>(new Set());

  const [mode, setMode] = useState<EditorMode>("edit");
  const [htmlContent, setHtmlContent] = useState<string>(sanitize(initialValue));
  const [htmlDraft, setHtmlDraft] = useState<string>(sanitize(initialValue));
  const [shortcutModalOpen, setShortcutModalOpen] = useState(false);
  const [assetsModalOpen, setAssetsModalOpen] = useState(false);
  const [assetsModalTab, setAssetsModalTab] = useState<"session" | "library">("session");
  const [insertModal, setInsertModal] = useState<{
    open: boolean;
    type: "link" | "image";
    value: string;
  }>({
    open: false,
    type: "link",
    value: "https://",
  });

  const [listedAssets, setListedAssets] = useState<ListedAsset[]>([]);

  const emitSanitizedToParent = useCallback((rawHtml: string) => {
    const clean = sanitize(rawHtml);
    setHtmlContent(clean);
    lastEmittedHtmlRef.current = clean;
    if (onChange) onChange(clean);
  }, [onChange]);

  const collectAssetIdsFromHtml = (html: string): Set<number> => {
    if (!html || typeof window === "undefined") return new Set();
    const parser = new DOMParser();
    const doc = parser.parseFromString(`<div id="rte-asset-root">${html}</div>`, "text/html");
    const root = doc.getElementById("rte-asset-root");
    if (!root) return new Set();
    const ids = new Set<number>();
    root.querySelectorAll("[data-asset-id]").forEach((node) => {
      const raw = node.getAttribute("data-asset-id");
      if (!raw) return;
      const parsed = Number.parseInt(raw, 10);
      if (Number.isFinite(parsed) && parsed > 0) {
        ids.add(parsed);
      }
    });
    return ids;
  };

  const syncDeletedAssetsFromHtml = useCallback((html: string): void => {
    const usedIds = collectAssetIdsFromHtml(html);
    const uploadedIds = Array.from(uploadedAssetIdsRef.current.values());
    uploadedIds.forEach((id) => {
      if (usedIds.has(id)) return;
      if (deletingAssetIdsRef.current.has(id)) return;
      deletingAssetIdsRef.current.add(id);
      void deleteAssetFile(id)
        .catch(() => {})
        .finally(() => {
          deletingAssetIdsRef.current.delete(id);
          uploadedAssetIdsRef.current.delete(id);
          setListedAssets((prev) => prev.filter((a) => a.assetId !== id));
        });
    });
  }, []);

  const editor = useEditor({
    immediatelyRender: false,
    editable,
    extensions: [
      StarterKit.configure({ codeBlock: false }), // disable default to use lowlight
      Underline,
      TextAlign.configure({ types: ['heading', 'paragraph'] }),
      Link.configure({ openOnClick: false }),
      Image,
      Table.configure({ resizable: true }),
      TableRow,
      TableHeader,
      TableCell,
      CodeBlockLowlight.configure({ lowlight }),
    ],
    content: initialValue,
    onUpdate: ({ editor }) => {
      const html = editor.getHTML();
      syncDeletedAssetsFromHtml(html);
      emitSanitizedToParent(html);
    },
    editorProps: {
      attributes: {
        class: cn(styles.editor, styles.editable, 'focus:outline-none tiptap-instance'),
        ...(minHeight != null
          ? { style: `min-height:${typeof minHeight === "number" ? `${minHeight}px` : minHeight}` }
          : {}),
      },
    },
  });

  useEffect(() => {
    const clean = sanitize(initialValue);
    if (lastEmittedHtmlRef.current !== null && clean === lastEmittedHtmlRef.current) return;
    setHtmlContent(clean);
    setHtmlDraft(clean);
    if (editor && clean !== editor.getHTML()) {
      editor.commands.setContent(clean);
    }
    lastEmittedHtmlRef.current = clean;
  }, [initialValue, editor]);

  useEffect(() => {
    if (!editor) return;
    editor.setEditable(editable);
  }, [editor, editable]);

  const activeFormats = editor ? {
    bold: editor.isActive('bold'),
    italic: editor.isActive('italic'),
    underline: editor.isActive('underline'),
    strike: editor.isActive('strike'),
    align: editor.isActive({ textAlign: 'justify' }) ? 'justify' as const :
           editor.isActive({ textAlign: 'right' }) ? 'right' as const :
           editor.isActive({ textAlign: 'center' }) ? 'center' as const : 'left' as const,
    ol: editor.isActive('orderedList'),
    ul: editor.isActive('bulletList'),
    h1: editor.isActive('heading', { level: 1 }),
    h2: editor.isActive('heading', { level: 2 }),
    h3: editor.isActive('heading', { level: 3 }),
    quote: editor.isActive('blockquote'),
  } : {
    bold: false, italic: false, underline: false, strike: false, align: "left" as const,
    ol: false, ul: false, h1: false, h2: false, h3: false, quote: false
  };

  const applyHtmlDraftInsertion = (template: string) => {
    const textarea = htmlTextareaRef.current;
    if (!textarea) return;
    const start = textarea.selectionStart ?? 0;
    const end = textarea.selectionEnd ?? 0;
    const before = htmlDraft.slice(0, start);
    const after = htmlDraft.slice(end);
    const next = `${before}${template}${after}`;
    setHtmlDraft(next);
    const clean = sanitize(next);
    setHtmlContent(clean);
    onChange?.(clean);
    requestAnimationFrame(() => {
      const cursor = start + template.length;
      textarea.focus();
      textarea.setSelectionRange(cursor, cursor);
    });
  };

  const validateFile = (file: File, kind: AssetKind): string | null => {
    if (kind === "image" && !file.type.startsWith("image/")) {
      return "이미지 파일만 업로드할 수 있습니다.";
    }
    if (kind === "image" && file.size > MAX_IMAGE_SIZE_BYTES) {
      return "이미지는 10MB 이하만 업로드 가능합니다.";
    }
    if (kind === "file" && file.size > MAX_FILE_SIZE_BYTES) {
      return "파일은 20MB 이하만 업로드 가능합니다.";
    }
    return null;
  };

  const cleanupSessionAssets = useCallback(async () => {
    if (disableAssetFeatures) return;
    const deletableIds = Array.from(uploadedAssetIdsRef.current.values());
    await cleanupUploadedAssets(deletableIds);
    uploadedAssetIdsRef.current.clear();
    setListedAssets([]);
    if (editor) {
      // Very basic manual cleanup of local assets if needed for current session
      const html = editor.getHTML();
      editor.commands.setContent(html.replace(/<div[^>]*data-local-id[^>]*>.*?<\/div>/gi, ''));
    }
  }, [disableAssetFeatures, editor]);

  useEffect(() => {
    onAssetsCleanupReady?.(cleanupSessionAssets);
  }, [cleanupSessionAssets, onAssetsCleanupReady]);

  useEffect(() => {
    if (!assetsModalOpen) setAssetsModalTab("session");
  }, [assetsModalOpen]);

  useEffect(() => {
    if (!showMediaLibraryTab && assetsModalTab === "library") {
      setAssetsModalTab("session");
    }
  }, [showMediaLibraryTab, assetsModalTab]);

  const insertFromMediaLibrary = useCallback(
    (asset: MediaAsset) => {
      if (!editable) return;
      const url = asset.public_url?.trim();
      if (!url) {
        showToast({
          message: "이 파일은 공개 URL이 없어 본문에 넣을 수 없습니다.",
          variant: "warning",
          duration: 3500,
        });
        return;
      }
      const kind: AssetKind = asset.mime.startsWith("image/") ? "image" : "file";
      const displayName =
        asset.original_filename?.trim() ||
        (asset.ext ? `첨부.${asset.ext}` : "첨부파일");
      const html = buildAssetHtml({
        kind,
        assetId: asset.id,
        url,
        originalName: displayName,
        sizeBytes: asset.size_bytes,
        mime: asset.mime,
      }).trim();

      if (mode === "html") {
        const textarea = htmlTextareaRef.current;
        if (!textarea) return;
        const start = textarea.selectionStart ?? 0;
        const end = textarea.selectionEnd ?? 0;
        const before = htmlDraft.slice(0, start);
        const after = htmlDraft.slice(end);
        const next = `${before}${html}${after}`;
        setHtmlDraft(next);
        syncDeletedAssetsFromHtml(next);
        const clean = sanitize(next);
        setHtmlContent(clean);
        onChange?.(clean);
        requestAnimationFrame(() => {
          const cursor = start + html.length;
          textarea.focus();
          textarea.setSelectionRange(cursor, cursor);
        });
      } else if (editor) {
        editor.chain().focus().insertContent(html).run();
      }

      setListedAssets((prev) => {
        if (prev.some((a) => a.assetId === asset.id)) return prev;
        return [
          ...prev,
          {
            assetId: asset.id,
            kind,
            url,
            originalName: displayName,
            sizeBytes: asset.size_bytes,
            mime: asset.mime,
          },
        ];
      });

      showToast({
        message: kind === "image" ? "이미지를 본문에 넣었습니다." : "파일 링크를 본문에 넣었습니다.",
        variant: "success",
        duration: 2200,
      });
    },
    [editable, mode, editor, htmlDraft, onChange, showToast, syncDeletedAssetsFromHtml]
  );

  const uploadSingleFile = async (
    file: File,
    kind: AssetKind,
    options?: { localId?: string; insertPlaceholder?: boolean }
  ) => {
    if (!editor) return;
    const localId = options?.localId ?? createLocalId();
    const insertPlaceholder = options?.insertPlaceholder ?? true;
    const error = validateFile(file, kind);
    const blobUrl = kind === "image" ? URL.createObjectURL(file) : undefined;

    if (insertPlaceholder) {
      const ph = kind === "image"
        ? buildImagePlaceholderHtml(localId, blobUrl || "")
        : buildFilePlaceholderHtml(localId, file.name, file.size, file.type);
      editor.commands.insertContent(ph);
    }

    if (error) {
      showToast({ message: error, variant: "warning", duration: 3500 });
      // Try to replace placeholder via regex
      const html = editor.getHTML();
      editor.commands.setContent(html.replace(new RegExp(`data-local-id="${localId}"`), `data-local-id="${localId}" data-error="${error}"`));
      return;
    }

    try {
      const uploaded = await uploadAssetFile({
        file,
        kind,
        domain: uploadDomain,
        status: uploadAsTemp ? "temp" : undefined,
      });
      const doneAsset = toEditorAssetFromUpload(uploaded, kind, localId, file);
      if (typeof doneAsset.assetId === "number" && doneAsset.assetId > 0) {
        uploadedAssetIdsRef.current.add(doneAsset.assetId);
        setListedAssets((prev) => [
          ...prev,
          {
            assetId: doneAsset.assetId!,
            kind,
            url: uploaded.url,
            originalName: doneAsset.originalName,
            sizeBytes: uploaded.sizeBytes,
            mime: uploaded.mime,
          },
        ]);
      }

      const doneHtml = buildAssetHtml(doneAsset);
      // Replace Placeholder HTML
      const domParser = new DOMParser();
      const currentDoc = domParser.parseFromString(editor.getHTML(), "text/html");
      const phEl = currentDoc.querySelector(`[data-local-id="${localId}"]`);
      if (phEl) {
        phEl.outerHTML = doneHtml;
        editor.commands.setContent(currentDoc.body.innerHTML);
      }
      if (blobUrl) URL.revokeObjectURL(blobUrl);
      showToast({
        message: kind === "image" ? "이미지를 등록했습니다." : "파일을 등록했습니다.",
        variant: "success",
        duration: 2500,
      });
    } catch (uploadError) {
      const message = uploadError instanceof Error ? uploadError.message : "업로드에 실패했습니다.";
      showToast({ message, variant: "error", duration: 4000 });
      const currentDoc = new DOMParser().parseFromString(editor.getHTML(), "text/html");
      const phEl = currentDoc.querySelector(`[data-local-id="${localId}"]`);
      if (phEl) {
        phEl.setAttribute("data-error", message);
        phEl.innerHTML += `<div style="color:red;font-size:12px;">${message}</div>`;
        editor.commands.setContent(currentDoc.body.innerHTML);
      }
    }
  };

  const uploadFiles = async (files: File[], preferredKind?: AssetKind) => {
    if (files.length === 0) return;
    for (const file of files) {
      const kind = preferredKind ?? inferFileKind(file);
      await uploadSingleFile(file, kind, { insertPlaceholder: true });
    }
  };

  const changeMode = (nextMode: EditorMode) => {
    if (nextMode === mode) return;

    if (nextMode === "html") {
      const next = editor ? editor.getHTML() : htmlContent;
      setHtmlDraft(next);
      setMode("html");
      return;
    }

    if (mode === "html") {
      const clean = sanitize(htmlDraft);
      setHtmlContent(clean);
      if (onChange) onChange(clean);
      setMode(nextMode);
      if (nextMode === "edit" && editor) {
        editor.commands.setContent(clean);
      }
      return;
    }

    if (mode === "edit") {
      const clean = editor ? sanitize(editor.getHTML()) : "";
      setHtmlContent(clean);
      if (onChange) onChange(clean);
      if (nextMode === "edit" && editor) editor.commands.setContent(clean);
    }

    setMode(nextMode);
  };

  const openInsertModal = (type: "link" | "image") => {
    setInsertModal({ open: true, type, value: "https://" });
  };

  const applyInsertModal = () => {
    const value = insertModal.value.trim();
    if (!value) return;
    if (insertModal.type === "link") {
      if (mode === "edit" && editor) {
        editor.commands.setLink({ href: value, target: '_blank' });
      } else {
        applyHtmlDraftInsertion(`<a href="${escapeHtml(value)}" target="_blank" rel="noreferrer">링크 텍스트</a>`);
      }
    } else {
      if (mode === "edit" && editor) {
        editor.commands.setImage({ src: value });
      } else {
        applyHtmlDraftInsertion(`<img src="${escapeHtml(value)}" alt="" />`);
      }
    }
    setInsertModal((prev) => ({ ...prev, open: false }));
  };

  const runShortcutAction = useCallback((action: string) => {
    if (action === "mode-edit") { changeMode("edit"); return; }
    if (action === "mode-html") { changeMode("html"); return; }
    if (action === "help") { setShortcutModalOpen(true); return; }

    if (mode === "html") {
      const map: Record<string, string> = {
        bold: "<strong>굵게</strong>",
        italic: "<em>기울임</em>",
        underline: "<u>밑줄</u>",
        strike: "<s>취소선</s>",
        h1: "<h1>제목 1</h1>",
        h2: "<h2>제목 2</h2>",
        h3: "<h3>제목 3</h3>",
        quote: "<blockquote>인용문</blockquote>",
        ol: "<ol><li>항목</li></ol>",
        ul: "<ul><li>항목</li></ul>",
        hr: "<hr />",
        "code-inline": "<code>inline code</code>",
        "code-block": "<pre><code>code block</code></pre>",
        table: "<table><tbody><tr><th>헤더1</th><th>헤더2</th></tr><tr><td>셀1</td><td>셀2</td></tr></tbody></table>",
      };
      if (action === "link") { openInsertModal("link"); return; }
      if (action === "image-url") { if (!disableAssetFeatures) openInsertModal("image"); return; }
      const template = map[action];
      if (template) applyHtmlDraftInsertion(template);
      return;
    }

    if (!editor) return;

    switch (action) {
      case "bold": editor.chain().focus().toggleBold().run(); break;
      case "italic": editor.chain().focus().toggleItalic().run(); break;
      case "underline": editor.chain().focus().toggleUnderline().run(); break;
      case "strike": editor.chain().focus().toggleStrike().run(); break;
      case "align-left": editor.chain().focus().setTextAlign('left').run(); break;
      case "align-center": editor.chain().focus().setTextAlign('center').run(); break;
      case "align-right": editor.chain().focus().setTextAlign('right').run(); break;
      case "align-justify": editor.chain().focus().setTextAlign('justify').run(); break;
      case "h1": editor.chain().focus().toggleHeading({ level: 1 }).run(); break;
      case "h2": editor.chain().focus().toggleHeading({ level: 2 }).run(); break;
      case "h3": editor.chain().focus().toggleHeading({ level: 3 }).run(); break;
      case "quote": editor.chain().focus().toggleBlockquote().run(); break;
      case "ol": editor.chain().focus().toggleOrderedList().run(); break;
      case "ul": editor.chain().focus().toggleBulletList().run(); break;
      case "hr": editor.chain().focus().setHorizontalRule().run(); break;
      case "code-inline": editor.chain().focus().toggleCode().run(); break;
      case "code-block":
        if (editor.isActive('codeBlock')) {
          editor.chain().focus().toggleCodeBlock().run();
        } else {
          const { from, to } = editor.state.selection;
          const text = editor.state.doc.textBetween(from, to, '\n');
          if (text) {
            editor.chain().focus().deleteSelection().insertContent({
              type: 'codeBlock',
              content: [{ type: 'text', text }]
            }).run();
          } else {
            editor.chain().focus().toggleCodeBlock().run();
          }
        }
        break;
      case "table": editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); break;
      case "link": openInsertModal("link"); break;
      case "image-url": if (!disableAssetFeatures) openInsertModal("image"); break;
      case "upload-image": if (!disableAssetFeatures) imageInputRef.current?.click(); break;
      case "upload-file": if (!disableAssetFeatures) fileInputRef.current?.click(); break;
    }
  }, [mode, htmlDraft, disableAssetFeatures, editor]);

  /**
   * 단축키 바인딩 — 안내 모달이 광고하는 커스텀 단축키를 실제로 동작하게 한다.
   * (TipTap 기본 키맵인 굵게/기울임/밑줄/목록 등은 그대로 두고, 바인딩이 없던 것만 보강)
   * - 컨테이너(rootRef)에 등록 → 편집 영역·HTML textarea 어디서 눌러도 잡힘
   * - Shift+숫자는 e.key가 "!@#"로 바뀌므로 반드시 e.code(Digit1 등)로 판별
   */
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const onKeyDown = (e: KeyboardEvent) => {
      const mod = e.metaKey || e.ctrlKey;
      if (!mod) return;
      const code = e.code;

      // 모드 전환 (편집/HTML 양쪽에서)
      if (e.shiftKey && code === "KeyE") { e.preventDefault(); setMode("edit"); return; }
      if (e.shiftKey && code === "KeyH") { e.preventDefault(); setMode("html"); return; }
      // 단축키 도움말
      if (!e.shiftKey && e.key === "/") { e.preventDefault(); setShortcutModalOpen(true); return; }

      // 이하는 리치 편집 모드에서만 (HTML 모드일 땐 textarea 기본 입력 유지)
      if (mode !== "edit") return;
      if (!e.shiftKey && code === "KeyK") { e.preventDefault(); runShortcutAction("link"); return; }
      if (e.shiftKey && code === "Digit1") { e.preventDefault(); runShortcutAction("h1"); return; }
      if (e.shiftKey && code === "Digit2") { e.preventDefault(); runShortcutAction("h2"); return; }
      if (e.shiftKey && code === "Digit3") { e.preventDefault(); runShortcutAction("h3"); return; }
      if (e.shiftKey && code === "Digit9") { e.preventDefault(); runShortcutAction("quote"); return; }
      if (!e.shiftKey && code === "Backquote") { e.preventDefault(); runShortcutAction("code-inline"); return; }
      if (e.shiftKey && code === "Backquote") { e.preventDefault(); runShortcutAction("code-block"); return; }
    };
    root.addEventListener("keydown", onKeyDown);
    return () => root.removeEventListener("keydown", onKeyDown);
  }, [mode, runShortcutAction]);

  const usedAssetIds = collectAssetIdsFromHtml(htmlContent);
  const unusedAssets = listedAssets.filter((a) => !usedAssetIds.has(a.assetId));

  const handleRemoveAsset = useCallback(
    async (asset: ListedAsset) => {
      const { assetId } = asset;
      const ok = await deleteMediaAsset(assetId);
      if (!ok) {
        showToast({ message: "미디어 삭제에 실패했습니다.", variant: "error", duration: 4000 });
        return;
      }
      showToast({
        message: asset.kind === "image" ? "이미지를 삭제했습니다." : "첨부 파일을 삭제했습니다.",
        variant: "success",
        duration: 2500,
      });
      uploadedAssetIdsRef.current.delete(assetId);
      setListedAssets((prev) => prev.filter((a) => a.assetId !== assetId));
      if (editor) {
        const domParser = new DOMParser();
        const doc = domParser.parseFromString(editor.getHTML(), "text/html");
        doc.querySelectorAll(`[data-asset-id="${assetId}"]`).forEach((el) => el.remove());
        editor.commands.setContent(doc.body.innerHTML);
      }
    },
    [editor, showToast]
  );

  const handleRemoveUnusedAssets = useCallback(async () => {
    const idsToRemove = unusedAssets.map((a) => a.assetId);
    if (idsToRemove.length === 0) return;
    const results = await Promise.all(idsToRemove.map((id) => deleteMediaAsset(id)));
    const okCount = results.filter(Boolean).length;
    idsToRemove.forEach((id, i) => {
      if (results[i]) uploadedAssetIdsRef.current.delete(id);
    });
    setListedAssets((prev) =>
      prev.filter((a) => {
        const idx = idsToRemove.indexOf(a.assetId);
        if (idx === -1) return true;
        return !results[idx];
      })
    );
    if (okCount === idsToRemove.length) {
      showToast({
        message: `미사용 첨부 ${okCount}건을 삭제했습니다.`,
        variant: "success",
        duration: 2500,
      });
    } else if (okCount > 0) {
      showToast({
        message: `${okCount}/${idsToRemove.length}건만 삭제되었습니다.`,
        variant: "warning",
        duration: 4000,
      });
    } else {
      showToast({ message: "미사용 첨부 삭제에 실패했습니다.", variant: "error", duration: 4000 });
    }
  }, [unusedAssets, usedAssetIds, showToast]);

  const shortcutRows = [
    { key: "Ctrl/Cmd + B", action: "굵게" },
    { key: "Ctrl/Cmd + I", action: "기울임" },
    { key: "Ctrl/Cmd + U", action: "밑줄" },
    { key: "Ctrl/Cmd + K", action: "링크" },
    { key: "Ctrl/Cmd + Shift + 1/2/3", action: "제목 1/2/3" },
    { key: "Ctrl/Cmd + Shift + 7/8", action: "번호 목록 / 글머리" },
    { key: "Ctrl/Cmd + Shift + 9", action: "인용" },
    { key: "Ctrl/Cmd + `", action: "인라인 코드" },
    { key: "Ctrl/Cmd + Shift + `", action: "코드 블록" },
    { key: "Ctrl/Cmd + Shift + E", action: "편집 모드" },
    { key: "Ctrl/Cmd + Shift + H", action: "HTML 모드" },
    { key: "Ctrl/Cmd + /", action: "단축키 도움말" },
  ];

  return (
    <div
      ref={rootRef}
      className={cn(styles.layout, className, !editable && "pointer-events-none opacity-70")}
    >
      <div className={styles.main}>
        <div className={styles.shell}>
          <div className={styles.toolbar}>
            <div className={styles.toolbarLeft}>
              <div className={styles.toolbarRow}>
                {/* 자주 쓰는 인라인 서식 */}
                <ToolbarButton onClick={() => runShortcutAction("bold")} title="굵게 (Ctrl/Cmd+B)" isActive={activeFormats.bold}>
                  <BoldIcon />
                </ToolbarButton>
                <ToolbarButton onClick={() => runShortcutAction("italic")} title="기울임 (Ctrl/Cmd+I)" isActive={activeFormats.italic}>
                  <ItalicIcon />
                </ToolbarButton>
                <ToolbarButton onClick={() => runShortcutAction("underline")} title="밑줄 (Ctrl/Cmd+U)" isActive={activeFormats.underline}>
                  <UnderlineIcon />
                </ToolbarButton>
                <ToolbarButton onClick={() => runShortcutAction("strike")} title="취소선" isActive={activeFormats.strike}>
                  <StrikethroughIcon />
                </ToolbarButton>
                <div className={styles.divider} />
                {/* 정렬 */}
                <ToolbarButton onClick={() => runShortcutAction("align-left")} title="왼쪽 정렬" isActive={activeFormats.align === "left"}>
                  <AlignLeftIcon />
                </ToolbarButton>
                <ToolbarButton onClick={() => runShortcutAction("align-center")} title="가운데 정렬" isActive={activeFormats.align === "center"}>
                  <AlignCenterIcon />
                </ToolbarButton>
                <ToolbarButton onClick={() => runShortcutAction("align-right")} title="오른쪽 정렬" isActive={activeFormats.align === "right"}>
                  <AlignRightIcon />
                </ToolbarButton>
                <div className={styles.divider} />
                {/* 목록 */}
                <ToolbarButton onClick={() => runShortcutAction("ul")} title="글머리 목록" isActive={activeFormats.ul}>
                  <ListBulletIcon />
                </ToolbarButton>
                <ToolbarButton onClick={() => runShortcutAction("ol")} title="번호 목록" isActive={activeFormats.ol}>
                  <ListOrderedIcon />
                </ToolbarButton>
                <div className={styles.divider} />
                {/* 링크·미디어 */}
                <ToolbarButton onClick={() => runShortcutAction("link")} title="링크 (Ctrl/Cmd+K)">
                  <LinkIcon />
                </ToolbarButton>
                {!disableAssetFeatures && (
                  <ToolbarButton onClick={() => runShortcutAction("upload-image")} title="사진 올리기">
                    <ImageIcon />
                  </ToolbarButton>
                )}
                {!disableAssetFeatures && (
                  <ToolbarButton onClick={() => runShortcutAction("upload-file")} title="파일 첨부">
                    <AttachFileIcon />
                  </ToolbarButton>
                )}
                <div className={styles.divider} />
                {/* 그 외 도구는 더보기로 접어 간결하게 */}
                <DropdownMenu
                  placement="bottom-start"
                  trigger={
                    <button type="button" aria-label="더보기" className={cn(styles.toolbarBtn, styles.moreBtn)}>
                      <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18" aria-hidden="true">
                        <circle cx="5" cy="12" r="1.6" />
                        <circle cx="12" cy="12" r="1.6" />
                        <circle cx="19" cy="12" r="1.6" />
                      </svg>
                      <span>더보기</span>
                    </button>
                  }
                >
                  <DropdownMenu.Item icon={<TitleH1Icon />} onClick={() => runShortcutAction("h1")}>제목 1</DropdownMenu.Item>
                  <DropdownMenu.Item icon={<TitleH2Icon />} onClick={() => runShortcutAction("h2")}>제목 2</DropdownMenu.Item>
                  <DropdownMenu.Item icon={<TitleH3Icon />} onClick={() => runShortcutAction("h3")}>제목 3</DropdownMenu.Item>
                  <DropdownMenu.Separator />
                  <DropdownMenu.Item icon={<QuoteIcon />} onClick={() => runShortcutAction("quote")}>인용구</DropdownMenu.Item>
                  <DropdownMenu.Item icon={<CodeIcon />} onClick={() => runShortcutAction("code-inline")}>인라인 코드</DropdownMenu.Item>
                  <DropdownMenu.Item icon={<CodeBlockIcon />} onClick={() => runShortcutAction("code-block")}>코드 블록</DropdownMenu.Item>
                  <DropdownMenu.Separator />
                  <DropdownMenu.Item icon={<TableIcon />} onClick={() => runShortcutAction("table")}>표 삽입</DropdownMenu.Item>
                  <DropdownMenu.Item icon={<HorizontalRuleIcon />} onClick={() => runShortcutAction("hr")}>가로선</DropdownMenu.Item>
                  <DropdownMenu.Item icon={<AlignJustifyIcon />} onClick={() => runShortcutAction("align-justify")}>양쪽 정렬</DropdownMenu.Item>
                  {!disableAssetFeatures && (
                    <DropdownMenu.Item icon={<ImageIcon />} onClick={() => runShortcutAction("image-url")}>이미지 URL</DropdownMenu.Item>
                  )}
                </DropdownMenu>
              </div>
            </div>
            {!disableAssetFeatures && (
              <button
                type="button"
                className={styles.assetsListButton}
                onClick={() => setAssetsModalOpen(true)}
                title="첨부 파일 목록"
              >
                <AttachFileIcon />
                <span>첨부 목록{listedAssets.length > 0 ? ` (${listedAssets.length})` : ""}</span>
              </button>
            )}
          </div>

          <input
            ref={imageInputRef}
            type="file"
            accept="image/*"
            className="hidden"
            onChange={async (event) => {
              const input = event.currentTarget;
              const files = Array.from(input.files ?? []);
              await uploadFiles(files, "image");
              input.value = "";
            }}
          />
          <input
            ref={fileInputRef}
            type="file"
            className="hidden"
            onChange={async (event) => {
              const input = event.currentTarget;
              const files = Array.from(input.files ?? []);
              await uploadFiles(files, "file");
              input.value = "";
            }}
          />

          {mode === "edit" && (
            <div className={styles.editorWrap}>
              {/* 텍스트를 드래그 선택하면 떠오르는 인라인 서식 툴바 */}
              {editor && (
                <BubbleMenu
                  editor={editor}
                  className={styles.bubbleMenu}
                  shouldShow={({ editor: ed, from, to }) =>
                    from !== to && !ed.isActive("codeBlock") && !ed.isActive("image")
                  }
                >
                  <BubbleButton label="굵게" active={activeFormats.bold} onRun={() => runShortcutAction("bold")}>
                    <BoldIcon />
                  </BubbleButton>
                  <BubbleButton label="기울임" active={activeFormats.italic} onRun={() => runShortcutAction("italic")}>
                    <ItalicIcon />
                  </BubbleButton>
                  <BubbleButton label="밑줄" active={activeFormats.underline} onRun={() => runShortcutAction("underline")}>
                    <UnderlineIcon />
                  </BubbleButton>
                  <BubbleButton label="취소선" active={activeFormats.strike} onRun={() => runShortcutAction("strike")}>
                    <StrikethroughIcon />
                  </BubbleButton>
                  <span className={styles.bubbleDivider} />
                  <BubbleButton label="링크" onRun={() => runShortcutAction("link")}>
                    <LinkIcon />
                  </BubbleButton>
                  <BubbleButton label="인라인 코드" onRun={() => runShortcutAction("code-inline")}>
                    <CodeIcon />
                  </BubbleButton>
                </BubbleMenu>
              )}
              {!disableAssetFeatures && (
                <p className={styles.dropHint} aria-hidden="true">
                  파일을 여기에 끌어다 놓거나 붙여넣기 하세요.
                </p>
              )}
              <EditorContent editor={editor} />
            </div>
          )}

          {mode === "html" && (
            <textarea
              ref={htmlTextareaRef}
              className={styles.htmlEditor}
              value={htmlDraft}
              onChange={(event) => {
                const next = event.target.value;
                setHtmlDraft(next);
                syncDeletedAssetsFromHtml(next);
                const clean = sanitize(next);
                setHtmlContent(clean);
                onChange?.(clean);
              }}
            />
          )}

          <div className={styles.editorFooter}>
            <div />
            <div className={styles.modeSwitcher}>
              <button
                type="button"
                className={cn(styles.modeSwitcherBtn, mode === "edit" && styles.modeSwitcherBtnActive)}
                onClick={() => changeMode("edit")}
              >
                <EditModeIcon />
                <span>편집</span>
              </button>
              <button
                type="button"
                className={cn(styles.modeSwitcherBtn, mode === "html" && styles.modeSwitcherBtnActive)}
                onClick={() => changeMode("html")}
              >
                <HtmlIcon />
                <span>HTML</span>
              </button>
              <button
                type="button"
                className={styles.modeSwitcherBtn}
                onClick={() => setShortcutModalOpen(true)}
              >
                <HelpKeyboardIcon />
                <span>도움말</span>
              </button>
            </div>
          </div>
        </div>
      </div>

      {!disableAssetFeatures && (
        <Modal
          open={assetsModalOpen}
          onClose={() => setAssetsModalOpen(false)}
          title="첨부 파일"
          size={showMediaLibraryTab ? "lg" : "md"}
          bodyClass={showMediaLibraryTab ? "max-h-[70vh] overflow-y-auto" : undefined}
        >
          {showMediaLibraryTab && mediaLibraryDomain ? (
            <div className="space-y-4 w-full">
              <div className="flex flex-wrap gap-2 border-b border-zinc-200 pb-2">
                <button
                  type="button"
                  onClick={() => setAssetsModalTab("session")}
                  className={cn(
                    "rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors",
                    assetsModalTab === "session"
                      ? "bg-sky-100 text-sky-800"
                      : "text-zinc-600 hover:bg-zinc-100"
                  )}
                >
                  이번 글 첨부
                  {listedAssets.length > 0 ? ` (${listedAssets.length})` : ""}
                </button>
                <button
                  type="button"
                  onClick={() => setAssetsModalTab("library")}
                  className={cn(
                    "rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors",
                    assetsModalTab === "library"
                      ? "bg-sky-100 text-sky-800"
                      : "text-zinc-600 hover:bg-zinc-100"
                  )}
                >
                  미디어 라이브러리 ({mediaLibraryDomain})
                </button>
              </div>
              {assetsModalTab === "session" ? (
                <AssetsPanel
                  assets={listedAssets}
                  usedIds={usedAssetIds}
                  onRemove={handleRemoveAsset}
                  onRemoveUnused={handleRemoveUnusedAssets}
                />
              ) : (
                <MediaLibraryPanel
                  domain={mediaLibraryDomain}
                  onPick={insertFromMediaLibrary}
                  insertDisabled={!editable}
                />
              )}
            </div>
          ) : (
            <AssetsPanel
              assets={listedAssets}
              usedIds={usedAssetIds}
              onRemove={handleRemoveAsset}
              onRemoveUnused={handleRemoveUnusedAssets}
            />
          )}
        </Modal>
      )}

      <Modal open={shortcutModalOpen} onClose={() => setShortcutModalOpen(false)} title="단축키 목록" size="md">
        <div className="space-y-2 text-sm w-full">
          {shortcutRows.map((row) => (
            <div key={row.key} className="flex items-center justify-between gap-4 border-b border-zinc-100 py-1.5">
              <span className="font-mono text-zinc-700">{row.key}</span>
              <span className="text-zinc-900">{row.action}</span>
            </div>
          ))}
        </div>
      </Modal>

      <Modal open={insertModal.open} onClose={() => setInsertModal((prev) => ({ ...prev, open: false }))} title={insertModal.type === "link" ? "링크 삽입" : "이미지 URL 삽입"} size="sm">
        <label className="block text-sm font-medium text-zinc-800">
          URL
          <input
            className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-500"
            value={insertModal.value}
            onChange={(event) => setInsertModal((prev) => ({ ...prev, value: event.target.value }))}
            placeholder="https://"
          />
        </label>
        <div className="flex items-center justify-end gap-2 mt-4">
          <Button type="button" variant="outline" size="sm" onClick={() => setInsertModal((prev) => ({ ...prev, open: false }))}>
            취소
          </Button>
          <Button type="button" variant="primary" size="sm" onClick={applyInsertModal}>
            적용
          </Button>
        </div>
      </Modal>
    </div>
  );
}

function ToolbarButton({
  onClick,
  title,
  className,
  isActive = false,
  children,
}: {
  onClick: () => void;
  title?: string;
  className?: string;
  isActive?: boolean;
  children: React.ReactNode;
}) {
  const button = (
    <button
      type="button"
      onMouseDown={(event) => {
        event.preventDefault();
        onClick();
      }}
      aria-label={title}
      className={cn(styles.toolbarBtn, isActive && styles.toolbarBtnActive, className)}
    >
      {children}
    </button>
  );
  if (!title) return button;
  return (
    <Tooltip content={title} placement="top" delay={300}>
      {button}
    </Tooltip>
  );
}

/** 선택 시 뜨는 버블 툴바용 버튼 (다크 pill 안에서 사용) */
function BubbleButton({
  label,
  active = false,
  onRun,
  children,
}: {
  label: string;
  active?: boolean;
  onRun: () => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      onMouseDown={(event) => {
        event.preventDefault();
        onRun();
      }}
      className={cn(styles.bubbleBtn, active && styles.bubbleBtnActive)}
    >
      {children}
    </button>
  );
}