"use client";

import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";

type QuillRange = {
  index: number;
  length: number;
};

type QuillLikeInstance = {
  clipboard: {
    convert: (input: { html: string; text?: string }) => QuillDelta;
    dangerouslyPasteHTML: {
      (value: string): void;
      (index: number, value: string, source?: string): void;
    };
  };
  deleteText: (index: number, length: number, source?: string) => void;
  focus: (options?: { preventScroll?: boolean }) => void;
  format: (name: string, value: unknown, source?: string) => void;
  formatLine: (index: number, length: number, name: string, value: unknown, source?: string) => void;
  getBounds?: (index: number, length?: number) => { height: number; left: number; top: number; width: number };
  getContents: (index?: number, length?: number) => QuillDelta;
  getFormat: (index?: number, length?: number) => Record<string, unknown>;
  getIndex: (blot: QuillBlot) => number;
  getLength: () => number;
  getModule: (name: string) => unknown;
  getSelection: (focus?: boolean) => QuillRange | null;
  getText: (index?: number, length?: number) => string;
  insertEmbed: (index: number, type: string, value: unknown, source?: string) => void;
  insertText: (index: number, text: string, source?: string) => void;
  off: (eventName: string, handler: (...args: unknown[]) => void) => void;
  on: (eventName: string, handler: (...args: unknown[]) => void) => void;
  root: HTMLDivElement;
  scroll: {
    find: (node: Node) => QuillBlot | null;
  };
  setSelection: (index: number, length: number, source?: string) => void;
  updateContents: (delta: QuillDelta, source?: string) => void;
};

type QuillDelta = {
  ops?: Array<Record<string, unknown>>;
};

type QuillHistory = {
  cutoff: () => void;
  redo: () => void;
  undo: () => void;
};

type QuillTableModule = {
  insertColumnRight: () => void;
  insertRowBelow: () => void;
};

type QuillBlot = {
  length?: () => number;
};

type QuillNamespace = {
  new (element: Element, options: unknown): QuillLikeInstance;
  import: (name: string) => unknown;
  imports: Record<string, unknown>;
  register: (target: unknown, overwrite?: boolean) => void;
};

type BlockStyle = "bullet" | "code" | "heading" | "number" | "quote" | "text";
type TextBlockKind =
  | "bullet"
  | "code"
  | "heading1"
  | "heading2"
  | "heading3"
  | "heading4"
  | "number"
  | "quote"
  | "text"
  | "todo";
type BlogRichBlockKind =
  | "audio"
  | "breadcrumb"
  | "button"
  | "callout"
  | "columns-2"
  | "columns-3"
  | "columns-4"
  | "database-board"
  | "database-calendar"
  | "database-chart"
  | "database-gallery"
  | "database-list"
  | "database-table"
  | "database-timeline"
  | "file"
  | "math"
  | "mermaid"
  | "page"
  | "table"
  | "tabs"
  | "toc"
  | "toggle"
  | "toggle-heading1"
  | "toggle-heading2"
  | "toggle-heading3"
  | "video";
type BlockInsertKind = TextBlockKind | BlogRichBlockKind | "divider" | "image";

type BlogRichBlock = {
  body: string;
  kind: BlogRichBlockKind;
  title: string;
  url: string;
};

type BlockInsertOption = {
  description: string;
  kind: BlockInsertKind;
  label: string;
  shortcut?: string;
};

type BlockInsertSection = {
  label: string;
  options: BlockInsertOption[];
};

type InlineMenuPosition = {
  left: number;
  range?: QuillRange;
  top: number;
};

type BlockTarget = {
  blockLeft: number;
  columnLayout: BlogColumnLayout | null;
  controlLeft: number;
  controlTop: number;
  height: number;
  index: number;
  isImageLayout: boolean;
  isNativeTable?: boolean;
  length: number;
  top: number;
  width: number;
};

type BlockMenuPosition = {
  left: number;
  style: TextBlockKind | "rich";
  target: BlockTarget;
  top: number;
};

type BlockInsertMenuPosition = {
  left: number;
  target: BlockTarget;
  top: number;
};

type RichMediaKind = "audio" | "file" | "video";

type MediaMenuPosition = {
  left: number;
  top: number;
};

type VideoAlignment = "center" | "left" | "right";

type VideoSettings = {
  align: VideoAlignment;
  caption: string;
  width: number;
};

type VideoResize = {
  block: BlogRichBlock;
  element: HTMLElement;
  pointerId: number;
  side: "left" | "right";
};

type BlockPointerDrag = {
  control: HTMLButtonElement;
  pointerId: number | null;
  source: BlockTarget;
  sourceElement: HTMLElement | null;
  startX: number;
  startY: number;
  started: boolean;
  target: BlockTarget | null;
  targetElement: HTMLElement | null;
  placement: "before" | "after" | "left" | "right" | null;
};

type BlockMarquee = {
  currentX: number;
  currentY: number;
  pointerId: number;
  startX: number;
  startY: number;
  started: boolean;
};

type BlockSelectionRect = {
  height: number;
  left: number;
  top: number;
  width: number;
};

const BLOG_EDITOR_DROP_CLASSES = [
  "blog-editor-drop-before",
  "blog-editor-drop-after",
  "blog-editor-drop-left",
  "blog-editor-drop-right",
] as const;

const BLOG_EDITOR_DROP_PADDING_PROPERTIES = [
  "--blog-drop-padding-top",
  "--blog-drop-padding-right",
  "--blog-drop-padding-bottom",
  "--blog-drop-padding-left",
] as const;

type BlogImage = {
  alt: string;
  src: string;
};

type ImageAlignment = "center" | "left" | "right";

type BlogImageLayout = {
  align: ImageAlignment;
  images: BlogImage[];
  split: number;
  width: number;
};

type BlogColumnLayout = {
  columns: [string, string];
  split: number;
  styles: [BlockStyle, BlockStyle];
};

type ImageLayoutResize = {
  element: HTMLElement;
  frameId: number | null;
  initialCanvasWidth: number;
  layout: BlogImageLayout;
  latestX: number;
  mode: "grid" | "single";
  parentLeft: number;
  parentWidth: number;
  pointerId: number;
  side: "left" | "right";
  startX: number;
};

type ColumnLayoutResize = {
  element: HTMLElement;
  layout: BlogColumnLayout;
  pointerId: number;
};

type BlockEmbedConstructor = {
  new (...args: unknown[]): object;
  create: (value?: unknown) => HTMLElement;
};

function escapeHtml(value: string) {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

function safeLink(value: string) {
  const normalized = value.trim();

  return /^(https?:\/\/|mailto:|\/|#)/i.test(normalized) ? normalized : "";
}

function safeImageUrl(value: string) {
  const normalized = value.trim();

  if (/^\/(?!\/)/.test(normalized)) {
    return normalized;
  }

  try {
    const url = new URL(normalized);
    return url.protocol === "https:" || url.protocol === "http:" ? url.href : "";
  } catch {
    return "";
  }
}

function clampPercentage(value: unknown, fallback: number, minimum: number, maximum: number) {
  const parsed = typeof value === "number" ? value : Number(value);

  if (!Number.isFinite(parsed)) return fallback;

  return Math.round(Math.min(maximum, Math.max(minimum, parsed)));
}

function parseMarkdownImage(value: string) {
  const match = value.match(
    /^!\[([^\]]*)\]\(((?:https?:\/\/|\/(?!\/))[^\s)]+)\)(?:\{width=(\d{1,3})(?:;align=(left|center|right))?\})?$/,
  );

  if (!match) {
    return null;
  }

  const src = safeImageUrl(match[2]);

  return src
    ? {
      alt: match[1],
      align: (match[4] as ImageAlignment | undefined) ?? "center",
      src,
      width: clampPercentage(match[3], 100, 28, 100),
    }
    : null;
}

function parseImageGridStart(value: string) {
  const match = value.match(/^:::image-grid(?:\{left=(\d{1,3})\})?$/);

  return match ? clampPercentage(match[1], 50, 25, 75) : null;
}

function normalizeBlogImage(value: unknown): BlogImage | null {
  if (!value || typeof value !== "object") return null;

  const input = value as { alt?: unknown; src?: unknown };
  const src = typeof input.src === "string" ? safeImageUrl(input.src) : "";

  if (!src) return null;

  return {
    alt: typeof input.alt === "string" ? input.alt.slice(0, 500) : "",
    src,
  };
}

function normalizeBlogImageLayout(value: unknown): BlogImageLayout | null {
  if (!value || typeof value !== "object") return null;

  const input = value as { align?: unknown; images?: unknown; split?: unknown; width?: unknown };
  const images = Array.isArray(input.images)
    ? input.images.map(normalizeBlogImage).filter((image): image is BlogImage => image !== null).slice(0, 2)
    : [];

  if (images.length === 0) return null;

  return {
    align: input.align === "left" || input.align === "right" ? input.align : "center",
    images,
    split: clampPercentage(input.split, 50, 25, 75),
    width: clampPercentage(input.width, 100, 28, 100),
  };
}

function imageMarkdown(image: BlogImage, width = 100, align: ImageAlignment = "center") {
  const alt = image.alt.replace(/[\[\]\\]/g, "\\$&");
  const suffix = width < 100 || align !== "center" ? `{width=${width};align=${align}}` : "";

  return `![${alt}](${image.src})${suffix}`;
}

function imageLayoutMarkdown(layout: BlogImageLayout) {
  if (layout.images.length === 1) {
    return imageMarkdown(layout.images[0], layout.width, layout.align);
  }

  return [
    `:::image-grid{left=${layout.split}}`,
    imageMarkdown(layout.images[0]),
    imageMarkdown(layout.images[1]),
    ":::",
  ].join("\n");
}

function imageLayoutEditorHtml(layout: BlogImageLayout) {
  return `<div class="blog-image-layout" data-layout="${escapeHtml(JSON.stringify(layout))}"></div>`;
}

const BLOG_RICH_BLOCK_KINDS: BlogRichBlockKind[] = [
  "audio",
  "breadcrumb",
  "button",
  "callout",
  "columns-2",
  "columns-3",
  "columns-4",
  "database-board",
  "database-calendar",
  "database-chart",
  "database-gallery",
  "database-list",
  "database-table",
  "database-timeline",
  "file",
  "math",
  "mermaid",
  "page",
  "table",
  "tabs",
  "toc",
  "toggle",
  "toggle-heading1",
  "toggle-heading2",
  "toggle-heading3",
  "video",
];

const BLOG_RICH_BLOCK_LABELS: Record<BlogRichBlockKind, string> = {
  audio: "오디오",
  breadcrumb: "이동 경로",
  button: "버튼",
  callout: "콜아웃",
  "columns-2": "2개의 열",
  "columns-3": "3개의 열",
  "columns-4": "4개의 열",
  "database-board": "보드 보기",
  "database-calendar": "캘린더 보기",
  "database-chart": "차트 보기",
  "database-gallery": "갤러리 보기",
  "database-list": "리스트 보기",
  "database-table": "표 보기",
  "database-timeline": "타임라인 보기",
  file: "파일",
  math: "수학 공식",
  mermaid: "Mermaid",
  page: "페이지",
  table: "표",
  tabs: "탭",
  toc: "목차",
  toggle: "토글 목록",
  "toggle-heading1": "토글 제목1",
  "toggle-heading2": "토글 제목2",
  "toggle-heading3": "토글 제목3",
  video: "동영상",
};

function defaultRichBlock(kind: BlogRichBlockKind): BlogRichBlock {
  const databaseBody = "첫 번째 항목\n두 번째 항목\n세 번째 항목";
  const defaults: Record<BlogRichBlockKind, Omit<BlogRichBlock, "kind">> = {
    audio: { body: "", title: "", url: "" },
    breadcrumb: { body: "홈 / 블로그 / 현재 글", title: "이동 경로", url: "" },
    button: { body: "", title: "새 버튼", url: "" },
    callout: { body: "강조할 내용을 입력하세요.", title: "💡", url: "" },
    "columns-2": { body: "첫 번째 열\n두 번째 열", title: "2개의 열", url: "column-widths:50,50" },
    "columns-3": { body: "첫 번째 열\n두 번째 열\n세 번째 열", title: "3개의 열", url: "column-widths:33.33,33.33,33.34" },
    "columns-4": { body: "첫 번째 열\n두 번째 열\n세 번째 열\n네 번째 열", title: "4개의 열", url: "column-widths:25,25,25,25" },
    "database-board": { body: databaseBody, title: "보드", url: "" },
    "database-calendar": { body: databaseBody, title: "캘린더", url: "" },
    "database-chart": { body: "항목 A: 30\n항목 B: 55\n항목 C: 80", title: "차트", url: "" },
    "database-gallery": { body: databaseBody, title: "갤러리", url: "" },
    "database-list": { body: databaseBody, title: "리스트", url: "" },
    "database-table": { body: "항목 | 상태 | 설명\n첫 번째 | 진행 중 | 내용을 입력하세요\n두 번째 | 완료 | 내용을 입력하세요", title: "표 데이터베이스", url: "" },
    "database-timeline": { body: databaseBody, title: "타임라인", url: "" },
    file: { body: "", title: "", url: "" },
    math: { body: "E = mc^2", title: "수학 공식", url: "" },
    mermaid: { body: "graph LR\n  A[시작] --> B[완료]", title: "Mermaid 다이어그램", url: "" },
    page: { body: "", title: "새 페이지", url: "/" },
    table: { body: " |  | \n |  | \n |  | ", title: "", url: "" },
    tabs: { body: "탭 1 | \n탭 2 | \n탭 3 | ", title: "탭", url: "" },
    toc: { body: "", title: "목차", url: "" },
    toggle: { body: "", title: "", url: "toggle-open:0" },
    "toggle-heading1": { body: "", title: "", url: "toggle-open:0" },
    "toggle-heading2": { body: "", title: "", url: "toggle-open:0" },
    "toggle-heading3": { body: "", title: "", url: "toggle-open:0" },
    video: { body: "", title: "", url: "" },
  };

  return { kind, ...defaults[kind] };
}

function normalizeBlogRichBlock(value: unknown): BlogRichBlock | null {
  if (!value || typeof value !== "object") return null;

  const input = value as { body?: unknown; kind?: unknown; title?: unknown; url?: unknown };

  if (typeof input.kind !== "string" || !BLOG_RICH_BLOCK_KINDS.includes(input.kind as BlogRichBlockKind)) {
    return null;
  }

  return {
    body: typeof input.body === "string" ? input.body.slice(0, 20_000) : "",
    kind: input.kind as BlogRichBlockKind,
    title: typeof input.title === "string" ? input.title.slice(0, 500) : "",
    url: typeof input.url === "string" ? input.url.slice(0, 2_000) : "",
  };
}

function richBlockMarkdown(block: BlogRichBlock) {
  return [
    `:::official-block{type=${block.kind};title=${encodeURIComponent(block.title)};url=${encodeURIComponent(block.url)}}`,
    block.body,
    ":::official-block-end",
  ].join("\n");
}

function parseRichBlock(lines: string[], startIndex: number) {
  const match = lines[startIndex]?.trim().match(
    /^:::official-block\{type=([a-z0-9-]+);title=([^;]*);url=([^}]*)\}$/,
  );

  if (!match || !BLOG_RICH_BLOCK_KINDS.includes(match[1] as BlogRichBlockKind)) return null;

  const endIndex = lines.findIndex(
    (line, index) => index > startIndex && line.trim() === ":::official-block-end",
  );

  if (endIndex < 0) return null;

  let title = "";
  let url = "";

  try {
    title = decodeURIComponent(match[2]);
    url = decodeURIComponent(match[3]);
  } catch {
    return null;
  }

  return {
    block: normalizeBlogRichBlock({
      body: lines.slice(startIndex + 1, endIndex).join("\n"),
      kind: match[1],
      title,
      url,
    }),
    nextIndex: endIndex + 1,
  };
}

function recoverRichColumnsFromLegacyLayout(layout: BlogColumnLayout) {
  const parsedColumns = layout.columns.map((column) => {
    const lines = column.replace(/\r/g, "").split("\n");
    const parsed = parseRichBlock(lines, 0);

    return parsed?.block?.kind.startsWith("columns-") && parsed.nextIndex === lines.length
      ? parsed.block
      : null;
  });
  const nestedIndex = parsedColumns.findIndex(Boolean);

  if (nestedIndex < 0 || parsedColumns.filter(Boolean).length !== 1) return null;

  const nested = parsedColumns[nestedIndex]!;
  const plainIndex = nestedIndex === 0 ? 1 : 0;
  const plain = layout.columns[plainIndex].trim();
  const nestedValues = richColumnsValues(nested);
  const count = nestedValues.length + 1;

  if (!plain || plain.includes("\n") || count > 4) return null;

  const nestedShare = nestedIndex === 0 ? layout.split : 100 - layout.split;
  const plainShare = 100 - nestedShare;
  const nestedWidths = columnWidths(nested.url, nestedValues.length)
    .map((width) => width * nestedShare / 100);
  let widths = nestedIndex === 0
    ? [...nestedWidths, plainShare]
    : [plainShare, ...nestedWidths];

  if (widths.some((width) => width < 10)) {
    widths = Array.from({ length: count }, () => 100 / count);
  }

  const kind = `columns-${count}` as BlogRichBlockKind;

  return normalizeBlogRichBlock({
    body: nestedIndex === 0
      ? [...nestedValues, plain].join("\n")
      : [plain, ...nestedValues].join("\n"),
    kind,
    title: BLOG_RICH_BLOCK_LABELS[kind],
    url: columnWidthsMeta(widths),
  });
}

function richBlockEditorHtml(block: BlogRichBlock) {
  return `<div class="blog-rich-block" data-block="${escapeHtml(JSON.stringify(block))}"></div>`;
}

function columnLayoutMarkdown(layout: BlogColumnLayout) {
  return [
    `:::columns{left=${layout.split};styles=${layout.styles.join(",")}}`,
    ":::column-left",
    layout.columns[0].trim(),
    ":::column-right",
    layout.columns[1].trim(),
    ":::columns-end",
  ].join("\n");
}

function columnLayoutEditorHtml(layout: BlogColumnLayout) {
  const rowId = `columns-${layout.split}-${layout.styles.join("_")}-${Math.random().toString(36).slice(2, 8)}`;
  const cellHtml = layout.columns.map((column) => {
    const content = column
      .replace(/^#{1,3}\s+/, "")
      .replace(/^>\s?/, "")
      .replace(/^[-*]\s+/, "")
      .replace(/^\d+\.\s+/, "")
      .replace(/^```\w*\n?|```$/g, "")
      .trim();
    return content.split("\n").map(renderInlineMarkdown).join("<br>");
  });

  return `<table><tbody><tr><td data-row="${rowId}">${cellHtml[0]}</td><td data-row="${rowId}">${cellHtml[1]}</td></tr></tbody></table>`;
}

function richTableEditorHtml(block: BlogRichBlock) {
  const rows = tableRows(block.body, 3, 3);
  const widths = tableColumnWidths(block.url, rows[0]?.length ?? 3)
    .map((width) => Math.round(width * 100) / 100)
    .join("_");
  const tableId = Math.random().toString(36).slice(2, 8);
  const rowHtml = rows.map((row, rowIndex) => {
    const rowId = `official-rich-table-${widths}-${tableId}-${rowIndex}`;
    return `<tr>${row.map((cell) => `<td data-row="${rowId}">${renderInlineMarkdown(cell)}</td>`).join("")}</tr>`;
  }).join("");

  return `<table><tbody>${rowHtml}</tbody></table>`;
}

function readNativeRichTable(node: HTMLElement) {
  if (node.tagName !== "TABLE") return null;

  const marker = node.querySelector<HTMLElement>("td[data-row^='official-rich-table-']");

  if (!marker) return null;

  const rows = Array.from(node.querySelectorAll<HTMLTableRowElement>("tr"))
    .map((row) => Array.from(row.querySelectorAll<HTMLTableCellElement>(":scope > td"))
      .map((cell) => inlineMarkdownFromNode(cell).replace(/\s*\n\s*/g, " ").trim()));

  if (rows.length === 0 || rows.every((row) => row.length === 0)) return null;

  const columnCount = Math.max(...rows.map((row) => row.length), 1);
  const widthsMatch = marker.dataset.row?.match(/^official-rich-table-([\d.]+(?:_[\d.]+)*)-/);
  const widths = widthsMatch?.[1]?.split("_").map(Number);
  const normalizedWidths = widths?.length === columnCount && widths.every((width) => Number.isFinite(width))
    ? tableWidthsMeta(widths)
    : tableWidthsMeta(Array.from({ length: columnCount }, () => 100 / columnCount));

  return {
    body: tableBody(rows),
    kind: "table",
    title: "",
    url: normalizedWidths,
  } satisfies BlogRichBlock;
}

function readBlogImageLayout(node: HTMLElement) {
  try {
    return normalizeBlogImageLayout(JSON.parse(node.dataset.layout ?? ""));
  } catch {
    return null;
  }
}

function parseColumnTableRowId(value: string) {
  const match = value.match(/^columns-(\d{1,3})-(text|heading|bullet|number|quote|code)_(text|heading|bullet|number|quote|code)-/);

  return match
    ? {
      split: clampPercentage(match[1], 50, 25, 75),
      styles: [match[2] as BlockStyle, match[3] as BlockStyle] as [BlockStyle, BlockStyle],
    }
    : null;
}

function markdownForColumnCell(cell: HTMLElement, style: BlockStyle) {
  const inline = inlineMarkdownFromNode(cell).trim();

  if (style === "heading") return `## ${inline}`;
  if (style === "bullet") return `- ${inline}`;
  if (style === "number") return `1. ${inline}`;
  if (style === "quote") return `> ${inline}`;
  if (style === "code") return `\`\`\`\n${cell.textContent?.trim() ?? ""}\n\`\`\``;
  return inline;
}

function readBlogColumnLayout(node: HTMLElement) {
  if (node.tagName !== "TABLE") return null;

  const cells = Array.from(node.querySelectorAll<HTMLElement>("tr:first-child > td")).slice(0, 2);
  const row = parseColumnTableRowId(cells[0]?.dataset.row ?? "");

  if (!row || cells.length !== 2) return null;

  return {
    columns: [markdownForColumnCell(cells[0], row.styles[0]), markdownForColumnCell(cells[1], row.styles[1])],
    split: row.split,
    styles: row.styles,
  } satisfies BlogColumnLayout;
}

function isBlogImageLayoutElement(value: Element | null) {
  return value instanceof HTMLElement && value.classList.contains("blog-image-layout");
}

function isBlogColumnLayoutElement(value: Element | null) {
  return value instanceof HTMLElement && value.tagName === "TABLE" && readBlogColumnLayout(value) !== null;
}

function applyBlogImageLayoutStyles(node: HTMLElement, layout: BlogImageLayout) {
  node.dataset.imageAlign = layout.align;
  node.style.setProperty("--blog-image-layout-width", `${layout.width}%`);
  node.style.setProperty("--blog-image-grid-left", `${layout.split}fr`);
  node.style.setProperty("--blog-image-grid-right", `${100 - layout.split}fr`);
  node.style.setProperty("--blog-image-divider-position", `${layout.split}%`);
}

function applyBlogColumnLayoutStyles(node: HTMLElement, layout: BlogColumnLayout) {
  node.style.setProperty("--blog-column-left", `${layout.split}%`);
  node.style.setProperty("--blog-column-right", `${100 - layout.split}%`);
}

function hydrateBlogColumnTables(root: HTMLElement) {
  root.querySelectorAll<HTMLElement>("table").forEach((table) => {
    const layout = readBlogColumnLayout(table);

    if (layout) {
      applyBlogColumnLayoutStyles(table, layout);
      table.querySelectorAll<HTMLElement>("td[data-row^='columns-']").forEach((cell, index) => {
        cell.setAttribute("contenteditable", "true");
        cell.setAttribute("role", "textbox");
        cell.setAttribute("aria-label", index === 0 ? "왼쪽 열" : "오른쪽 열");
      });
      return;
    }

    const richTable = readNativeRichTable(table);

    if (!richTable) return;

    table.classList.add("blog-native-table");
    const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>("tr"));
    const firstRowCells = Array.from(rows[0]?.querySelectorAll<HTMLTableCellElement>(":scope > td") ?? []);
    const widths = tableColumnWidths(richTable.url, firstRowCells.length);

    firstRowCells.forEach((cell, index) => {
      cell.style.width = `${widths[index]}%`;
    });

    rows.forEach((row, rowIndex) => {
      Array.from(row.querySelectorAll<HTMLTableCellElement>(":scope > td")).forEach((cell, columnIndex) => {
        cell.setAttribute("role", "textbox");
        cell.setAttribute("aria-label", `${rowIndex + 1}행 ${columnIndex + 1}열`);
      });
    });
  });
}

function imageWidgetIcon(
  name: "align" | "caption" | "check" | "download" | "expand" | "more",
) {
  const paths = {
    align: '<path d="M3 5h18M7 9h10v6H7zM3 19h18"/>',
    caption: '<path d="M4.5 5.5h15v10h-15zM7 18.5h10M7.5 9h9M7.5 12h6"/>',
    check: '<path d="m5 12 4 4L19 6.5"/>',
    download: '<path d="M12 3.5v11m0 0 4-4m-4 4-4-4M5 19.5h14"/>',
    expand: '<path d="M8.5 4H4v4.5M15.5 4H20v4.5M8.5 20H4v-4.5M15.5 20H20v-4.5"/>',
    more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
  } as const;

  return `<svg viewBox="0 0 24 24" aria-hidden="true">${paths[name]}</svg>`;
}

function createImageToolbarButton(
  action: string,
  label: string,
  icon: Parameters<typeof imageWidgetIcon>[0],
  active = false,
) {
  const button = document.createElement("button");
  button.type = "button";
  button.dataset.imageLayoutAction = action;
  button.dataset.active = active ? "true" : "false";
  button.innerHTML = imageWidgetIcon(icon);
  button.setAttribute("aria-label", label);
  button.title = label;
  button.tabIndex = -1;
  return button;
}

function createBlogImageFigure(image: BlogImage, imageIndex: number, editingCaption: boolean) {
  const figure = document.createElement("figure");
  figure.className = "blog-image-layout__figure";
  const imageElement = document.createElement("img");
  imageElement.alt = image.alt;
  imageElement.draggable = false;
  imageElement.src = image.src;
  figure.append(imageElement);

  if (image.alt || editingCaption) {
    const caption = document.createElement("figcaption");
    if (editingCaption) {
      const input = document.createElement("input");
      input.type = "text";
      input.value = image.alt;
      input.placeholder = "캡션 작성";
      input.dataset.imageCaption = String(imageIndex);
      input.setAttribute("aria-label", "이미지 캡션");
      caption.append(input);
    } else {
      caption.textContent = image.alt;
    }
    figure.append(caption);
  }

  return figure;
}

function createImageLayoutResizeHandle(mode: "grid" | "single", side: "left" | "right" = "right") {
  const handle = document.createElement("button");
  handle.type = "button";
  handle.className = `blog-image-layout__resize-handle blog-image-layout__resize-handle--${mode} blog-image-layout__resize-handle--${side}`;
  handle.setAttribute("aria-label", mode === "grid" ? "이미지 열 너비 조절" : "이미지 너비 조절");
  handle.dataset.imageLayoutResize = mode === "grid" ? "grid" : `single-${side}`;
  handle.tabIndex = -1;
  return handle;
}

function renderBlogImageLayoutNode(node: HTMLElement, layout: BlogImageLayout) {
  node.replaceChildren();
  node.dataset.layout = JSON.stringify(layout);
  node.setAttribute("contenteditable", "false");
  applyBlogImageLayoutStyles(node, layout);

  const canvas = document.createElement("div");
  canvas.className = `blog-image-layout__canvas ${layout.images.length === 2 ? "blog-image-layout__canvas--grid" : "blog-image-layout__canvas--single"}`;
  const editingCaptionIndex = Number(node.dataset.imageCaptionEditing);

  for (const [imageIndex, image] of layout.images.entries()) {
    canvas.append(createBlogImageFigure(
      image,
      imageIndex,
      Number.isInteger(editingCaptionIndex) && editingCaptionIndex === imageIndex,
    ));
  }

  if (layout.images.length === 2) {
    canvas.append(createImageLayoutResizeHandle("grid"));
  } else {
    canvas.append(
      createImageLayoutResizeHandle("single", "left"),
      createImageLayoutResizeHandle("single", "right"),
    );
  }

  const toolbar = document.createElement("div");
  toolbar.className = "blog-image-layout__toolbar";
  toolbar.setAttribute("contenteditable", "false");

  if (layout.images.length === 1) {
    toolbar.append(
      createImageToolbarButton(
        "toggle-align-menu",
        "이미지 정렬",
        "align",
        node.dataset.imageAlignmentOpen === "true",
      ),
      createImageToolbarButton(
        "toggle-caption",
        "캡션",
        "caption",
        Boolean(layout.images[0]?.alt.trim()) || Number.isInteger(editingCaptionIndex),
      ),
      createImageToolbarButton("preview", "크게 보기", "expand"),
      createImageToolbarButton("download", "다운로드", "download"),
      createImageToolbarButton(
        "toggle-more-menu",
        "더 보기",
        "more",
        node.dataset.imageMoreOpen === "true",
      ),
    );
  } else {
    toolbar.append(
      createImageToolbarButton("unstack", "열 해제", "align"),
      createImageToolbarButton(
        "toggle-more-menu",
        "더 보기",
        "more",
        node.dataset.imageMoreOpen === "true",
      ),
    );
  }

  canvas.append(toolbar);

  if (layout.images.length === 1 && node.dataset.imageAlignmentOpen === "true") {
    const popover = document.createElement("div");
    popover.className = "blog-image-layout__popover blog-image-layout__popover--align";
    popover.setAttribute("role", "dialog");
    popover.setAttribute("aria-label", "이미지 정렬");

    for (const [alignment, label] of [
      ["left", "왼쪽"],
      ["center", "가운데"],
      ["right", "오른쪽"],
    ] as const) {
      const button = document.createElement("button");
      button.type = "button";
      button.dataset.imageLayoutAction = `align-${alignment}`;
      button.dataset.active = layout.align === alignment ? "true" : "false";
      button.innerHTML = `${imageWidgetIcon("align")}<span>${label}</span>${layout.align === alignment ? imageWidgetIcon("check") : ""}`;
      popover.append(button);
    }
    canvas.append(popover);
  }

  if (node.dataset.imageMoreOpen === "true") {
    const popover = document.createElement("div");
    popover.className = "blog-image-layout__popover blog-image-layout__popover--more";
    popover.setAttribute("role", "dialog");
    popover.setAttribute("aria-label", "이미지 더 보기");

    if (layout.images.length === 1) {
      for (const [width, label] of [[50, "작게"], [75, "보통"], [100, "전체 너비"]] as const) {
        const button = document.createElement("button");
        button.type = "button";
        button.dataset.imageLayoutAction = `width-${width}`;
        button.dataset.active = layout.width === width ? "true" : "false";
        button.innerHTML = `<span>${label}</span>${layout.width === width ? imageWidgetIcon("check") : ""}`;
        popover.append(button);
      }
    }

    const remove = document.createElement("button");
    remove.type = "button";
    remove.dataset.imageLayoutAction = "remove";
    remove.className = "blog-image-layout__popover-danger";
    remove.textContent = "삭제";
    popover.append(remove);
    canvas.append(popover);
  }

  node.append(canvas);
}

function registerBlogImageLayout(Quill: QuillNamespace) {
  if (Quill.imports["formats/blog-image-layout"]) return;

  const BlockEmbed = Quill.import("blots/block/embed") as BlockEmbedConstructor;

  class BlogImageLayoutBlot extends BlockEmbed {
    static blotName = "blog-image-layout";
    static className = "blog-image-layout";
    static tagName = "DIV";

    static create(value: unknown) {
      const node = super.create() as HTMLElement;
      const layout = normalizeBlogImageLayout(value);

      if (layout) {
        renderBlogImageLayoutNode(node, layout);
      }

      return node;
    }

    static value(node: HTMLElement) {
      return readBlogImageLayout(node);
    }
  }

  Quill.register(BlogImageLayoutBlot, true);
}

function richBlockEditableField(
  field: "body" | "title" | "url",
  value: string,
  className: string,
  placeholder = "",
) {
  const element = document.createElement(field === "body" ? "textarea" : "input");
  element.className = className;
  element.dataset.richBlockField = field;
  element.setAttribute("spellcheck", "true");
  element.setAttribute("placeholder", placeholder);
  element.value = value;
  isolateRichBlockField(element);

  if (element instanceof HTMLTextAreaElement) {
    element.rows = Math.max(2, Math.min(8, value.split("\n").length + 1));
  } else {
    element.type = field === "url" ? "url" : "text";
  }

  return element;
}

function isolateRichBlockField(element: HTMLInputElement | HTMLTextAreaElement) {
  for (const eventName of [
    "beforeinput",
    "click",
    "compositionend",
    "compositionstart",
    "compositionupdate",
    "input",
    "keydown",
    "keyup",
    "mousedown",
    "paste",
    "pointerdown",
  ]) {
    element.addEventListener(eventName, (event) => event.stopPropagation());
  }
}

function audioWaveformIcon() {
  const wrapper = document.createElement("span");
  wrapper.className = "blog-rich-block__audio-icon";
  wrapper.innerHTML = `
    <svg viewBox="0 0 20 20" aria-hidden="true">
      <path d="M8.65 2.525c.345 0 .625.28.625.625v13.7a.625.625 0 1 1-1.25 0V3.15c0-.345.28-.625.625-.625M6.575 5.45a.625.625 0 1 0-1.25 0v9.1a.625.625 0 1 0 1.25 0zm-2.7 3.2a.625.625 0 0 0-1.25 0v2.7a.625.625 0 1 0 1.25 0zm10.8-2.7a.625.625 0 1 0-1.25 0v8.1a.625.625 0 0 0 1.25 0zm2.075 2.075c.345 0 .625.28.625.625v2.7a.625.625 0 1 1-1.25 0v-2.7c0-.345.28-.625.625-.625m-4.775-.775a.625.625 0 1 0-1.25 0v5.5a.625.625 0 1 0 1.25 0z" />
    </svg>
  `;
  return wrapper;
}

function documentBlockIcon(className = "blog-rich-block__document-icon") {
  const wrapper = document.createElement("span");
  wrapper.className = className;
  wrapper.innerHTML = `
    <svg viewBox="0 0 20 20" aria-hidden="true">
      <path d="M5.25 2.75h6.1l3.4 3.4v11.1H5.25z" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linejoin="round" />
      <path d="M11.15 2.9v3.55h3.4M7.7 9h4.8M7.7 11.6h4.8M7.7 14.2h3.4" fill="none" stroke="currentColor" stroke-width="1.15" stroke-linecap="round" />
    </svg>
  `;
  return wrapper;
}

function videoBlockIcon() {
  const wrapper = document.createElement("span");
  wrapper.className = "blog-rich-block__media-icon";
  wrapper.innerHTML = `
    <svg viewBox="0 0 20 20" aria-hidden="true">
      <path d="M7.814 12.407c0 .295.323.479.58.33l4.165-2.407a.38.38 0 0 0 0-.66L8.394 7.263a.385.385 0 0 0-.58.33z" />
      <path d="M4.5 4.125A2.125 2.125 0 0 0 2.375 6.25v7.5c0 1.174.951 2.125 2.125 2.125h11a2.125 2.125 0 0 0 2.125-2.125v-7.5A2.125 2.125 0 0 0 15.5 4.125zM3.625 6.25c0-.483.392-.875.875-.875h11c.483 0 .875.392.875.875v7.5a.875.875 0 0 1-.875.875h-11a.875.875 0 0 1-.875-.875z" />
    </svg>
  `;
  return wrapper;
}

function BlockIconElement(type: BlogRichBlockKind) {
  const wrapper = document.createElement("span");
  wrapper.className = "blog-rich-block__database-view-icon";
  const normalizedType = type.startsWith("database-") && !NOTION_BLOCK_ICON_PATHS[type]
    ? "toc"
    : type;
  const path = NOTION_BLOCK_ICON_PATHS[normalizedType as BlockInsertKind | "rich"];
  wrapper.innerHTML = path
    ? `<svg aria-hidden="true" viewBox="0 0 20 20"><path d="${path}"></path></svg>`
    : documentBlockIcon().innerHTML;
  return wrapper;
}

const VIDEO_TOOLBAR_ICON_PATHS: Record<string, { paths: string[]; viewBox?: string }> = {
  "video-align-left": {
    paths: ["M2.4 2.175a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25zm1.2 2A1.825 1.825 0 0 0 1.775 6v4c0 1.008.817 1.825 1.825 1.825H8A1.825 1.825 0 0 0 9.825 10V6A1.825 1.825 0 0 0 8 4.175zM3.025 6c0-.318.258-.575.575-.575H8c.318 0 .575.257.575.575v4a.575.575 0 0 1-.575.575H3.6A.575.575 0 0 1 3.025 10zM2.4 12.575a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25z"],
    viewBox: "1.77 0 12.45 16",
  },
  "video-align-center": {
    paths: ["M2.4 2.175a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25zm3.4 2h4.4c1.008 0 1.825.817 1.825 1.825v4a1.825 1.825 0 0 1-1.825 1.825H5.8A1.825 1.825 0 0 1 3.975 10V6c0-1.008.817-1.825 1.825-1.825M5.225 6v4c0 .318.258.575.575.575h4.4a.575.575 0 0 0 .575-.575V6a.575.575 0 0 0-.575-.575H5.8A.575.575 0 0 0 5.225 6M2.4 12.575a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25z"],
    viewBox: "1.77 0 12.45 16",
  },
  "video-align-right": {
    paths: ["M2.4 2.175a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25zm5.6 2A1.825 1.825 0 0 0 6.175 6v4c0 1.008.817 1.825 1.825 1.825h4.4A1.825 1.825 0 0 0 14.225 10V6A1.825 1.825 0 0 0 12.4 4.175zM7.425 6c0-.318.257-.575.575-.575h4.4c.318 0 .575.257.575.575v4a.575.575 0 0 1-.575.575H8A.575.575 0 0 1 7.425 10zM2.4 12.575a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25z"],
    viewBox: "1.77 0 12.45 16",
  },
  "video-width-50": {
    paths: ["M3.5 3.25A1.75 1.75 0 0 0 1.75 5v6A1.75 1.75 0 0 0 3.5 12.75h4A1.75 1.75 0 0 0 9.25 11V5A1.75 1.75 0 0 0 7.5 3.25zm0 1.25h4a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5h-4A.5.5 0 0 1 3 11V5a.5.5 0 0 1 .5-.5M11.5 2.25a.625.625 0 1 0 0 1.25h2.25v9H11.5a.625.625 0 1 0 0 1.25h2.875a.625.625 0 0 0 .625-.625V2.875a.625.625 0 0 0-.625-.625z"],
    viewBox: "0 0 16 16",
  },
  "video-width-75": {
    paths: ["M2.75 3.25A1.75 1.75 0 0 0 1 5v6a1.75 1.75 0 0 0 1.75 1.75h8.5A1.75 1.75 0 0 0 13 11V5a1.75 1.75 0 0 0-1.75-1.75zm0 1.25h8.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5h-8.5a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5M14.375 2.25a.625.625 0 0 0-.625.625v10.25a.625.625 0 1 0 1.25 0V2.875a.625.625 0 0 0-.625-.625"],
    viewBox: "0 0 16 16",
  },
  "video-width-100": {
    paths: ["M2.75 3.25A1.75 1.75 0 0 0 1 5v6a1.75 1.75 0 0 0 1.75 1.75h10.5A1.75 1.75 0 0 0 15 11V5a1.75 1.75 0 0 0-1.75-1.75zm0 1.25h10.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H2.75a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5"],
    viewBox: "0 0 16 16",
  },
  "video-replace": {
    paths: ["M13.2 5.25A6 6 0 0 0 3.43 4.1L2.2 5.33V2.75a.625.625 0 1 0-1.25 0v4.1c0 .345.28.625.625.625h4.1a.625.625 0 1 0 0-1.25H3.08l1.24-1.24a4.75 4.75 0 1 1-.32 6.38.625.625 0 0 0-.96.8A6 6 0 1 0 13.2 5.25"],
    viewBox: "0 0 16 16",
  },
  "video-remove": {
    paths: ["M5.25 2.25h5.5l.45 1.25h2.175a.625.625 0 1 1 0 1.25h-.57l-.55 8.02A1.75 1.75 0 0 1 10.51 14.4H5.49a1.75 1.75 0 0 1-1.746-1.63l-.55-8.02h-.57a.625.625 0 1 1 0-1.25H4.8zm-.803 2.5.544 7.935a.5.5 0 0 0 .499.465h5.02a.5.5 0 0 0 .499-.465l.544-7.935zM6 7a.625.625 0 0 1 1.25 0v3.5a.625.625 0 1 1-1.25 0zm3.375-.625c.345 0 .625.28.625.625v3.5a.625.625 0 1 1-1.25 0V7c0-.345.28-.625.625-.625"],
    viewBox: "0 0 16 16",
  },
};

function videoToolbarIcon(action: string) {
  const icon = VIDEO_TOOLBAR_ICON_PATHS[action];
  if (!icon) return "";

  return `<svg aria-hidden="true" viewBox="${icon.viewBox ?? "0 0 16 16"}">${icon.paths.map((path) => `<path d="${path}"></path>`).join("")}</svg>`;
}

function tableRows(value: string, minimumRows = 3, minimumColumns = 3) {
  const parsed = value
    ? value
      .split("\n")
      .map((line) => line.split("|").map((cell) => cell.trim()))
    : [];
  const columnCount = Math.max(minimumColumns, ...parsed.map((row) => row.length), 0);
  const rows = parsed.length > 0 ? parsed : [[]];

  while (rows.length < minimumRows) rows.push([]);

  return rows.map((row) => Array.from({ length: columnCount }, (_, index) => row[index] ?? ""));
}

function tableBody(rows: string[][]) {
  return rows.map((row) => row.map((cell) => cell.trim()).join(" | ")).join("\n");
}

function tableColumnWidths(value: string, columnCount: number) {
  const match = value.match(/^table-widths:([\d.,]+)$/);
  const parsed = match?.[1]
    .split(",")
    .map(Number)
    .filter((width) => Number.isFinite(width) && width >= 8);

  if (!parsed || parsed.length !== columnCount) {
    return Array.from({ length: columnCount }, () => 100 / columnCount);
  }

  const total = parsed.reduce((sum, width) => sum + width, 0);
  return parsed.map((width) => width / total * 100);
}

function tableWidthsMeta(widths: number[]) {
  return `table-widths:${widths.map((width) => Math.round(width * 100) / 100).join(",")}`;
}

const VIDEO_SETTINGS_PATTERN = /^video-settings:width=(\d{1,3});align=(left|center|right)$/;
const TOGGLE_STATE_PATTERN = /^toggle-open:(0|1)$/;
const COLUMN_WIDTHS_PATTERN = /^column-widths:([\d.,]+)$/;

const RICH_WIDGET_ICON_PATHS = {
  filter: {
    path: "M2.4 3.7a.7.7 0 1 0 0 1.4h11.2a.7.7 0 1 0 0-1.4zm9.5 3.594H4.1a.7.7 0 1 0 0 1.4h7.8a.7.7 0 1 0 0-1.4M5.8 10.9a.7.7 0 1 0 0 1.4h4.4a.7.7 0 1 0 0-1.4z",
    viewBox: "0 0 16 16",
  },
  more: {
    path: "M4 8a1.25 1.25 0 1 1 0 2.5A1.25 1.25 0 0 1 4 8m6 0a1.25 1.25 0 1 1 0 2.5A1.25 1.25 0 0 1 10 8m6 0a1.25 1.25 0 1 1 0 2.5A1.25 1.25 0 0 1 16 8",
    viewBox: "0 0 20 20",
  },
  plus: {
    path: "M8 2.65a.75.75 0 0 1 .75.75v3.85h3.85a.75.75 0 0 1 0 1.5H8.75v3.85l-.004.077a.75.75 0 0 1-1.492 0L7.25 12.6V8.75H3.4a.75.75 0 0 1 0-1.5h3.85V3.4A.75.75 0 0 1 8 2.65",
    viewBox: "0 0 16 16",
  },
  search: {
    path: "M8.875 2.625a6.25 6.25 0 1 0 3.955 11.09l3.983 3.982a.625.625 0 1 0 .884-.884l-3.983-3.982a6.25 6.25 0 0 0-4.84-10.205m-5 6.25a5 5 0 1 1 10 0 5 5 0 0 1-10 0",
    viewBox: "0 0 20 20",
  },
  settings: {
    path: "M2.25 5.531h5.692a2.126 2.126 0 0 0 4.116 0h1.692a.625.625 0 1 0 0-1.25H12a2.126 2.126 0 0 0-4 0H2.25a.625.625 0 1 0 0 1.25M10 4.125a.875.875 0 1 1 0 1.75.875.875 0 0 1 0-1.75m-4 9c.921 0 1.706-.586 2-1.406h5.75a.625.625 0 0 0 0-1.25H8.058a2.126 2.126 0 0 0-4.116 0H2.25a.625.625 0 1 0 0 1.25H4a2.13 2.13 0 0 0 2 1.406m0-1.25a.875.875 0 1 1 0-1.75.875.875 0 0 1 0 1.75",
    viewBox: "0 0 16 16",
  },
  sort: {
    path: "M11.348 2.672a.625.625 0 0 0-.884 0L7.666 5.471a.625.625 0 1 0 .884.883l1.731-1.73v8.262a.625.625 0 1 0 1.25 0V4.623l1.732 1.731a.625.625 0 0 0 .884-.883zM5.093 2.49a.625.625 0 0 0-.625.624v8.263L2.737 9.646a.625.625 0 1 0-.884.883l2.798 2.799c.244.244.64.244.884 0l2.798-2.798a.625.625 0 0 0-.884-.884l-1.73 1.73V3.115a.625.625 0 0 0-.626-.625",
    viewBox: "0 0 16 16",
  },
} as const;

function richWidgetIcon(name: keyof typeof RICH_WIDGET_ICON_PATHS) {
  const icon = RICH_WIDGET_ICON_PATHS[name];
  return `<svg aria-hidden="true" viewBox="${icon.viewBox}"><path d="${icon.path}"></path></svg>`;
}

function toggleOpenState(value: string) {
  return value.match(TOGGLE_STATE_PATTERN)?.[1] === "1";
}

function toggleStateMeta(open: boolean) {
  return `toggle-open:${open ? "1" : "0"}`;
}

function columnWidths(value: string, count: number) {
  const parsed = value.match(COLUMN_WIDTHS_PATTERN)?.[1]
    .split(",")
    .map(Number)
    .filter((width) => Number.isFinite(width) && width >= 10);

  if (!parsed || parsed.length !== count) {
    return Array.from({ length: count }, () => 100 / count);
  }

  const total = parsed.reduce((sum, width) => sum + width, 0);
  return parsed.map((width) => width / total * 100);
}

function columnWidthsMeta(widths: number[]) {
  return `column-widths:${widths.map((width) => Math.round(width * 100) / 100).join(",")}`;
}

function videoSettings(value: string): VideoSettings {
  const [firstLine = "", ...captionLines] = value.split("\n");
  const match = firstLine.match(VIDEO_SETTINGS_PATTERN);

  if (!match) {
    return {
      align: "center",
      caption: value.trim(),
      width: 100,
    };
  }

  return {
    align: match[2] as VideoAlignment,
    caption: captionLines.join("\n").trim(),
    width: clampPercentage(match[1], 100, 28, 100),
  };
}

function videoSettingsBody(settings: VideoSettings) {
  return [
    `video-settings:width=${clampPercentage(settings.width, 100, 28, 100)};align=${settings.align}`,
    settings.caption.trim(),
  ].filter(Boolean).join("\n");
}

function floatingMenuTop(
  anchorTop: number,
  anchorBottom: number,
  menuHeight: number,
  gap = 8,
) {
  const viewportPadding = 8;
  const spaceBelow = window.innerHeight - anchorBottom - viewportPadding;
  const spaceAbove = anchorTop - viewportPadding;
  const preferred = spaceBelow >= menuHeight || spaceBelow >= spaceAbove
    ? anchorBottom + gap
    : anchorTop - menuHeight - gap;

  return Math.max(
    viewportPadding,
    Math.min(window.innerHeight - menuHeight - viewportPadding, preferred),
  );
}

function positionRichPopover(anchor: HTMLElement | null, panel: HTMLElement) {
  if (!anchor) return;

  window.requestAnimationFrame(() => {
    if (!anchor.isConnected || !panel.isConnected) return;

    const anchorRect = anchor.getBoundingClientRect();
    const panelRect = panel.getBoundingClientRect();
    const viewportPadding = 8;
    const left = Math.max(
      viewportPadding,
      Math.min(window.innerWidth - panelRect.width - viewportPadding, anchorRect.left),
    );

    panel.style.left = `${left}px`;
    panel.style.top = `${floatingMenuTop(
      anchorRect.top,
      anchorRect.bottom,
      panelRect.height,
      6,
    )}px`;
  });
}

function richBlockLineInput(value: string, index: number, placeholder: string, className = "") {
  const input = document.createElement("input");
  input.type = "text";
  input.value = value;
  input.placeholder = placeholder;
  input.dataset.richBlockBodyLine = String(index);
  input.className = className;
  input.setAttribute("spellcheck", "true");
  isolateRichBlockField(input);
  return input;
}

function richBlockInlineEditor(
  value: string,
  index: number,
  placeholder: string,
  {
    ariaLabel = `${index + 1}열 내용`,
    className = "blog-rich-block__column-editor",
  }: {
    ariaLabel?: string;
    className?: string;
  } = {},
) {
  const editor = document.createElement("div");
  editor.className = `blog-rich-block__inline-editor ${className}`;
  editor.contentEditable = "true";
  editor.dataset.placeholder = placeholder;
  editor.dataset.richBlockBodyLine = String(index);
  editor.innerHTML = renderInlineMarkdown(value);
  editor.setAttribute("aria-label", ariaLabel);
  editor.setAttribute("role", "textbox");
  editor.setAttribute("spellcheck", "true");

  for (const eventName of [
    "beforeinput",
    "click",
    "compositionend",
    "compositionstart",
    "compositionupdate",
    "input",
    "keydown",
    "keyup",
    "mousedown",
    "pointerdown",
  ]) {
    editor.addEventListener(eventName, (event) => event.stopPropagation());
  }

  return editor;
}

function appendDatabaseHeader(node: HTMLElement, block: BlogRichBlock, viewLabel: string) {
  const header = document.createElement("div");
  header.className = "blog-rich-block__database-header";
  const title = richBlockEditableField(
    "title",
    block.title,
    "blog-rich-block__database-title",
    "제목 없는 데이터베이스",
  );
  const tools = document.createElement("div");
  tools.className = "blog-rich-block__database-tools";

  for (const [tool, action, icon] of [
    ["필터", "toggle-database-filter", "filter"],
    ["정렬", "sort-database-items", "sort"],
    ["검색", "toggle-database-search", "search"],
    ["설정", "toggle-database-settings", "settings"],
  ] as const) {
    const button = document.createElement("button");
    button.type = "button";
    button.innerHTML = richWidgetIcon(icon);
    button.dataset.richBlockAction = action;
    button.tabIndex = -1;
    button.setAttribute("aria-label", tool);
    button.title = tool;
    tools.append(button);
  }

  const create = document.createElement("div");
  create.className = "blog-rich-block__database-create";
  const add = document.createElement("button");
  add.type = "button";
  add.textContent = "새로 만들기";
  add.dataset.richBlockAction = "add-database-item";
  const more = document.createElement("button");
  more.type = "button";
  more.innerHTML = richWidgetIcon("plus");
  more.dataset.richBlockAction = "add-database-item";
  more.setAttribute("aria-label", "새 항목 추가");
  create.append(add, more);
  tools.append(create);

  const view = document.createElement("span");
  view.className = "blog-rich-block__database-view";
  view.append(BlockIconElement(block.kind), document.createTextNode(viewLabel));
  header.append(title, view, tools);
  node.append(header);

  if (node.dataset.databaseSearchOpen === "true") {
    const search = document.createElement("input");
    search.type = "search";
    search.className = "blog-rich-block__database-search";
    search.placeholder = "항목 검색";
    search.setAttribute("aria-label", "데이터베이스 항목 검색");
    search.dataset.richDatabaseQuery = "true";
    isolateRichBlockField(search);
    search.addEventListener("input", () => {
      const query = search.value.trim().toLocaleLowerCase("ko-KR");
      node.querySelectorAll<HTMLElement>("[data-rich-database-item]").forEach((item) => {
        item.hidden = Boolean(query) && !item.textContent?.toLocaleLowerCase("ko-KR").includes(query);
      });
    });
    node.append(search);
  }

  if (node.dataset.databaseFilterOpen === "true" || node.dataset.databaseSettingsOpen === "true") {
    const panel = document.createElement("div");
    panel.className = "blog-rich-block__database-panel";
    panel.setAttribute("role", "dialog");
    const isSettings = node.dataset.databaseSettingsOpen === "true";
    panel.setAttribute("aria-label", isSettings ? "데이터베이스 보기 설정" : "데이터베이스 필터");
    const title = document.createElement("strong");
    title.textContent = isSettings ? "보기 설정" : "필터";
    const description = document.createElement("span");
    description.textContent = isSettings
      ? "현재 보기에 표시되는 항목과 순서를 편집할 수 있습니다."
      : "이름에 포함된 텍스트로 항목을 바로 필터링합니다.";
    panel.append(title, description);
    if (!isSettings) {
      const filter = document.createElement("input");
      filter.type = "search";
      filter.placeholder = "필터 값 입력";
      filter.dataset.richDatabaseQuery = "true";
      filter.setAttribute("aria-label", "데이터베이스 필터 값");
      isolateRichBlockField(filter);
      filter.addEventListener("input", () => {
        const query = filter.value.trim().toLocaleLowerCase("ko-KR");
        node.querySelectorAll<HTMLElement>("[data-rich-database-item]").forEach((item) => {
          item.hidden = Boolean(query) && !item.textContent?.toLocaleLowerCase("ko-KR").includes(query);
        });
      });
      panel.append(filter);
    }
    node.append(panel);
    positionRichPopover(
      node.querySelector<HTMLElement>(
        `[data-rich-block-action="${isSettings ? "toggle-database-settings" : "toggle-database-filter"}"]`,
      ),
      panel,
    );
  }
}

function appendTableEditor(node: HTMLElement, block: BlogRichBlock, database = false) {
  const rows = tableRows(block.body, database ? 2 : 3, 3);
  const widths = tableColumnWidths(block.url, rows[0].length);
  const shell = document.createElement("div");
  shell.className = "blog-rich-block__table-shell";

  const columnHandles = document.createElement("div");
  columnHandles.className = "blog-rich-block__table-column-handles";
  columnHandles.style.gridTemplateColumns = widths.map((width) => `${width}fr`).join(" ");
  for (let columnIndex = 0; columnIndex < rows[0].length; columnIndex += 1) {
    const handle = document.createElement("button");
    handle.type = "button";
    handle.tabIndex = -1;
    handle.className = "blog-rich-block__table-column-handle";
    handle.dataset.index = String(columnIndex);
    handle.dataset.richBlockAction = "toggle-table-column-menu";
    handle.dataset.tableColumn = String(columnIndex);
    handle.setAttribute("aria-label", `${columnIndex + 1}열 선택`);
    columnHandles.append(handle);
  }
  shell.append(columnHandles);

  const grid = document.createElement("div");
  grid.className = "blog-rich-block__table-grid";
  grid.style.gridTemplateColumns = widths.map((width) => `minmax(84px, ${width}fr)`).join(" ");

  rows.forEach((row, rowIndex) => {
    row.forEach((cell, columnIndex) => {
      const cellShell = document.createElement("div");
      cellShell.className = `blog-rich-block__table-cell${database && rowIndex === 0 ? " blog-rich-block__table-cell--header" : ""}`;
      cellShell.dataset.tableColumn = String(columnIndex);
      cellShell.dataset.tableRow = String(rowIndex);
      const input = document.createElement("input");
      input.type = "text";
      input.value = cell;
      input.placeholder = " ";
      input.dataset.richTableCell = `${rowIndex}:${columnIndex}`;
      input.setAttribute("aria-label", `${rowIndex + 1}행 ${columnIndex + 1}열`);
      input.setAttribute("spellcheck", "true");
      isolateRichBlockField(input);

      if (columnIndex === 0) {
        const rowHandle = document.createElement("button");
        rowHandle.type = "button";
        rowHandle.tabIndex = -1;
        rowHandle.className = "blog-rich-block__table-row-handle";
        rowHandle.dataset.index = String(rowIndex);
        rowHandle.dataset.richBlockAction = "toggle-table-row-menu";
        rowHandle.dataset.tableRow = String(rowIndex);
        rowHandle.setAttribute("aria-label", `${rowIndex + 1}행 선택`);
        cellShell.append(rowHandle);
      }

      cellShell.append(input);

      if (columnIndex < row.length - 1) {
        const resize = document.createElement("button");
        resize.type = "button";
        resize.tabIndex = -1;
        resize.className = "blog-rich-block__table-resize";
        resize.dataset.richTableResize = String(columnIndex);
        resize.setAttribute("aria-label", `${columnIndex + 1}열 너비 조절`);
        cellShell.append(resize);
      }

      grid.append(cellShell);
    });
  });

  const setHoveredHandles = (rowIndex: number | null, columnIndex: number | null) => {
    columnHandles.querySelectorAll<HTMLElement>("[data-table-column]").forEach((handle) => {
      handle.dataset.hovered = Number(handle.dataset.tableColumn) === columnIndex ? "true" : "false";
    });
    grid.querySelectorAll<HTMLElement>("[data-rich-block-action='toggle-table-row-menu']").forEach((handle) => {
      handle.dataset.hovered = Number(handle.dataset.tableRow) === rowIndex ? "true" : "false";
    });
  };

  const handleCellHover = (event: Event) => {
    if (!(event.target instanceof Element)) return;
    const cell = event.target.closest<HTMLElement>(".blog-rich-block__table-cell");
    if (!cell || !grid.contains(cell)) return;
    setHoveredHandles(Number(cell.dataset.tableRow), Number(cell.dataset.tableColumn));
  };
  const handleColumnHover = (event: Event) => {
    if (!(event.target instanceof Element)) return;
    const handle = event.target.closest<HTMLElement>("[data-table-column]");
    if (!handle || !columnHandles.contains(handle)) return;
    setHoveredHandles(null, Number(handle.dataset.tableColumn));
  };

  grid.addEventListener("pointerover", handleCellHover);
  grid.addEventListener("mousemove", handleCellHover);
  columnHandles.addEventListener("pointerover", handleColumnHover);
  columnHandles.addEventListener("mousemove", handleColumnHover);
  shell.addEventListener("pointerleave", () => setHoveredHandles(null, null));

  const addColumn = document.createElement("button");
  addColumn.type = "button";
  addColumn.className = "blog-rich-block__table-add-column";
  addColumn.dataset.richBlockAction = "add-table-column";
  addColumn.setAttribute("aria-label", "열 추가");
  addColumn.innerHTML = richWidgetIcon("plus");

  const addRow = document.createElement("button");
  addRow.type = "button";
  addRow.className = "blog-rich-block__table-add-row";
  addRow.dataset.richBlockAction = "add-table-row";
  addRow.setAttribute("aria-label", "행 추가");
  addRow.innerHTML = richWidgetIcon("plus");

  shell.append(grid, addColumn, addRow);
  node.append(shell);

  const menuKind = node.dataset.tableMenuKind;
  const menuIndex = Number(node.dataset.tableMenuIndex);
  if ((menuKind === "row" || menuKind === "column") && Number.isInteger(menuIndex)) {
    const anchor = menuKind === "row"
      ? shell.querySelector<HTMLElement>(`[data-rich-block-action="toggle-table-row-menu"][data-index="${menuIndex}"]`)
      : shell.querySelector<HTMLElement>(`[data-rich-block-action="toggle-table-column-menu"][data-index="${menuIndex}"]`);

    if (anchor) {
      anchor.dataset.menuOpen = "true";
      const menu = document.createElement("div");
      menu.className = "blog-rich-block__table-menu";
      menu.setAttribute("aria-label", `${menuIndex + 1}${menuKind === "row" ? "행" : "열"} 메뉴`);
      menu.setAttribute("role", "dialog");
      const title = document.createElement("strong");
      title.textContent = `${menuIndex + 1}${menuKind === "row" ? "행" : "열"}`;
      menu.append(title);

      const appendAction = (label: string, action: string, danger = false, disabled = false) => {
        const button = document.createElement("button");
        button.type = "button";
        button.dataset.index = String(menuIndex);
        button.dataset.richBlockAction = action;
        button.dataset.danger = danger ? "true" : "false";
        button.disabled = disabled;
        button.textContent = label;
        menu.append(button);
      };

      if (menuKind === "row") {
        appendAction("위에 행 삽입", "insert-table-row-before");
        appendAction("아래에 행 삽입", "insert-table-row-after");
        appendAction("행 삭제", "delete-table-row", true, rows.length <= 1);
      } else {
        appendAction("왼쪽에 열 삽입", "insert-table-column-before");
        appendAction("오른쪽에 열 삽입", "insert-table-column-after");
        appendAction("열 삭제", "delete-table-column", true, rows[0].length <= 1);
      }

      node.append(menu);
      positionRichPopover(anchor, menu);
    }
  }
}

function appendDatabaseView(node: HTMLElement, block: BlogRichBlock) {
  const items = block.body.split("\n").map((line) => line.trim()).filter(Boolean);
  const values = items.length > 0 ? items : ["새 항목"];
  const viewLabel = BLOG_RICH_BLOCK_LABELS[block.kind];
  appendDatabaseHeader(node, block, viewLabel);

  if (block.kind === "database-table") {
    appendTableEditor(node, block, true);
    return;
  }

  if (block.kind === "database-chart") {
    const chart = document.createElement("div");
    chart.className = "blog-rich-block__chart";
    values.forEach((line, index) => {
      const [label = "항목", rawValue = "0"] = line.split(":");
      const value = Math.max(0, Math.min(100, Number(rawValue) || 0));
      const row = document.createElement("div");
      const input = richBlockLineInput(line, index, "항목: 50", "blog-rich-block__chart-input");
      const track = document.createElement("span");
      track.className = "blog-rich-block__chart-track";
      const bar = document.createElement("span");
      bar.style.width = `${value}%`;
      track.append(bar);
      row.append(input, track, document.createTextNode(`${value}`));
      input.setAttribute("aria-label", `${label.trim()} 차트 값`);
      chart.append(row);
    });
    node.append(chart);
    return;
  }

  if (block.kind === "database-calendar") {
    const calendar = document.createElement("div");
    calendar.className = "blog-rich-block__calendar";
    ["월", "화", "수", "목", "금", "토", "일"].forEach((day) => {
      const label = document.createElement("span");
      label.className = "blog-rich-block__calendar-day";
      label.textContent = day;
      calendar.append(label);
    });
    Array.from({ length: 14 }, (_, index) => {
      const day = document.createElement("div");
      day.className = "blog-rich-block__calendar-cell";
      day.dataset.day = String(index + 1);
      if (index < values.length) day.append(richBlockLineInput(values[index], index, "새 항목"));
      calendar.append(day);
    });
    node.append(calendar);
    return;
  }

  if (block.kind === "database-board") {
    const groups = ["시작 전", "진행 중", "완료"];
    const board = document.createElement("div");
    board.className = "blog-rich-block__database-board";
    groups.forEach((group, groupIndex) => {
      const column = document.createElement("section");
      column.className = "blog-rich-block__database-board-column";
      column.dataset.status = group;
      const header = document.createElement("header");
      const label = document.createElement("span");
      label.className = `blog-rich-block__database-status blog-rich-block__database-status--${groupIndex}`;
      label.textContent = group;
      const groupItems = values
        .map((line, index) => {
          const [title, rawStatus] = line.split("::").map((part) => part.trim());
          return { index, status: rawStatus || "시작 전", title: title || "새 페이지" };
        })
        .filter((item) => item.status === group);
      const count = document.createElement("span");
      count.className = "blog-rich-block__database-count";
      count.textContent = String(groupItems.length);
      const add = document.createElement("button");
      add.type = "button";
      add.innerHTML = richWidgetIcon("plus");
      add.dataset.richBlockAction = "add-database-group-item";
      add.dataset.value = group;
      add.setAttribute("aria-label", `${group} 새 페이지`);
      header.append(label, count, add);
      column.append(header);

      groupItems.forEach((item) => {
        const row = document.createElement("div");
        row.className = "blog-rich-block__database-board-card";
        row.dataset.richDatabaseItem = "true";
        const input = richBlockLineInput(item.title, item.index, "새 페이지");
        input.dataset.richDatabaseStatus = item.status;
        row.append(input);
        column.append(row);
      });

      const footer = document.createElement("button");
      footer.type = "button";
      footer.className = "blog-rich-block__database-board-add";
      footer.dataset.richBlockAction = "add-database-group-item";
      footer.dataset.value = group;
      footer.innerHTML = `${richWidgetIcon("plus")}<span>새 페이지</span>`;
      column.append(footer);
      board.append(column);
    });
    node.append(board);
    return;
  }

  const view = document.createElement("div");
  view.className = `blog-rich-block__database-view-content blog-rich-block__database-view-content--${block.kind.replace("database-", "")}`;

  values.forEach((item, index) => {
    const row = document.createElement("div");
    row.className = "blog-rich-block__database-item";
    row.dataset.richDatabaseItem = "true";
    row.append(
      documentBlockIcon(),
      richBlockInlineEditor(item, index, "새 항목", {
        ariaLabel: `${index + 1}번째 목록 항목`,
        className: "blog-rich-block__database-inline-editor",
      }),
    );
    view.append(row);
  });

  node.append(view);
}

function appendRichMedia(node: HTMLElement, block: BlogRichBlock) {
  const action = document.createElement("button");
  action.type = "button";
  action.dataset.richMediaAction = block.kind;

  if (!block.url) {
    action.className = "blog-rich-block__media-placeholder";
    action.append(
      block.kind === "audio"
        ? audioWaveformIcon()
        : block.kind === "video"
          ? videoBlockIcon()
          : documentBlockIcon("blog-rich-block__media-icon"),
      document.createTextNode(
        block.kind === "audio"
          ? "오디오 파일 추가"
          : block.kind === "video"
            ? "동영상 추가"
            : "파일 업로드 또는 임베드",
      ),
    );
    node.append(action);
    return;
  }

  if (block.kind === "audio") {
    const audioShell = document.createElement("div");
    audioShell.className = "blog-rich-block__audio-player";
    const title = document.createElement("div");
    title.className = "blog-rich-block__audio-title";
    title.append(audioWaveformIcon(), document.createTextNode(block.title || "오디오"));
    const audio = document.createElement("audio");
    audio.controls = true;
    audio.preload = "metadata";
    audio.src = block.url;
    action.className = "blog-rich-block__audio-replace";
    action.textContent = "변경";
    audioShell.append(title, audio, action);
    node.append(audioShell);
    return;
  }

  if (block.kind === "video") {
    const settings = videoSettings(block.body);
    const videoShell = document.createElement("figure");
    videoShell.className = "blog-rich-block__video-player";
    videoShell.dataset.align = settings.align;
    videoShell.style.setProperty("--blog-video-width", `${settings.width}%`);
    const video = document.createElement("video");
    video.controls = true;
    video.preload = "metadata";
    video.src = block.url;
    video.setAttribute("playsinline", "");
    const toolbar = document.createElement("div");
    toolbar.className = "blog-rich-block__video-toolbar";

    const appendToolbarButton = (label: string, actionName: string, active = false) => {
      const button = document.createElement("button");
      button.type = "button";
      button.dataset.richBlockAction = actionName;
      button.dataset.active = active ? "true" : "false";
      button.setAttribute("aria-label", label);
      button.title = label;
      button.innerHTML = videoToolbarIcon(actionName);
      const dispatchAction = () => {
        button.dispatchEvent(new CustomEvent("blog-rich-block-action", {
          bubbles: true,
          detail: { action: actionName },
        }));
      };
      button.addEventListener("pointerdown", (event) => {
        if (event.button !== 0) return;
        event.preventDefault();
        event.stopPropagation();
        dispatchAction();
      });
      button.addEventListener("click", (event) => {
        if (event.detail !== 0) return;
        event.preventDefault();
        event.stopPropagation();
        dispatchAction();
      });
      toolbar.append(button);
    };

    appendToolbarButton("왼쪽 정렬", "video-align-left", settings.align === "left");
    appendToolbarButton("중앙 정렬", "video-align-center", settings.align === "center");
    appendToolbarButton("오른쪽 정렬", "video-align-right", settings.align === "right");
    appendToolbarButton("50% 너비", "video-width-50", settings.width === 50);
    appendToolbarButton("75% 너비", "video-width-75", settings.width === 75);
    appendToolbarButton("전체 너비", "video-width-100", settings.width === 100);
    appendToolbarButton("동영상 교체", "video-replace");
    appendToolbarButton("동영상 삭제", "video-remove");

    for (const side of ["left", "right"] as const) {
      const resizeHandle = document.createElement("button");
      resizeHandle.type = "button";
      resizeHandle.className = `blog-rich-block__video-resize blog-rich-block__video-resize--${side}`;
      resizeHandle.dataset.richVideoResize = side;
      resizeHandle.setAttribute("aria-label", `동영상 ${side === "left" ? "왼쪽" : "오른쪽"} 너비 조절`);
      resizeHandle.tabIndex = -1;
      videoShell.append(resizeHandle);
    }

    const caption = document.createElement("figcaption");
    const captionInput = document.createElement("input");
    captionInput.type = "text";
    captionInput.value = settings.caption;
    captionInput.placeholder = "캡션 추가";
    captionInput.dataset.richVideoCaption = "true";
    captionInput.setAttribute("aria-label", "동영상 캡션");
    isolateRichBlockField(captionInput);
    caption.append(captionInput);
    videoShell.append(video, toolbar, caption);
    node.append(videoShell);
    return;
  }

  action.className = "blog-rich-block__file-row";
  const copy = document.createElement("span");
  copy.className = "blog-rich-block__file-copy";
  const title = document.createElement("strong");
  title.textContent = block.title || "파일";
  const meta = document.createElement("small");
  meta.textContent = "클릭하여 파일 변경";
  copy.append(title, meta);
  action.append(documentBlockIcon("blog-rich-block__media-icon"), copy);
  node.append(action);
}

function renderBlogRichBlockNode(node: HTMLElement, block: BlogRichBlock) {
  node.replaceChildren();
  node.className = `blog-rich-block blog-rich-block--${block.kind}`;
  node.dataset.block = JSON.stringify(block);
  node.dataset.kind = block.kind;
  node.setAttribute("contenteditable", "false");

  if (["audio", "file", "video"].includes(block.kind)) {
    appendRichMedia(node, block);
    return;
  }

  if (block.kind === "toc") {
    const copy = document.createElement("div");
    copy.className = "blog-rich-block__toc";
    const title = document.createElement("strong");
    title.textContent = "목차";
    const list = document.createElement("ol");
    const refresh = () => {
      const editor = node.closest(".ql-editor");
      if (!editor) return;
      const headings = Array.from(editor.querySelectorAll<HTMLElement>("h1,h2,h3,h4"))
        .filter((heading) => heading.textContent?.trim());
      list.replaceChildren();
      if (headings.length === 0) {
        const empty = document.createElement("li");
        empty.className = "blog-rich-block__toc-empty";
        empty.textContent = "제목 블록을 추가하면 여기에 자동으로 표시됩니다.";
        list.append(empty);
        return;
      }
      headings.forEach((heading) => {
        const item = document.createElement("li");
        item.dataset.level = heading.tagName.slice(1);
        const button = document.createElement("button");
        button.type = "button";
        button.textContent = heading.textContent?.trim() ?? "";
        button.addEventListener("click", () => heading.scrollIntoView({ behavior: "smooth", block: "center" }));
        item.append(button);
        list.append(item);
      });
    };
    copy.append(title, list);
    node.append(copy);
    window.requestAnimationFrame(refresh);
    return;
  }

  if (block.kind.startsWith("toggle")) {
    const details = document.createElement("div");
    details.className = "blog-rich-block__toggle";
    const open = toggleOpenState(block.url);
    details.dataset.open = open ? "true" : "false";
    const summary = document.createElement("div");
    summary.className = "blog-rich-block__toggle-summary";
    const toggle = document.createElement("button");
    toggle.type = "button";
    toggle.dataset.richBlockAction = "toggle-open";
    toggle.setAttribute("aria-expanded", open ? "true" : "false");
    toggle.setAttribute("aria-label", open ? "토글 닫기" : "토글 열기");
    toggle.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m5.5 3.5 5 4.5-5 4.5z" fill="currentColor"/></svg>';
    const headingLevel = block.kind.match(/heading([1-3])$/)?.[1] ?? "";
    const title = richBlockEditableField(
      "title",
      block.title,
      `blog-rich-block__toggle-title${headingLevel ? ` blog-rich-block__toggle-title--h${headingLevel}` : ""}`,
      block.kind === "toggle" ? "토글" : `토글 제목${headingLevel}`,
    );
    summary.append(toggle, title);
    const body = richBlockEditableField(
      "body",
      block.body,
      "blog-rich-block__toggle-body",
      "빈 토글입니다. 클릭하거나 블록을 내부로 드래그하세요.",
    );
    details.append(summary, body);
    node.append(details);
    return;
  }

  if (block.kind === "callout") {
    const callout = document.createElement("div");
    callout.className = "blog-rich-block__callout";
    const icon = document.createElement("button");
    icon.type = "button";
    icon.className = "blog-rich-block__emoji";
    icon.dataset.richBlockAction = "toggle-callout-emoji";
    icon.setAttribute("aria-label", `${block.title || "💡"} 콜아웃 아이콘 변경`);
    icon.textContent = block.title || "💡";
    callout.append(
      icon,
      richBlockInlineEditor(block.body, 0, "내용을 입력하세요.", {
        ariaLabel: "콜아웃 내용",
        className: "blog-rich-block__callout-body",
      }),
    );
    if (node.dataset.calloutEmojiOpen === "true") {
      const palette = document.createElement("div");
      palette.className = "blog-rich-block__emoji-palette";
      ["💡", "📌", "✅", "⚠️", "📣", "🧭", "📎", "💬", "⭐", "🚀", "🔎", "📝"].forEach((emoji) => {
        const option = document.createElement("button");
        option.type = "button";
        option.dataset.richBlockAction = "set-callout-emoji";
        option.dataset.value = emoji;
        option.dataset.active = emoji === (block.title || "💡") ? "true" : "false";
        option.setAttribute("aria-label", `${emoji} 아이콘 선택`);
        option.textContent = emoji;
        palette.append(option);
      });
      callout.append(palette);
    }
    node.append(callout);
    return;
  }

  if (block.kind === "table") {
    appendTableEditor(node, block);
    return;
  }

  if (block.kind.startsWith("database-")) {
    appendDatabaseView(node, block);
    return;
  }

  if (block.kind.startsWith("columns-")) {
    const count = Number(block.kind.slice(-1));
    const values = block.body.split("\n");
    const widths = columnWidths(block.url, count);
    const columns = document.createElement("div");
    columns.className = "blog-rich-block__columns";
    columns.style.gridTemplateColumns = widths
      .flatMap((width, index) => index < count - 1 ? [`minmax(0, ${width}fr)`, "18px"] : [`minmax(0, ${width}fr)`])
      .join(" ");
    Array.from({ length: count }, (_, index) => {
      const column = document.createElement("div");
      column.className = "blog-rich-block__column";
      column.append(richBlockInlineEditor(values[index] ?? "", index, "내용을 입력하세요."));
      columns.append(column);
      if (index < count - 1) {
        const resize = document.createElement("button");
        resize.type = "button";
        resize.className = "blog-rich-block__column-resize";
        resize.dataset.richColumnResize = String(index);
        resize.setAttribute("aria-label", `${index + 1}열과 ${index + 2}열 너비 조절`);
        resize.tabIndex = -1;
        columns.append(resize);
      }
    });
    node.append(columns);
    return;
  }

  if (block.kind === "tabs") {
    const tabs = block.body.split("\n").map((line) => {
      const [title, ...content] = line.split("|");
      return { content: content.join("|").trim(), title: title.trim() };
    });
    if (tabs.length === 0) tabs.push({ content: "", title: "새 탭" });
    const activeIndex = Math.min(Number(node.dataset.activeTab) || 0, tabs.length - 1);
    node.dataset.activeTab = String(activeIndex);
    const shell = document.createElement("div");
    shell.className = "blog-rich-block__tabs";
    const tabList = document.createElement("div");
    tabList.className = "blog-rich-block__tabs-list";
    tabs.forEach((tab, index) => {
      const button = document.createElement("button");
      button.type = "button";
      button.dataset.richBlockAction = "select-tab";
      button.dataset.index = String(index);
      button.dataset.active = index === activeIndex ? "true" : "false";
      button.textContent = tab.title || `탭 ${index + 1}`;
      tabList.append(button);
    });
    const add = document.createElement("button");
    add.type = "button";
    add.dataset.richBlockAction = "add-tab";
    add.innerHTML = richWidgetIcon("plus");
    add.setAttribute("aria-label", "탭 추가");
    tabList.append(add);
    const panel = document.createElement("div");
    panel.className = "blog-rich-block__tab-panel";
    const panelTools = document.createElement("div");
    panelTools.className = "blog-rich-block__tab-panel-tools";
    const deleteTab = document.createElement("button");
    deleteTab.type = "button";
    deleteTab.dataset.richBlockAction = "delete-tab";
    deleteTab.disabled = tabs.length <= 1;
    deleteTab.textContent = "현재 탭 삭제";
    panelTools.append(deleteTab);
    const title = richBlockLineInput(tabs[activeIndex].title, activeIndex, "탭 이름", "blog-rich-block__tab-title");
    title.dataset.richTabTitle = String(activeIndex);
    delete title.dataset.richBlockBodyLine;
    const content = document.createElement("textarea");
    content.value = tabs[activeIndex].content;
    content.placeholder = "내용을 입력하세요.";
    content.dataset.richTabContent = String(activeIndex);
    content.rows = Math.max(2, Math.min(8, content.value.split("\n").length + 1));
    isolateRichBlockField(content);
    panel.append(title, content, panelTools);
    shell.append(tabList, panel);
    node.append(shell);
    return;
  }

  if (block.kind === "page") {
    const page = document.createElement("div");
    page.className = "blog-rich-block__page";
    page.append(
      documentBlockIcon(),
      richBlockEditableField("title", block.title, "blog-rich-block__page-title", "새 페이지"),
      richBlockEditableField("url", block.url, "blog-rich-block__page-url", "/"),
    );
    node.append(page);
    return;
  }

  if (block.kind === "button") {
    const shell = document.createElement("div");
    shell.className = "blog-rich-block__button-shell";
    const preview = document.createElement("button");
    preview.type = "button";
    preview.className = "blog-rich-block__button-preview";
    preview.dataset.richBlockAction = "toggle-button-config";
    preview.textContent = block.title || "새 버튼";
    if (!block.url) preview.dataset.invalid = "true";
    shell.append(preview);
    const configOpen = node.dataset.buttonConfigOpen ?? (!block.url ? "true" : "false");
    node.dataset.buttonConfigOpen = configOpen;
    if (configOpen === "true") {
      const editor = document.createElement("div");
      editor.className = "blog-rich-block__button-editor";
      editor.setAttribute("role", "dialog");
      editor.setAttribute("aria-label", "버튼 설정");
      const header = document.createElement("div");
      header.className = "blog-rich-block__button-editor-header";
      const icon = document.createElement("button");
      icon.type = "button";
      icon.className = "blog-rich-block__button-icon";
      icon.setAttribute("aria-label", "아이콘 추가");
      icon.textContent = "☺";
      header.append(
        icon,
        richBlockEditableField("title", block.title, "blog-rich-block__button-title", "새 버튼"),
      );
      const condition = document.createElement("p");
      condition.className = "blog-rich-block__button-condition";
      condition.innerHTML = "<span>다음 경우에 사용:</span><strong>버튼 클릭</strong>";
      const action = document.createElement("label");
      action.className = "blog-rich-block__button-action";
      action.innerHTML = "<span>작업</span>";
      action.append(richBlockEditableField("url", block.url, "blog-rich-block__button-url", "링크 주소를 입력하세요."));
      const done = document.createElement("button");
      done.type = "button";
      done.className = "blog-rich-block__button-done";
      done.dataset.richBlockAction = "close-button-config";
      done.textContent = "완료";
      editor.append(header, condition, action, done);
      shell.append(editor);
      positionRichPopover(preview, editor);
    }
    node.append(shell);
    return;
  }

  if (block.kind === "breadcrumb") {
    node.append(richBlockEditableField(
      "body",
      block.body,
      "blog-rich-block__breadcrumb",
      "홈 / 블로그 / 현재 글",
    ));
    return;
  }

  if (block.kind === "math" || block.kind === "mermaid") {
    const code = document.createElement("div");
    code.className = `blog-rich-block__code-widget blog-rich-block__code-widget--${block.kind}`;
    const badge = document.createElement("span");
    badge.textContent = block.kind === "math" ? "수식" : "Mermaid";
    code.append(
      badge,
      richBlockEditableField(
        "body",
        block.body,
        "blog-rich-block__code-input",
        block.kind === "math" ? "E = mc^2" : "graph LR",
      ),
    );
    node.append(code);
  }
}

function refreshRichTocBlocks(root: HTMLElement) {
  window.requestAnimationFrame(() => {
    root.querySelectorAll<HTMLElement>(".blog-rich-block--toc").forEach((node) => {
      const block = readBlogRichBlock(node);
      if (block?.kind === "toc") renderBlogRichBlockNode(node, block);
    });
  });
}

function readBlogRichBlock(node: HTMLElement) {
  try {
    return normalizeBlogRichBlock(JSON.parse(node.dataset.block ?? ""));
  } catch {
    return null;
  }
}

function readRichColumnsBlock(node: HTMLElement | null) {
  if (!node?.classList.contains("blog-rich-block")) return null;

  const block = readBlogRichBlock(node);

  return block?.kind.startsWith("columns-") ? block : null;
}

function richColumnsValues(block: BlogRichBlock) {
  const count = Number(block.kind.slice(-1));
  const values = block.body.split("\n");

  return Array.from({ length: count }, (_, index) => values[index] ?? "");
}

function isSimpleEditorTextBlock(node: HTMLElement | null) {
  return Boolean(
    node &&
    ["BLOCKQUOTE", "H1", "H2", "H3", "H4", "LI", "P", "PRE"].includes(node.tagName) &&
    !node.closest(".blog-image-layout, .blog-rich-block, table"),
  );
}

function canMergeWithRichColumns(source: HTMLElement | null, destination: HTMLElement | null) {
  const sourceColumns = readRichColumnsBlock(source);
  const destinationColumns = readRichColumnsBlock(destination);

  if (!sourceColumns && !destinationColumns) return false;

  const sourceCount = sourceColumns ? Number(sourceColumns.kind.slice(-1)) : 1;
  const destinationCount = destinationColumns ? Number(destinationColumns.kind.slice(-1)) : 1;
  const sourceSupported = Boolean(sourceColumns) || isSimpleEditorTextBlock(source);
  const destinationSupported = Boolean(destinationColumns) || isSimpleEditorTextBlock(destination);

  return sourceSupported && destinationSupported && sourceCount + destinationCount <= 4;
}

function updateBlogRichBlockFromFields(node: HTMLElement) {
  const current = readBlogRichBlock(node);

  if (!current) return null;

  let body = node.querySelector<HTMLTextAreaElement>('[data-rich-block-field="body"]')?.value ?? current.body;
  const url = node.querySelector<HTMLInputElement>('[data-rich-block-field="url"]')?.value ?? current.url;
  const tableCells = Array.from(node.querySelectorAll<HTMLInputElement>("[data-rich-table-cell]"));

  if (tableCells.length > 0) {
    const coordinates = tableCells.map((input) => {
      const [row, column] = (input.dataset.richTableCell ?? "0:0").split(":").map(Number);
      return { column, row, value: input.value };
    });
    const rowCount = Math.max(...coordinates.map((cell) => cell.row)) + 1;
    const columnCount = Math.max(...coordinates.map((cell) => cell.column)) + 1;
    const rows = Array.from({ length: rowCount }, () => Array.from({ length: columnCount }, () => ""));
    coordinates.forEach((cell) => {
      rows[cell.row][cell.column] = cell.value;
    });
    body = tableBody(rows);
  }

  const bodyLines = Array.from(node.querySelectorAll<HTMLElement>("[data-rich-block-body-line]"));
  if (bodyLines.length > 0) {
    body = bodyLines
      .sort((left, right) => Number(left.dataset.richBlockBodyLine) - Number(right.dataset.richBlockBodyLine))
      .map((field) => {
        const value = field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement
          ? field.value
          : inlineMarkdownFromNode(field).replace(/\s*\n\s*/g, " ").trim();

        return field.dataset.richDatabaseStatus
          ? `${value} :: ${field.dataset.richDatabaseStatus}`
          : value;
      })
      .join("\n");
  }

  const activeTabTitle = node.querySelector<HTMLInputElement>("[data-rich-tab-title]");
  const activeTabContent = node.querySelector<HTMLTextAreaElement>("[data-rich-tab-content]");
  if (activeTabTitle && activeTabContent) {
    const index = Number(activeTabTitle.dataset.richTabTitle) || 0;
    const tabs = current.body.split("\n").map((line) => {
      const [title, ...content] = line.split("|");
      return { content: content.join("|").trim(), title: title.trim() };
    });
    while (tabs.length <= index) tabs.push({ content: "", title: `탭 ${tabs.length + 1}` });
    tabs[index] = { content: activeTabContent.value, title: activeTabTitle.value };
    body = tabs.map((tab) => `${tab.title} | ${tab.content}`).join("\n");
  }

  const videoCaption = node.querySelector<HTMLInputElement>("[data-rich-video-caption]");
  if (current.kind === "video" && videoCaption) {
    body = videoSettingsBody({
      ...videoSettings(current.body),
      caption: videoCaption.value,
    });
  }

  const next: BlogRichBlock = {
    ...current,
    body,
    title: node.querySelector<HTMLInputElement>('[data-rich-block-field="title"]')?.value ?? current.title,
    url,
  };
  node.dataset.block = JSON.stringify(next);
  return next;
}

function registerBlogRichBlock(Quill: QuillNamespace) {
  if (Quill.imports["formats/blog-rich-block"]) return;

  const BlockEmbed = Quill.import("blots/block/embed") as BlockEmbedConstructor;

  class BlogRichBlockBlot extends BlockEmbed {
    static blotName = "blog-rich-block";
    static className = "blog-rich-block";
    static tagName = "DIV";

    static create(value: unknown) {
      const node = super.create() as HTMLElement;
      const block = normalizeBlogRichBlock(value);

      if (block) renderBlogRichBlockNode(node, block);

      return node;
    }

    static value(node: HTMLElement) {
      return readBlogRichBlock(node);
    }
  }

  Quill.register(BlogRichBlockBlot, true);
}

function parseColumnLayoutStart(value: string) {
  const match = value.match(/^:::columns(?:\{left=(\d{1,3})(?:;styles=([a-z]+),([a-z]+))?\})?$/);

  if (!match) return null;

  const normalizeStyle = (style: string | undefined): BlockStyle => (
    style && ["bullet", "code", "heading", "number", "quote", "text"].includes(style)
      ? style as BlockStyle
      : "text"
  );

  return {
    split: clampPercentage(match[1], 50, 25, 75),
    styles: [normalizeStyle(match[2]), normalizeStyle(match[3])] as [BlockStyle, BlockStyle],
  };
}

function parseColumnLayout(lines: string[], startIndex: number) {
  const config = parseColumnLayoutStart(lines[startIndex].trim());

  if (config === null || lines[startIndex + 1]?.trim() !== ":::column-left") return null;

  const rightIndex = lines.findIndex((line, index) => index > startIndex + 1 && line.trim() === ":::column-right");
  const endIndex = lines.findIndex((line, index) => index > rightIndex && line.trim() === ":::columns-end");

  if (rightIndex < 0 || endIndex < 0) return null;

  return {
    layout: {
      columns: [
        lines.slice(startIndex + 2, rightIndex).join("\n").trim(),
        lines.slice(rightIndex + 1, endIndex).join("\n").trim(),
      ] as [string, string],
      split: config.split,
      styles: config.styles,
    },
    nextIndex: endIndex + 1,
  };
}

function renderInlineMarkdown(value: string) {
  let result = escapeHtml(value);

  result = result.replace(/`([^`]+)`/g, "<code>$1</code>");
  result = result.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
  result = result.replace(/~~([^~]+)~~/g, "<s>$1</s>");
  result = result.replace(/==([^=]+)==/g, "<u>$1</u>");
  result = result.replace(/\*([^*]+)\*/g, "<em>$1</em>");
  result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label: string, href: string) => {
    const safeHref = safeLink(href.replaceAll("&amp;", "&"));
    return safeHref ? `<a href="${escapeHtml(safeHref)}">${label}</a>` : label;
  });

  return result;
}

function isSpecialMarkdownLine(line: string) {
  return (
    Boolean(parseMarkdownImage(line)) ||
    parseImageGridStart(line) !== null ||
    parseColumnLayoutStart(line) !== null ||
    /^:::official-block\{/.test(line) ||
    /^#{1,4}\s+/.test(line) ||
    /^>\s?/.test(line) ||
    /^[-*]\s+\[[ xX]\]\s+/.test(line) ||
    /^[-*]\s+/.test(line) ||
    /^\d+\.\s+/.test(line) ||
    /^```/.test(line) ||
    /^\|/.test(line)
  );
}

function markdownToEditorHtml(markdown: string) {
  const lines = markdown.replace(/\r/g, "").split("\n");
  const blocks: string[] = [];
  let index = 0;

  while (index < lines.length) {
    const line = lines[index].trim();

    if (!line) {
      index += 1;
      continue;
    }

    const columnLayout = parseColumnLayout(lines, index);

    if (columnLayout) {
      const recoveredColumns = recoverRichColumnsFromLegacyLayout(columnLayout.layout);
      blocks.push(recoveredColumns
        ? richBlockEditorHtml(recoveredColumns)
        : columnLayoutEditorHtml(columnLayout.layout));
      index = columnLayout.nextIndex;
      continue;
    }

    const richBlock = parseRichBlock(lines, index);

    if (richBlock?.block) {
      blocks.push(
        richBlock.block.kind === "table"
          ? richTableEditorHtml(richBlock.block)
          : richBlockEditorHtml(richBlock.block),
      );
      index = richBlock.nextIndex;
      continue;
    }

    if (/^```/.test(line)) {
      const codeLines: string[] = [];
      index += 1;

      while (index < lines.length && !/^```/.test(lines[index].trim())) {
        codeLines.push(lines[index]);
        index += 1;
      }

      if (index < lines.length) index += 1;
      blocks.push(`<pre class="ql-syntax" spellcheck="false">${escapeHtml(codeLines.join("\n"))}</pre>`);
      continue;
    }

    const imageGridSplit = parseImageGridStart(line);

    if (
      imageGridSplit !== null &&
      index + 3 < lines.length &&
      lines[index + 3].trim() === ":::") {
      const leftImage = parseMarkdownImage(lines[index + 1].trim());
      const rightImage = parseMarkdownImage(lines[index + 2].trim());

      if (leftImage && rightImage) {
        blocks.push(
          imageLayoutEditorHtml({
            align: "center",
            images: [
              { alt: leftImage.alt, src: leftImage.src },
              { alt: rightImage.alt, src: rightImage.src },
            ],
            split: imageGridSplit,
            width: 100,
          }),
        );
        index += 4;
        continue;
      }
    }

    const image = parseMarkdownImage(line);

    if (image) {
      blocks.push(
        imageLayoutEditorHtml({
          align: image.align,
          images: [{ alt: image.alt, src: image.src }],
          split: 50,
          width: image.width,
        }),
      );
      index += 1;
      continue;
    }

    const heading = line.match(/^(#{1,4})\s+(.+)$/);
    if (heading) {
      const level = heading[1].length;
      blocks.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
      index += 1;
      continue;
    }

    if (/^>\s?/.test(line)) {
      const quoteLines: string[] = [];

      while (index < lines.length && /^>\s?/.test(lines[index].trim())) {
        quoteLines.push(lines[index].trim().replace(/^>\s?/, ""));
        index += 1;
      }

      blocks.push(`<blockquote>${quoteLines.map(renderInlineMarkdown).join("<br>")}</blockquote>`);
      continue;
    }

    if (/^[-*]\s+\[[ xX]\]\s+/.test(line)) {
      const items: Array<{ checked: boolean; text: string }> = [];

      while (index < lines.length && /^[-*]\s+\[[ xX]\]\s+/.test(lines[index].trim())) {
        const item = lines[index].trim();
        items.push({
          checked: /^[-*]\s+\[[xX]\]/.test(item),
          text: item.replace(/^[-*]\s+\[[ xX]\]\s+/, ""),
        });
        index += 1;
      }

      blocks.push(
        `<ul>${items.map((item) => `<li data-list="${item.checked ? "checked" : "unchecked"}">${renderInlineMarkdown(item.text)}</li>`).join("")}</ul>`,
      );
      continue;
    }

    if (/^[-*]\s+/.test(line)) {
      const items: string[] = [];

      while (index < lines.length && /^[-*]\s+/.test(lines[index].trim())) {
        items.push(lines[index].trim().replace(/^[-*]\s+/, ""));
        index += 1;
      }

      blocks.push(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ul>`);
      continue;
    }

    if (/^\d+\.\s+/.test(line)) {
      const items: string[] = [];

      while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
        items.push(lines[index].trim().replace(/^\d+\.\s+/, ""));
        index += 1;
      }

      blocks.push(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ol>`);
      continue;
    }

    if (/^\|/.test(line)) {
      const tableLines: string[] = [];

      while (index < lines.length && /^\|/.test(lines[index].trim())) {
        const current = lines[index].trim();
        if (!/^\|?\s*:?-{3,}/.test(current)) tableLines.push(current);
        index += 1;
      }

      tableLines.forEach((tableLine, rowIndex) => {
        const cells = tableLine.split("|").map((cell) => cell.trim()).filter(Boolean);
        blocks.push(
          `<p>${cells.map((cell) => rowIndex === 0 ? `<strong>${renderInlineMarkdown(cell)}</strong>` : renderInlineMarkdown(cell)).join(" <span class=\"blog-editor-table-divider\">|</span> ")}</p>`,
        );
      });
      continue;
    }

    const paragraphLines = [line];
    index += 1;

    while (index < lines.length) {
      const nextLine = lines[index].trim();
      if (!nextLine || isSpecialMarkdownLine(nextLine)) break;
      paragraphLines.push(nextLine);
      index += 1;
    }

    blocks.push(`<p>${renderInlineMarkdown(paragraphLines.join(" "))}</p>`);
  }

  return blocks.join("") || "<p><br></p>";
}

function inlineMarkdownFromNode(node: Node): string {
  if (node.nodeType === Node.TEXT_NODE) {
    return node.nodeValue ?? "";
  }

  if (!(node instanceof HTMLElement)) {
    return "";
  }

  if (node.tagName === "IMG") {
    const src = safeImageUrl(node.getAttribute("src") ?? "");
    const alt = (node.getAttribute("alt") ?? "").replace(/[\[\]\\]/g, "\\$&");
    return src ? `![${alt}](${src})` : "";
  }

  const content = Array.from(node.childNodes).map(inlineMarkdownFromNode).join("");

  if (node.tagName === "BR") return "\n";
  if (node.tagName === "STRONG" || node.tagName === "B") return `**${content}**`;
  if (node.tagName === "EM" || node.tagName === "I") return `*${content}*`;
  if (node.tagName === "S" || node.tagName === "DEL" || node.tagName === "STRIKE") return `~~${content}~~`;
  if (node.tagName === "U") return `==${content}==`;
  if (node.tagName === "CODE") return `\`${content}\``;

  if (node.tagName === "A") {
    const href = safeLink(node.getAttribute("href") ?? "");
    return href ? `[${content}](${href})` : content;
  }

  return content;
}

function htmlToMarkdown(html: string) {
  const documentBody = new DOMParser().parseFromString(html, "text/html").body;
  const blocks: string[] = [];
  const children = Array.from(documentBody.childNodes);

  for (let index = 0; index < children.length; index += 1) {
    const node = children[index];

    if (node.nodeType === Node.TEXT_NODE && !node.textContent?.trim()) continue;

    if (!(node instanceof HTMLElement)) {
      const text = inlineMarkdownFromNode(node).trim();
      if (text) blocks.push(text);
      continue;
    }

    const inline = inlineMarkdownFromNode(node).trim();

    if (node.classList.contains("blog-rich-block")) {
      const block = updateBlogRichBlockFromFields(node);

      if (block) blocks.push(richBlockMarkdown(block));
      continue;
    }

    if (node.tagName === "TABLE") {
      const layout = readBlogColumnLayout(node);
      const richTable = readNativeRichTable(node);

      if (layout) {
        blocks.push(columnLayoutMarkdown(layout));
      } else if (richTable) {
        blocks.push(richBlockMarkdown(richTable));
      }
      continue;
    }

    if (node.classList.contains("blog-image-layout")) {
      const layout = readBlogImageLayout(node);

      if (layout) blocks.push(imageLayoutMarkdown(layout));
      continue;
    }

    if (/^H[1-4]$/.test(node.tagName)) {
      blocks.push(`${"#".repeat(Number(node.tagName.slice(1)))} ${inline}`);
      continue;
    }

    if (node.tagName === "BLOCKQUOTE") {
      blocks.push(inline.split("\n").map((line) => `> ${line}`).join("\n"));
      continue;
    }

    if (node.tagName === "PRE" || node.classList.contains("ql-code-block")) {
      const codeLines = [node.textContent ?? ""];

      while (
        children[index + 1] instanceof HTMLElement &&
        (children[index + 1] as HTMLElement).classList.contains("ql-code-block")
      ) {
        index += 1;
        codeLines.push(children[index].textContent ?? "");
      }

      blocks.push(`\`\`\`\n${codeLines.join("\n").trimEnd()}\n\`\`\``);
      continue;
    }

    if (node.tagName === "UL" || node.tagName === "OL") {
      const listItems = Array.from(node.children).filter((child) => child.tagName === "LI");
      const taskList = listItems.some((item) => ["checked", "unchecked"].includes(item.getAttribute("data-list") ?? ""));
      const bulletList = node.tagName === "UL" || listItems.some((item) => item.getAttribute("data-list") === "bullet");
      blocks.push(
        listItems
          .map((item, itemIndex) => {
            const marker = taskList
              ? `- [${item.getAttribute("data-list") === "checked" ? "x" : " "}]`
              : bulletList
                ? "-"
                : `${itemIndex + 1}.`;
            return `${marker} ${inlineMarkdownFromNode(item).trim()}`;
          })
          .join("\n"),
      );
      continue;
    }

    if (inline) blocks.push(inline);
  }

  return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
}

const NOTION_BLOCK_ICON_PATHS: Partial<Record<BlockInsertKind | "rich", string>> = {
  audio: "M9.207 3.197c.619-.578 1.63-.14 1.63.708v12.417c0 .847-1.011 1.286-1.63.708l-3.523-3.291H2.712a.625.625 0 0 1-.625-.625v-6c0-.346.28-.625.625-.625h2.972zm.38 1.356L6.357 7.57a.63.63 0 0 1-.426.169H3.337v4.75H5.93c.158 0 .31.06.426.168l3.23 3.017zm3.224 2.08a.625.625 0 0 1 .88.08 5.31 5.31 0 0 1 0 6.8.625.625 0 0 1-.96-.8 4.06 4.06 0 0 0 0-5.2.625.625 0 0 1 .08-.88 M16.224 4.755a.625.625 0 0 0-1.024.717 8.09 8.09 0 0 1 0 9.283.625.625 0 0 0 1.024.717 9.34 9.34 0 0 0 0-10.717",
  bullet: "M4.809 12.75a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5M16 13.375a.625.625 0 1 1 0 1.25H8.5a.625.625 0 0 1 0-1.25zM4.809 4.75a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5M16 5.375a.625.625 0 1 1 0 1.25H8.5a.625.625 0 0 1 0-1.25z",
  button: "M10.208 12.93a.625.625 0 0 1 0-.885l1.42-1.42H6.65a.625.625 0 1 1 0-1.25h4.979l-1.42-1.42a.625.625 0 1 1 .883-.884l2.487 2.487a.625.625 0 0 1 0 .884l-2.487 2.487a.625.625 0 0 1-.884 0 M2.375 6.25c0-1.174.951-2.125 2.125-2.125h11c1.174 0 2.125.951 2.125 2.125v7.5a2.125 2.125 0 0 1-2.125 2.125h-11a2.125 2.125 0 0 1-2.125-2.125zM4.5 5.375a.875.875 0 0 0-.875.875v7.5c0 .483.392.875.875.875h11a.875.875 0 0 0 .875-.875v-7.5a.875.875 0 0 0-.875-.875z",
  callout: "M13.276 5.766a.6.6 0 0 1 .11.033l.034.017.067.037q.019.012.036.026l.051.042q.018.015.033.034a.6.6 0 0 1 .107.158l.014.03q.046.11.047.235v1.26a.626.626 0 0 1-1.25 0v-.635h-1.9v5.994h.32l.125.013a.625.625 0 0 1 0 1.224l-.126.013h-.934l-.01.001h-.945a.625.625 0 0 1 0-1.25h.32V7.002h-1.9v.635a.626.626 0 0 1-1.25 0v-1.26a.625.625 0 0 1 .626-.625h6.3z M14.75 3.125c1.174 0 2.125.951 2.125 2.125v9.5a2.125 2.125 0 0 1-2.125 2.125h-9.5a2.125 2.125 0 0 1-2.125-2.125v-9.5c0-1.174.951-2.125 2.125-2.125zm-9.5 1.25a.875.875 0 0 0-.875.875v9.5c0 .483.392.875.875.875h9.5a.875.875 0 0 0 .875-.875v-9.5a.875.875 0 0 0-.875-.875z",
  code: "M11.971 3.1c.332.094.525.44.43.772l-3.6 12.6a.625.625 0 0 1-1.202-.343l3.6-12.6a.625.625 0 0 1 .772-.43M5.417 5.598a.626.626 0 0 1 .885.884L2.784 10l3.518 3.519a.625.625 0 0 1-.885.883l-3.96-3.96a.626.626 0 0 1 0-.884zm8.281 0a.626.626 0 0 1 .884 0l3.96 3.96a.626.626 0 0 1 0 .884l-3.96 3.96a.626.626 0 0 1-.884-.883L17.215 10l-3.517-3.518a.626.626 0 0 1 0-.884",
  "database-board": "M2.375 6.25c0-1.174.951-2.125 2.125-2.125h11c1.174 0 2.125.951 2.125 2.125v7.5a2.125 2.125 0 0 1-2.125 2.125h-11a2.125 2.125 0 0 1-2.125-2.125zm10.584 8.375H15.5a.875.875 0 0 0 .875-.875v-7.5a.875.875 0 0 0-.875-.875h-2.541zm-1.25-9.25H8.292v9.25h3.417zm-7.209 0a.875.875 0 0 0-.875.875v7.5c0 .483.392.875.875.875h2.542v-9.25z",
  "database-gallery": "M4 3.375c-.897 0-1.625.728-1.625 1.625v2.5c0 .897.728 1.625 1.625 1.625h3.5c.897 0 1.625-.728 1.625-1.625V5c0-.897-.728-1.625-1.625-1.625zM3.625 5c0-.207.168-.375.375-.375h3.5c.207 0 .375.168.375.375v2.5a.375.375 0 0 1-.375.375H4a.375.375 0 0 1-.375-.375zM4 10.875c-.897 0-1.625.727-1.625 1.625V15c0 .898.728 1.625 1.625 1.625h3.5c.897 0 1.625-.727 1.625-1.625v-2.5c0-.898-.728-1.625-1.625-1.625zM3.625 12.5c0-.207.168-.375.375-.375h3.5c.207 0 .375.168.375.375V15a.375.375 0 0 1-.375.375H4A.375.375 0 0 1 3.625 15zm7.25-7.5c0-.897.727-1.625 1.625-1.625H16c.898 0 1.625.728 1.625 1.625v2.5c0 .897-.727 1.625-1.625 1.625h-3.5A1.625 1.625 0 0 1 10.875 7.5zm1.625-.375a.375.375 0 0 0-.375.375v2.5c0 .207.168.375.375.375H16a.375.375 0 0 0 .375-.375V5A.375.375 0 0 0 16 4.625zm0 6.25c-.898 0-1.625.727-1.625 1.625V15c0 .898.727 1.625 1.625 1.625H16c.898 0 1.625-.727 1.625-1.625v-2.5c0-.898-.727-1.625-1.625-1.625zm-.375 1.625c0-.207.168-.375.375-.375H16c.207 0 .375.168.375.375V15a.375.375 0 0 1-.375.375h-3.5a.375.375 0 0 1-.375-.375z",
  "database-table": "M4.5 4.125A2.125 2.125 0 0 0 2.375 6.25v7.5c0 1.174.951 2.125 2.125 2.125h11a2.125 2.125 0 0 0 2.125-2.125v-7.5A2.125 2.125 0 0 0 15.5 4.125zm11.875 7h-5.75v-2.25h5.75zm-5.75 1.25h5.75v1.375a.875.875 0 0 1-.875.875h-4.875zm-1.25-1.25h-5.75v-2.25h5.75zm-5.75 1.25h5.75v2.25H4.5a.875.875 0 0 1-.875-.875zm0-4.75V6.25c0-.483.392-.875.875-.875h4.875v2.25zm7 0v-2.25H15.5c.483 0 .875.392.875.875v1.375z",
  divider: "M4 9.375a.625.625 0 1 0 0 1.25h12a.625.625 0 1 0 0-1.25z",
  file: "M10.184 3.64A3.475 3.475 0 0 1 15.1 8.554l-5.374 5.374a2.05 2.05 0 1 1-2.9-2.9l2.688-2.686a.625.625 0 0 1 .884.884L7.71 11.913a.8.8 0 0 0 1.13 1.131l5.375-5.374a2.225 2.225 0 1 0-3.147-3.146L5.694 9.898a3.65 3.65 0 1 0 5.162 5.161l4.702-4.702a.625.625 0 0 1 .884.884l-4.702 4.702a4.9 4.9 0 1 1-6.93-6.93z",
  image: "M4.5 3.125A2.125 2.125 0 0 0 2.375 5.25v9.5c0 1.174.951 2.125 2.125 2.125h11a2.125 2.125 0 0 0 2.125-2.125v-9.5A2.125 2.125 0 0 0 15.5 3.125zm0 1.25h11c.483 0 .875.392.875.875v6.225l-2.53-2.53a.625.625 0 0 0-.884 0l-2.49 2.49-1.78-1.78a.625.625 0 0 0-.884 0l-4.182 4.182V5.25c0-.483.392-.875.875-.875m-.875 11.23 4.624-4.624 1.78 1.78a.625.625 0 0 0 .884 0l2.49-2.49 2.972 2.972v1.512a.875.875 0 0 1-.875.875h-11a.875.875 0 0 1-.875-.875zM7.25 5.625a1.625 1.625 0 1 0 0 3.25 1.625 1.625 0 0 0 0-3.25",
  math: "M12.908 5.638a.625.625 0 0 1 0 1.224l-.126.013H8.831l2.921 2.663a.626.626 0 0 1 0 .924l-2.921 2.663h3.951l.126.013a.625.625 0 0 1 0 1.224l-.126.013H7.218a.626.626 0 0 1-.421-1.087L10.402 10 6.797 6.712a.625.625 0 0 1 .42-1.087h5.565z M14.75 3.125c1.174 0 2.125.951 2.125 2.125v9.5a2.125 2.125 0 0 1-2.125 2.125h-9.5a2.125 2.125 0 0 1-2.125-2.125v-9.5c0-1.174.951-2.125 2.125-2.125zm-9.5 1.25a.875.875 0 0 0-.875.875v9.5c0 .483.392.875.875.875h9.5a.875.875 0 0 0 .875-.875v-9.5a.875.875 0 0 0-.875-.875z",
  number: "M5.088 3.026a.55.55 0 0 1 .27.474v4a.55.55 0 0 1-1.1 0V4.435l-.24.134a.55.55 0 1 1-.535-.962l1.059-.588a.55.55 0 0 1 .546.007M8.5 5.375a.625.625 0 1 0 0 1.25H16a.625.625 0 1 0 0-1.25zm0 8a.625.625 0 0 0 0 1.25H16a.625.625 0 1 0 0-1.25zM6 16.55H3.5a.55.55 0 0 1-.417-.908l1.923-2.24a.7.7 0 0 0 .166-.45.335.335 0 0 0-.266-.327l-.164-.035a.6.6 0 0 0-.245.004l-.03.007a.57.57 0 0 0-.426.44.55.55 0 1 1-1.08-.206 1.67 1.67 0 0 1 1.248-1.304l.029-.007c.24-.058.49-.061.732-.01l.164.035c.664.14 1.138.726 1.138 1.404 0 .427-.153.84-.432 1.165L4.697 15.45H6a.55.55 0 0 1 0 1.1",
  page: "M13.3 14.25a.55.55 0 0 1-.55.55h-5.5a.55.55 0 1 1 0-1.1h5.5a.55.55 0 0 1 .55.55m-.55-1.95a.55.55 0 1 0 0-1.1h-5.5a.55.55 0 0 0 0 1.1z M6.25 2.375A2.125 2.125 0 0 0 4.125 4.5v11c0 1.174.951 2.125 2.125 2.125h7.5a2.125 2.125 0 0 0 2.125-2.125V8.121c0-.563-.224-1.104-.622-1.502L11.63 2.997a2.13 2.13 0 0 0-1.502-.622zM5.375 4.5c0-.483.392-.875.875-.875h3.7V6.25A2.05 2.05 0 0 0 12 8.3h2.625v7.2a.875.875 0 0 1-.875.875h-7.5a.875.875 0 0 1-.875-.875zm8.691 2.7H12a.95.95 0 0 1-.95-.95V4.184z",
  quote: "M15.796 4.971a5.067 5.067 0 0 0-5.067 5.067v.635a4.433 4.433 0 0 0 4.433 4.433 3.164 3.164 0 1 0-3.11-3.75 3.2 3.2 0 0 1-.073-.683v-.635a3.817 3.817 0 0 1 3.817-3.817h.635a.625.625 0 1 0 0-1.25zm-9.054 0a5.067 5.067 0 0 0-5.067 5.068v.634a4.433 4.433 0 0 0 4.433 4.433 3.164 3.164 0 1 0-3.11-3.75 3.2 3.2 0 0 1-.073-.683v-.634A3.817 3.817 0 0 1 6.742 6.22h.635a.625.625 0 1 0 0-1.25z",
  table: "M4.5 4.125A2.125 2.125 0 0 0 2.375 6.25v7.5c0 1.174.951 2.125 2.125 2.125h11a2.125 2.125 0 0 0 2.125-2.125v-7.5A2.125 2.125 0 0 0 15.5 4.125zm11.875 7h-5.75v-2.25h5.75zm-5.75 1.25h5.75v1.375a.875.875 0 0 1-.875.875h-4.875zm-1.25-1.25h-5.75v-2.25h5.75zm-5.75 1.25h5.75v2.25H4.5a.875.875 0 0 1-.875-.875zm0-4.75V6.25c0-.483.392-.875.875-.875h4.875v2.25zm7 0v-2.25H15.5c.483 0 .875.392.875.875v1.375z",
  tabs: "M3.125 5.25c0-1.174.951-2.125 2.125-2.125h9.5c1.174 0 2.125.951 2.125 2.125v9.514a2.125 2.125 0 0 1-2.125 2.111h-9.5a2.125 2.125 0 0 1-2.125-2.125zm12.5.627V5.25a.875.875 0 0 0-.875-.875h-2.014c.166.32.26.684.26 1.07v.06q0 .097-.027.184h1.781c.312 0 .608.067.875.188m-3.85-.188a.6.6 0 0 1-.028-.185v-.06c0-.59-.48-1.069-1.07-1.069H9.062c.12.267.188.563.188.875v.44zm-4.65-1.314H5.25a.875.875 0 0 0-.875.875v9.5c0 .483.392.875.875.875h9.5a.875.875 0 0 0 .875-.875V7.814a.875.875 0 0 0-.875-.875H8.625A.625.625 0 0 1 8 6.314V5.25a.875.875 0 0 0-.875-.875",
  toc: "M8.125 14.75c0-.345.28-.625.625-.625h10.1a.625.625 0 0 1 0 1.25H8.75a.625.625 0 0 1-.625-.625M7 9.375a.625.625 0 1 0 0 1.25h10.1a.625.625 0 1 0 0-1.25zm-1.75-4.75a.625.625 0 0 0 0 1.25h10.1a.625.625 0 1 0 0-1.25zM2.9 9.375a.625.625 0 1 0 0 1.25h1.5a.625.625 0 1 0 0-1.25zM.525 5.25c0-.345.28-.625.625-.625h1.5a.625.625 0 0 1 0 1.25h-1.5a.625.625 0 0 1-.625-.625m4.125 8.875a.625.625 0 0 0 0 1.25h1.5a.625.625 0 1 0 0-1.25z",
  todo: "M6.184 10.804a1.1 1.1 0 0 1 1.1 1.1v2.8a1.1 1.1 0 0 1-1.1 1.1h-2.8a1.1 1.1 0 0 1-1.1-1.1v-2.8a1.1 1.1 0 0 1 1.1-1.1zm-2.65 3.75h2.5v-2.5h-2.5zm13.339-1.875a.625.625 0 0 1 0 1.25H9.748a.625.625 0 1 1 0-1.25zM6.748 3.394a.625.625 0 0 1 1.072.642l-2.85 4.75a.626.626 0 0 1-1.01.086l-1.9-2.217a.626.626 0 0 1 .948-.813l1.336 1.557zm10.125 2.634a.626.626 0 0 1 0 1.25H9.748a.625.625 0 1 1 0-1.25z",
  toggle: "M2.7 7.519c0 .39.421.633.757.436L6.05 6.437a.506.506 0 0 0 0-.874L3.457 4.045a.503.503 0 0 0-.757.436zm5.8-2.144a.625.625 0 1 0 0 1.25H16a.625.625 0 1 0 0-1.25zm0 8a.625.625 0 1 0 0 1.25H16a.625.625 0 1 0 0-1.25zm-5.043 2.58a.503.503 0 0 1-.757-.436V12.48c0-.39.421-.633.757-.436l2.593 1.518a.506.506 0 0 1 0 .874z",
  video: "M7.814 12.407c0 .295.323.479.58.33l4.165-2.407a.38.38 0 0 0 0-.66L8.394 7.263a.385.385 0 0 0-.58.33z M4.5 4.125A2.125 2.125 0 0 0 2.375 6.25v7.5c0 1.174.951 2.125 2.125 2.125h11a2.125 2.125 0 0 0 2.125-2.125v-7.5A2.125 2.125 0 0 0 15.5 4.125zM3.625 6.25c0-.483.392-.875.875-.875h11c.483 0 .875.392.875.875v7.5a.875.875 0 0 1-.875.875h-11a.875.875 0 0 1-.875-.875z",
};

function BlockIcon({ type }: { type: BlockInsertKind | "rich" }) {
  const normalizedType = type.startsWith("toggle-heading")
    ? "toggle"
    : type.startsWith("columns-")
      ? "database-board"
      : type.startsWith("database-") && !NOTION_BLOCK_ICON_PATHS[type]
        ? "toc"
        : type;
  const path = NOTION_BLOCK_ICON_PATHS[normalizedType as BlockInsertKind | "rich"];

  if (/^heading[1-4]$/.test(type)) {
    return (
      <svg aria-hidden="true" viewBox="0 0 20 20" className="h-5 w-5 fill-current">
        <text x="2.2" y="14.3" fill="currentColor" fontFamily="ui-sans-serif, sans-serif" fontSize="12.5" fontWeight="700">
          H{type.at(-1)}
        </text>
      </svg>
    );
  }

  if (path) {
    return (
      <svg aria-hidden="true" viewBox="0 0 20 20" className="h-5 w-5 fill-current">
        <path d={path} />
      </svg>
    );
  }

  return (
    <svg aria-hidden="true" viewBox="0 0 20 20" className="h-5 w-5 fill-none stroke-current" strokeWidth="1.35">
      <rect x="3" y="3.5" width="14" height="13" rx="2" />
      <path d="M6 7h8M6 10h8M6 13h5" strokeLinecap="round" />
    </svg>
  );
}

const EDITOR_BLOCK_SELECTOR = "p,h1,h2,h3,h4,blockquote,li,.ql-code-block,pre.ql-syntax,.blog-image-layout,.blog-rich-block,table";

const BLOCK_TRANSFORM_OPTIONS: Array<[TextBlockKind, string, string]> = [
  ["text", "텍스트", "일반 문단"],
  ["heading1", "제목1", "가장 큰 제목"],
  ["heading2", "제목2", "섹션 제목"],
  ["heading3", "제목3", "하위 제목"],
  ["heading4", "제목4", "작은 제목"],
  ["bullet", "글머리 기호 목록", "순서 없는 목록"],
  ["number", "번호 매기기 목록", "순서 있는 목록"],
  ["todo", "할 일 목록", "체크 가능한 목록"],
  ["quote", "인용", "인용문"],
  ["code", "코드", "코드와 설정값"],
];

const BLOCK_INSERT_SECTIONS: BlockInsertSection[] = [
  {
    label: "기본 블록",
    options: [
      ...BLOCK_TRANSFORM_OPTIONS.map(([kind, label, description]) => ({ kind, label, description })),
      { kind: "toggle", label: "토글 목록", description: "접고 펼칠 수 있는 내용", shortcut: ">" },
      { kind: "callout", label: "콜아웃", description: "중요한 내용을 강조" },
      { kind: "page", label: "페이지", description: "다른 콘텐츠 페이지 연결" },
      { kind: "table", label: "표", description: "행과 열로 정보 정리" },
      { kind: "divider", label: "구분선", description: "내용 사이를 구분", shortcut: "---" },
    ],
  },
  {
    label: "미디어",
    options: [
      { kind: "image", label: "이미지", description: "NCP 스토리지에 업로드" },
      { kind: "video", label: "동영상", description: "동영상 주소와 설명" },
      { kind: "audio", label: "오디오", description: "오디오 주소와 설명" },
      { kind: "file", label: "파일", description: "다운로드할 파일 연결" },
      { kind: "code", label: "코드", description: "코드 또는 설정값", shortcut: "```" },
    ],
  },
  {
    label: "데이터베이스",
    options: [
      { kind: "database-table", label: "표 보기", description: "열 기반 데이터 정리" },
      { kind: "database-board", label: "보드 보기", description: "상태별 항목 배치" },
      { kind: "database-gallery", label: "갤러리 보기", description: "시각적인 항목 모음" },
      { kind: "database-list", label: "리스트 보기", description: "간결한 항목 목록" },
      { kind: "database-calendar", label: "캘린더 보기", description: "날짜별 콘텐츠 정리" },
      { kind: "database-timeline", label: "타임라인 보기", description: "순서와 기간 표시" },
      { kind: "database-chart", label: "차트 보기", description: "항목별 수치 비교" },
    ],
  },
  {
    label: "고급 블록",
    options: [
      { kind: "toc", label: "목차", description: "제목2 기준 자동 목차" },
      { kind: "math", label: "수학 공식 블록", description: "수식 또는 기호" },
      { kind: "button", label: "버튼", description: "클릭 가능한 링크 버튼" },
      { kind: "breadcrumb", label: "이동 경로", description: "현재 콘텐츠의 경로" },
      { kind: "tabs", label: "탭", description: "여러 내용을 탭으로 구분" },
      { kind: "toggle-heading1", label: "토글 제목1", description: "큰 제목과 접기" },
      { kind: "toggle-heading2", label: "토글 제목2", description: "중간 제목과 접기" },
      { kind: "toggle-heading3", label: "토글 제목3", description: "작은 제목과 접기" },
      { kind: "columns-2", label: "2개의 열", description: "본문을 2열로 배치" },
      { kind: "columns-3", label: "3개의 열", description: "본문을 3열로 배치" },
      { kind: "columns-4", label: "4개의 열", description: "본문을 4열로 배치" },
      { kind: "mermaid", label: "코드 - Mermaid", description: "흐름도 문법 작성" },
    ],
  },
];

function findEditorBlock(root: HTMLDivElement, target: EventTarget | null) {
  if (!(target instanceof Element)) return null;

  const columnLayout = target.closest("table");

  if (columnLayout instanceof HTMLElement && root.contains(columnLayout) && readBlogColumnLayout(columnLayout)) return columnLayout;

  const block = target.closest(EDITOR_BLOCK_SELECTOR);
  return block instanceof HTMLElement && root.contains(block) ? block : null;
}

function getEditorDragBlocks(root: HTMLDivElement) {
  return Array.from(root.querySelectorAll<HTMLElement>(EDITOR_BLOCK_SELECTOR)).filter((block) => {
    const columnLayout = block.closest("table");

    if (
      columnLayout instanceof HTMLElement &&
      columnLayout !== block &&
      readBlogColumnLayout(columnLayout)
    ) {
      return false;
    }

    const imageLayout = block.closest(".blog-image-layout");
    return !(imageLayout instanceof HTMLElement && imageLayout !== block);
  });
}

function resetDropTargetElement(block: HTMLElement) {
  block.classList.remove(...BLOG_EDITOR_DROP_CLASSES);
  BLOG_EDITOR_DROP_PADDING_PROPERTIES.forEach((property) => block.style.removeProperty(property));
}

function prepareDropTargetElement(block: HTMLElement) {
  resetDropTargetElement(block);

  const style = window.getComputedStyle(block);
  block.style.setProperty("--blog-drop-padding-top", style.paddingTop);
  block.style.setProperty("--blog-drop-padding-right", style.paddingRight);
  block.style.setProperty("--blog-drop-padding-bottom", style.paddingBottom);
  block.style.setProperty("--blog-drop-padding-left", style.paddingLeft);
}

function fallbackBlockTarget(root: HTMLDivElement, block: HTMLElement) {
  let index = 0;

  for (const candidate of root.querySelectorAll<HTMLElement>(EDITOR_BLOCK_SELECTOR)) {
    const length = Math.max(1, (candidate.innerText || candidate.textContent || "").length + 1);

    if (candidate === block) {
      return { index, length };
    }

    index += length;
  }

  return { index: 0, length: 1 };
}

function resolveBlockTarget(
  quill: QuillLikeInstance,
  block: HTMLElement,
  editorShell: HTMLElement | null,
): BlockTarget {
  const fallback = fallbackBlockTarget(quill.root, block);
  let index = fallback.index;
  let length = fallback.length;

  try {
    const blot = quill.scroll.find(block);

    if (blot) {
      index = quill.getIndex(blot);
      const blotLength = blot.length?.();

      if (typeof blotLength === "number" && blotLength > 0) {
        length = blotLength;
      }
    }
  } catch {
    // The DOM can be replaced by Quill while the pointer is moving. The text-based fallback stays usable.
  }

  const rootRect = quill.root.getBoundingClientRect();
  const blockRect = block.getBoundingClientRect();
  const shellRect = editorShell?.getBoundingClientRect() ?? rootRect;

  return {
    blockLeft: Math.max(0, blockRect.left - shellRect.left),
    columnLayout: readBlogColumnLayout(block),
    controlLeft: Math.max(6, blockRect.left - shellRect.left - 58),
    controlTop: Math.max(0, blockRect.top - shellRect.top),
    height: Math.max(1, blockRect.height),
    index,
    isImageLayout: isBlogImageLayoutElement(block),
    isNativeTable: block.classList.contains("blog-native-table"),
    length: Math.max(1, length),
    top: Math.max(0, blockRect.top - rootRect.top),
    width: Math.max(1, blockRect.width),
  };
}

function blockStyleFromFormat(format: Record<string, unknown>): TextBlockKind {
  if ([1, 2, 3, 4].includes(Number(format.header))) return `heading${Number(format.header)}` as TextBlockKind;
  if (format.list === "checked" || format.list === "unchecked") return "todo";
  if (format.list === "bullet") return "bullet";
  if (format.list === "ordered") return "number";
  if (format.blockquote) return "quote";
  if (format["code-block"]) return "code";
  return "text";
}

function FormatButton({
  children,
  label,
  onClick,
}: {
  children: ReactNode;
  label: string;
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={label}
      onMouseDown={(event) => {
        event.preventDefault();
        event.stopPropagation();
      }}
      onClick={onClick}
      className="inline-flex h-8 min-w-8 items-center justify-center rounded-[7px] px-1.5 text-[#526784] transition-colors hover:bg-[#edf4ff] hover:text-[#2f6df6]"
    >
      {children}
    </button>
  );
}

export function NotionBlogContentEditor({
  draftToken,
  fullWidth,
  onImageUploadStatusChange,
  value,
  onChange,
}: {
  draftToken: string;
  fullWidth: boolean;
  onImageUploadStatusChange: (isUploading: boolean) => void;
  value: string;
  onChange: (value: string) => void;
}) {
  const editorShellRef = useRef<HTMLDivElement | null>(null);
  const editorRootRef = useRef<HTMLDivElement | null>(null);
  const imageInputRef = useRef<HTMLInputElement | null>(null);
  const mediaInputRef = useRef<HTMLInputElement | null>(null);
  const mediaBlockRef = useRef<HTMLElement | null>(null);
  const quillRef = useRef<QuillLikeInstance | null>(null);
  const onChangeRef = useRef(onChange);
  const draftTokenRef = useRef(draftToken);
  const imageInsertRangeRef = useRef<QuillRange | null>(null);
  const lastValueRef = useRef(value);
  const applyingValueRef = useRef(false);
  const activeBlockElementRef = useRef<HTMLElement | null>(null);
  const blockPointerDragRef = useRef<BlockPointerDrag | null>(null);
  const dropBlockElementRef = useRef<HTMLElement | null>(null);
  const dropIndicatorRef = useRef<HTMLDivElement | null>(null);
  const imageLayoutResizeRef = useRef<ImageLayoutResize | null>(null);
  const columnLayoutResizeRef = useRef<ColumnLayoutResize | null>(null);
  const videoResizeRef = useRef<VideoResize | null>(null);
  const blockMarqueeRef = useRef<BlockMarquee | null>(null);
  const selectedBlockElementsRef = useRef<HTMLElement[]>([]);
  const skipNextGripClickRef = useRef(false);
  const [editorReady, setEditorReady] = useState(false);
  const [activeBlock, setActiveBlock] = useState<BlockTarget | null>(null);
  const [blockSelectionRect, setBlockSelectionRect] = useState<BlockSelectionRect | null>(null);
  const [selectedBlockCount, setSelectedBlockCount] = useState(0);
  const [inlineMenu, setInlineMenu] = useState<InlineMenuPosition | null>(null);
  const [blockMenu, setBlockMenu] = useState<BlockMenuPosition | null>(null);
  const [blockActionQuery, setBlockActionQuery] = useState("");
  const [blockMenuPanel, setBlockMenuPanel] = useState<"root" | "transform">("root");
  const [blockInsertMenu, setBlockInsertMenu] = useState<BlockInsertMenuPosition | null>(null);
  const [blockInsertQuery, setBlockInsertQuery] = useState("");
  const [imageUploadMessage, setImageUploadMessage] = useState<string | null>(null);
  const [imagePreview, setImagePreview] = useState<BlogImage | null>(null);
  const [mediaKind, setMediaKind] = useState<RichMediaKind>("audio");
  const [mediaMenu, setMediaMenu] = useState<MediaMenuPosition | null>(null);
  const [mediaMenuTab, setMediaMenuTab] = useState<"link" | "upload">("upload");
  const [mediaLink, setMediaLink] = useState("");
  const [mediaMenuError, setMediaMenuError] = useState<string | null>(null);
  const [mediaUploading, setMediaUploading] = useState(false);

  function clearActiveBlock() {
    activeBlockElementRef.current?.removeAttribute("data-image-layout-selected");
    activeBlockElementRef.current?.removeAttribute("data-column-layout-selected");
    activeBlockElementRef.current?.removeAttribute("data-rich-media-selected");
    activeBlockElementRef.current = null;
    setActiveBlock(null);
  }

  function clearBlockSelection() {
    selectedBlockElementsRef.current.forEach((element) => {
      element.removeAttribute("data-block-selected");
    });
    selectedBlockElementsRef.current = [];
    setSelectedBlockCount(0);
    setBlockSelectionRect(null);
  }

  function selectedBlockRange() {
    const quill = quillRef.current;
    const elements = selectedBlockElementsRef.current.filter((element) => element.isConnected);

    if (!quill || elements.length === 0) return null;

    const targets = elements
      .map((element) => resolveBlockTarget(quill, element, editorShellRef.current))
      .sort((left, right) => left.index - right.index);
    const start = targets[0].index;
    const end = Math.max(...targets.map((target) => target.index + target.length));

    return {
      end,
      length: Math.max(1, end - start),
      start,
    };
  }

  function deleteSelectedBlocks() {
    const quill = quillRef.current;
    const range = selectedBlockRange();

    if (!quill || !range) return;

    quill.deleteText(range.start, range.length, "user");
    quill.setSelection(Math.max(0, Math.min(range.start, quill.getLength() - 1)), 0, "silent");
    quill.focus({ preventScroll: true });
    clearBlockSelection();
    clearActiveBlock();
  }

  function duplicateSelectedBlocks() {
    const quill = quillRef.current;
    const range = selectedBlockRange();

    if (!quill || !range) return;

    const contents = quill.getContents(range.start, range.length);
    quill.updateContents({
      ops: [{ retain: range.end }, ...(contents.ops ?? [])],
    }, "user");
    quill.setSelection(range.end, 0, "silent");
    quill.focus({ preventScroll: true });
    clearBlockSelection();
    clearActiveBlock();
  }

  const deleteSelectedBlocksFromEvent = useEffectEvent(() => {
    deleteSelectedBlocks();
  });

  const duplicateSelectedBlocksFromEvent = useEffectEvent(() => {
    duplicateSelectedBlocks();
  });

  function closeMediaMenu() {
    mediaBlockRef.current = null;
    setMediaMenu(null);
    setMediaMenuError(null);
    setMediaUploading(false);
  }

  function commitEditorValue() {
    const quill = quillRef.current;

    if (!quill || applyingValueRef.current) return;

    const nextValue = htmlToMarkdown(quill.root.innerHTML);
    lastValueRef.current = nextValue;
    onChangeRef.current(nextValue);
  }

  function openMediaMenu(element: HTMLElement) {
    const block = readBlogRichBlock(element);
    const rect = element.getBoundingClientRect();

    if (!block || !["audio", "file", "video"].includes(block.kind)) return;

    mediaBlockRef.current = element;
    setMediaKind(block.kind as RichMediaKind);
    setInlineMenu(null);
    setBlockMenu(null);
    setBlockInsertMenu(null);
    setMediaMenuTab("upload");
    setMediaLink(block.url);
    setMediaMenuError(null);
    setMediaMenu({
      left: Math.max(12, Math.min(window.innerWidth - 312, rect.left + Math.min(170, rect.width / 2) - 150)),
      top: floatingMenuTop(rect.top, rect.bottom, 238),
    });
  }

  function updateMediaBlock(url: string, title: string) {
    const element = mediaBlockRef.current;
    const block = element ? readBlogRichBlock(element) : null;

    if (!element || !block || !["audio", "file", "video"].includes(block.kind)) return;

    replaceRichBlock(element, {
      ...block,
      title: title.trim().slice(0, 500) || BLOG_RICH_BLOCK_LABELS[block.kind],
      url,
    });
    closeMediaMenu();
  }

  function mediaTitleFromUrl(url: string) {
    try {
      const segment = new URL(url).pathname.split("/").filter(Boolean).at(-1);
      return segment ? decodeURIComponent(segment) : BLOG_RICH_BLOCK_LABELS[mediaKind];
    } catch {
      return BLOG_RICH_BLOCK_LABELS[mediaKind];
    }
  }

  function replaceRichBlock(element: HTMLElement, block: BlogRichBlock) {
    const quill = quillRef.current;

    if (!quill) return;

    const target = resolveBlockTarget(quill, element, editorShellRef.current);
    const history = quill.getModule("history") as QuillHistory | null;
    const ops: Array<Record<string, unknown>> = [];

    if (target.index > 0) {
      ops.push({ retain: target.index });
    }

    ops.push(
      { delete: target.length },
      { insert: { "blog-rich-block": block } },
    );

    // Structural widget edits must be one distinct undo step, not merged with nearby typing.
    history?.cutoff();
    quill.updateContents({ ops }, "user");
    history?.cutoff();
    quill.setSelection(target.index, 1, "silent");
    quill.focus({ preventScroll: true });
  }

  const handleRichBlockActionFromEvent = useEffectEvent((element: HTMLElement, action: string, actionValue?: string) => {
    const block = updateBlogRichBlockFromFields(element) ?? readBlogRichBlock(element);

    if (!block) return;

    if (block.kind === "video") {
      if (action === "video-replace") {
        openMediaMenu(element);
        return;
      }

      if (action === "video-remove") {
        const quill = quillRef.current;
        if (quill) {
          const target = resolveBlockTarget(quill, element, editorShellRef.current);
          quill.deleteText(target.index, target.length, "user");
          quill.setSelection(Math.max(0, target.index - 1), 0, "silent");
          quill.focus({ preventScroll: true });
          clearActiveBlock();
        }
        return;
      }

      const width = action.match(/^video-width-(50|75|100)$/)?.[1];
      const align = action.match(/^video-align-(left|center|right)$/)?.[1] as VideoAlignment | undefined;

      if (width || align) {
        const settings = videoSettings(block.body);
        replaceRichBlock(element, {
          ...block,
          body: videoSettingsBody({
            ...settings,
            align: align ?? settings.align,
            width: width ? Number(width) : settings.width,
          }),
        });
        return;
      }
    }

    if (action === "toggle-open") {
      replaceRichBlock(element, {
        ...block,
        url: toggleStateMeta(!toggleOpenState(block.url)),
      });
      return;
    }

    if (action === "add-table-row" || action === "add-table-column") {
      const rows = tableRows(block.body, block.kind === "database-table" ? 2 : 3, 3);

      if (action === "add-table-row") {
        rows.push(Array.from({ length: rows[0].length }, () => ""));
      } else {
        rows.forEach((row) => row.push(""));
        block.url = tableWidthsMeta(Array.from({ length: rows[0].length }, () => 100 / rows[0].length));
      }

      replaceRichBlock(element, { ...block, body: tableBody(rows) });
      return;
    }

    if (action === "toggle-table-row-menu" || action === "toggle-table-column-menu") {
      const kind = action === "toggle-table-row-menu" ? "row" : "column";
      const index = Number(actionValue);
      const isOpen = element.dataset.tableMenuKind === kind && Number(element.dataset.tableMenuIndex) === index;

      if (isOpen) {
        delete element.dataset.tableMenuKind;
        delete element.dataset.tableMenuIndex;
      } else {
        element.dataset.tableMenuKind = kind;
        element.dataset.tableMenuIndex = String(index);
      }

      renderBlogRichBlockNode(element, block);
      return;
    }

    if (
      action === "insert-table-row-before" ||
      action === "insert-table-row-after" ||
      action === "delete-table-row" ||
      action === "insert-table-column-before" ||
      action === "insert-table-column-after" ||
      action === "delete-table-column"
    ) {
      const rows = tableRows(block.body, block.kind === "database-table" ? 2 : 3, 3);
      const index = Math.max(0, Number(actionValue) || 0);
      let nextUrl = block.url;

      if (action === "insert-table-row-before" || action === "insert-table-row-after") {
        const insertAt = Math.min(rows.length, index + (action.endsWith("after") ? 1 : 0));
        rows.splice(insertAt, 0, Array.from({ length: rows[0].length }, () => ""));
      } else if (action === "delete-table-row") {
        if (rows.length <= 1 || index >= rows.length) return;
        rows.splice(index, 1);
      } else {
        const columnCount = rows[0].length;

        if (action === "delete-table-column") {
          if (columnCount <= 1 || index >= columnCount) return;
          rows.forEach((row) => row.splice(index, 1));
          const widths = tableColumnWidths(block.url, columnCount).filter((_, columnIndex) => columnIndex !== index);
          const total = widths.reduce((sum, width) => sum + width, 0);
          nextUrl = tableWidthsMeta(widths.map((width) => width / total * 100));
        } else {
          const insertAt = Math.min(columnCount, index + (action.endsWith("after") ? 1 : 0));
          rows.forEach((row) => row.splice(insertAt, 0, ""));
          const widths = tableColumnWidths(block.url, columnCount);
          const sourceIndex = Math.min(index, widths.length - 1);
          const splitWidth = widths[sourceIndex] / 2;

          if (splitWidth >= 8) {
            widths[sourceIndex] = splitWidth;
            widths.splice(insertAt, 0, splitWidth);
            nextUrl = tableWidthsMeta(widths);
          } else {
            nextUrl = tableWidthsMeta(Array.from({ length: columnCount + 1 }, () => 100 / (columnCount + 1)));
          }
        }
      }

      delete element.dataset.tableMenuKind;
      delete element.dataset.tableMenuIndex;
      replaceRichBlock(element, { ...block, body: tableBody(rows), url: nextUrl });
      return;
    }

    if (action === "add-database-item") {
      const nextBody = [...block.body.split("\n").map((line) => line.trim()).filter(Boolean), "새 항목"].join("\n");
      replaceRichBlock(element, { ...block, body: nextBody });
      return;
    }

    if (action === "add-database-group-item") {
      const status = actionValue?.trim() || "시작 전";
      const nextBody = [
        ...block.body.split("\n").map((line) => line.trim()).filter(Boolean),
        `새 페이지 :: ${status}`,
      ].join("\n");
      replaceRichBlock(element, { ...block, body: nextBody });
      window.requestAnimationFrame(() => {
        const inputs = Array.from(element.querySelectorAll<HTMLInputElement>("[data-rich-database-status]"))
          .filter((input) => input.dataset.richDatabaseStatus === status);
        inputs.at(-1)?.select();
      });
      return;
    }

    if (action === "sort-database-items") {
      const lines = block.body.split("\n").map((line) => line.trim()).filter(Boolean);
      const direction = element.dataset.databaseSort === "asc" ? "desc" : "asc";
      const header = block.kind === "database-table" ? lines.shift() : undefined;
      lines.sort((left, right) => left.localeCompare(right, "ko-KR") * (direction === "asc" ? 1 : -1));
      element.dataset.databaseSort = direction;
      replaceRichBlock(element, {
        ...block,
        body: header ? [header, ...lines].join("\n") : lines.join("\n"),
      });
      return;
    }

    if (action === "toggle-database-search") {
      element.dataset.databaseSearchOpen = element.dataset.databaseSearchOpen === "true" ? "false" : "true";
      element.dataset.databaseFilterOpen = "false";
      element.dataset.databaseSettingsOpen = "false";
      renderBlogRichBlockNode(element, block);
      window.requestAnimationFrame(() => {
        element.querySelector<HTMLInputElement>("[data-rich-database-query]")?.focus();
      });
      return;
    }

    if (action === "toggle-database-filter" || action === "toggle-database-settings") {
      const key = action === "toggle-database-filter" ? "databaseFilterOpen" : "databaseSettingsOpen";
      const next = element.dataset[key] === "true" ? "false" : "true";
      element.dataset.databaseFilterOpen = "false";
      element.dataset.databaseSettingsOpen = "false";
      element.dataset.databaseSearchOpen = "false";
      element.dataset[key] = next;
      renderBlogRichBlockNode(element, block);
      return;
    }

    if (action === "toggle-callout-emoji") {
      element.dataset.calloutEmojiOpen = element.dataset.calloutEmojiOpen === "true" ? "false" : "true";
      renderBlogRichBlockNode(element, block);
      return;
    }

    if (action === "set-callout-emoji") {
      replaceRichBlock(element, { ...block, title: actionValue?.trim() || "💡" });
      return;
    }

    if (action === "toggle-button-config") {
      element.dataset.buttonConfigOpen = element.dataset.buttonConfigOpen === "true" ? "false" : "true";
      renderBlogRichBlockNode(element, block);
      return;
    }

    if (action === "close-button-config") {
      element.dataset.buttonConfigOpen = "false";
      renderBlogRichBlockNode(element, block);
      return;
    }

    if (action === "select-tab") {
      element.dataset.activeTab = String(Math.max(0, Number(actionValue) || 0));
      renderBlogRichBlockNode(element, block);
      window.requestAnimationFrame(() => {
        element.querySelector<HTMLInputElement>("[data-rich-tab-title]")?.focus();
      });
      return;
    }

    if (action === "add-tab") {
      const tabs = block.body.split("\n").filter(Boolean);
      const nextIndex = tabs.length;
      tabs.push(`탭 ${nextIndex + 1} | `);
      element.dataset.activeTab = String(nextIndex);
      replaceRichBlock(element, { ...block, body: tabs.join("\n") });
      return;
    }

    if (action === "delete-tab") {
      const tabs = block.body.split("\n").filter(Boolean);
      if (tabs.length <= 1) return;
      const activeIndex = Math.min(Number(element.dataset.activeTab) || 0, tabs.length - 1);
      tabs.splice(activeIndex, 1);
      element.dataset.activeTab = String(Math.max(0, activeIndex - 1));
      replaceRichBlock(element, { ...block, body: tabs.join("\n") });
    }
  });

  const beginRichTableResize = useEffectEvent((event: PointerEvent, element: HTMLElement, columnIndex: number) => {
    const block = updateBlogRichBlockFromFields(element) ?? readBlogRichBlock(element);
    const grid = element.querySelector<HTMLElement>(".blog-rich-block__table-grid");
    const handles = element.querySelector<HTMLElement>(".blog-rich-block__table-column-handles");
    const columnCount = grid?.querySelectorAll(".blog-rich-block__table-cell").length
      ? tableRows(block?.body ?? "", block?.kind === "database-table" ? 2 : 3, 3)[0].length
      : 0;

    if (!block || !grid || !handles || columnIndex < 0 || columnIndex >= columnCount - 1) return;

    event.preventDefault();
    event.stopImmediatePropagation();
    const startX = event.clientX;
    const startWidths = tableColumnWidths(block.url, columnCount);
    const totalWidth = Math.max(1, grid.getBoundingClientRect().width);

    const applyWidths = (widths: number[]) => {
      grid.style.gridTemplateColumns = widths.map((width) => `minmax(84px, ${width}fr)`).join(" ");
      handles.style.gridTemplateColumns = widths.map((width) => `${width}fr`).join(" ");
    };

    const move = (moveEvent: PointerEvent) => {
      const delta = (moveEvent.clientX - startX) / totalWidth * 100;
      const pairTotal = startWidths[columnIndex] + startWidths[columnIndex + 1];
      const left = Math.max(8, Math.min(pairTotal - 8, startWidths[columnIndex] + delta));
      const widths = [...startWidths];
      widths[columnIndex] = left;
      widths[columnIndex + 1] = pairTotal - left;
      applyWidths(widths);
      element.dataset.pendingTableWidths = tableWidthsMeta(widths);
    };

    const finish = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", finish);
      window.removeEventListener("pointercancel", finish);
      const meta = element.dataset.pendingTableWidths;
      delete element.dataset.pendingTableWidths;

      if (meta) {
        const latest = updateBlogRichBlockFromFields(element) ?? block;
        replaceRichBlock(element, { ...latest, url: meta });
      }
    };

    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", finish);
    window.addEventListener("pointercancel", finish);
  });

  const beginRichColumnResize = useEffectEvent((event: PointerEvent, element: HTMLElement, columnIndex: number) => {
    const block = updateBlogRichBlockFromFields(element) ?? readBlogRichBlock(element);
    const columns = element.querySelector<HTMLElement>(".blog-rich-block__columns");
    const count = block?.kind.startsWith("columns-") ? Number(block.kind.slice(-1)) : 0;

    if (!block || !columns || columnIndex < 0 || columnIndex >= count - 1) return;

    event.preventDefault();
    event.stopImmediatePropagation();
    const startX = event.clientX;
    const startWidths = columnWidths(block.url, count);
    const totalWidth = Math.max(1, columns.getBoundingClientRect().width);
    element.classList.add("blog-rich-block--resizing");

    const applyWidths = (widths: number[]) => {
      columns.style.gridTemplateColumns = widths
        .flatMap((width, index) => index < count - 1 ? [`minmax(0, ${width}fr)`, "18px"] : [`minmax(0, ${width}fr)`])
        .join(" ");
    };

    const move = (moveEvent: PointerEvent) => {
      const delta = (moveEvent.clientX - startX) / totalWidth * 100;
      const pairTotal = startWidths[columnIndex] + startWidths[columnIndex + 1];
      const left = Math.max(12, Math.min(pairTotal - 12, startWidths[columnIndex] + delta));
      const widths = [...startWidths];
      widths[columnIndex] = left;
      widths[columnIndex + 1] = pairTotal - left;
      applyWidths(widths);
      element.dataset.pendingColumnWidths = columnWidthsMeta(widths);
    };

    const finish = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", finish);
      window.removeEventListener("pointercancel", finish);
      element.classList.remove("blog-rich-block--resizing");
      const meta = element.dataset.pendingColumnWidths;
      delete element.dataset.pendingColumnWidths;
      if (!meta) return;
      const latest = updateBlogRichBlockFromFields(element) ?? block;
      replaceRichBlock(element, { ...latest, url: meta });
    };

    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", finish);
    window.addEventListener("pointercancel", finish);
  });

  function beginVideoResize(
    event: PointerEvent,
    element: HTMLElement,
    side: "left" | "right",
  ) {
    const block = updateBlogRichBlockFromFields(element) ?? readBlogRichBlock(element);

    if (!block || block.kind !== "video") return;

    event.preventDefault();
    event.stopImmediatePropagation();
    clearBlockSelection();
    element.dataset.richMediaSelected = "true";
    element.classList.add("blog-rich-block--resizing");
    videoResizeRef.current = {
      block,
      element,
      pointerId: event.pointerId,
      side,
    };
  }

  const beginVideoResizeFromEvent = useEffectEvent((
    event: PointerEvent,
    element: HTMLElement,
    side: "left" | "right",
  ) => {
    beginVideoResize(event, element, side);
  });

  function updateVideoResize(event: PointerEvent) {
    const resize = videoResizeRef.current;

    if (!resize || resize.pointerId !== event.pointerId) return;

    const figure = resize.element.querySelector<HTMLElement>(".blog-rich-block__video-player");
    const parentRect = resize.element.getBoundingClientRect();
    const figureRect = figure?.getBoundingClientRect();

    if (!figure || !figureRect || parentRect.width < 1) return;

    const pixels = resize.side === "left"
      ? figureRect.right - event.clientX
      : event.clientX - figureRect.left;
    const width = clampPercentage(pixels / parentRect.width * 100, videoSettings(resize.block.body).width, 28, 100);
    figure.style.setProperty("--blog-video-width", `${width}%`);
    resize.element.dataset.pendingVideoWidth = String(width);
  }

  function finishVideoResize(event: PointerEvent) {
    const resize = videoResizeRef.current;

    if (!resize || resize.pointerId !== event.pointerId) return;

    const width = Number(resize.element.dataset.pendingVideoWidth);
    const latest = updateBlogRichBlockFromFields(resize.element) ?? resize.block;
    delete resize.element.dataset.pendingVideoWidth;
    resize.element.classList.remove("blog-rich-block--resizing");
    videoResizeRef.current = null;

    if (Number.isFinite(width)) {
      replaceRichBlock(resize.element, {
        ...latest,
        body: videoSettingsBody({
          ...videoSettings(latest.body),
          width,
        }),
      });
    }
  }

  function clearDropTarget() {
    if (dropBlockElementRef.current) {
      resetDropTargetElement(dropBlockElementRef.current);
    }
    quillRef.current?.root
      .querySelectorAll<HTMLElement>(BLOG_EDITOR_DROP_CLASSES.map((className) => `.${className}`).join(", "))
      .forEach(resetDropTargetElement);
    dropBlockElementRef.current = null;

    if (dropIndicatorRef.current) {
      dropIndicatorRef.current.hidden = true;
    }
  }

  function showDropIndicator(
    block: HTMLElement,
    placement: "before" | "after" | "left" | "right",
  ) {
    const indicator = dropIndicatorRef.current;
    const shell = editorShellRef.current;

    if (!indicator || !shell) return;

    const blockRect = block.getBoundingClientRect();
    const shellRect = shell.getBoundingClientRect();
    const dropSpaceCenter = 7;
    const lineThickness = 2;
    const horizontal = placement === "before" || placement === "after";
    const left = horizontal
      ? blockRect.left - shellRect.left + 1
      : (
          placement === "left"
            ? blockRect.left - shellRect.left + dropSpaceCenter
            : blockRect.right - shellRect.left - dropSpaceCenter
        ) - lineThickness / 2;
    const top = horizontal
      ? (
          placement === "before"
            ? blockRect.top - shellRect.top + dropSpaceCenter
            : blockRect.bottom - shellRect.top - dropSpaceCenter
        ) - lineThickness / 2
      : blockRect.top - shellRect.top + 1;

    indicator.hidden = false;
    indicator.style.left = `${left}px`;
    indicator.style.top = `${top}px`;
    indicator.style.width = `${horizontal ? Math.max(1, blockRect.width - 2) : lineThickness}px`;
    indicator.style.height = `${horizontal ? lineThickness : Math.max(1, blockRect.height - 2)}px`;
  }

  function resolveVerticalDropSlot(
    quill: QuillLikeInstance,
    sourceElement: HTMLElement | null,
    clientY: number,
  ) {
    const blocks = getEditorDragBlocks(quill.root);

    if (blocks.length === 0) return null;

    const entries = blocks.map((element) => ({
      element,
      rect: element.getBoundingClientRect(),
    }));
    const slots: Array<{
      coordinate: number;
      element: HTMLElement;
      placement: "before" | "after";
    }> = [
      {
        coordinate: entries[0].rect.top,
        element: entries[0].element,
        placement: "before",
      },
    ];

    for (let index = 1; index < entries.length; index += 1) {
      const previous = entries[index - 1];
      const current = entries[index];

      slots.push({
        coordinate: (previous.rect.bottom + current.rect.top) / 2,
        element: current.element,
        placement: "before",
      });
    }

    const lastEntry = entries.at(-1)!;
    slots.push({
      coordinate: lastEntry.rect.bottom,
      element: lastEntry.element,
      placement: "after",
    });

    const slot = slots.reduce((nearest, candidate) =>
      Math.abs(candidate.coordinate - clientY) < Math.abs(nearest.coordinate - clientY)
        ? candidate
        : nearest,
    );
    const sourceIndex = sourceElement ? blocks.indexOf(sourceElement) : -1;
    const destinationIndex = blocks.indexOf(slot.element);
    const isNoOp =
      sourceIndex >= 0 &&
      (
        destinationIndex === sourceIndex ||
        (slot.placement === "before" && destinationIndex === sourceIndex + 1) ||
        (slot.placement === "after" && destinationIndex + 1 === sourceIndex)
      );

    if (isNoOp) return null;

    return {
      block: slot.element,
      placement: slot.placement,
      target: resolveBlockTarget(quill, slot.element, editorShellRef.current),
    };
  }

  function activateBlock(block: HTMLElement) {
    const quill = quillRef.current;

    if (!quill) return null;

    const target = resolveBlockTarget(quill, block, editorShellRef.current);

    if (activeBlockElementRef.current !== block) {
      activeBlockElementRef.current?.removeAttribute("data-image-layout-selected");
      activeBlockElementRef.current?.removeAttribute("data-column-layout-selected");
      activeBlockElementRef.current?.removeAttribute("data-rich-media-selected");
      activeBlockElementRef.current = block;

      if (isBlogImageLayoutElement(block)) {
        block.dataset.imageLayoutSelected = "true";
      }

      if (isBlogColumnLayoutElement(block)) {
        block.dataset.columnLayoutSelected = "true";
      }

      if (block.matches(".blog-rich-block--audio, .blog-rich-block--file, .blog-rich-block--video")) {
        block.dataset.richMediaSelected = "true";
      }

      setActiveBlock(target);
    }

    return target;
  }

  function currentBlockTarget() {
    if (activeBlock) return activeBlock;

    const quill = quillRef.current;

    if (!quill) return null;

    const range = quill.getSelection(true);
    const blocks = Array.from(quill.root.querySelectorAll<HTMLElement>(EDITOR_BLOCK_SELECTOR));
    const matchedBlock = blocks.find((block) => {
      const target = resolveBlockTarget(quill, block, editorShellRef.current);
      return range && range.index >= target.index && range.index < target.index + target.length;
    });

    if (matchedBlock) return activateBlock(matchedBlock);

    return {
      blockLeft: 72,
      columnLayout: null,
      controlLeft: 8,
      controlTop: 0,
      height: 28,
      index: range?.index ?? Math.max(0, quill.getText().length - 1),
      isImageLayout: false,
      length: Math.max(1, range?.length ?? 0),
      top: 0,
      width: 320,
    };
  }

  function focusTrailingBlankBlock() {
    const quill = quillRef.current;

    if (!quill) return null;

    const blocks = getEditorDragBlocks(quill.root);
    const lastBlock = blocks.at(-1);
    const lastIsEmptyTextBlock = Boolean(
      lastBlock?.matches("p,h1,h2,h3,h4,blockquote,li") &&
      !(lastBlock?.textContent ?? "").trim() &&
      !lastBlock?.querySelector("img,video,audio,.blog-rich-block,.blog-image-layout"),
    );

    if (lastBlock && lastIsEmptyTextBlock) {
      const target = activateBlock(lastBlock);
      if (target) {
        quill.setSelection(target.index, 0, "silent");
        quill.focus({ preventScroll: true });
      }
      return target;
    }

    const insertIndex = Math.max(0, quill.getLength() - 1);
    quill.insertText(insertIndex, "\n", "user");
    quill.setSelection(insertIndex, 0, "silent");
    quill.focus({ preventScroll: true });

    const nextBlocks = getEditorDragBlocks(quill.root);
    const nextLastBlock = nextBlocks.at(-1);
    return nextLastBlock
      ? activateBlock(nextLastBlock)
      : {
        blockLeft: 72,
        columnLayout: null,
        controlLeft: 8,
        controlTop: Math.max(0, quill.root.scrollHeight - 120),
        height: 28,
        index: insertIndex,
        isImageLayout: false,
        length: 1,
        top: Math.max(0, quill.root.scrollHeight - 120),
        width: Math.max(320, quill.root.clientWidth - 112),
      } satisfies BlockTarget;
  }

  function applyBlockFormat(quill: QuillLikeInstance, target: BlockTarget, style: TextBlockKind) {
    const headingLevel = style.match(/^heading([1-4])$/)?.[1];
    quill.formatLine(target.index, target.length, "header", headingLevel ? Number(headingLevel) : false, "user");
    quill.formatLine(
      target.index,
      target.length,
      "list",
      style === "bullet"
        ? "bullet"
        : style === "number"
          ? "ordered"
          : style === "todo"
            ? "unchecked"
            : false,
      "user",
    );
    quill.formatLine(target.index, target.length, "blockquote", style === "quote", "user");
    quill.formatLine(target.index, target.length, "code-block", style === "code", "user");
  }

  function openBlockMenu(
    target = currentBlockTarget(),
    anchor?: { bottom?: number; left: number; top: number },
  ) {
    const quill = quillRef.current;

    if (!target || !quill) return;

    const rootBounds = quill.root.getBoundingClientRect();
    const left = anchor?.left ?? rootBounds.left + 46;
    const anchorTop = anchor?.top ?? rootBounds.top + target.top;
    const anchorBottom = anchor?.bottom ?? anchorTop + target.height;
    setInlineMenu(null);
    setBlockInsertMenu(null);
    setBlockActionQuery("");
    setBlockMenuPanel("root");
    setBlockMenu({
      left: Math.max(12, Math.min(window.innerWidth - 286, left)),
      style: target.isImageLayout || activeBlockElementRef.current?.classList.contains("blog-rich-block")
        ? "rich"
        : blockStyleFromFormat(quill.getFormat(target.index, target.length)),
      target,
      top: floatingMenuTop(anchorTop, anchorBottom, 366),
    });
  }

  function openBlockInsertMenu(
    target = currentBlockTarget(),
    anchor?: { bottom?: number; left: number; top: number },
  ) {
    const quill = quillRef.current;

    if (!target || !quill) return;

    const rootBounds = quill.root.getBoundingClientRect();
    const left = anchor?.left ?? rootBounds.left + 46;
    const anchorTop = anchor?.top ?? rootBounds.top + target.top;
    const anchorBottom = anchor?.bottom ?? anchorTop + target.height;
    setInlineMenu(null);
    setBlockMenu(null);
    setBlockInsertQuery("");
    setBlockInsertMenu({
      left: Math.max(12, Math.min(window.innerWidth - 336, left)),
      target,
      top: floatingMenuTop(anchorTop, anchorBottom, 470),
    });
  }

  function applyBlock(style: TextBlockKind, target = blockMenu?.target ?? currentBlockTarget()) {
    const quill = quillRef.current;

    if (!quill || !target) return;

    quill.setSelection(target.index, Math.max(0, target.length - 1), "silent");
    applyBlockFormat(quill, target, style);
    quill.focus();
    setBlockMenu(null);
    setBlockInsertMenu(null);
    setInlineMenu(null);
    clearActiveBlock();
  }

  function addTextBlockAfter(style: TextBlockKind, target = blockMenu?.target ?? currentBlockTarget()) {
    const quill = quillRef.current;

    if (!quill || !target) return;

    const insertIndex = target.index + target.length;
    const nextTarget: BlockTarget = {
      blockLeft: target.blockLeft,
      columnLayout: null,
      controlLeft: target.controlLeft,
      controlTop: target.controlTop + target.height,
      height: 28,
      index: insertIndex,
      isImageLayout: false,
      length: 1,
      top: target.top + target.height,
      width: target.width,
    };
    quill.insertText(insertIndex, "\n", "user");
    applyBlockFormat(quill, nextTarget, style);
    quill.setSelection(insertIndex, 0, "silent");
    quill.focus();
    setBlockMenu(null);
    setBlockInsertMenu(null);
    clearActiveBlock();
  }

  function addBlockAfter(kind: BlockInsertKind, target = blockInsertMenu?.target ?? currentBlockTarget()) {
    const quill = quillRef.current;

    if (!quill || !target) return;

    if (kind === "image") {
      imageInsertRangeRef.current = { index: target.index + target.length, length: 0 };
      imageInputRef.current?.click();
      setBlockInsertMenu(null);
      return;
    }

    if (BLOCK_TRANSFORM_OPTIONS.some(([style]) => style === kind)) {
      addTextBlockAfter(kind as TextBlockKind, target);
      return;
    }

    const insertIndex = target.index + target.length;
    const plainLineTarget: BlockTarget = {
      ...target,
      columnLayout: null,
      index: insertIndex,
      isImageLayout: false,
      length: 1,
    };

    if (kind === "divider") {
      quill.insertText(insertIndex, "---\n", "user");
      applyBlockFormat(quill, { ...plainLineTarget, length: 4 }, "text");
      quill.setSelection(insertIndex + 4, 0, "silent");
    } else if (kind === "table") {
      const table = defaultRichBlock("table");
      quill.clipboard.dangerouslyPasteHTML(
        insertIndex,
        `${richTableEditorHtml(table)}<p><br></p>`,
        "user",
      );
      hydrateBlogColumnTables(quill.root);
      window.requestAnimationFrame(() => {
        const tables = Array.from(quill.root.querySelectorAll<HTMLElement>(".blog-native-table"));
        const element = tables.find((candidate) => {
          const candidateTarget = resolveBlockTarget(quill, candidate, editorShellRef.current);
          return candidateTarget.index === insertIndex;
        }) ?? tables.at(-1);
        const nextTarget = element
          ? resolveBlockTarget(quill, element, editorShellRef.current)
          : null;

        quill.setSelection(nextTarget ? nextTarget.index + nextTarget.length : insertIndex, 0, "silent");
        quill.focus({ preventScroll: true });
      });
    } else {
      quill.insertEmbed(insertIndex, "blog-rich-block", defaultRichBlock(kind as BlogRichBlockKind), "user");
      quill.insertText(insertIndex + 1, "\n", "user");
      applyBlockFormat(quill, { ...plainLineTarget, index: insertIndex + 1 }, "text");
      quill.setSelection(insertIndex + 2, 0, "silent");

      if (["audio", "file", "video"].includes(kind)) {
        window.requestAnimationFrame(() => {
          const elements = Array.from(quill.root.querySelectorAll<HTMLElement>(`.blog-rich-block[data-kind='${kind}']`));
          const element = elements.find((candidate) => resolveBlockTarget(quill, candidate, editorShellRef.current)?.index === insertIndex)
            ?? elements.at(-1);
          if (element) openMediaMenu(element);
        });
      }
    }

    quill.focus({ preventScroll: true });
    setBlockMenu(null);
    setBlockInsertMenu(null);
    clearActiveBlock();
  }

  function addNativeTableSection(kind: "column" | "row") {
    const quill = quillRef.current;
    const table = activeBlockElementRef.current;

    if (!quill || !table?.classList.contains("blog-native-table")) return;

    const cell = kind === "column"
      ? table.querySelector<HTMLTableCellElement>("tr:first-child > td:last-child")
      : table.querySelector<HTMLTableCellElement>("tr:last-child > td:first-child");
    const blot = cell ? quill.scroll.find(cell) : null;

    if (!blot) return;

    const history = quill.getModule("history") as QuillHistory;
    const tableModule = quill.getModule("table") as QuillTableModule;
    history.cutoff();
    quill.setSelection(quill.getIndex(blot), 0, "silent");

    if (kind === "column") {
      tableModule.insertColumnRight();
    } else {
      tableModule.insertRowBelow();
    }

    history.cutoff();
    hydrateBlogColumnTables(quill.root);
    window.requestAnimationFrame(() => activateBlock(table));
  }

  function duplicateBlock(target = blockMenu?.target ?? currentBlockTarget()) {
    const quill = quillRef.current;

    if (!quill || !target) return;

    const copiedContents = quill.getContents(target.index, target.length);
    quill.updateContents({
      ops: [{ retain: target.index + target.length }, ...(copiedContents.ops ?? [])],
    }, "user");
    quill.setSelection(target.index + target.length, 0, "silent");
    quill.focus();
    setBlockMenu(null);
    setBlockInsertMenu(null);
    clearActiveBlock();
  }

  function deleteBlock(target = blockMenu?.target ?? currentBlockTarget()) {
    const quill = quillRef.current;

    if (!quill || !target) return;

    quill.deleteText(target.index, target.length, "user");
    quill.setSelection(Math.max(0, target.index - 1), 0, "silent");
    quill.focus();
    setBlockMenu(null);
    setBlockInsertMenu(null);
    clearActiveBlock();
  }

  function moveBlock(source: BlockTarget, destination: BlockTarget, placement: "before" | "after") {
    const quill = quillRef.current;

    if (!quill || source.index === destination.index) {
      clearDropTarget();
      return;
    }

    const sourceContents = quill.getContents(source.index, source.length);
    const sourceOps = sourceContents.ops ?? [];

    if (sourceOps.length === 0) return;

    let insertIndex = destination.index + (placement === "after" ? destination.length : 0);
    quill.deleteText(source.index, source.length, "user");

    if (source.index < insertIndex) {
      insertIndex -= source.length;
    }

    quill.updateContents(
      {
        ops: [{ retain: Math.max(0, insertIndex) }, ...sourceOps],
      },
      "user",
    );
    quill.setSelection(Math.max(0, insertIndex), Math.max(0, source.length - 1), "silent");
    quill.focus();
    clearDropTarget();
    clearActiveBlock();
  }

  const syncMarkdownValue = useEffectEvent(() => {
    commitEditorValue();
  });

  function markdownFromBlock(element: HTMLElement | null) {
    if (!element || isBlogColumnLayoutElement(element)) return "";
    return htmlToMarkdown(element.outerHTML).trim();
  }

  function styleFromBlockMarkdown(markdown: string): BlockStyle {
    if (/^#{1,3}\s+/.test(markdown)) return "heading";
    if (/^[-*]\s+/.test(markdown)) return "bullet";
    if (/^\d+\.\s+/.test(markdown)) return "number";
    if (/^>\s?/.test(markdown)) return "quote";
    if (/^```/.test(markdown)) return "code";
    return "text";
  }

  function mergeBlockWithRichColumns(
    source: BlockTarget,
    sourceElement: HTMLElement | null,
    destination: BlockTarget,
    destinationElement: HTMLElement | null,
    placement: "left" | "right",
  ) {
    const quill = quillRef.current;
    const sourceColumns = readRichColumnsBlock(sourceElement);
    const destinationColumns = readRichColumnsBlock(destinationElement);

    if (!quill || (!sourceColumns && !destinationColumns)) return false;
    if (!canMergeWithRichColumns(sourceElement, destinationElement)) {
      clearDropTarget();
      return true;
    }

    const sourceValues = sourceColumns
      ? richColumnsValues(sourceColumns)
      : [markdownFromBlock(sourceElement)];
    const destinationValues = destinationColumns
      ? richColumnsValues(destinationColumns)
      : [markdownFromBlock(destinationElement)];

    if ((!sourceColumns && !sourceValues[0]) || (!destinationColumns && !destinationValues[0])) {
      clearDropTarget();
      return true;
    }

    const values = placement === "left"
      ? [...sourceValues, ...destinationValues]
      : [...destinationValues, ...sourceValues];
    const count = values.length;

    if (count < 2 || count > 4) {
      clearDropTarget();
      return true;
    }

    const nextKind = `columns-${count}` as BlogRichBlockKind;
    const keeperElement = destinationColumns ? destinationElement : sourceElement;
    const keeperTarget = destinationColumns ? destination : source;
    const removedTarget = destinationColumns ? source : destination;

    if (!keeperElement) {
      clearDropTarget();
      return true;
    }

    renderBlogRichBlockNode(keeperElement, {
      body: values.join("\n"),
      kind: nextKind,
      title: BLOG_RICH_BLOCK_LABELS[nextKind],
      url: columnWidthsMeta(Array.from({ length: count }, () => 100 / count)),
    });

    let selectionIndex = keeperTarget.index;
    quill.deleteText(removedTarget.index, removedTarget.length, "user");
    if (removedTarget.index < selectionIndex) selectionIndex -= removedTarget.length;
    quill.setSelection(Math.max(0, selectionIndex), 1, "silent");
    quill.focus({ preventScroll: true });
    commitEditorValue();
    clearDropTarget();
    clearActiveBlock();
    return true;
  }

  function mergeBlocksIntoColumns(
    source: BlockTarget,
    sourceElement: HTMLElement | null,
    destination: BlockTarget,
    destinationElement: HTMLElement | null,
    placement: "left" | "right",
  ) {
    const quill = quillRef.current;
    const sourceMarkdown = markdownFromBlock(sourceElement);
    const destinationMarkdown = markdownFromBlock(destinationElement);

    if (
      !quill ||
      !sourceMarkdown ||
      !destinationMarkdown ||
      sourceMarkdown.includes(":::official-block{") ||
      destinationMarkdown.includes(":::official-block{")
    ) {
      clearDropTarget();
      return;
    }

    const layout: BlogColumnLayout = {
      columns: placement === "left"
        ? [sourceMarkdown, destinationMarkdown]
        : [destinationMarkdown, sourceMarkdown],
      split: 50,
      styles: placement === "left"
        ? [styleFromBlockMarkdown(sourceMarkdown), styleFromBlockMarkdown(destinationMarkdown)]
        : [styleFromBlockMarkdown(destinationMarkdown), styleFromBlockMarkdown(sourceMarkdown)],
    };
    let insertIndex = destination.index;

    quill.deleteText(source.index, source.length, "user");

    if (source.index < insertIndex) insertIndex -= source.length;

    quill.deleteText(insertIndex, destination.length, "user");
    quill.clipboard.dangerouslyPasteHTML(insertIndex, columnLayoutEditorHtml(layout), "user");
    quill.setSelection(insertIndex, 0, "silent");
    quill.focus({ preventScroll: true });
    clearDropTarget();
    clearActiveBlock();
  }

  function replaceImageLayout(element: HTMLElement, nextLayout: BlogImageLayout) {
    const quill = quillRef.current;

    if (!quill) return;

    const target = resolveBlockTarget(quill, element, editorShellRef.current);
    quill.deleteText(target.index, target.length, "user");
    quill.insertEmbed(target.index, "blog-image-layout", nextLayout, "user");
    quill.setSelection(target.index, 1, "silent");
    quill.focus({ preventScroll: true });
  }

  function mergeImageLayouts(
    source: BlockTarget,
    sourceElement: HTMLElement | null,
    destination: BlockTarget,
    destinationElement: HTMLElement | null,
    placement: "left" | "right",
  ) {
    const quill = quillRef.current;
    const sourceLayout = sourceElement ? readBlogImageLayout(sourceElement) : null;
    const destinationLayout = destinationElement ? readBlogImageLayout(destinationElement) : null;

    if (!quill || !sourceLayout || !destinationLayout || sourceLayout.images.length !== 1 || destinationLayout.images.length !== 1) {
      clearDropTarget();
      return;
    }

    const mergedLayout: BlogImageLayout = {
      align: "center",
      images: placement === "left"
        ? [sourceLayout.images[0], destinationLayout.images[0]]
        : [destinationLayout.images[0], sourceLayout.images[0]],
      split: 50,
      width: 100,
    };
    let insertIndex = destination.index;

    quill.deleteText(source.index, source.length, "user");

    if (source.index < insertIndex) {
      insertIndex -= source.length;
    }

    quill.deleteText(insertIndex, destination.length, "user");
    quill.insertEmbed(insertIndex, "blog-image-layout", mergedLayout, "user");
    quill.setSelection(insertIndex, 1, "silent");
    quill.focus({ preventScroll: true });
    clearDropTarget();
    clearActiveBlock();
  }

  function separateImageLayout(element: HTMLElement) {
    const quill = quillRef.current;
    const layout = readBlogImageLayout(element);

    if (!quill || !layout || layout.images.length !== 2) return;

    const target = resolveBlockTarget(quill, element, editorShellRef.current);
    quill.deleteText(target.index, target.length, "user");
    quill.insertEmbed(target.index, "blog-image-layout", { align: "center", images: [layout.images[0]], split: 50, width: 100 }, "user");
    quill.insertText(target.index + 1, "\n", "user");
    quill.insertEmbed(target.index + 2, "blog-image-layout", { align: "center", images: [layout.images[1]], split: 50, width: 100 }, "user");
    quill.setSelection(target.index, 1, "silent");
    quill.focus({ preventScroll: true });
    clearActiveBlock();
  }

  function removeImageLayout(element: HTMLElement) {
    const quill = quillRef.current;

    if (!quill) return;

    const target = resolveBlockTarget(quill, element, editorShellRef.current);
    quill.deleteText(target.index, target.length, "user");
    quill.setSelection(Math.max(0, target.index - 1), 0, "silent");
    quill.focus({ preventScroll: true });
    clearActiveBlock();
  }

  function separateColumnLayout(element: HTMLElement) {
    const quill = quillRef.current;
    const layout = readBlogColumnLayout(element);

    if (!quill || !layout) return;

    const target = resolveBlockTarget(quill, element, editorShellRef.current);
    const html = markdownToEditorHtml(layout.columns.filter(Boolean).join("\n\n"));
    const contents = quill.clipboard.convert({ html });
    quill.deleteText(target.index, target.length, "user");
    quill.updateContents({
      ops: [{ retain: target.index }, ...(contents.ops ?? [])],
    }, "user");
    quill.setSelection(target.index, 0, "silent");
    quill.focus({ preventScroll: true });
    clearActiveBlock();
  }

  function beginColumnLayoutResize(event: PointerEvent, element: HTMLElement) {
    const layout = readBlogColumnLayout(element);

    if (!layout) return;

    event.preventDefault();
    event.stopImmediatePropagation();
    activeBlockElementRef.current?.removeAttribute("data-column-layout-selected");
    activeBlockElementRef.current = element;
    element.dataset.columnLayoutSelected = "true";
    element.classList.add("blog-column-layout--resizing");
    columnLayoutResizeRef.current = { element, layout, pointerId: event.pointerId };
    setActiveBlock(resolveBlockTarget(quillRef.current!, element, editorShellRef.current));
  }

  function updateColumnLayoutResize(event: PointerEvent) {
    const resize = columnLayoutResizeRef.current;

    if (!resize || resize.pointerId !== event.pointerId) return;

    const rect = resize.element.getBoundingClientRect();

    if (rect.width < 1) return;

    const split = clampPercentage(((event.clientX - rect.left) / rect.width) * 100, resize.layout.split, 25, 75);
    applyBlogColumnLayoutStyles(resize.element, { ...resize.layout, split });
    const shellRect = editorShellRef.current?.getBoundingClientRect();
    const handle = editorShellRef.current?.querySelector<HTMLElement>("[data-column-layout-resize]");

    if (shellRect && handle) {
      handle.style.left = `${rect.left - shellRect.left + rect.width * split / 100 - 9}px`;
    }
  }

  function finishColumnLayoutResize(event: PointerEvent) {
    const resize = columnLayoutResizeRef.current;

    if (!resize || resize.pointerId !== event.pointerId) return;

    const quill = quillRef.current;
    const rect = resize.element.getBoundingClientRect();
    const split = rect.width > 0
      ? clampPercentage(((event.clientX - rect.left) / rect.width) * 100, resize.layout.split, 25, 75)
      : resize.layout.split;
    const nextLayout = { ...resize.layout, split };

    columnLayoutResizeRef.current = null;
    resize.element.classList.remove("blog-column-layout--resizing");

    if (!quill) return;

    const target = resolveBlockTarget(quill, resize.element, editorShellRef.current);
    quill.deleteText(target.index, target.length, "user");
    quill.clipboard.dangerouslyPasteHTML(target.index, columnLayoutEditorHtml(nextLayout), "user");
    quill.setSelection(target.index, 0, "silent");
    quill.focus({ preventScroll: true });
    clearActiveBlock();
  }

  function imageLayoutFromResize(resize: ImageLayoutResize, clientX: number) {
    if (resize.parentWidth < 1) return resize.layout;

    if (resize.mode === "grid") {
      return {
        ...resize.layout,
        split: clampPercentage(
          ((clientX - resize.parentLeft) / resize.parentWidth) * 100,
          resize.layout.split,
          25,
          75,
        ),
      };
    }

    const delta = resize.side === "right"
      ? clientX - resize.startX
      : resize.startX - clientX;

    return {
      ...resize.layout,
      width: clampPercentage(
        ((resize.initialCanvasWidth + delta) / resize.parentWidth) * 100,
        resize.layout.width,
        28,
        100,
      ),
    };
  }

  function beginImageLayoutResize(
    event: PointerEvent,
    element: HTMLElement,
    mode: "grid" | "single",
    side: "left" | "right" = "right",
  ) {
    const layout = readBlogImageLayout(element);

    if (!layout || (mode === "grid") !== (layout.images.length === 2)) return;

    event.preventDefault();
    event.stopImmediatePropagation();
    activeBlockElementRef.current?.removeAttribute("data-image-layout-selected");
    activeBlockElementRef.current = element;
    element.dataset.imageLayoutSelected = "true";
    element.classList.add("blog-image-layout--resizing");
    const parentRect = element.getBoundingClientRect();
    const canvasRect = element.querySelector<HTMLElement>(".blog-image-layout__canvas")?.getBoundingClientRect();
    imageLayoutResizeRef.current = {
      element,
      frameId: null,
      initialCanvasWidth: canvasRect?.width ?? parentRect.width * layout.width / 100,
      latestX: event.clientX,
      layout,
      mode,
      parentLeft: parentRect.left,
      parentWidth: parentRect.width,
      pointerId: event.pointerId,
      side,
      startX: event.clientX,
    };
    setActiveBlock(resolveBlockTarget(quillRef.current!, element, editorShellRef.current));
  }

  function updateImageLayoutResize(event: PointerEvent) {
    const resize = imageLayoutResizeRef.current;

    if (!resize || resize.pointerId !== event.pointerId) return;

    resize.latestX = event.clientX;
    if (resize.frameId !== null) return;

    resize.frameId = window.requestAnimationFrame(() => {
      const current = imageLayoutResizeRef.current;

      if (!current || current !== resize) return;

      current.frameId = null;
      applyBlogImageLayoutStyles(current.element, imageLayoutFromResize(current, current.latestX));
    });
  }

  function finishImageLayoutResize(event: PointerEvent) {
    const resize = imageLayoutResizeRef.current;

    if (!resize || resize.pointerId !== event.pointerId) return;

    if (resize.frameId !== null) window.cancelAnimationFrame(resize.frameId);
    const nextLayout = imageLayoutFromResize(resize, event.clientX);

    imageLayoutResizeRef.current = null;
    resize.element.classList.remove("blog-image-layout--resizing");
    replaceImageLayout(resize.element, nextLayout);
  }

  const handleImageLayoutActionFromEvent = useEffectEvent((element: HTMLElement, action: string) => {
    const layout = readBlogImageLayout(element);

    if (!layout) return;

    if (action === "unstack") {
      separateImageLayout(element);
      return;
    }

    if (action === "remove") {
      removeImageLayout(element);
      return;
    }

    if (action === "toggle-align-menu") {
      element.dataset.imageAlignmentOpen = element.dataset.imageAlignmentOpen === "true" ? "false" : "true";
      element.dataset.imageMoreOpen = "false";
      renderBlogImageLayoutNode(element, layout);
      return;
    }

    if (action === "toggle-more-menu") {
      element.dataset.imageMoreOpen = element.dataset.imageMoreOpen === "true" ? "false" : "true";
      element.dataset.imageAlignmentOpen = "false";
      renderBlogImageLayoutNode(element, layout);
      return;
    }

    const alignment = action.match(/^align-(left|center|right)$/)?.[1] as ImageAlignment | undefined;
    if (alignment && layout.images.length === 1) {
      replaceImageLayout(element, { ...layout, align: alignment });
      return;
    }

    if (action === "toggle-caption" && layout.images.length === 1) {
      if (element.dataset.imageCaptionEditing === "0") {
        delete element.dataset.imageCaptionEditing;
      } else {
        element.dataset.imageCaptionEditing = "0";
      }
      renderBlogImageLayoutNode(element, layout);
      window.requestAnimationFrame(() => {
        element.querySelector<HTMLInputElement>("[data-image-caption]")?.focus();
      });
      return;
    }

    if (action === "preview" && layout.images[0]) {
      setImagePreview(layout.images[0]);
      return;
    }

    if (action === "download" && layout.images[0]) {
      const anchor = document.createElement("a");
      anchor.href = layout.images[0].src;
      anchor.download = layout.images[0].alt || "image";
      anchor.target = "_blank";
      anchor.rel = "noopener noreferrer";
      anchor.click();
      return;
    }

    const width = action.match(/^width-(50|75|100)$/)?.[1];

    if (width && layout?.images.length === 1) {
      replaceImageLayout(element, { ...layout, width: Number(width) });
    }
  });

  const handleContextBlockMenu = useEffectEvent((quill: QuillLikeInstance, event: MouseEvent) => {
    const block = findEditorBlock(quill.root, event.target);
    const lastBlock = getEditorDragBlocks(quill.root).at(-1);
    const target = block
      ? activateBlock(block)
      : lastBlock && event.clientY >= lastBlock.getBoundingClientRect().bottom
        ? focusTrailingBlankBlock()
        : currentBlockTarget();

    if (target) openBlockMenu(target, { bottom: event.clientY, left: event.clientX, top: event.clientY });
  });

  useEffect(() => {
    onChangeRef.current = onChange;
  }, [onChange]);

  useEffect(() => {
    draftTokenRef.current = draftToken;
  }, [draftToken]);

  async function uploadImageFiles(files: File[], insertionRange: QuillRange | null = null) {
    const quill = quillRef.current;

    if (!quill || files.length === 0) return;

    const formData = new FormData();
    formData.append("draftToken", draftTokenRef.current);

    for (const file of files) {
      formData.append("files", file);
    }

    setImageUploadMessage(null);
    onImageUploadStatusChange(true);

    try {
      const response = await fetch("/api/kavenix/blog/uploads", {
        method: "POST",
        body: formData,
      });
      const result = (await response.json().catch(() => null)) as {
        message?: string;
        ok?: boolean;
        uploads?: Array<{ publicUrl?: string }>;
      } | null;
      const uploadedUrls = result?.uploads
        ?.map((upload) => typeof upload.publicUrl === "string" ? safeImageUrl(upload.publicUrl) : "")
        .filter(Boolean) ?? [];

      if (!response.ok || !result?.ok || uploadedUrls.length === 0) {
        throw new Error(result?.message || "이미지를 업로드하지 못했습니다.");
      }

      const range = insertionRange ?? quill.getSelection(true) ?? { index: Math.max(0, quill.getText().length - 1), length: 0 };
      quill.deleteText(range.index, range.length, "user");

      let insertIndex = range.index;

      for (const publicUrl of uploadedUrls) {
        quill.insertEmbed(
          insertIndex,
          "blog-image-layout",
          { align: "center", images: [{ alt: "", src: publicUrl }], split: 50, width: 100 },
          "user",
        );
        quill.insertText(insertIndex + 1, "\n", "user");
        insertIndex += 2;
      }

      quill.setSelection(insertIndex, 0, "silent");
      quill.focus();
    } catch (error) {
      setImageUploadMessage(error instanceof Error ? error.message : "이미지를 업로드하지 못했습니다.");
    } finally {
      onImageUploadStatusChange(false);
    }
  }

  async function uploadMediaFile(file: File) {
    if (!mediaBlockRef.current) return;

    const formData = new FormData();
    formData.append("draftToken", draftTokenRef.current);
    formData.append("kind", mediaKind);
    formData.append("files", file);
    setMediaUploading(true);
    setMediaMenuError(null);
    onImageUploadStatusChange(true);

    try {
      const response = await fetch("/api/kavenix/blog/uploads", {
        method: "POST",
        body: formData,
      });
      const result = (await response.json().catch(() => null)) as {
        message?: string;
        ok?: boolean;
        uploads?: Array<{ filename?: string; publicUrl?: string }>;
      } | null;
      const upload = result?.uploads?.[0];
      const publicUrl = typeof upload?.publicUrl === "string" ? safeImageUrl(upload.publicUrl) : "";

      if (!response.ok || !result?.ok || !publicUrl) {
        throw new Error(result?.message || `${BLOG_RICH_BLOCK_LABELS[mediaKind]} 파일을 업로드하지 못했습니다.`);
      }

      updateMediaBlock(publicUrl, upload?.filename || file.name);
    } catch (error) {
      setMediaMenuError(error instanceof Error ? error.message : `${BLOG_RICH_BLOCK_LABELS[mediaKind]} 파일을 업로드하지 못했습니다.`);
    } finally {
      setMediaUploading(false);
      onImageUploadStatusChange(false);
    }
  }

  const uploadImageFilesFromEditorEvent = useEffectEvent((files: File[], insertionRange: QuillRange | null) => {
    void uploadImageFiles(files, insertionRange);
  });

  function beginBlockMarqueeFromEvent(event: PointerEvent) {
    const quill = quillRef.current;
    const pointerTarget = event.target;
    const editorSurface = quill?.root.parentElement;
    const isBlankEditorSurface =
      pointerTarget === quill?.root ||
      pointerTarget === editorSurface ||
      pointerTarget === editorShellRef.current;

    if (
      !quill ||
      event.button !== 0 ||
      !isBlankEditorSurface ||
      event.ctrlKey ||
      event.metaKey
    ) {
      return;
    }

    event.preventDefault();
    clearBlockSelection();
    clearActiveBlock();
    setInlineMenu(null);
    setBlockMenu(null);
    setBlockInsertMenu(null);
    closeMediaMenu();
    blockMarqueeRef.current = {
      currentX: event.clientX,
      currentY: event.clientY,
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      started: false,
    };
  }

  useEffect(() => {
    const quill = quillRef.current;

    if (!quill || lastValueRef.current === value) return;

    applyingValueRef.current = true;
    quill.clipboard.dangerouslyPasteHTML(markdownToEditorHtml(value));
    hydrateBlogColumnTables(quill.root);
    lastValueRef.current = value;
    applyingValueRef.current = false;
  }, [value]);

  useEffect(() => {
    let disposed = false;
    let quill: QuillLikeInstance | null = null;
    let handleTextChange: (() => void) | null = null;
    let handleSelectionChange: ((...args: unknown[]) => void) | null = null;
    let handleNativeSelection: (() => void) | null = null;
    let handleContextMenu: ((event: MouseEvent) => void) | null = null;
    let handlePaste: ((event: ClipboardEvent) => void) | null = null;
    let handleDragOver: ((event: DragEvent) => void) | null = null;
    let handleDrop: ((event: DragEvent) => void) | null = null;
    let handleImageLayoutPointerDown: ((event: PointerEvent) => void) | null = null;
    let handleImageLayoutAction: ((event: MouseEvent) => void) | null = null;
    let handleImageLayoutInput: ((event: Event) => void) | null = null;
    let handleRichMediaAction: ((event: MouseEvent) => void) | null = null;
    let handleRichBlockAction: ((event: MouseEvent) => void) | null = null;
    let handleRichBlockCustomAction: ((event: Event) => void) | null = null;
    let handleRichBlockPointerDown: ((event: PointerEvent) => void) | null = null;
    let handleRichBlockInput: ((event: Event) => void) | null = null;

    const bootEditor = async () => {
      if (!editorRootRef.current) return;

      const { default: ImportedQuill } = await import("quill");

      if (disposed || !editorRootRef.current) return;

      const Quill = ImportedQuill as unknown as QuillNamespace;
      registerBlogImageLayout(Quill);
      registerBlogRichBlock(Quill);
      quill = new Quill(editorRootRef.current, {
        formats: ["bold", "italic", "underline", "strike", "link", "image", "blog-image-layout", "blog-rich-block", "table", "header", "list", "blockquote", "code", "code-block"],
        modules: {
          history: { delay: 500, maxStack: 100, userOnly: true },
          table: true,
          toolbar: false,
        },
        placeholder: "여기에 내용을 작성하세요. 텍스트를 선택하면 바로 서식을 바꿀 수 있습니다.",
        theme: "snow",
      });
      quill.root.setAttribute("aria-label", "블로그 본문 편집기");
      quill.root.setAttribute("spellcheck", "true");
      const shouldNormalizeNestedColumns =
        lastValueRef.current.includes(":::columns") &&
        lastValueRef.current.includes(":::official-block{type=columns-");
      applyingValueRef.current = true;
      quill.clipboard.dangerouslyPasteHTML(markdownToEditorHtml(lastValueRef.current));
      hydrateBlogColumnTables(quill.root);
      applyingValueRef.current = false;

      handleTextChange = () => {
        if (!quill || applyingValueRef.current) return;
        hydrateBlogColumnTables(quill.root);
        syncMarkdownValue();
        refreshRichTocBlocks(quill.root);
        clearActiveBlock();
      };
      const showInlineMenu = (range: QuillRange | null) => {
        if (!quill || !range || range.length === 0 || !quill.getBounds || !quill.getText(range.index, range.length).trim()) {
          setInlineMenu(null);
          return;
        }

        const bounds = quill.getBounds(range.index, range.length);
        const rootBounds = quill.root.getBoundingClientRect();
        setInlineMenu({
          left: Math.max(12, Math.min(window.innerWidth - 238, rootBounds.left + bounds.left + bounds.width / 2 - 112)),
          range,
          top: Math.max(8, rootBounds.top + bounds.top - 44),
        });
      };
      handleSelectionChange = (...args) => {
        showInlineMenu(args[0] as QuillRange | null);
      };
      handleNativeSelection = () => {
        window.requestAnimationFrame(() => {
          showInlineMenu(quill?.getSelection() ?? null);
        });
      };
      handleContextMenu = (event) => {
        if (!quill) return;
        event.preventDefault();
        handleContextBlockMenu(quill, event);
      };
      handlePaste = (event) => {
        const files = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith("image/"));

        if (files.length === 0) return;
        event.preventDefault();
        event.stopImmediatePropagation();
        uploadImageFilesFromEditorEvent(files, quill?.getSelection(true) ?? null);
      };
      handleDragOver = (event) => {
        if (Array.from(event.dataTransfer?.files ?? []).some((file) => file.type.startsWith("image/"))) {
          event.preventDefault();
          event.stopImmediatePropagation();
        }
      };
      handleDrop = (event) => {
        const files = Array.from(event.dataTransfer?.files ?? []).filter((file) => file.type.startsWith("image/"));

        if (files.length === 0) return;
        event.preventDefault();
        event.stopImmediatePropagation();
        uploadImageFilesFromEditorEvent(files, quill?.getSelection(true) ?? null);
      };
      handleImageLayoutPointerDown = (event) => {
        if (!(event.target instanceof Element)) return;

        const resizeHandle = event.target.closest<HTMLElement>("[data-image-layout-resize]");
        const element = event.target.closest<HTMLElement>(".blog-image-layout");
        const resizeKind = resizeHandle?.dataset.imageLayoutResize;

        if (element) {
          window.getSelection()?.removeAllRanges();
          setInlineMenu(null);
        }

        if (
          resizeHandle &&
          element &&
          (resizeKind === "grid" || resizeKind === "single-left" || resizeKind === "single-right")
        ) {
          beginImageLayoutResize(
            event,
            element,
            resizeKind === "grid" ? "grid" : "single",
            resizeKind === "single-left" ? "left" : "right",
          );
        }
      };
      handleImageLayoutAction = (event) => {
        if (!(event.target instanceof Element)) return;

        const action = event.target.closest<HTMLElement>("[data-image-layout-action]");
        const element = event.target.closest<HTMLElement>(".blog-image-layout");

        if (!action || !element || !action.dataset.imageLayoutAction) return;

        event.preventDefault();
        event.stopImmediatePropagation();
        handleImageLayoutActionFromEvent(element, action.dataset.imageLayoutAction);
      };
      handleImageLayoutInput = (event) => {
        if (
          !(event.target instanceof HTMLInputElement) ||
          event.target.dataset.imageCaption === undefined
        ) return;

        const element = event.target.closest<HTMLElement>(".blog-image-layout");
        const layout = element ? readBlogImageLayout(element) : null;
        const imageIndex = Number(event.target.dataset.imageCaption);

        if (!element || !layout || !Number.isInteger(imageIndex) || !layout.images[imageIndex]) return;

        const images = layout.images.map((image, index) => index === imageIndex
          ? { ...image, alt: event.target instanceof HTMLInputElement ? event.target.value : image.alt }
          : image);
        element.dataset.layout = JSON.stringify({ ...layout, images });
        syncMarkdownValue();
      };
      handleRichMediaAction = (event) => {
        if (!(event.target instanceof Element)) return;

        const action = event.target.closest<HTMLElement>("[data-rich-media-action]");
        const element = event.target.closest<HTMLElement>(".blog-rich-block");

        if (!action || !element) return;

        event.preventDefault();
        event.stopImmediatePropagation();
        openMediaMenu(element);
      };
      handleRichBlockAction = (event) => {
        if (!(event.target instanceof Element)) return;

        const clickedTableControl = event.target.closest(
          ".blog-rich-block__table-menu, [data-rich-block-action='toggle-table-row-menu'], [data-rich-block-action='toggle-table-column-menu']",
        );
        const clickedRichBlock = event.target.closest<HTMLElement>(".blog-rich-block");

        quill?.root.querySelectorAll<HTMLElement>(".blog-rich-block[data-table-menu-kind]").forEach((openElement) => {
          if (clickedTableControl && clickedRichBlock === openElement) return;

          const currentBlock = updateBlogRichBlockFromFields(openElement) ?? readBlogRichBlock(openElement);
          delete openElement.dataset.tableMenuKind;
          delete openElement.dataset.tableMenuIndex;
          if (currentBlock) renderBlogRichBlockNode(openElement, currentBlock);
        });

        const action = event.target.closest<HTMLElement>("[data-rich-block-action]");
        const element = event.target.closest<HTMLElement>(".blog-rich-block");

        if (!action || !element || !action.dataset.richBlockAction) return;
        if (action.dataset.richBlockAction.startsWith("video-")) return;

        event.preventDefault();
        event.stopImmediatePropagation();
        handleRichBlockActionFromEvent(
          element,
          action.dataset.richBlockAction,
          action.dataset.value ?? action.dataset.index,
        );
      };
      handleRichBlockCustomAction = (event) => {
        if (!(event instanceof CustomEvent) || !(event.target instanceof Element)) return;

        const element = event.target.closest<HTMLElement>(".blog-rich-block");
        const action = typeof event.detail?.action === "string" ? event.detail.action : "";

        if (!element || !action.startsWith("video-")) return;

        event.preventDefault();
        event.stopImmediatePropagation();
        handleRichBlockActionFromEvent(element, action);
      };
      handleRichBlockPointerDown = (event) => {
        if (!(event.target instanceof Element)) return;

        const videoResize = event.target.closest<HTMLElement>("[data-rich-video-resize]");
        const columnResize = event.target.closest<HTMLElement>("[data-rich-column-resize]");
        const resize = event.target.closest<HTMLElement>("[data-rich-table-resize]");
        const element = event.target.closest<HTMLElement>(".blog-rich-block");

        if (videoResize && element && (videoResize.dataset.richVideoResize === "left" || videoResize.dataset.richVideoResize === "right")) {
          beginVideoResizeFromEvent(event, element, videoResize.dataset.richVideoResize);
          return;
        }

        if (columnResize && element) {
          beginRichColumnResize(event, element, Number(columnResize.dataset.richColumnResize));
          return;
        }

        if (!resize || !element) return;

        beginRichTableResize(event, element, Number(resize.dataset.richTableResize));
      };
      handleRichBlockInput = (event) => {
        if (!(event.target instanceof Element)) return;

        const element = event.target.closest<HTMLElement>(".blog-rich-block");

        if (event.target instanceof HTMLInputElement && event.target.dataset.richDatabaseQuery && element) {
          const query = event.target.value.trim().toLocaleLowerCase("ko-KR");
          element.querySelectorAll<HTMLElement>(".blog-rich-block__database-item").forEach((item) => {
            item.hidden = Boolean(query) && !(item.textContent ?? "").toLocaleLowerCase("ko-KR").includes(query);
          });
          return;
        }

        if (!element || !updateBlogRichBlockFromFields(element)) return;

        syncMarkdownValue();
      };

      quill.on("text-change", handleTextChange);
      quill.on("selection-change", handleSelectionChange);
      quill.root.addEventListener("contextmenu", handleContextMenu);
      quill.root.addEventListener("keyup", handleNativeSelection);
      quill.root.addEventListener("mouseup", handleNativeSelection);
      quill.root.addEventListener("paste", handlePaste, true);
      quill.root.addEventListener("dragover", handleDragOver, true);
      quill.root.addEventListener("drop", handleDrop, true);
      quill.root.addEventListener("pointerdown", handleImageLayoutPointerDown, true);
      quill.root.addEventListener("pointerdown", handleRichBlockPointerDown, true);
      quill.root.addEventListener("click", handleImageLayoutAction, true);
      quill.root.addEventListener("click", handleRichMediaAction, true);
      quill.root.addEventListener("click", handleRichBlockAction, true);
      quill.root.addEventListener("blog-rich-block-action", handleRichBlockCustomAction);
      quill.root.addEventListener("input", handleImageLayoutInput, true);
      quill.root.addEventListener("input", handleRichBlockInput, true);
      quillRef.current = quill;
      setEditorReady(true);

      if (shouldNormalizeNestedColumns) {
        window.requestAnimationFrame(commitEditorValue);
      }
    };

    void bootEditor().catch(() => {
      if (!disposed) setEditorReady(false);
    });

    return () => {
      disposed = true;

      if (quill && handleTextChange) quill.off("text-change", handleTextChange);
      if (quill && handleSelectionChange) quill.off("selection-change", handleSelectionChange);
      if (quill && handleContextMenu) quill.root.removeEventListener("contextmenu", handleContextMenu);
      if (quill && handleNativeSelection) quill.root.removeEventListener("keyup", handleNativeSelection);
      if (quill && handleNativeSelection) quill.root.removeEventListener("mouseup", handleNativeSelection);
      if (quill && handlePaste) quill.root.removeEventListener("paste", handlePaste, true);
      if (quill && handleDragOver) quill.root.removeEventListener("dragover", handleDragOver, true);
      if (quill && handleDrop) quill.root.removeEventListener("drop", handleDrop, true);
      if (quill && handleImageLayoutPointerDown) quill.root.removeEventListener("pointerdown", handleImageLayoutPointerDown, true);
      if (quill && handleRichBlockPointerDown) quill.root.removeEventListener("pointerdown", handleRichBlockPointerDown, true);
      if (quill && handleImageLayoutAction) quill.root.removeEventListener("click", handleImageLayoutAction, true);
      if (quill && handleRichMediaAction) quill.root.removeEventListener("click", handleRichMediaAction, true);
      if (quill && handleRichBlockAction) quill.root.removeEventListener("click", handleRichBlockAction, true);
      if (quill && handleRichBlockCustomAction) quill.root.removeEventListener("blog-rich-block-action", handleRichBlockCustomAction);
      if (quill && handleImageLayoutInput) quill.root.removeEventListener("input", handleImageLayoutInput, true);
      if (quill && handleRichBlockInput) quill.root.removeEventListener("input", handleRichBlockInput, true);
      clearDropTarget();
      quillRef.current = null;
    };
  }, []);

  useEffect(() => {
    const dismiss = (event?: Event) => {
      const target = event?.target;

      if (
        target instanceof Element &&
        target.closest(
          ".blog-editor-inline-menu, .blog-editor-block-menu, .blog-editor-block-insert-menu, .blog-editor-block-controls, .blog-editor-media-menu, .blog-image-layout__toolbar, .blog-image-layout__popover, .blog-rich-block__button-editor, .blog-rich-block__database-panel",
        )
      ) {
        return;
      }

      quillRef.current?.root.querySelectorAll<HTMLElement>(".blog-image-layout").forEach((element) => {
        if (element.dataset.imageAlignmentOpen !== "true" && element.dataset.imageMoreOpen !== "true") return;
        element.dataset.imageAlignmentOpen = "false";
        element.dataset.imageMoreOpen = "false";
        const layout = readBlogImageLayout(element);
        if (layout) renderBlogImageLayoutNode(element, layout);
      });
      quillRef.current?.root.querySelectorAll<HTMLElement>(".blog-rich-block").forEach((element) => {
        const block = readBlogRichBlock(element);
        const hasOpenPopover =
          element.dataset.buttonConfigOpen === "true" ||
          element.dataset.databaseFilterOpen === "true" ||
          element.dataset.databaseSettingsOpen === "true";

        if (!block || !hasOpenPopover) return;

        element.dataset.buttonConfigOpen = "false";
        element.dataset.databaseFilterOpen = "false";
        element.dataset.databaseSettingsOpen = "false";
        renderBlogRichBlockNode(element, block);
      });
      setInlineMenu(null);
      setBlockMenu(null);
      setBlockInsertMenu(null);
      closeMediaMenu();
    };

    window.addEventListener("scroll", dismiss, true);
    window.addEventListener("resize", dismiss);
    document.addEventListener("mousedown", dismiss);

    return () => {
      window.removeEventListener("scroll", dismiss, true);
      window.removeEventListener("resize", dismiss);
      document.removeEventListener("mousedown", dismiss);
    };
  }, []);

  function selectedRange() {
    return quillRef.current?.getSelection(true) ?? { index: 0, length: 0 };
  }

  function handleImageInputChange(event: React.ChangeEvent<HTMLInputElement>) {
    const files = Array.from(event.target.files ?? []);
    event.target.value = "";
    void uploadImageFiles(files, imageInsertRangeRef.current);
  }

  function handleMediaInputChange(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (file) void uploadMediaFile(file);
  }

  function submitMediaLink() {
    const publicUrl = safeImageUrl(mediaLink);

    if (!publicUrl) {
      setMediaMenuError("http 또는 https로 시작하는 주소를 입력해 주세요.");
      return;
    }

    updateMediaBlock(publicUrl, mediaTitleFromUrl(publicUrl));
  }

  function applyInline(name: "bold" | "italic" | "link" | "strike" | "underline") {
    const quill = quillRef.current;
    if (!quill) return;

    const range = inlineMenu?.range ?? selectedRange();
    quill.setSelection(range.index, range.length, "silent");

    if (name === "link") {
      const href = window.prompt("연결할 주소를 입력하세요.", "https://");
      const safeHref = href ? safeLink(href) : "";
      if (!safeHref) return;
      quill.format("link", safeHref, "user");
    } else {
      const format = quill.getFormat(range.index, Math.max(1, range.length));
      quill.format(name, !format[name], "user");
    }

    quill.focus();
    setInlineMenu(null);
  }

  function handleEditorMouseMove(event: React.MouseEvent<HTMLDivElement>) {
    const quill = quillRef.current;

    if (!quill) return;
    if (event.target instanceof Element && event.target.closest(".blog-editor-block-controls, .blog-editor-block-menu, .blog-editor-block-insert-menu, .blog-editor-inline-menu")) return;

    const block = findEditorBlock(quill.root, event.target);
    if (block) activateBlock(block);
  }

  function handleEditorPointerDown(event: React.PointerEvent<HTMLDivElement>) {
    const quill = quillRef.current;

    if (!quill) return;
    const block = findEditorBlock(quill.root, event.target);
    if (block) {
      if (!selectedBlockElementsRef.current.includes(block)) clearBlockSelection();
      activateBlock(block);
      return;
    }

    beginBlockMarqueeFromEvent(event.nativeEvent);
  }

  function updateBlockMarquee(event: PointerEvent) {
    const quill = quillRef.current;
    const marquee = blockMarqueeRef.current;

    if (!quill || !marquee || marquee.pointerId !== event.pointerId) return;

    marquee.currentX = event.clientX;
    marquee.currentY = event.clientY;

    if (!marquee.started) {
      if (Math.hypot(event.clientX - marquee.startX, event.clientY - marquee.startY) < 5) return;
      marquee.started = true;
    }

    const left = Math.min(marquee.startX, event.clientX);
    const right = Math.max(marquee.startX, event.clientX);
    const top = Math.min(marquee.startY, event.clientY);
    const bottom = Math.max(marquee.startY, event.clientY);
    const rect: BlockSelectionRect = {
      height: Math.max(1, bottom - top),
      left,
      top,
      width: Math.max(1, right - left),
    };
    const selected = getEditorDragBlocks(quill.root).filter((element) => {
      const blockRect = element.getBoundingClientRect();
      return (
        blockRect.bottom >= top &&
        blockRect.top <= bottom &&
        blockRect.right >= left &&
        blockRect.left - 72 <= right
      );
    });

    selectedBlockElementsRef.current.forEach((element) => {
      if (!selected.includes(element)) element.removeAttribute("data-block-selected");
    });
    selected.forEach((element) => element.dataset.blockSelected = "true");
    selectedBlockElementsRef.current = selected;
    setSelectedBlockCount(selected.length);
    setBlockSelectionRect(rect);

    if (event.clientY > window.innerHeight - 48) {
      window.scrollBy({ top: 18 });
    } else if (event.clientY < 48) {
      window.scrollBy({ top: -18 });
    }
  }

  function finishBlockMarquee(event: PointerEvent, cancelled = false) {
    const quill = quillRef.current;
    const marquee = blockMarqueeRef.current;

    if (!quill || !marquee || marquee.pointerId !== event.pointerId) return;

    blockMarqueeRef.current = null;
    setBlockSelectionRect(null);

    if (cancelled) {
      clearBlockSelection();
      return;
    }

    if (marquee.started) {
      if (selectedBlockElementsRef.current.length === 0) clearBlockSelection();
      window.getSelection()?.removeAllRanges();
      return;
    }

    const lastBlock = getEditorDragBlocks(quill.root).at(-1);
    if (!lastBlock || event.clientY >= lastBlock.getBoundingClientRect().bottom - 2) {
      focusTrailingBlankBlock();
    }
  }

  function beginBlockDrag(
    event: React.MouseEvent<HTMLButtonElement> | React.PointerEvent<HTMLButtonElement>,
    target: BlockTarget,
  ) {
    if (blockPointerDragRef.current) return;

    event.preventDefault();
    event.stopPropagation();
    blockPointerDragRef.current = {
      control: event.currentTarget,
      pointerId: "pointerId" in event ? event.pointerId : null,
      source: target,
      sourceElement: activeBlockElementRef.current,
      startX: event.clientX,
      startY: event.clientY,
      started: false,
      target: null,
      targetElement: null,
      placement: null,
    };
    setInlineMenu(null);
    setBlockMenu(null);
    setBlockInsertMenu(null);
  }

  function matchesBlockDragPointer(drag: BlockPointerDrag, event: MouseEvent | PointerEvent) {
    return !("pointerId" in event) || drag.pointerId === null || event.pointerId === drag.pointerId;
  }

  function updateBlockPointerDrag(event: MouseEvent | PointerEvent) {
    const quill = quillRef.current;
    const drag = blockPointerDragRef.current;

    if (!quill || !drag || !matchesBlockDragPointer(drag, event)) return;

    if (!drag.started) {
      const moved = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
      if (moved < 6) return;
      drag.started = true;
      drag.sourceElement?.classList.add("blog-editor-dragging-block");
    }

    const pointedElement = document.elementFromPoint(event.clientX, event.clientY);
    const pointedBlock = findEditorBlock(quill.root, pointedElement);
    const blockRect = pointedBlock?.getBoundingClientRect() ?? null;
    const sourceLayout = drag.sourceElement ? readBlogImageLayout(drag.sourceElement) : null;
    const targetLayout = pointedBlock && isBlogImageLayoutElement(pointedBlock)
      ? readBlogImageLayout(pointedBlock)
      : null;
    const canMergeRichColumns = canMergeWithRichColumns(drag.sourceElement, pointedBlock);
    const canCreatePlainColumns =
      drag.sourceElement !== null &&
      pointedBlock !== null &&
      !isBlogColumnLayoutElement(drag.sourceElement) &&
      !isBlogColumnLayoutElement(pointedBlock) &&
      !drag.sourceElement.classList.contains("blog-rich-block") &&
      !pointedBlock.classList.contains("blog-rich-block");
    const canCreateColumns = canMergeRichColumns || canCreatePlainColumns;
    const isInsidePointedBlock = Boolean(
      blockRect &&
      event.clientX >= blockRect.left &&
      event.clientX <= blockRect.right &&
      event.clientY >= blockRect.top + Math.min(14, blockRect.height * 0.2) &&
      event.clientY <= blockRect.bottom - Math.min(14, blockRect.height * 0.2),
    );
    const horizontalRatio =
      blockRect && blockRect.width > 0
        ? (event.clientX - blockRect.left) / blockRect.width
        : 0.5;
    const horizontalPlacement = canCreateColumns && isInsidePointedBlock && horizontalRatio <= 0.28
      ? "left"
      : canCreateColumns && isInsidePointedBlock && horizontalRatio >= 0.72
        ? "right"
        : null;
    const imagePlacement =
      canCreateColumns &&
      isInsidePointedBlock &&
      blockRect &&
      sourceLayout?.images.length === 1 &&
      targetLayout?.images.length === 1
        ? event.clientX < blockRect.left + blockRect.width / 2
          ? "left"
          : "right"
        : null;
    const verticalSlot = horizontalPlacement || imagePlacement
      ? null
      : resolveVerticalDropSlot(quill, drag.sourceElement, event.clientY);
    const block = horizontalPlacement || imagePlacement
      ? pointedBlock
      : verticalSlot?.block ?? null;
    const placement = horizontalPlacement ?? imagePlacement ?? verticalSlot?.placement ?? null;
    const target = horizontalPlacement || imagePlacement
      ? block
        ? resolveBlockTarget(quill, block, editorShellRef.current)
        : null
      : verticalSlot?.target ?? null;

    if (
      !block ||
      !placement ||
      !target ||
      target.index === drag.source.index
    ) {
      drag.target = null;
      drag.targetElement = null;
      drag.placement = null;
      clearDropTarget();
      return;
    }

    const targetChanged = drag.targetElement !== block || drag.placement !== placement;

    if (!targetChanged) return;

    drag.target = target;
    drag.targetElement = block;
    drag.placement = placement;

    if (dropBlockElementRef.current !== block) {
      clearDropTarget();
      dropBlockElementRef.current = block;
    }

    prepareDropTargetElement(block);
    block.classList.add(
      placement === "before"
        ? "blog-editor-drop-before"
        : placement === "after"
          ? "blog-editor-drop-after"
          : placement === "left"
            ? "blog-editor-drop-left"
            : "blog-editor-drop-right",
    );
    showDropIndicator(block, placement);
  }

  function finishBlockPointerDrag(event: MouseEvent | PointerEvent) {
    const drag = blockPointerDragRef.current;

    if (!drag || !matchesBlockDragPointer(drag, event)) return;

    blockPointerDragRef.current = null;
    drag.sourceElement?.classList.remove("blog-editor-dragging-block");

    if (drag.started && drag.target && drag.placement) {
      skipNextGripClickRef.current = true;
      window.setTimeout(() => {
        skipNextGripClickRef.current = false;
      }, 0);
      if (drag.placement === "left" || drag.placement === "right") {
        const sourceLayout = drag.sourceElement ? readBlogImageLayout(drag.sourceElement) : null;
        const targetLayout = drag.targetElement ? readBlogImageLayout(drag.targetElement) : null;

        if (
          mergeBlockWithRichColumns(
            drag.source,
            drag.sourceElement,
            drag.target,
            drag.targetElement,
            drag.placement,
          )
        ) {
          return;
        }

        if (sourceLayout?.images.length === 1 && targetLayout?.images.length === 1) {
          mergeImageLayouts(drag.source, drag.sourceElement, drag.target, drag.targetElement, drag.placement);
        } else {
          mergeBlocksIntoColumns(drag.source, drag.sourceElement, drag.target, drag.targetElement, drag.placement);
        }
      } else {
        moveBlock(drag.source, drag.target, drag.placement);
      }
      return;
    }

    clearDropTarget();
  }

  const handleBlockPointerMove = useEffectEvent((event: PointerEvent) => {
    updateBlockPointerDrag(event);
    updateBlockMarquee(event);
    updateVideoResize(event);
  });

  const handleBlockPointerUp = useEffectEvent((event: PointerEvent) => {
    finishBlockPointerDrag(event);
    finishBlockMarquee(event);
    finishVideoResize(event);
  });

  const handleBlockPointerCancel = useEffectEvent((event: PointerEvent) => {
    finishBlockPointerDrag(event);
    finishBlockMarquee(event, true);
    finishVideoResize(event);
  });

  const handleBlockMouseMove = useEffectEvent((event: MouseEvent) => {
    updateBlockPointerDrag(event);
  });

  const handleBlockMouseUp = useEffectEvent((event: MouseEvent) => {
    finishBlockPointerDrag(event);
  });

  const handleImageLayoutPointerMove = useEffectEvent((event: PointerEvent) => {
    updateImageLayoutResize(event);
  });

  const handleImageLayoutPointerUp = useEffectEvent((event: PointerEvent) => {
    finishImageLayoutResize(event);
  });

  const handleColumnLayoutPointerMove = useEffectEvent((event: PointerEvent) => {
    updateColumnLayoutResize(event);
  });

  const handleColumnLayoutPointerUp = useEffectEvent((event: PointerEvent) => {
    finishColumnLayoutResize(event);
  });

  useEffect(() => {
    window.addEventListener("pointermove", handleBlockPointerMove);
    window.addEventListener("pointerup", handleBlockPointerUp);
    window.addEventListener("pointercancel", handleBlockPointerCancel);
    window.addEventListener("mousemove", handleBlockMouseMove);
    window.addEventListener("mouseup", handleBlockMouseUp);
    window.addEventListener("pointermove", handleImageLayoutPointerMove);
    window.addEventListener("pointerup", handleImageLayoutPointerUp);
    window.addEventListener("pointercancel", handleImageLayoutPointerUp);
    window.addEventListener("pointermove", handleColumnLayoutPointerMove);
    window.addEventListener("pointerup", handleColumnLayoutPointerUp);
    window.addEventListener("pointercancel", handleColumnLayoutPointerUp);

    return () => {
      window.removeEventListener("pointermove", handleBlockPointerMove);
      window.removeEventListener("pointerup", handleBlockPointerUp);
      window.removeEventListener("pointercancel", handleBlockPointerCancel);
      window.removeEventListener("mousemove", handleBlockMouseMove);
      window.removeEventListener("mouseup", handleBlockMouseUp);
      window.removeEventListener("pointermove", handleImageLayoutPointerMove);
      window.removeEventListener("pointerup", handleImageLayoutPointerUp);
      window.removeEventListener("pointercancel", handleImageLayoutPointerUp);
      window.removeEventListener("pointermove", handleColumnLayoutPointerMove);
      window.removeEventListener("pointerup", handleColumnLayoutPointerUp);
      window.removeEventListener("pointercancel", handleColumnLayoutPointerUp);
      blockPointerDragRef.current?.sourceElement?.classList.remove("blog-editor-dragging-block");
      blockPointerDragRef.current = null;
      if (imageLayoutResizeRef.current?.frameId !== null && imageLayoutResizeRef.current?.frameId !== undefined) {
        window.cancelAnimationFrame(imageLayoutResizeRef.current.frameId);
      }
      imageLayoutResizeRef.current?.element.classList.remove("blog-image-layout--resizing");
      imageLayoutResizeRef.current = null;
      columnLayoutResizeRef.current?.element.classList.remove("blog-column-layout--resizing");
      columnLayoutResizeRef.current = null;
      videoResizeRef.current?.element.classList.remove("blog-rich-block--resizing");
      videoResizeRef.current = null;
      blockMarqueeRef.current = null;
      selectedBlockElementsRef.current.forEach((element) => element.removeAttribute("data-block-selected"));
      selectedBlockElementsRef.current = [];
      clearDropTarget();
    };
  }, []);

  useEffect(() => {
    if (selectedBlockCount === 0) return;

    const handleSelectedBlockKeyDown = (event: KeyboardEvent) => {
      const target = event.target;
      const isNestedField = target instanceof Element && Boolean(
        target.closest("input,textarea,[data-rich-block-field],[data-rich-video-caption]"),
      );

      if (isNestedField) return;

      if (event.key === "Escape") {
        event.preventDefault();
        clearBlockSelection();
        return;
      }

      if (event.key === "Backspace" || event.key === "Delete") {
        event.preventDefault();
        deleteSelectedBlocksFromEvent();
        return;
      }

      if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase("en-US") === "d") {
        event.preventDefault();
        duplicateSelectedBlocksFromEvent();
      }
    };

    window.addEventListener("keydown", handleSelectedBlockKeyDown, true);
    return () => window.removeEventListener("keydown", handleSelectedBlockKeyDown, true);
  }, [selectedBlockCount]);

  const activeColumnLayout = activeBlock?.columnLayout ?? null;
  const activeNativeTable = Boolean(activeBlock?.isNativeTable);
  const normalizedInsertQuery = blockInsertQuery.trim().toLocaleLowerCase("ko-KR");
  const visibleInsertSections = BLOCK_INSERT_SECTIONS
    .map((section) => ({
      ...section,
      options: normalizedInsertQuery
        ? section.options.filter((option) =>
          `${option.label} ${option.description}`.toLocaleLowerCase("ko-KR").includes(normalizedInsertQuery))
        : section.options,
    }))
    .filter((section) => section.options.length > 0);

  return (
    <div
      ref={editorShellRef}
      className="official-blog-editor relative min-h-[560px]"
      data-full-width={fullWidth ? "true" : "false"}
      onMouseLeave={clearActiveBlock}
      onMouseMove={handleEditorMouseMove}
      onPointerDown={handleEditorPointerDown}
    >
      <input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" multiple className="hidden" onChange={handleImageInputChange} />
      <input
        ref={mediaInputRef}
        type="file"
        accept={
          mediaKind === "audio"
            ? "audio/mpeg,audio/wav,audio/x-wav,audio/ogg,audio/mp4,audio/x-m4a,audio/aac,audio/flac,.mp3,.wav,.ogg,.m4a,.aac,.flac"
            : mediaKind === "video"
              ? "video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov"
              : ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.txt,application/pdf,application/zip,text/plain"
        }
        className="hidden"
        onChange={handleMediaInputChange}
      />
      {imagePreview ? (
        <div
          role="dialog"
          aria-label="이미지 크게 보기"
          aria-modal="true"
          className="blog-editor-image-preview"
          onMouseDown={() => setImagePreview(null)}
        >
          <button
            type="button"
            aria-label="이미지 크게 보기 닫기"
            onMouseDown={(event) => event.stopPropagation()}
            onClick={() => setImagePreview(null)}
          >
            닫기
          </button>
          {/* The image source is uploaded to the runtime-configured NCP CDN. */}
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={imagePreview.src}
            alt={imagePreview.alt}
            onMouseDown={(event) => event.stopPropagation()}
          />
        </div>
      ) : null}
      <div ref={editorRootRef} className={editorReady ? "" : "opacity-0"} />
      <div ref={dropIndicatorRef} className="blog-editor-drop-indicator" hidden aria-hidden="true" />
      {blockSelectionRect ? (
        <div
          aria-hidden="true"
          className="blog-editor-selection-rect"
          style={blockSelectionRect}
        />
      ) : null}
      <span className="sr-only" aria-live="polite">
        {selectedBlockCount > 0 ? `${selectedBlockCount}개 블록 선택됨` : ""}
      </span>
      {imageUploadMessage ? <p className="px-4 pb-2 text-[12px] font-medium text-[#c0445b]">{imageUploadMessage}</p> : null}

      {activeBlock && selectedBlockCount === 0 ? (
        <div className="blog-editor-block-controls blog-editor-block-controls-visible" style={{ left: activeBlock.controlLeft, top: activeBlock.controlTop + 2 }} onMouseDown={(event) => event.stopPropagation()}>
          <button type="button" onMouseDown={(event) => { event.preventDefault(); event.stopPropagation(); }} onClick={(event) => { const rect = event.currentTarget.getBoundingClientRect(); openBlockInsertMenu(activeBlock, { bottom: rect.bottom, left: rect.left, top: rect.top }); }} title="이 블록 아래에 추가" aria-label="이 블록 아래에 추가">
            <span className="text-[18px] leading-none">+</span>
          </button>
          <button type="button" onPointerDown={(event) => beginBlockDrag(event, activeBlock)} onMouseDown={(event) => beginBlockDrag(event, activeBlock)} onClick={(event) => { if (skipNextGripClickRef.current) return; const rect = event.currentTarget.getBoundingClientRect(); openBlockMenu(activeBlock, { bottom: rect.bottom, left: rect.right + 6, top: rect.top }); }} title="드래그해 순서 변경 또는 클릭해 블록 메뉴 열기" aria-label="드래그해 순서 변경 또는 클릭해 블록 메뉴 열기" className="cursor-grab active:cursor-grabbing">
            <span className="blog-editor-grip" aria-hidden="true"><i /><i /><i /><i /><i /><i /></span>
          </button>
        </div>
      ) : null}

      {activeBlock && activeColumnLayout && selectedBlockCount === 0 ? (
        <>
          <button
            type="button"
            aria-label="열 너비 조절"
            data-column-layout-resize="true"
            className="blog-editor-column-resize-control"
            style={{
              height: activeBlock.height,
              left: activeBlock.blockLeft + activeBlock.width * activeColumnLayout.split / 100 - 9,
              top: activeBlock.controlTop,
            }}
            onPointerDown={(event) => {
              const element = activeBlockElementRef.current;
              if (element) beginColumnLayoutResize(event.nativeEvent, element);
            }}
          />
          <button
            type="button"
            data-column-layout-action="unstack"
            className="blog-editor-column-unstack"
            style={{
              left: activeBlock.blockLeft + activeBlock.width - 58,
              top: Math.max(0, activeBlock.controlTop - 28),
            }}
            onMouseDown={(event) => {
              event.preventDefault();
              event.stopPropagation();
            }}
            onClick={() => {
              const element = activeBlockElementRef.current;
              if (element) separateColumnLayout(element);
            }}
          >
            열 해제
          </button>
        </>
      ) : null}

      {activeBlock && activeNativeTable && selectedBlockCount === 0 ? (
        <>
          <button
            type="button"
            aria-label="표 열 추가"
            className="blog-editor-native-table-add blog-editor-native-table-add--column"
            style={{
              height: activeBlock.height,
              left: activeBlock.blockLeft + activeBlock.width + 3,
              top: activeBlock.controlTop,
            }}
            onMouseDown={(event) => {
              event.preventDefault();
              event.stopPropagation();
            }}
            onClick={() => addNativeTableSection("column")}
          >
            <span aria-hidden="true">+</span>
          </button>
          <button
            type="button"
            aria-label="표 행 추가"
            className="blog-editor-native-table-add blog-editor-native-table-add--row"
            style={{
              left: activeBlock.blockLeft,
              top: activeBlock.controlTop + activeBlock.height + 3,
              width: activeBlock.width,
            }}
            onMouseDown={(event) => {
              event.preventDefault();
              event.stopPropagation();
            }}
            onClick={() => addNativeTableSection("row")}
          >
            <span aria-hidden="true">+</span>
          </button>
        </>
      ) : null}

      {blockInsertMenu ? (
        <div
          role="dialog"
          aria-label="블록 추가"
          className="blog-editor-block-menu blog-editor-block-insert-menu"
          style={{ left: blockInsertMenu.left, top: blockInsertMenu.top }}
          onMouseDown={(event) => event.stopPropagation()}
          onKeyDown={(event) => {
            if (event.key === "Escape") setBlockInsertMenu(null);
          }}
        >
          <div className="border-b border-[#edf1f6] p-2">
            <input
              autoFocus
              value={blockInsertQuery}
              onChange={(event) => setBlockInsertQuery(event.target.value)}
              className="h-9 w-full rounded-[7px] border border-transparent bg-[#f4f5f7] px-2.5 text-[13px] text-[#26364f] outline-none transition-colors placeholder:text-[#8c96a5] focus:border-[#b8cff8] focus:bg-white"
              placeholder="필터링 기준을 입력하세요."
              aria-label="추가할 블록 검색"
            />
          </div>
          <div className="blog-editor-block-insert-scroll">
            {visibleInsertSections.map((section) => (
              <section key={section.label} className="py-1.5">
                <p className="px-2.5 pb-1 pt-1 text-[11px] font-semibold text-[#8a94a4]">{section.label}</p>
                {section.options.map((option) => (
                  <button
                    key={`${section.label}-${option.kind}`}
                    type="button"
                    onClick={() => addBlockAfter(option.kind, blockInsertMenu.target)}
                    className="group flex w-full items-center gap-2 rounded-[7px] px-2 py-1.5 text-left transition-colors hover:bg-[#f0f1f2]"
                  >
                    <span className="inline-flex h-7 w-7 shrink-0 items-center justify-center text-[#4d5969]">
                      <BlockIcon type={option.kind} />
                    </span>
                    <span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[#303947]">{option.label}</span>
                    {option.shortcut ? <span className="shrink-0 text-[11px] text-[#a0a7b1]">{option.shortcut}</span> : null}
                  </button>
                ))}
              </section>
            ))}
            {visibleInsertSections.length === 0 ? (
              <p className="px-3 py-8 text-center text-[12px] text-[#8a96a8]">일치하는 블록이 없습니다.</p>
            ) : null}
          </div>
          <div className="flex items-center justify-between border-t border-[#edf1f6] px-3 py-2 text-[11px] text-[#8d97a5]">
            <span>메뉴 닫기</span>
            <span>esc</span>
          </div>
        </div>
      ) : null}

      {mediaMenu ? (
        <div
          role="dialog"
          aria-label={`${BLOG_RICH_BLOCK_LABELS[mediaKind]} 추가`}
          className="blog-editor-media-menu"
          style={{ left: mediaMenu.left, top: mediaMenu.top }}
          onMouseDown={(event) => event.stopPropagation()}
          onKeyDown={(event) => {
            if (event.key === "Escape") closeMediaMenu();
          }}
        >
          <div
            role="tablist"
            aria-label={`${BLOG_RICH_BLOCK_LABELS[mediaKind]} 추가 방식`}
            className="blog-editor-media-menu__tabs"
          >
            <button
              type="button"
              id="blog-media-upload-tab"
              role="tab"
              aria-selected={mediaMenuTab === "upload"}
              aria-controls="blog-media-tab-panel"
              onClick={() => {
                setMediaMenuTab("upload");
                setMediaMenuError(null);
              }}
            >
              업로드
            </button>
            <button
              type="button"
              id="blog-media-link-tab"
              role="tab"
              aria-selected={mediaMenuTab === "link"}
              aria-controls="blog-media-tab-panel"
              onClick={() => {
                setMediaMenuTab("link");
                setMediaMenuError(null);
              }}
            >
              링크
            </button>
          </div>
          <div
            id="blog-media-tab-panel"
            role="tabpanel"
            aria-labelledby={
              mediaMenuTab === "upload"
                ? "blog-media-upload-tab"
                : "blog-media-link-tab"
            }
            className="blog-editor-media-menu__content"
          >
            {mediaMenuTab === "upload" ? (
              <>
                <button type="button" disabled={mediaUploading} className="blog-editor-media-menu__upload" onClick={() => mediaInputRef.current?.click()}>
                  {mediaUploading ? "업로드 중..." : "파일을 선택하세요"}
                </button>
                <p>
                  {mediaKind === "audio"
                    ? "MP3, WAV, OGG, M4A, AAC, FLAC · 최대 50MB"
                    : mediaKind === "video"
                      ? "MP4, WebM, MOV · 최대 200MB"
                      : "PDF, Office, ZIP, TXT · 최대 50MB"}
                </p>
              </>
            ) : (
              <form onSubmit={(event) => { event.preventDefault(); submitMediaLink(); }} className="space-y-2">
                <input autoFocus type="url" value={mediaLink} onChange={(event) => setMediaLink(event.target.value)} placeholder="https://example.com/file" aria-label={`${BLOG_RICH_BLOCK_LABELS[mediaKind]} 링크`} />
                <button type="submit" className="blog-editor-media-menu__upload">{BLOG_RICH_BLOCK_LABELS[mediaKind]} 연결</button>
              </form>
            )}
            {mediaMenuError ? <p className="!text-[#c0445b]">{mediaMenuError}</p> : null}
          </div>
        </div>
      ) : null}

      {inlineMenu && !activeBlock?.isImageLayout ? (
        <div className="blog-editor-inline-menu" style={{ left: inlineMenu.left, top: inlineMenu.top }} onMouseDown={(event) => { event.preventDefault(); event.stopPropagation(); }}>
          <FormatButton label="굵게" onClick={() => applyInline("bold")}><strong className="text-[14px]">B</strong></FormatButton>
          <FormatButton label="기울임" onClick={() => applyInline("italic")}><em className="text-[14px]">I</em></FormatButton>
          <FormatButton label="밑줄" onClick={() => applyInline("underline")}><span className="text-[14px] underline">U</span></FormatButton>
          <FormatButton label="취소선" onClick={() => applyInline("strike")}><span className="text-[14px] line-through">S</span></FormatButton>
          <FormatButton label="링크" onClick={() => applyInline("link")}><span className="text-[14px]">↗</span></FormatButton>
        </div>
      ) : null}

      {blockMenu ? (
        <div
          role="dialog"
          aria-label="블록 작업"
          className="blog-editor-block-menu blog-editor-block-action-menu"
          style={{ left: blockMenu.left, top: blockMenu.top }}
          onMouseDown={(event) => event.stopPropagation()}
          onKeyDown={(event) => {
            if (event.key === "Escape") setBlockMenu(null);
          }}
        >
          <div className="border-b border-[#edf1f6] p-2">
            <input
              autoFocus
              value={blockActionQuery}
              onChange={(event) => setBlockActionQuery(event.target.value)}
              className="h-9 w-full rounded-[7px] border border-[#d7dde6] bg-white px-2.5 text-[13px] text-[#26364f] outline-none transition-colors placeholder:text-[#8c96a5] focus:border-[#8ab8ff] focus:ring-2 focus:ring-[#dceaff]"
              placeholder="작업을 검색하세요"
              aria-label="블록 작업 검색"
            />
          </div>
          {blockMenuPanel === "transform" ? (
            <div className="max-h-[330px] overflow-y-auto p-1.5">
              <button type="button" onClick={() => { setBlockMenuPanel("root"); setBlockActionQuery(""); }} className="mb-1 flex w-full items-center gap-2 rounded-[7px] px-2 py-2 text-left text-[12px] font-semibold text-[#526784] hover:bg-[#f0f1f2]">
                <span aria-hidden="true">←</span> 전환
              </button>
              {BLOCK_TRANSFORM_OPTIONS
                .filter(([, label, description]) => `${label} ${description}`.toLocaleLowerCase("ko-KR").includes(blockActionQuery.trim().toLocaleLowerCase("ko-KR")))
                .map(([style, label]) => (
                  <button key={style} type="button" onClick={() => applyBlock(style, blockMenu.target)} className={`flex w-full items-center gap-2 rounded-[7px] px-2 py-1.5 text-left text-[13px] transition-colors hover:bg-[#f0f1f2] ${blockMenu.style === style ? "bg-[#f0f1f2] font-semibold" : ""}`}>
                    <span className="inline-flex h-7 w-7 items-center justify-center text-[#4d5969]"><BlockIcon type={style} /></span>
                    <span>{label}</span>
                  </button>
                ))}
            </div>
          ) : (
            <div className="p-1.5">
              <p className="px-2 pb-1 pt-1 text-[11px] font-semibold text-[#939ca9]">텍스트</p>
              {(!blockActionQuery || "전환".includes(blockActionQuery)) && blockMenu.style !== "rich" ? (
                <button type="button" onClick={() => { setBlockMenuPanel("transform"); setBlockActionQuery(""); }} className="flex w-full items-center gap-2 rounded-[7px] px-2 py-2 text-left text-[13px] font-medium text-[#343d4a] hover:bg-[#f0f1f2]">
                  <span className="inline-flex h-6 w-6 items-center justify-center"><BlockIcon type={blockMenu.style} /></span>
                  <span className="flex-1">전환</span>
                  <span className="text-[#9aa2ad]">›</span>
                </button>
              ) : null}
              {(!blockActionQuery || "아래에 블록 추가".includes(blockActionQuery)) ? (
                <button type="button" onClick={() => openBlockInsertMenu(blockMenu.target)} className="flex w-full items-center gap-2 rounded-[7px] px-2 py-2 text-left text-[13px] font-medium text-[#343d4a] hover:bg-[#f0f1f2]"><span className="inline-flex h-6 w-6 items-center justify-center text-[17px]">+</span><span>아래에 블록 추가</span></button>
              ) : null}
              <div className="my-1 border-t border-[#edf1f6]" />
              {(!blockActionQuery || "복제".includes(blockActionQuery)) ? (
                <button type="button" onClick={() => duplicateBlock(blockMenu.target)} className="flex w-full items-center gap-2 rounded-[7px] px-2 py-2 text-left text-[13px] font-medium text-[#343d4a] hover:bg-[#f0f1f2]"><span className="inline-flex h-6 w-6 items-center justify-center">□</span><span className="flex-1">복제</span><span className="text-[11px] text-[#9aa2ad]">Ctrl+D</span></button>
              ) : null}
              {(!blockActionQuery || "옮기기 이동".includes(blockActionQuery)) ? (
                <button type="button" onClick={() => { setBlockMenu(null); setBlockActionQuery(""); }} className="flex w-full items-center gap-2 rounded-[7px] px-2 py-2 text-left text-[13px] font-medium text-[#343d4a] hover:bg-[#f0f1f2]"><span className="inline-flex h-6 w-6 items-center justify-center">↗</span><span className="flex-1">옮기기</span><span className="text-[11px] text-[#9aa2ad]">그랩 드래그</span></button>
              ) : null}
              {(!blockActionQuery || "삭제".includes(blockActionQuery)) ? (
                <button type="button" onClick={() => deleteBlock(blockMenu.target)} className="flex w-full items-center gap-2 rounded-[7px] px-2 py-2 text-left text-[13px] font-medium text-[#c2474f] hover:bg-[#fff1f2]"><span className="inline-flex h-6 w-6 items-center justify-center">♙</span><span className="flex-1">삭제</span><span className="text-[11px] text-[#c58c91]">Del</span></button>
              ) : null}
            </div>
          )}
        </div>
      ) : null}
    </div>
  );
}
