use leptos::ev;
use leptos::mount::{mount_to, mount_to_body, UnmountHandle};
use leptos::prelude::*;
use leptos_dom::helpers::window_event_listener;
use leptos_tiptap::{
    TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditor,
    TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel, TiptapHighlightAttributes,
    TiptapLinkResource, TiptapMarkName, TiptapRange, TiptapSelectionState, TiptapTextAlign,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::{
    any::Any,
    cell::Cell,
    collections::HashMap,
    fmt::Display,
};
use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
use web_sys::{
    window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement,
    MouseEvent, Node, Storage, WheelEvent,
};

const EDITOR_STAGE_SELECTOR: &str = "#editor-stage";
const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror";
const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell";
const SPIKE_STORAGE_KEY: &str = "mnote.leptos-tiptap-spike.document";
const HANDLE_MULTILINE_HEIGHT: f64 = 40.0;
const HANDLE_GUTTER_WIDTH: f64 = 92.0;
const CONTENT_COLUMN_MAX_WIDTH: f64 = 708.0;
const CONTENT_COLUMN_HORIZONTAL_PADDING: f64 = 48.0;
const HANDLE_STAGE_PADDING_LEFT: f64 = 18.0;
const HANDLE_TRIGGER_WIDTH: f64 = 32.0;
const HANDLE_TRIGGER_GAP: f64 = 6.0;
const HANDLE_MENU_GAP: f64 = 10.0;
const RUNTIME_NAME: &str = "8123-leptos-tiptap-runtime";
const RUNTIME_VERSION: &str = "1.1.0";
const PROTOCOL: &str = "mnote.leptos_tiptap.bridge.v1";
const EVENT_PREFIX: &str = "mnote:leptos-tiptap-spike";
const READY_EVENT: &str = "mnote:leptos-tiptap-spike:ready";
const CHANGE_EVENT: &str = "mnote:leptos-tiptap-spike:change";
const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state";
const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status";
const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection";
const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command";
const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height";
const STANDALONE_ROOT_ID: &str = "mnote-leptos-tiptap-standalone-root";

#[derive(Clone, Copy)]
struct ToolbarColorOption {
    id: &'static str,
    label: &'static str,
    value: &'static str,
}

#[derive(Clone, Copy)]
struct ToolbarAlignOption {
    id: &'static str,
    label: &'static str,
    alignment: TiptapTextAlign,
}

const TOOLBAR_TEXT_COLORS: [ToolbarColorOption; 10] = [
    ToolbarColorOption {
        id: "default",
        label: "默认文字",
        value: "#111827",
    },
    ToolbarColorOption {
        id: "gray",
        label: "灰色",
        value: "#6b7280",
    },
    ToolbarColorOption {
        id: "brown",
        label: "棕色",
        value: "#8b5e3c",
    },
    ToolbarColorOption {
        id: "orange",
        label: "橙色",
        value: "#c96b1f",
    },
    ToolbarColorOption {
        id: "yellow",
        label: "黄色",
        value: "#b7791f",
    },
    ToolbarColorOption {
        id: "green",
        label: "绿色",
        value: "#18794e",
    },
    ToolbarColorOption {
        id: "blue",
        label: "蓝色",
        value: "#1d4ed8",
    },
    ToolbarColorOption {
        id: "purple",
        label: "紫色",
        value: "#7c3aed",
    },
    ToolbarColorOption {
        id: "pink",
        label: "粉色",
        value: "#be185d",
    },
    ToolbarColorOption {
        id: "red",
        label: "红色",
        value: "#b91c1c",
    },
];

const TOOLBAR_HIGHLIGHT_COLORS: [ToolbarColorOption; 8] = [
    ToolbarColorOption {
        id: "yellow",
        label: "黄色背景",
        value: "#fef3c7",
    },
    ToolbarColorOption {
        id: "green",
        label: "绿色背景",
        value: "#dcfce7",
    },
    ToolbarColorOption {
        id: "blue",
        label: "蓝色背景",
        value: "#dbeafe",
    },
    ToolbarColorOption {
        id: "purple",
        label: "紫色背景",
        value: "#ede9fe",
    },
    ToolbarColorOption {
        id: "pink",
        label: "粉色背景",
        value: "#fce7f3",
    },
    ToolbarColorOption {
        id: "red",
        label: "红色背景",
        value: "#fee2e2",
    },
    ToolbarColorOption {
        id: "orange",
        label: "橙色背景",
        value: "#ffedd5",
    },
    ToolbarColorOption {
        id: "gray",
        label: "灰色背景",
        value: "#e5e7eb",
    },
];

const TOOLBAR_ALIGN_OPTIONS: [ToolbarAlignOption; 4] = [
    ToolbarAlignOption {
        id: "left",
        label: "左对齐",
        alignment: TiptapTextAlign::Left,
    },
    ToolbarAlignOption {
        id: "center",
        label: "居中",
        alignment: TiptapTextAlign::Center,
    },
    ToolbarAlignOption {
        id: "right",
        label: "右对齐",
        alignment: TiptapTextAlign::Right,
    },
    ToolbarAlignOption {
        id: "justify",
        label: "两端对齐",
        alignment: TiptapTextAlign::Justify,
    },
];

const SPIKE_STYLE: &str = r#"
:root {
  --tt-color-text: #111827;
  --tt-color-text-gray: #6b7280;
  --tt-color-text-brown: #8b5e3c;
  --tt-color-text-orange: #c96b1f;
  --tt-color-text-yellow: #b7791f;
  --tt-color-text-green: #18794e;
  --tt-color-text-blue: #1d4ed8;
  --tt-color-text-purple: #7c3aed;
  --tt-color-text-pink: #be185d;
  --tt-color-text-red: #b91c1c;
  --tt-color-highlight-yellow: #fef3c7;
  --tt-color-highlight-green: #dcfce7;
  --tt-color-highlight-blue: #dbeafe;
  --tt-color-highlight-purple: #ede9fe;
  --tt-color-highlight-pink: #fce7f3;
  --tt-color-highlight-red: #fee2e2;
  --tt-color-highlight-orange: #ffedd5;
  --tt-color-highlight-gray: #e5e7eb;
}

.app-shell {
  max-width: 1180px;
  margin: 0 auto;
  padding: 28px 20px 72px;
}

.app-shell-embedded {
  max-width: none;
  margin: 0;
  padding: 0;
}

.app-shell-embedded .hero-bar,
.app-shell-embedded .title-input,
.app-shell-embedded .editor-subcopy,
.app-shell-embedded .footer-strip,
.app-shell-embedded .debug-drawer,
.app-shell-embedded .editor-crumbs:first-child,
.app-shell-embedded .editor-crumbs:last-child {
  display: none;
}

.app-shell-embedded .editor-card {
  border: none;
  border-radius: 0;
  box-shadow: none;
  background: transparent;
  overflow: visible;
}

.app-shell-embedded .editor-topbar {
  display: none;
}

.app-shell-embedded .editor-stage {
  margin: 0;
  border: none;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
}

.hero-bar {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 18px;
  margin-bottom: 20px;
}

.hero-meta {
  display: grid;
  gap: 10px;
}

.hero-tag {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  width: fit-content;
  padding: 6px 12px;
  border-radius: 999px;
  background: rgba(15, 118, 110, 0.12);
  color: var(--accent-strong);
  font-size: 12px;
  letter-spacing: .08em;
  text-transform: uppercase;
}

.hero-title {
  margin: 0;
  font-size: 40px;
  line-height: 1.04;
}

.hero-copy {
  margin: 0;
  max-width: 680px;
  color: var(--muted);
  line-height: 1.75;
}

.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  justify-content: flex-end;
}

.hero-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 14px;
  border: 1px solid var(--line);
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.92);
  color: var(--ink);
  font-size: 13px;
  box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
}

.hero-chip strong {
  color: var(--accent-strong);
}

.editor-card {
  position: relative;
  border: 1px solid rgba(100, 116, 139, 0.18);
  border-radius: 30px;
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(254, 251, 246, 0.98) 100%);
  box-shadow: 0 28px 64px rgba(15, 23, 42, 0.08);
  overflow: hidden;
}

.editor-topbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 14px;
  padding: 18px 22px 12px;
}

.editor-crumbs {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--muted);
  font-size: 13px;
}

.editor-crumbs strong {
  color: var(--ink);
}

.title-input {
  width: 100%;
  border: none;
  outline: none;
  background: transparent;
  font-size: 48px;
  line-height: 1.05;
  font-weight: 700;
  color: #0f172a;
  padding: 8px 22px 6px;
}

.title-input::placeholder {
  color: #94a3b8;
}

.editor-subcopy {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 16px;
  align-items: center;
  padding: 0 22px 18px;
  color: var(--muted);
  font-size: 14px;
}

.hint-pill {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border-radius: 999px;
  background: rgba(15, 118, 110, 0.09);
  color: var(--accent-strong);
  font-size: 13px;
}

.editor-stage {
  position: relative;
  width: min(100%, 900px);
  margin: 0 auto 18px;
  border: 1px solid rgba(100, 116, 139, 0.18);
  border-radius: 26px;
  background: rgba(255, 255, 255, 0.92);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}

.editor-stage[data-page-wide-layout="true"] {
  width: min(100%, 1120px);
}

.block-handle-shell {
  position: absolute;
  left: 12px;
  z-index: 14;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.block-handle-shell[data-dragging="true"] {
  pointer-events: none;
  opacity: 0.72;
}

.block-handle-insert,
.block-handle-trigger {
  width: 32px;
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(15, 23, 42, 0.12);
  border-radius: 11px;
  background: rgba(255, 255, 255, 0.98);
  color: #334155;
  box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
  transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease;
}

.block-handle-insert {
  color: var(--accent-strong);
  cursor: pointer;
}

.block-handle-insert span {
  font-size: 20px;
  line-height: 1;
}

.block-handle-insert:hover,
.block-handle-trigger:hover {
  transform: translateY(-1px);
  border-color: rgba(15, 118, 110, 0.34);
  box-shadow: 0 18px 36px rgba(15, 23, 42, 0.16);
}

.block-handle-trigger:active {
  cursor: grabbing;
}

.block-handle-trigger {
  cursor: grab;
  font-size: 18px;
  letter-spacing: -0.08em;
}

.block-drag-menu {
  position: absolute;
  top: 0;
  left: calc(100% + 10px);
  min-width: 248px;
  display: grid;
  gap: 8px;
  padding: 12px;
  border: 1px solid rgba(15, 23, 42, 0.1);
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.98);
  color: var(--ink);
  box-shadow: 0 22px 48px rgba(15, 23, 42, 0.18);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
}

.block-drag-menu-label {
  display: grid;
  gap: 4px;
}

.block-drag-menu-label strong {
  font-size: 14px;
}

.block-drag-menu-label span {
  color: var(--muted);
  font-size: 12px;
}

.block-drag-menu-back {
  width: 100%;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  border: none;
  background: transparent;
  color: var(--muted);
  padding: 2px 2px 6px;
  cursor: pointer;
}

.block-drag-menu-back:hover {
  color: var(--accent-strong);
}

.block-drag-menu-actions {
  display: grid;
  gap: 6px;
}

.block-drag-menu-section {
  display: grid;
  gap: 6px;
}

.block-drag-menu-divider {
  height: 1px;
  background: rgba(148, 163, 184, 0.22);
}

.block-drag-menu-item {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 12px;
  text-align: left;
  border: 1px solid transparent;
  border-radius: 14px;
  background: transparent;
  color: inherit;
  padding: 10px 12px;
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease;
}

.block-drag-menu-item:hover {
  background: rgba(15, 118, 110, 0.08);
  border-color: rgba(15, 118, 110, 0.18);
}

.block-drop-indicator {
  position: absolute;
  left: 56px;
  right: 34px;
  height: 2px;
  border-radius: 999px;
  background: rgba(15, 118, 110, 0.78);
  box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
  z-index: 13;
}

.floating-toolbar {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(-50%, calc(-100% - 14px));
  z-index: 12;
  display: flex;
  align-items: center;
  gap: 2px;
  flex-wrap: nowrap;
  max-width: min(720px, calc(100% - 32px));
  padding: 3px;
  border: 1px solid rgba(15, 23, 42, 0.08);
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.98);
  color: #0f172a;
  box-shadow: 0 18px 40px rgba(15, 23, 42, 0.14);
  backdrop-filter: blur(16px);
}

.floating-toolbar-group {
  display: flex;
  align-items: center;
  gap: 2px;
}

.toolbar-popover-anchor {
  position: relative;
  display: inline-flex;
  align-items: center;
}

.toolbar-separator {
  width: 1px;
  align-self: stretch;
  margin: 4px 3px;
  background: rgba(148, 163, 184, 0.28);
}

.toolbar-btn {
  min-width: 34px;
  height: 34px;
  border: none;
  border-radius: 12px;
  background: transparent;
  color: inherit;
  padding: 0 10px;
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease, transform .12s ease;
  font-size: 14px;
  font-weight: 600;
}

.toolbar-btn:hover {
  background: rgba(148, 163, 184, 0.16);
}

.toolbar-btn[data-active="true"] {
  background: rgba(15, 118, 110, 0.14);
  color: var(--accent-strong);
}

.toolbar-btn[data-wide="true"] {
  min-width: 56px;
  justify-content: space-between;
}

.turn-into-panel {
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%);
  min-width: 220px;
  max-height: min(420px, calc(100vh - 32px));
  display: grid;
  gap: 6px;
  padding: 10px;
  border-radius: 18px;
  border: 1px solid rgba(15, 23, 42, 0.1);
  background: rgba(255, 255, 255, 0.98);
  color: var(--ink);
  box-shadow: 0 20px 44px rgba(15, 23, 42, 0.16);
  overflow-y: auto;
  overscroll-behavior: contain;
  z-index: 2;
}

.toolbar-popover-panel {
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%);
  min-width: 248px;
  max-height: min(440px, calc(100vh - 32px));
  display: grid;
  gap: 10px;
  padding: 12px;
  border-radius: 18px;
  border: 1px solid rgba(15, 23, 42, 0.1);
  background: rgba(255, 255, 255, 0.98);
  color: var(--ink);
  box-shadow: 0 20px 44px rgba(15, 23, 42, 0.16);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  z-index: 2;
}

.turn-into-item,
.slash-item,
.toolbar-menu-item {
  width: 100%;
  display: grid;
  gap: 4px;
  text-align: left;
  border: 1px solid transparent;
  border-radius: 14px;
  background: transparent;
  color: inherit;
  padding: 10px 12px;
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease;
}

.turn-into-item:hover,
.slash-item:hover,
.slash-item[data-active="true"],
.toolbar-menu-item:hover,
.toolbar-menu-item[data-active="true"] {
  background: rgba(15, 118, 110, 0.08);
  border-color: rgba(15, 118, 110, 0.18);
}

.turn-into-item span,
.slash-item span,
.toolbar-menu-item span {
  color: var(--muted);
  font-size: 12px;
}

.toolbar-popover-section {
  display: grid;
  gap: 8px;
}

.toolbar-popover-title {
  color: var(--muted);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.02em;
}

.toolbar-color-grid {
  display: grid;
  gap: 6px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.toolbar-color-item {
  display: flex;
  align-items: center;
  gap: 10px;
}

.toolbar-color-chip {
  width: 18px;
  height: 18px;
  flex: 0 0 18px;
  border-radius: 999px;
  border: 1px solid rgba(15, 23, 42, 0.14);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
}

.toolbar-more-list {
  display: grid;
  gap: 6px;
}

.toolbar-more-item strong {
  font-size: 14px;
}

.slash-menu {
  position: absolute;
  top: 24px;
  left: 24px;
  z-index: 11;
  width: min(360px, calc(100% - 48px));
  display: grid;
  gap: 8px;
  padding: 10px;
  border: 1px solid rgba(15, 23, 42, 0.1);
  border-radius: 22px;
  background: rgba(255, 255, 255, 0.98);
  box-shadow: 0 24px 48px rgba(15, 23, 42, 0.16);
}

.slash-list {
  display: grid;
  gap: 6px;
}

.editor-surface {
  display: block;
  width: 100%;
  min-height: 580px;
  padding: 76px 0 32px;
}

.app-shell-embedded .editor-surface {
  min-height: 640px;
  padding: 18px 0 28vh;
}

.editor-surface .ProseMirror {
  outline: none;
  min-height: 472px;
  line-height: 1.78;
  font-size: 17px;
  color: #1f2937;
}

.app-shell-embedded .editor-surface .ProseMirror {
  min-height: 600px;
  width: min(100%, 708px);
  margin: 0 auto;
  padding: 2.25rem 3rem 30vh;
  font-family: "DM Sans", "Noto Sans SC", "PingFang SC", sans-serif;
}

.app-shell-embedded .editor-surface[data-small-text="true"] .ProseMirror,
.editor-surface[data-small-text="true"] .ProseMirror {
  font-size: 15px;
  line-height: 1.66;
}

.app-shell-embedded .editor-surface[data-layout-density="compact"] .ProseMirror,
.editor-surface[data-layout-density="compact"] .ProseMirror {
  line-height: 1.54;
}

.app-shell-embedded .editor-surface[data-layout-density="spacious"] .ProseMirror,
.editor-surface[data-layout-density="spacious"] .ProseMirror {
  line-height: 1.92;
}

.editor-surface .ProseMirror h1,
.editor-surface .ProseMirror h2,
.editor-surface .ProseMirror h3 {
  line-height: 1.12;
  color: #0f172a;
  margin: 1.12em 0 0.46em;
  letter-spacing: -0.02em;
}

.editor-surface .ProseMirror h1 {
  font-size: 2.1em;
}

.editor-surface .ProseMirror h2 {
  font-size: 1.56em;
}

.editor-surface .ProseMirror h3 {
  font-size: 1.28em;
}

.editor-surface .ProseMirror ul,
.editor-surface .ProseMirror ol {
  padding-left: 1.5em;
}

.editor-surface .ProseMirror ul[data-type="taskList"] {
  list-style: none;
  padding-left: 0.25em;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li:not(:has(> p:first-child)) {
  list-style-type: none;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p,
.editor-surface .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p span {
  opacity: 0.5;
  text-decoration: line-through;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li label {
  position: relative;
  padding-top: 0.375rem;
  padding-right: 0.5rem;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li label span {
  display: block;
  width: 1em;
  height: 1em;
  border: 1px solid rgba(148, 163, 184, 0.7);
  border-radius: 0.25rem;
  position: relative;
  cursor: pointer;
  background-color: rgba(255, 255, 255, 0.96);
  transition:
    background-color 80ms ease-out,
    border-color 80ms ease-out;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li label span::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 0.75em;
  height: 0.75em;
  background-color: #ffffff;
  opacity: 0;
  -webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") center/contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") center/contain no-repeat;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"]:checked + span {
  background: var(--accent-strong);
  border-color: var(--accent-strong);
}

.editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"]:checked + span::before {
  opacity: 1;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li > div {
  flex: 1 1 0%;
  min-width: 0;
}

.editor-surface .ProseMirror ul[data-type="taskList"] li > div > p {
  margin: 0;
}

.editor-surface .ProseMirror pre {
  background: #14222b;
  color: #f8fafc;
  padding: 16px 18px;
  border-radius: 18px;
  overflow: auto;
}

.editor-surface .ProseMirror blockquote {
  margin: 1.1em 0;
  padding-left: 16px;
  border-left: 3px solid rgba(15, 118, 110, 0.5);
  color: #334155;
}

.editor-surface .ProseMirror hr {
  margin: 1.6em 0;
  border: none;
  border-top: 1px solid rgba(100, 116, 139, 0.28);
}

.editor-surface .ProseMirror p.is-editor-empty:first-child::before,
.editor-surface .ProseMirror p.is-empty::before {
  color: #94a3b8;
  content: attr(data-placeholder);
  float: left;
  height: 0;
  pointer-events: none;
}

.editor-surface .ProseMirror p.is-editor-empty,
.editor-surface .ProseMirror p.is-empty {
  min-height: 1.6em;
}

.footer-strip {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  padding: 0 22px 22px;
  color: var(--muted);
  font-size: 13px;
}

.footer-strip code {
  color: var(--accent-strong);
}

.debug-drawer {
  margin-top: 22px;
  border: 1px solid rgba(100, 116, 139, 0.18);
  border-radius: 22px;
  background: rgba(255, 255, 255, 0.85);
  overflow: hidden;
}

.debug-drawer summary {
  cursor: pointer;
  list-style: none;
  padding: 18px 20px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-weight: 600;
}

.debug-drawer summary::-webkit-details-marker {
  display: none;
}

.debug-grid {
  display: grid;
  gap: 16px;
  padding: 0 20px 20px;
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.debug-card {
  border: 1px solid rgba(100, 116, 139, 0.18);
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.88);
  padding: 16px;
}

.debug-card h3 {
  margin: 0 0 10px;
  font-size: 14px;
}

.debug-card pre {
  margin: 0;
  max-height: 260px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-word;
  color: #1e293b;
}

@media (max-width: 920px) {
  .hero-bar,
  .editor-topbar,
  .footer-strip {
    flex-direction: column;
    align-items: flex-start;
  }

  .title-input {
    font-size: 34px;
  }

  .editor-surface {
    padding: 88px 0 24px;
    min-height: 460px;
  }

  .app-shell-embedded .editor-surface {
    padding: 12px 0 24vh;
  }

  .app-shell-embedded .editor-surface .ProseMirror {
    padding: 1.5rem 1.5rem 24vh;
  }

  .debug-grid {
    grid-template-columns: 1fr;
  }

  .floating-toolbar,
  .slash-menu,
  .block-drag-menu {
    left: 16px;
    right: 16px;
    width: auto;
    transform: none;
  }

  .floating-toolbar {
    top: 12px;
  }
}

@media (max-width: 768px) {
  .editor-stage {
    width: 100%;
  }

  .block-handle-shell,
  .block-drop-indicator {
    display: none;
  }

  .app-shell-embedded .editor-surface {
    padding-left: 0;
  }
}
"#;

#[derive(Clone, Copy, PartialEq, Eq)]
enum SlashActionKind {
    Paragraph,
    Heading1,
    Heading2,
    Heading3,
    BulletList,
    OrderedList,
    Todo,
    Quote,
    CodeBlock,
    Divider,
}

#[derive(Clone, Copy)]
struct SlashAction {
    kind: SlashActionKind,
    id: &'static str,
    label: &'static str,
    description: &'static str,
}

const SLASH_ACTIONS: [SlashAction; 10] = [
    SlashAction {
        kind: SlashActionKind::Paragraph,
        id: "paragraph",
        label: "段落",
        description: "回到普通正文块",
    },
    SlashAction {
        kind: SlashActionKind::Heading1,
        id: "heading-1",
        label: "一级标题",
        description: "建立页面主层级",
    },
    SlashAction {
        kind: SlashActionKind::Heading2,
        id: "heading-2",
        label: "二级标题",
        description: "整理章节结构",
    },
    SlashAction {
        kind: SlashActionKind::Heading3,
        id: "heading-3",
        label: "三级标题",
        description: "补细粒度小节",
    },
    SlashAction {
        kind: SlashActionKind::BulletList,
        id: "bullet",
        label: "无序列表",
        description: "记录普通要点",
    },
    SlashAction {
        kind: SlashActionKind::OrderedList,
        id: "ordered",
        label: "有序列表",
        description: "记录步骤顺序",
    },
    SlashAction {
        kind: SlashActionKind::Todo,
        id: "todo",
        label: "Todo 列表",
        description: "切到真正的 taskList/taskItem",
    },
    SlashAction {
        kind: SlashActionKind::Quote,
        id: "quote",
        label: "引用块",
        description: "包住注释与摘录",
    },
    SlashAction {
        kind: SlashActionKind::CodeBlock,
        id: "code-block",
        label: "代码块",
        description: "插入带语义的代码块",
    },
    SlashAction {
        kind: SlashActionKind::Divider,
        id: "divider",
        label: "分割线",
        description: "插入 horizontal rule",
    },
];

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistedSpikeDocument {
    #[serde(default)]
    document_id: Option<String>,
    #[serde(default)]
    workspace_id: Option<String>,
    title: String,
    content: Value,
    #[serde(default)]
    html: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct PersistedDocumentIdentity {
    document_id: Option<String>,
    workspace_id: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BridgeEnvelope<T>
where
    T: Serialize,
{
    protocol: &'static str,
    runtime: &'static str,
    version: &'static str,
    source: &'static str,
    event: &'static str,
    payload: T,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BridgeSelectorsPayload {
    root: &'static str,
    stage: &'static str,
    editor: &'static str,
    toolbar: &'static str,
    slash_menu: &'static str,
    handle: &'static str,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct HostStatusPayload {
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: String,
    dirty_count: u32,
    selected_block_index: Option<usize>,
    current_block_id: Option<String>,
    editor_focused: bool,
    read_only: bool,
    slash_open: bool,
    toolbar_open: bool,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReadyPayload {
    runtime_name: &'static str,
    selectors: BridgeSelectorsPayload,
    supported_commands: Vec<&'static str>,
    supports_embedded_mode: bool,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct StatePayload {
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: String,
    dirty_count: u32,
    selected_block_index: Option<usize>,
    editor_focused: bool,
    slash_open: bool,
    toolbar_open: bool,
    read_only: bool,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SelectionPayload {
    selection: TiptapSelectionState,
    summary: String,
    editor_focused: bool,
    current_block_index: Option<usize>,
    current_block_id: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ChangeMetaPayload {
    dirty_count: u32,
    editor_focused: bool,
    slash_open: bool,
    toolbar_open: bool,
    selected_block_index: Option<usize>,
    revision: Option<i64>,
    conflict_detection_key: Option<String>,
    read_only: bool,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ChangePayload {
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: String,
    content: Value,
    meta: ChangeMetaPayload,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct HeightPayload {
    height: f64,
}

#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HostCommandEnvelope {
    protocol: Option<String>,
    runtime: Option<String>,
    version: Option<String>,
    source: Option<String>,
    event: Option<String>,
    payload: Option<HostCommandPayload>,
}

#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RuntimePageOptions {
    wide_layout: Option<bool>,
    small_text: Option<bool>,
    layout_density: Option<String>,
    show_heading_numbers: Option<bool>,
    embed_default_block_id: Option<String>,
}

#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HostCommandPayload {
    command: Option<String>,
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: Option<String>,
    content: Option<Value>,
    editable: Option<bool>,
    page_options: Option<RuntimePageOptions>,
    block_id: Option<String>,
    block_index: Option<usize>,
    text: Option<String>,
    reference_document_id: Option<String>,
    reference_block_id: Option<String>,
    current_block_id: Option<String>,
    selection: Option<Value>,
    revision: Option<i64>,
    conflict_detection_key: Option<String>,
    read_only: Option<bool>,
}

#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyHostEnvelope {
    protocol: Option<String>,
    runtime: Option<String>,
    version: Option<String>,
    source: Option<String>,
    event: Option<String>,
    payload: Option<HostDocumentPayload>,
}

#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HostDocumentPayload {
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: Option<String>,
    content: Option<Value>,
    revision: Option<i64>,
    conflict_detection_key: Option<String>,
    read_only: Option<bool>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum HostCommandKind {
    Undo,
    Redo,
    ReplaceContent,
    SetPageOptions,
    InsertInlineReference,
    InsertEmbedReference,
    RequestCurrentBlockId,
    SetEditable,
    Focus,
    Bootstrap,
}

impl HostCommandKind {
    fn from_event_name(event_name: &str) -> Option<Self> {
        match event_name {
            "undo" => Some(Self::Undo),
            "redo" => Some(Self::Redo),
            "replaceContent" | "replace-document" => Some(Self::ReplaceContent),
            "setPageOptions" | "set-page-options" => Some(Self::SetPageOptions),
            "insertInlineReference" => Some(Self::InsertInlineReference),
            "insertEmbedReference" => Some(Self::InsertEmbedReference),
            "requestCurrentBlockId" => Some(Self::RequestCurrentBlockId),
            "setEditable" | "set-editable" => Some(Self::SetEditable),
            "focus" => Some(Self::Focus),
            "bootstrap" => Some(Self::Bootstrap),
            _ => None,
        }
    }

    fn from_payload(payload: &HostCommandPayload) -> Option<Self> {
        payload
            .command
            .as_deref()
            .and_then(Self::from_event_name)
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct CurrentBlockInfo {
    index: Option<usize>,
    block_id: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_command_aliases_and_canonical_names() {
        assert_eq!(
            HostCommandKind::from_event_name("replace-document"),
            Some(HostCommandKind::ReplaceContent)
        );
        assert_eq!(
            HostCommandKind::from_event_name("replaceContent"),
            Some(HostCommandKind::ReplaceContent)
        );
        assert_eq!(
            HostCommandKind::from_event_name("requestCurrentBlockId"),
            Some(HostCommandKind::RequestCurrentBlockId)
        );
    }

    #[test]
    fn reads_command_from_payload_field() {
        let payload = HostCommandPayload {
            command: Some("setEditable".to_string()),
            document_id: None,
            workspace_id: None,
            title: None,
            content: None,
            editable: Some(true),
            page_options: None,
            block_id: None,
            block_index: None,
            text: None,
            reference_document_id: None,
            reference_block_id: None,
            current_block_id: None,
            selection: None,
            revision: None,
            conflict_detection_key: None,
            read_only: None,
        };

        assert_eq!(HostCommandKind::from_payload(&payload), Some(HostCommandKind::SetEditable));
    }

    #[test]
    fn resolves_embedded_mode_from_explicit_runtime_mode_first() {
        assert_eq!(
            resolve_runtime_delivery_mode(Some(RuntimeDeliveryMode::Embedded), false),
            RuntimeDeliveryMode::Embedded
        );
        assert_eq!(
            resolve_runtime_delivery_mode(Some(RuntimeDeliveryMode::Standalone), true),
            RuntimeDeliveryMode::Standalone
        );
    }

    #[test]
    fn resolves_embedded_mode_from_url_when_runtime_mode_absent() {
        assert_eq!(
            resolve_runtime_delivery_mode(None, true),
            RuntimeDeliveryMode::Embedded
        );
        assert_eq!(
            resolve_runtime_delivery_mode(None, false),
            RuntimeDeliveryMode::Standalone
        );
    }

    #[test]
    fn persisted_document_storage_key_isolated_per_document_identity() {
        let doc_a = persisted_document_identity(
            Some("doc-a".to_string()),
            Some("ws-1".to_string()),
        );
        let doc_b = persisted_document_identity(
            Some("doc-b".to_string()),
            Some("ws-1".to_string()),
        );
        let doc_without_workspace =
            persisted_document_identity(Some("doc-a".to_string()), None);

        assert_eq!(
            persisted_document_storage_key(&doc_a),
            "mnote.leptos-tiptap-spike.document:ws-1:doc-a"
        );
        assert_eq!(
            persisted_document_storage_key(&doc_b),
            "mnote.leptos-tiptap-spike.document:ws-1:doc-b"
        );
        assert_eq!(
            persisted_document_storage_key(&doc_without_workspace),
            "mnote.leptos-tiptap-spike.document:doc-a"
        );
        assert_ne!(
            persisted_document_storage_key(&doc_a),
            persisted_document_storage_key(&doc_b)
        );
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RuntimeDeliveryMode {
    Standalone,
    Embedded,
}

#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MountOptions {
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: Option<String>,
    content: Option<Value>,
    html: Option<String>,
    editable: Option<bool>,
    read_only: Option<bool>,
    revision: Option<i64>,
    conflict_detection_key: Option<String>,
    page_options: Option<RuntimePageOptions>,
}

struct RuntimeMountContext {
    id: u32,
    target: EventTarget,
    mode: RuntimeDeliveryMode,
}

#[derive(Clone, Debug)]
struct RuntimeMountOptions {
    options: MountOptions,
    mode: RuntimeDeliveryMode,
}

struct MountedRuntimeListener {
    target: EventTarget,
    listener: Closure<dyn FnMut(Event)>,
}

struct MountedRuntime {
    listeners: Vec<MountedRuntimeListener>,
    mount_handle: Box<dyn Any>,
}

impl Drop for MountedRuntime {
    fn drop(&mut self) {
        for listener in &self.listeners {
            let _ = listener.target.remove_event_listener_with_callback(
                COMMAND_EVENT,
                listener.listener.as_ref().unchecked_ref(),
            );
        }
    }
}

thread_local! {
    static RUNTIME_MOUNT_CONTEXT: std::cell::RefCell<Option<RuntimeMountContext>> = const { std::cell::RefCell::new(None) };
    static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell<Option<RuntimeMountOptions>> = const { std::cell::RefCell::new(None) };
    static MOUNTED_HANDLES: std::cell::RefCell<HashMap<u32, MountedRuntime>> = std::cell::RefCell::new(HashMap::new());
    static PENDING_RUNTIME_LISTENERS: std::cell::RefCell<HashMap<u32, Vec<MountedRuntimeListener>>> = std::cell::RefCell::new(HashMap::new());
    static NEXT_MOUNT_ID: Cell<u32> = const { Cell::new(1) };
}

fn next_mount_id() -> u32 {
    NEXT_MOUNT_ID.with(|cell| {
        let next = cell.get();
        cell.set(next.saturating_add(1).max(1));
        next
    })
}

fn set_runtime_mount_context(context: Option<RuntimeMountContext>) {
    RUNTIME_MOUNT_CONTEXT.with(|cell| {
        *cell.borrow_mut() = context;
    });
}

fn runtime_mount_context() -> Option<(u32, EventTarget, RuntimeDeliveryMode)> {
    RUNTIME_MOUNT_CONTEXT.with(|cell| {
        cell.borrow()
            .as_ref()
            .map(|context| (context.id, context.target.clone(), context.mode))
    })
}

fn runtime_delivery_mode() -> RuntimeDeliveryMode {
    resolve_runtime_delivery_mode(
        runtime_mount_context().map(|(_, _, mode)| mode),
        url_requests_embedded_mode(),
    )
}

fn set_runtime_mount_options(options: Option<RuntimeMountOptions>) {
    RUNTIME_MOUNT_OPTIONS.with(|cell| {
        *cell.borrow_mut() = options;
    });
}

fn runtime_mount_options() -> Option<RuntimeMountOptions> {
    RUNTIME_MOUNT_OPTIONS.with(|cell| cell.borrow().clone())
}

fn runtime_event_target() -> Option<EventTarget> {
    if let Some((_, target, _)) = runtime_mount_context() {
        return Some(target);
    }

    window()
        .and_then(|win| win.document())
        .and_then(|document| document.body())
        .map(|body| body.into())
}

fn runtime_block_id_from_index(index: usize) -> Option<String> {
    Some(format!("top-level-{index}"))
}

fn current_runtime_block_info() -> CurrentBlockInfo {
    hovered_block_from_selection()
        .and_then(|block| {
            Some(CurrentBlockInfo {
                index: Some(block.index),
                block_id: runtime_block_id_from_index(block.index),
            })
        })
        .unwrap_or(CurrentBlockInfo {
            index: None,
            block_id: None,
        })
}

fn current_runtime_block_info_from_hovered(hovered_block: Option<HoveredBlockState>) -> CurrentBlockInfo {
    hovered_block
        .map(|block| CurrentBlockInfo {
            index: Some(block.index),
            block_id: runtime_block_id_from_index(block.index),
        })
        .unwrap_or(CurrentBlockInfo {
            index: None,
            block_id: None,
        })
}

fn dispatch_runtime_event<T>(event_name: &'static str, payload: &T)
where
    T: Serialize,
{
    let envelope = BridgeEnvelope {
        protocol: PROTOCOL,
        runtime: RUNTIME_NAME,
        version: RUNTIME_VERSION,
        source: EVENT_PREFIX,
        event: event_name,
        payload,
    };
    let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else {
        return;
    };

    let Some(target) = runtime_event_target() else {
        return;
    };

    let init = CustomEventInit::new();
    init.set_detail(&detail_value);
    if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) {
        let _ = target.dispatch_event(&event);
    }
}

fn dispatch_custom_event_to_target<T>(target: &EventTarget, event_name: &'static str, payload: &T)
where
    T: Serialize,
{
    let envelope = BridgeEnvelope {
        protocol: PROTOCOL,
        runtime: RUNTIME_NAME,
        version: RUNTIME_VERSION,
        source: EVENT_PREFIX,
        event: event_name,
        payload,
    };
    let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else {
        return;
    };

    let init = CustomEventInit::new();
    init.set_detail(&detail_value);
    init.set_bubbles(true);
    if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) {
        let _ = target.dispatch_event(&event);
    }
}

fn dispatch_ready_event(payload: &ReadyPayload) {
    dispatch_runtime_event(READY_EVENT, payload);
}

fn dispatch_change_event(payload: &ChangePayload) {
    dispatch_runtime_event(CHANGE_EVENT, payload);
}

fn dispatch_state_event(payload: &StatePayload) {
    dispatch_runtime_event(STATE_EVENT, payload);
}

fn dispatch_status_event(payload: &HostStatusPayload) {
    dispatch_runtime_event(STATUS_EVENT, payload);
}

fn dispatch_selection_event(payload: &SelectionPayload) {
    dispatch_runtime_event(SELECTION_EVENT, payload);
}

fn register_unmount_handle<M: Any + leptos::prelude::Mountable + 'static>(
    id: u32,
    target: EventTarget,
    listener: Closure<dyn FnMut(Event)>,
    handle: UnmountHandle<M>,
) {
    let mut listeners = vec![MountedRuntimeListener { target, listener }];
    PENDING_RUNTIME_LISTENERS.with(|registry| {
        if let Some(mut pending) = registry.borrow_mut().remove(&id) {
            listeners.append(&mut pending);
        }
    });
    MOUNTED_HANDLES.with(|registry| {
        registry.borrow_mut().insert(
            id,
            MountedRuntime {
                listeners,
                mount_handle: Box::new(handle),
            },
        );
    });
}

fn register_runtime_listener(
    id: u32,
    target: EventTarget,
    listener: Closure<dyn FnMut(Event)>,
) {
    MOUNTED_HANDLES.with(|registry| {
        if let Some(runtime) = registry.borrow_mut().get_mut(&id) {
            runtime
                .listeners
                .push(MountedRuntimeListener { target, listener });
        } else {
            PENDING_RUNTIME_LISTENERS.with(|pending_registry| {
                pending_registry
                    .borrow_mut()
                    .entry(id)
                    .or_default()
                    .push(MountedRuntimeListener { target, listener });
            });
        }
    });
}

fn take_unmount_handle(id: u32) -> Option<MountedRuntime> {
    PENDING_RUNTIME_LISTENERS.with(|registry| {
        registry.borrow_mut().remove(&id);
    });
    MOUNTED_HANDLES.with(|registry| registry.borrow_mut().remove(&id))
}

#[wasm_bindgen]
pub fn mount(container: Element, options: JsValue) -> Result<u32, JsValue> {
    console_error_panic_hook::set_once();
    let options = if options.is_undefined() || options.is_null() {
        MountOptions::default()
    } else {
        serde_wasm_bindgen::from_value(options)?
    };
    let target = container
        .dyn_into::<HtmlElement>()
        .map_err(|_| JsValue::from_str("mount 目标必须是 HTML 元素"))?;
    let mount_id = mount_app_into(target, options, RuntimeDeliveryMode::Embedded);
    Ok(mount_id)
}

#[wasm_bindgen]
pub fn unmount(mount_id: u32) -> Result<(), JsValue> {
    if let Some(handle) = take_unmount_handle(mount_id) {
        if runtime_mount_context().map(|(id, _, _)| id) == Some(mount_id) {
            set_runtime_mount_context(None);
            set_runtime_mount_options(None);
        }
        drop(handle);
        Ok(())
    } else {
        Err(JsValue::from_str("找不到对应的挂载句柄"))
    }
}

fn mount_app_into(target: HtmlElement, options: MountOptions, mode: RuntimeDeliveryMode) -> u32 {
    let mount_id = next_mount_id();
    let context_target: EventTarget = target.clone().into();
    let mount_options = RuntimeMountOptions {
        options: options.clone(),
        mode,
    };

    set_runtime_mount_options(Some(mount_options.clone()));
    set_runtime_mount_context(Some(RuntimeMountContext {
        id: mount_id,
        target: context_target.clone(),
        mode,
    }));

    let handle = mount_to(target, move || {
        view! {
            <App mount_options=options />
        }
    });

    let command_listener = build_command_listener(mount_id);
    let _ = context_target.add_event_listener_with_callback(
        COMMAND_EVENT,
        command_listener.as_ref().unchecked_ref(),
    );

    register_unmount_handle(mount_id, context_target, command_listener, handle);
    mount_id
}

fn build_command_listener(mount_id: u32) -> Closure<dyn FnMut(Event)> {
    Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
        let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
            return;
        };
        let detail = custom_event.detail();
        let Ok(envelope) = serde_wasm_bindgen::from_value::<HostCommandEnvelope>(detail) else {
            return;
        };
        if envelope.protocol.as_deref() != Some(PROTOCOL) {
            return;
        }

        let Some(payload) = envelope.payload else {
            return;
        };
        let Some(command_kind) = HostCommandKind::from_payload(&payload) else {
            return;
        };

        let _ = mount_id;
        let _ = command_kind;
    }))
}

#[derive(Clone, Debug, PartialEq)]
struct HoveredBlockState {
    index: usize,
    label: String,
    top: f64,
    height: f64,
}

#[derive(Clone, Debug, PartialEq)]
struct FloatingToolbarAnchor {
    top: f64,
    left: f64,
}

#[derive(Clone, Debug, PartialEq)]
struct BlockMenuLayout {
    max_height: f64,
    open_upward: bool,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DropPlacement {
    Before,
    After,
}

#[derive(Clone, Debug, PartialEq)]
struct DropIndicatorState {
    index: usize,
    top: f64,
    placement: DropPlacement,
}

#[derive(Clone, Debug, PartialEq)]
struct PendingDragState {
    index: usize,
    anchor: HoveredBlockState,
    start_x: i32,
    start_y: i32,
}

fn local_storage() -> Option<Storage> {
    window().and_then(|win| win.local_storage().ok().flatten())
}

fn default_title() -> String {
    "Leptos Tiptap 主编辑器 P0".to_string()
}

fn current_query_param(key: &str) -> Option<String> {
    window()
        .and_then(|win| win.location().search().ok())
        .and_then(|search| {
            let query = search.trim_start_matches('?');
            query.split('&').find_map(|pair| {
                let (candidate_key, candidate_value) = pair.split_once('=')?;
                if candidate_key == key && !candidate_value.trim().is_empty() {
                    Some(candidate_value.trim().to_string())
                } else {
                    None
                }
            })
        })
}

fn normalize_identity_value(value: Option<String>) -> Option<String> {
    value.and_then(|value| {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed.to_string())
        }
    })
}

fn persisted_document_identity(
    document_id: Option<String>,
    workspace_id: Option<String>,
) -> PersistedDocumentIdentity {
    PersistedDocumentIdentity {
        document_id: normalize_identity_value(document_id),
        workspace_id: normalize_identity_value(workspace_id),
    }
}

fn resolve_persisted_document_identity(
    explicit_document_id: Option<String>,
    explicit_workspace_id: Option<String>,
) -> PersistedDocumentIdentity {
    persisted_document_identity(
        explicit_document_id.or_else(|| current_query_param("documentId")),
        explicit_workspace_id.or_else(|| current_query_param("workspaceId")),
    )
}

fn persisted_document_storage_key(identity: &PersistedDocumentIdentity) -> String {
    match (&identity.workspace_id, &identity.document_id) {
        (Some(workspace_id), Some(document_id)) => {
            format!("{SPIKE_STORAGE_KEY}:{workspace_id}:{document_id}")
        }
        (None, Some(document_id)) => format!("{SPIKE_STORAGE_KEY}:{document_id}"),
        _ => SPIKE_STORAGE_KEY.to_string(),
    }
}

fn runtime_persisted_identity(
    document_id: ReadSignal<Option<String>>,
    workspace_id: ReadSignal<Option<String>>,
) -> PersistedDocumentIdentity {
    persisted_document_identity(
        document_id.get_untracked(),
        workspace_id.get_untracked(),
    )
}

fn persist_document_state(
    identity: &PersistedDocumentIdentity,
    title: &str,
    content: &Value,
    html: Option<String>,
) -> Result<(), String> {
    let payload = PersistedSpikeDocument {
        document_id: identity.document_id.clone(),
        workspace_id: identity.workspace_id.clone(),
        title: title.to_string(),
        content: content.clone(),
        html,
    };
    let raw = serde_json::to_string(&payload).map_err(|err| format!("序列化草稿失败：{err}"))?;
    local_storage()
        .ok_or_else(|| "浏览器 localStorage 不可用".to_string())?
        .set_item(&persisted_document_storage_key(identity), &raw)
        .map_err(|err| format!("写入 localStorage 失败：{err:?}"))
}

fn load_persisted_document(identity: &PersistedDocumentIdentity) -> Option<PersistedSpikeDocument> {
    let raw = local_storage()?
        .get_item(&persisted_document_storage_key(identity))
        .ok()
        .flatten()?;
    let document = serde_json::from_str::<PersistedSpikeDocument>(&raw).ok()?;
    let stored_identity = persisted_document_identity(
        document.document_id.clone(),
        document.workspace_id.clone(),
    );
    if stored_identity == *identity || identity.document_id.is_none() {
        Some(document)
    } else {
        None
    }
}

fn url_requests_embedded_mode() -> bool {
    window()
        .and_then(|win| win.location().search().ok())
        .map(|search| search.contains("embedded=1"))
        .unwrap_or(false)
}

fn resolve_runtime_delivery_mode(
    explicit_mode: Option<RuntimeDeliveryMode>,
    url_embedded: bool,
) -> RuntimeDeliveryMode {
    if let Some(mode) = explicit_mode {
        return mode;
    }

    if url_embedded {
        RuntimeDeliveryMode::Embedded
    } else {
        RuntimeDeliveryMode::Standalone
    }
}

fn is_embedded_mode() -> bool {
    runtime_delivery_mode() == RuntimeDeliveryMode::Embedded
}

fn current_document_id() -> Option<String> {
    current_query_param("documentId")
}

fn current_workspace_id() -> Option<String> {
    current_query_param("workspaceId")
}

fn selection_event_payload(
    selection: &TiptapSelectionState,
    editor_focused: bool,
    hovered_block: Option<HoveredBlockState>,
) -> SelectionPayload {
    SelectionPayload {
        selection: selection.clone(),
        summary: selection_summary(selection),
        editor_focused,
        current_block_index: hovered_block.as_ref().map(|block| block.index),
        current_block_id: hovered_block.and_then(|block| runtime_block_id_from_index(block.index)),
    }
}

fn runtime_state_payload(
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: String,
    dirty_count: u32,
    hovered_block: Option<HoveredBlockState>,
    editor_focused: bool,
    slash_open: bool,
    turn_into_open: bool,
    color_menu_open: bool,
    more_menu_open: bool,
    read_only: bool,
) -> StatePayload {
    StatePayload {
        document_id,
        workspace_id,
        title,
        dirty_count,
        selected_block_index: hovered_block.map(|block| block.index),
        editor_focused,
        slash_open,
        toolbar_open: toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open),
        read_only,
    }
}

fn runtime_status_payload(
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: String,
    dirty_count: u32,
    hovered_block: Option<HoveredBlockState>,
    editor_focused: bool,
    slash_open: bool,
    turn_into_open: bool,
    color_menu_open: bool,
    more_menu_open: bool,
    read_only: bool,
) -> HostStatusPayload {
    let current_block_index = hovered_block.as_ref().map(|block| block.index);
    HostStatusPayload {
        document_id,
        workspace_id,
        title,
        dirty_count,
        selected_block_index: current_block_index,
        current_block_id: current_block_index.and_then(runtime_block_id_from_index),
        editor_focused,
        read_only,
        slash_open,
        toolbar_open: toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open),
    }
}

fn dispatch_bridge_event<T>(event_name: &'static str, payload: &T)
where
    T: Serialize,
{
    dispatch_runtime_event(event_name, payload);
}

fn read_editor_snapshot(editor: TiptapEditorHandle) -> (String, Value, String) {
    let html = editor
        .get_html()
        .unwrap_or_else(|err| format!("读取 HTML 失败：{err}"));
    let json_value = editor.get_json().unwrap_or_else(|err| {
        json!({
            "type": "error",
            "message": format!("读取 JSON 失败：{err}"),
        })
    });
    let json_text = serde_json::to_string_pretty(&json_value)
        .unwrap_or_else(|err| format!("格式化 JSON 失败：{err}"));
    (html, json_value, json_text)
}

fn sync_editor_outputs(
    editor: TiptapEditorHandle,
    set_html_output: WriteSignal<String>,
    set_document_json: WriteSignal<Value>,
    set_json_output: WriteSignal<String>,
) -> Value {
    let (html, json_value, json_text) = read_editor_snapshot(editor);
    set_html_output.set(html);
    set_document_json.set(json_value.clone());
    set_json_output.set(json_text);
    json_value
}

fn editor_root_element() -> Option<Element> {
    window()
        .and_then(|win| win.document())
        .and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten())
}

fn editor_stage_element() -> Option<Element> {
    window()
        .and_then(|win| win.document())
        .and_then(|document| {
            document
                .query_selector(EDITOR_STAGE_SELECTOR)
                .ok()
                .flatten()
        })
}

fn content_column_width(stage_rect_width: f64) -> f64 {
    let available = (stage_rect_width - (CONTENT_COLUMN_HORIZONTAL_PADDING * 2.0)).max(0.0);
    available.min(CONTENT_COLUMN_MAX_WIDTH)
}

fn content_column_left(stage_rect_width: f64) -> f64 {
    let column_width = content_column_width(stage_rect_width);
    ((stage_rect_width - column_width) / 2.0).max(0.0)
}

fn handle_lane_left(stage_rect_width: f64) -> f64 {
    (content_column_left(stage_rect_width)
        - HANDLE_TRIGGER_WIDTH
        - HANDLE_TRIGGER_GAP
        - HANDLE_MENU_GAP)
        .max(HANDLE_STAGE_PADDING_LEFT)
}

fn handle_corridor_width(stage_rect_width: f64) -> f64 {
    let lane_left = handle_lane_left(stage_rect_width);
    let content_left = content_column_left(stage_rect_width);
    let content_gap = (content_left - lane_left).max(0.0);
    (HANDLE_TRIGGER_WIDTH + HANDLE_TRIGGER_GAP + content_gap).max(HANDLE_GUTTER_WIDTH.min(stage_rect_width))
}

fn direct_block_from_element(mut element: Element, root: &Element) -> Option<Element> {
    loop {
        let parent = element.parent_element()?;
        if parent.is_same_node(Some(root)) {
            return Some(element);
        }
        element = parent;
    }
}

fn top_level_block_index(root: &Element, block: &Element) -> Option<usize> {
    let children = root.children();
    (0..children.length()).find_map(|index| {
        children
            .item(index)
            .filter(|child| child.is_same_node(Some(block)))
            .map(|_| index as usize)
    })
}

fn block_label_for_element(block: &Element) -> String {
    match block.get_attribute("data-type").as_deref() {
        Some("taskList") => "Todo 列表".to_string(),
        Some("taskItem") => "Todo 项".to_string(),
        _ => match block.tag_name().as_str() {
            "H1" => "一级标题".to_string(),
            "H2" => "二级标题".to_string(),
            "H3" => "三级标题".to_string(),
            "P" => "段落".to_string(),
            "UL" => "无序列表".to_string(),
            "OL" => "有序列表".to_string(),
            "BLOCKQUOTE" => "引用块".to_string(),
            "PRE" => "代码块".to_string(),
            "HR" => "分割线".to_string(),
            other => format!("块节点 {other}"),
        },
    }
}

fn target_element(target: web_sys::EventTarget) -> Option<Element> {
    target.clone().dyn_into::<Element>().ok().or_else(|| {
        target
            .dyn_into::<Node>()
            .ok()
            .and_then(|node| node.parent_element())
    })
}

fn event_target_matches_selector(target: Option<web_sys::EventTarget>, selector: &str) -> bool {
    target
        .and_then(target_element)
        .and_then(|element| element.closest(selector).ok().flatten())
        .is_some()
}

fn current_target_html_element(event: &WheelEvent) -> Option<HtmlElement> {
    event.current_target()?.dyn_into::<HtmlElement>().ok()
}

fn trap_scroll_inside_menu(event: &WheelEvent) {
    event.prevent_default();
    event.stop_propagation();

    let Some(menu) = current_target_html_element(event) else {
        return;
    };

    let current = f64::from(menu.scroll_top());
    let max_scroll = f64::from((menu.scroll_height() - menu.client_height()).max(0));
    let next = (current + event.delta_y()).clamp(0.0, max_scroll);
    menu.set_scroll_top(next.round() as i32);
}

fn hovered_block_from_target(target: web_sys::EventTarget) -> Option<HoveredBlockState> {
    let root = editor_root_element()?;
    let element = target_element(target)?;

    if element
        .closest(HANDLE_SHELL_SELECTOR)
        .ok()
        .flatten()
        .is_some()
    {
        return None;
    }

    let block = direct_block_from_element(element, &root)?;
    let index = top_level_block_index(&root, &block)?;
    block_state_from_index(index)
}

fn hovered_block_from_selection() -> Option<HoveredBlockState> {
    let selection = window().and_then(|win| win.get_selection().ok().flatten())?;
    let anchor_node = selection.anchor_node()?;
    let element = anchor_node
        .dyn_ref::<Element>()
        .cloned()
        .or_else(|| anchor_node.parent_element())?;
    let root = editor_root_element()?;
    let block = direct_block_from_element(element, &root)?;
    let index = top_level_block_index(&root, &block)?;
    block_state_from_index(index)
}

fn block_state_from_index(index: usize) -> Option<HoveredBlockState> {
    let root = editor_root_element()?;
    let stage = editor_stage_element()?;
    let block = root.children().item(index as u32)?;
    let block_rect = block.get_bounding_client_rect();
    let stage_rect = stage.get_bounding_client_rect();
    let block_height = if block_rect.height() > 0.0 {
        block_rect.height()
    } else {
        28.0
    };

    Some(HoveredBlockState {
        index,
        label: block_label_for_element(&block),
        top: block_rect.top() - stage_rect.top(),
        height: block_height,
    })
}

fn prosemirror_node_size(node: &Value) -> Option<u32> {
    match node.get("type").and_then(Value::as_str) {
        Some("text") => Some(
            node.get("text")
                .and_then(Value::as_str)
                .map(|text| text.encode_utf16().count() as u32)
                .unwrap_or(0),
        ),
        Some("hardBreak") | Some("horizontalRule") => Some(1),
        _ => {
            let Some(children) = node.get("content").and_then(Value::as_array) else {
                return Some(1);
            };

            let content_size = children.iter().try_fold(0_u32, |acc, child| {
                prosemirror_node_size(child).map(|size| acc + size)
            })?;
            Some(content_size + 2)
        }
    }
}

fn nested_edge_child_size(node: &Value, at_start: bool) -> Option<u32> {
    let outer = node
        .get("content")
        .and_then(Value::as_array)
        .and_then(|children| {
            if at_start {
                children.first()
            } else {
                children.last()
            }
        })?;
    let inner = outer
        .get("content")
        .and_then(Value::as_array)
        .and_then(|children| {
            if at_start {
                children.first()
            } else {
                children.last()
            }
        })?;
    prosemirror_node_size(inner)
}

fn top_level_block_range(document: &Value, index: usize) -> Option<TiptapRange> {
    let content = document.get("content").and_then(Value::as_array)?;
    let mut position = 0_u32;

    for (current_index, node) in content.iter().enumerate() {
        let node_size = prosemirror_node_size(node)?;
        if current_index == index {
            let from = position + nested_edge_child_size(node, true).unwrap_or(1);
            let mut to = position
                + node_size.saturating_sub(nested_edge_child_size(node, false).unwrap_or(1));
            if to < from {
                to = from;
            }
            return Some(TiptapRange { from, to });
        }
        position += node_size;
    }

    None
}

fn node_is_within_root(node: Node, root: &Element) -> bool {
    let mut current = Some(node);
    while let Some(node) = current {
        if node.is_same_node(Some(root)) {
            return true;
        }
        current = node.parent_node();
    }
    false
}

fn active_editor_text_selection() -> Option<web_sys::Selection> {
    let selection = window().and_then(|win| win.get_selection().ok().flatten())?;
    if selection.is_collapsed() {
        return None;
    }

    let root = editor_root_element()?;
    let anchor_inside = selection
        .anchor_node()
        .map(|node| node_is_within_root(node, &root))
        .unwrap_or(false);
    let focus_inside = selection
        .focus_node()
        .map(|node| node_is_within_root(node, &root))
        .unwrap_or(false);

    if anchor_inside || focus_inside {
        Some(selection)
    } else {
        None
    }
}

fn floating_toolbar_anchor_from_selection() -> Option<FloatingToolbarAnchor> {
    let selection = active_editor_text_selection()?;
    let range = selection.get_range_at(0).ok()?;
    let rect = range.get_bounding_client_rect();
    if rect.width() <= 0.0 && rect.height() <= 0.0 {
        return None;
    }

    let stage = editor_stage_element()?;
    let stage_rect = stage.get_bounding_client_rect();

    Some(FloatingToolbarAnchor {
        top: (rect.top() - stage_rect.top()).max(60.0),
        left: rect.left() + (rect.width() / 2.0) - stage_rect.left(),
    })
}

fn hover_anchor_top(block: &HoveredBlockState) -> f64 {
    if block.height > HANDLE_MULTILINE_HEIGHT {
        block.top + 12.0
    } else {
        block.top + (block.height / 2.0)
    }
}

fn hover_anchor_transform(block: &HoveredBlockState) -> &'static str {
    if block.height > HANDLE_MULTILINE_HEIGHT {
        "translateY(0)"
    } else {
        "translateY(-50%)"
    }
}

fn pointer_in_handle_corridor(client_x: i32, client_y: i32, block: &HoveredBlockState) -> bool {
    let Some(stage) = editor_stage_element() else {
        return false;
    };
    let stage_rect = stage.get_bounding_client_rect();
    let x = f64::from(client_x) - stage_rect.left();
    let y = f64::from(client_y) - stage_rect.top();
    let block_top = block.top;
    let block_bottom = block.top + block.height;
    let corridor_left = handle_lane_left(stage_rect.width());
    let corridor_right = corridor_left + handle_corridor_width(stage_rect.width());

    x >= corridor_left
        && x <= corridor_right
        && y >= block_top - 8.0
        && y <= block_bottom + 8.0
}

fn block_menu_layout(block: &HoveredBlockState) -> Option<BlockMenuLayout> {
    let stage = editor_stage_element()?;
    let stage_rect = stage.get_bounding_client_rect();
    let anchor_top = hover_anchor_top(block);
    let stage_padding = 18.0;
    let space_above = (anchor_top - stage_padding).max(0.0);
    let space_below = (stage_rect.height() - anchor_top - stage_padding).max(0.0);
    let open_upward = space_below < 340.0 && space_above > space_below;
    let available_height = if open_upward {
        space_above
    } else {
        space_below
    };
    let max_height = if available_height >= 220.0 {
        available_height
    } else {
        available_height.max(160.0)
    };

    Some(BlockMenuLayout {
        max_height,
        open_upward,
    })
}

fn drop_indicator_from_target(
    target: web_sys::EventTarget,
    client_y: i32,
) -> Option<DropIndicatorState> {
    let hovered = hovered_block_from_target(target)?;
    let root = editor_root_element()?;
    let block = root.children().item(hovered.index as u32)?;
    let stage = editor_stage_element()?;
    let block_rect = block.get_bounding_client_rect();
    let stage_rect = stage.get_bounding_client_rect();
    let midpoint = block_rect.top() + (block_rect.height() / 2.0);
    let placement = if f64::from(client_y) <= midpoint {
        DropPlacement::Before
    } else {
        DropPlacement::After
    };
    let top = match placement {
        DropPlacement::Before => block_rect.top() - stage_rect.top(),
        DropPlacement::After => block_rect.bottom() - stage_rect.top(),
    };

    Some(DropIndicatorState {
        index: hovered.index,
        top,
        placement,
    })
}

fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option<DropIndicatorState> {
    let target = window()
        .and_then(|win| win.document())
        .and_then(|document| document.element_from_point(client_x as f32, client_y as f32))?;
    let target: web_sys::EventTarget = target.into();
    drop_indicator_from_target(target, client_y)
}

fn document_content_mut(document: &mut Value) -> Result<&mut Vec<Value>, String> {
    document
        .get_mut("content")
        .and_then(Value::as_array_mut)
        .ok_or_else(|| "文档 JSON 缺少顶层 content 数组".to_string())
}

fn node_with_attrs(
    type_name: &str,
    attrs: Option<Map<String, Value>>,
    content: Vec<Value>,
) -> Value {
    let mut node = Map::new();
    node.insert("type".to_string(), Value::String(type_name.to_string()));
    if let Some(attrs) = attrs.filter(|attrs| !attrs.is_empty()) {
        node.insert("attrs".to_string(), Value::Object(attrs));
    }
    if !content.is_empty() {
        node.insert("content".to_string(), Value::Array(content));
    }
    Value::Object(node)
}

fn paragraph_node(content: Vec<Value>) -> Value {
    node_with_attrs("paragraph", None, content)
}

fn list_item_node(content: Vec<Value>) -> Value {
    node_with_attrs("listItem", None, vec![paragraph_node(content)])
}

fn task_item_node(content: Vec<Value>) -> Value {
    let mut attrs = Map::new();
    attrs.insert("checked".to_string(), Value::Bool(false));
    node_with_attrs("taskItem", Some(attrs), vec![paragraph_node(content)])
}

fn collect_plain_text(node: &Value) -> String {
    match node.get("type").and_then(Value::as_str) {
        Some("text") => node
            .get("text")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string(),
        Some("hardBreak") => "\n".to_string(),
        _ => node
            .get("content")
            .and_then(Value::as_array)
            .map(|children| {
                children
                    .iter()
                    .map(collect_plain_text)
                    .collect::<Vec<_>>()
                    .join("")
            })
            .unwrap_or_default(),
    }
}

fn inline_from_first_child(children: &[Value]) -> Vec<Value> {
    children
        .first()
        .map(extract_inline_content)
        .unwrap_or_default()
}

fn extract_inline_content(node: &Value) -> Vec<Value> {
    match node.get("type").and_then(Value::as_str) {
        Some("paragraph") | Some("heading") => node
            .get("content")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default(),
        Some("blockquote") => node
            .get("content")
            .and_then(Value::as_array)
            .map(|children| inline_from_first_child(children))
            .unwrap_or_default(),
        Some("bulletList") | Some("orderedList") | Some("taskList") => node
            .get("content")
            .and_then(Value::as_array)
            .and_then(|items| items.first())
            .and_then(|item| item.get("content"))
            .and_then(Value::as_array)
            .map(|children| inline_from_first_child(children))
            .unwrap_or_default(),
        Some("codeBlock") => {
            let text = collect_plain_text(node);
            if text.is_empty() {
                Vec::new()
            } else {
                vec![json!({"type": "text", "text": text})]
            }
        }
        Some("horizontalRule") => Vec::new(),
        _ => node
            .get("content")
            .and_then(Value::as_array)
            .map(|children| {
                children
                    .iter()
                    .flat_map(extract_inline_content)
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default(),
    }
}

fn turn_into_block(node: &Value, action: SlashActionKind) -> Value {
    let inline = extract_inline_content(node);
    match action {
        SlashActionKind::Paragraph => paragraph_node(inline),
        SlashActionKind::Heading1 => {
            let mut attrs = Map::new();
            attrs.insert("level".to_string(), json!(1));
            node_with_attrs("heading", Some(attrs), inline)
        }
        SlashActionKind::Heading2 => {
            let mut attrs = Map::new();
            attrs.insert("level".to_string(), json!(2));
            node_with_attrs("heading", Some(attrs), inline)
        }
        SlashActionKind::Heading3 => {
            let mut attrs = Map::new();
            attrs.insert("level".to_string(), json!(3));
            node_with_attrs("heading", Some(attrs), inline)
        }
        SlashActionKind::BulletList => {
            node_with_attrs("bulletList", None, vec![list_item_node(inline)])
        }
        SlashActionKind::OrderedList => {
            node_with_attrs("orderedList", None, vec![list_item_node(inline)])
        }
        SlashActionKind::Todo => node_with_attrs("taskList", None, vec![task_item_node(inline)]),
        SlashActionKind::Quote => node_with_attrs("blockquote", None, vec![paragraph_node(inline)]),
        SlashActionKind::CodeBlock => {
            let text = collect_plain_text(node);
            let mut attrs = Map::new();
            attrs.insert("language".to_string(), Value::String("rust".to_string()));
            let content = if text.is_empty() {
                Vec::new()
            } else {
                vec![json!({"type": "text", "text": text})]
            };
            node_with_attrs("codeBlock", Some(attrs), content)
        }
        SlashActionKind::Divider => node_with_attrs("horizontalRule", None, Vec::new()),
    }
}

fn duplicate_top_level_block(document: &mut Value, index: usize) -> Result<(), String> {
    let content = document_content_mut(document)?;
    let block = content
        .get(index)
        .cloned()
        .ok_or_else(|| format!("找不到第 {index} 个块"))?;
    content.insert(index + 1, block);
    Ok(())
}

fn delete_top_level_block(document: &mut Value, index: usize) -> Result<(), String> {
    let content = document_content_mut(document)?;
    if index >= content.len() {
        return Err(format!("找不到第 {index} 个块"));
    }

    if content.len() == 1 {
        content[0] = paragraph_node(Vec::new());
        return Ok(());
    }

    content.remove(index);
    Ok(())
}

fn reorder_top_level_block(
    document: &mut Value,
    source: usize,
    target: usize,
    placement: DropPlacement,
) -> Result<(), String> {
    let content = document_content_mut(document)?;
    if source >= content.len() || target >= content.len() {
        return Err("拖拽目标超出当前顶层块范围".to_string());
    }

    let block = content.remove(source);
    let mut insert_at = match placement {
        DropPlacement::Before => target,
        DropPlacement::After => target + 1,
    };

    if source < insert_at {
        insert_at = insert_at.saturating_sub(1);
    }

    if insert_at > content.len() {
        insert_at = content.len();
    }

    content.insert(insert_at, block);
    Ok(())
}

fn replace_top_level_block_kind(
    document: &mut Value,
    index: usize,
    action: SlashActionKind,
) -> Result<(), String> {
    let content = document_content_mut(document)?;
    let next = turn_into_block(
        content
            .get(index)
            .ok_or_else(|| format!("找不到第 {index} 个块"))?,
        action,
    );
    content[index] = next;
    Ok(())
}

fn has_active_text_selection() -> bool {
    active_editor_text_selection().is_some()
}

fn toolbar_overlay_locked(
    turn_into_open: bool,
    color_menu_open: bool,
    more_menu_open: bool,
) -> bool {
    turn_into_open || color_menu_open || more_menu_open
}

fn sync_text_selection_overlay(
    set_text_selection_active: WriteSignal<bool>,
    set_floating_toolbar_anchor: WriteSignal<Option<FloatingToolbarAnchor>>,
) -> bool {
    let toolbar_anchor = floating_toolbar_anchor_from_selection();
    let has_text_selection = toolbar_anchor.is_some();
    set_text_selection_active.set(has_text_selection);
    set_floating_toolbar_anchor.set(toolbar_anchor);
    has_text_selection
}

fn sync_editor_overlay_state(
    set_editor_focused: WriteSignal<bool>,
    set_text_selection_active: WriteSignal<bool>,
    set_floating_toolbar_anchor: WriteSignal<Option<FloatingToolbarAnchor>>,
    set_slash_open: WriteSignal<bool>,
    set_turn_into_open: WriteSignal<bool>,
    set_hovered_block: WriteSignal<Option<HoveredBlockState>>,
    set_block_menu_anchor: WriteSignal<Option<HoveredBlockState>>,
    set_block_menu_open: WriteSignal<bool>,
) {
    let focused = active_editor_stage();
    set_editor_focused.set(focused);
    let has_text_selection =
        sync_text_selection_overlay(set_text_selection_active, set_floating_toolbar_anchor);

    if focused && has_text_selection {
        set_slash_open.set(false);
        set_turn_into_open.set(false);
        set_block_menu_open.set(false);
        set_block_menu_anchor.set(None);
        set_hovered_block.set(None);
    }
}

fn try_sync_editor_overlay_state(
    set_editor_focused: WriteSignal<bool>,
    set_text_selection_active: WriteSignal<bool>,
    set_floating_toolbar_anchor: WriteSignal<Option<FloatingToolbarAnchor>>,
    set_slash_open: WriteSignal<bool>,
    set_turn_into_open: WriteSignal<bool>,
    set_hovered_block: WriteSignal<Option<HoveredBlockState>>,
    set_block_menu_anchor: WriteSignal<Option<HoveredBlockState>>,
    set_block_menu_open: WriteSignal<bool>,
) -> bool {
    let focused = active_editor_stage();
    if set_editor_focused.try_set(focused).is_none() {
        return false;
    }

    let toolbar_anchor = floating_toolbar_anchor_from_selection();
    let has_text_selection = toolbar_anchor.is_some();
    if set_text_selection_active
        .try_set(has_text_selection)
        .is_none()
    {
        return false;
    }
    if set_floating_toolbar_anchor
        .try_set(toolbar_anchor)
        .is_none()
    {
        return false;
    }

    if focused && has_text_selection {
        if set_slash_open.try_set(false).is_none() {
            return false;
        }
        if set_turn_into_open.try_set(false).is_none() {
            return false;
        }
        if set_block_menu_open.try_set(false).is_none() {
            return false;
        }
        if set_block_menu_anchor.try_set(None).is_none() {
            return false;
        }
        if set_hovered_block.try_set(None).is_none() {
            return false;
        }
    }

    true
}

fn apply_document_update(
    editor: TiptapEditorHandle,
    persisted_identity: &PersistedDocumentIdentity,
    next_document: Value,
    set_dirty_count: WriteSignal<u32>,
    set_html_output: WriteSignal<String>,
    set_document_json: WriteSignal<Value>,
    set_json_output: WriteSignal<String>,
    title: ReadSignal<String>,
    set_command_feedback: WriteSignal<String>,
    success_message: impl Into<String>,
) {
    let success_message = success_message.into();
    let next_payload = next_document
        .get("content")
        .and_then(Value::as_array)
        .cloned()
        .map(Value::Array)
        .unwrap_or_else(|| next_document.clone());
    match editor.set_content(TiptapContent::json(next_payload)) {
        Ok(()) => {
            set_dirty_count.update(|count| *count += 1);
            let (html, snapshot, json_text) = read_editor_snapshot(editor);
            set_html_output.set(html.clone());
            set_document_json.set(snapshot.clone());
            set_json_output.set(json_text);
            match persist_document_state(persisted_identity, &title.get_untracked(), &snapshot, Some(html)) {
                Ok(()) => {
                    set_command_feedback.set(format!("{success_message}，并已写入本地草稿"));
                }
                Err(err) => {
                    set_command_feedback.set(format!("{success_message}，但本地保存失败：{err}"));
                }
            }
        }
        Err(err) => {
            set_command_feedback.set(format!("更新文档失败：{err}"));
        }
    }
}

fn apply_html_update(
    editor: TiptapEditorHandle,
    persisted_identity: &PersistedDocumentIdentity,
    next_html: String,
    set_dirty_count: WriteSignal<u32>,
    set_html_output: WriteSignal<String>,
    set_document_json: WriteSignal<Value>,
    set_json_output: WriteSignal<String>,
    title: ReadSignal<String>,
    set_command_feedback: WriteSignal<String>,
    success_message: impl Into<String>,
) {
    let success_message = success_message.into();
    match editor.set_content(TiptapContent::html(next_html)) {
        Ok(()) => {
            set_dirty_count.update(|count| *count += 1);
            let (html, snapshot, json_text) = read_editor_snapshot(editor);
            set_html_output.set(html.clone());
            set_document_json.set(snapshot.clone());
            set_json_output.set(json_text);
            match persist_document_state(persisted_identity, &title.get_untracked(), &snapshot, Some(html)) {
                Ok(()) => {
                    set_command_feedback.set(format!("{success_message}，并已写入本地草稿"));
                }
                Err(err) => {
                    set_command_feedback.set(format!("{success_message}，但本地保存失败：{err}"));
                }
            }
        }
        Err(err) => {
            set_command_feedback.set(format!("更新文档失败：{err}"));
        }
    }
}

fn duplicate_top_level_block_html(current_html: &str, index: usize) -> Result<String, String> {
    let document = window()
        .and_then(|win| win.document())
        .ok_or_else(|| "浏览器 document 不可用".to_string())?;
    let container = document
        .create_element("div")
        .map_err(|err| format!("创建 HTML 容器失败：{err:?}"))?;
    container.set_inner_html(current_html);

    let children = container.children();
    let current = children
        .item(index as u32)
        .ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?;
    let cloned = current
        .clone_node_with_deep(true)
        .map_err(|err| format!("复制 HTML 块失败：{err:?}"))?
        .dyn_into::<Element>()
        .map_err(|_| "复制出的节点不是 Element".to_string())?;

    if let Some(next_sibling) = children.item(index as u32 + 1) {
        container
            .insert_before(&cloned, Some(&next_sibling))
            .map_err(|err| format!("插入复制块失败：{err:?}"))?;
    } else {
        container
            .append_child(&cloned)
            .map_err(|err| format!("追加复制块失败：{err:?}"))?;
    }

    Ok(container.inner_html())
}

fn insert_top_level_paragraph_after_html(
    current_html: &str,
    index: usize,
) -> Result<String, String> {
    let document = window()
        .and_then(|win| win.document())
        .ok_or_else(|| "浏览器 document 不可用".to_string())?;
    let container = document
        .create_element("div")
        .map_err(|err| format!("创建 HTML 容器失败：{err:?}"))?;
    container.set_inner_html(current_html);

    let children = container.children();
    children
        .item(index as u32)
        .ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?;
    let paragraph = document
        .create_element("p")
        .map_err(|err| format!("创建空段落失败：{err:?}"))?;

    if let Some(next_sibling) = children.item(index as u32 + 1) {
        container
            .insert_before(&paragraph, Some(&next_sibling))
            .map_err(|err| format!("插入新块失败：{err:?}"))?;
    } else {
        container
            .append_child(&paragraph)
            .map_err(|err| format!("追加新块失败：{err:?}"))?;
    }

    Ok(container.inner_html())
}

fn delete_top_level_block_html(current_html: &str, index: usize) -> Result<String, String> {
    let document = window()
        .and_then(|win| win.document())
        .ok_or_else(|| "浏览器 document 不可用".to_string())?;
    let container = document
        .create_element("div")
        .map_err(|err| format!("创建 HTML 容器失败：{err:?}"))?;
    container.set_inner_html(current_html);

    let children = container.children();
    if children.length() <= 1 {
        container.set_inner_html("<p></p>");
        return Ok(container.inner_html());
    }

    let current = children
        .item(index as u32)
        .ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?;
    container
        .remove_child(&current)
        .map_err(|err| format!("删除 HTML 块失败：{err:?}"))?;
    Ok(container.inner_html())
}

fn reorder_top_level_block_html(
    current_html: &str,
    source: usize,
    target: usize,
    placement: DropPlacement,
) -> Result<String, String> {
    let document = window()
        .and_then(|win| win.document())
        .ok_or_else(|| "浏览器 document 不可用".to_string())?;
    let container = document
        .create_element("div")
        .map_err(|err| format!("创建 HTML 容器失败：{err:?}"))?;
    container.set_inner_html(current_html);

    let initial_children = container.children();
    let block_count = initial_children.length() as usize;
    if source >= block_count || target >= block_count {
        return Err("拖拽目标超出当前顶层块范围".to_string());
    }

    let current = initial_children
        .item(source as u32)
        .ok_or_else(|| format!("找不到第 {source} 个 HTML 顶层块"))?;
    let moving = current
        .clone_node_with_deep(true)
        .map_err(|err| format!("复制拖拽块失败：{err:?}"))?
        .dyn_into::<Element>()
        .map_err(|_| "复制出的拖拽块不是 Element".to_string())?;

    container
        .remove_child(&current)
        .map_err(|err| format!("移除原始拖拽块失败：{err:?}"))?;

    let mut insert_at = match placement {
        DropPlacement::Before => target,
        DropPlacement::After => target + 1,
    };
    if source < insert_at {
        insert_at = insert_at.saturating_sub(1);
    }

    let current_children = container.children();
    let current_len = current_children.length() as usize;
    if insert_at >= current_len {
        container
            .append_child(&moving)
            .map_err(|err| format!("追加拖拽块失败：{err:?}"))?;
    } else {
        let next_sibling = current_children
            .item(insert_at as u32)
            .ok_or_else(|| format!("找不到第 {insert_at} 个 HTML 插入位置"))?;
        container
            .insert_before(&moving, Some(&next_sibling))
            .map_err(|err| format!("插入拖拽块失败：{err:?}"))?;
    }

    Ok(container.inner_html())
}

fn p0_extensions() -> Vec<TiptapExtension> {
    vec![
        TiptapExtension::Document,
        TiptapExtension::Dropcursor,
        TiptapExtension::Gapcursor,
        TiptapExtension::Text,
        TiptapExtension::Paragraph,
        TiptapExtension::Heading,
        TiptapExtension::Bold,
        TiptapExtension::Italic,
        TiptapExtension::Strike,
        TiptapExtension::Code,
        TiptapExtension::Blockquote,
        TiptapExtension::BulletList,
        TiptapExtension::OrderedList,
        TiptapExtension::ListItem,
        TiptapExtension::TaskItem,
        TiptapExtension::TaskList,
        TiptapExtension::CodeBlock,
        TiptapExtension::HorizontalRule,
        TiptapExtension::History,
        TiptapExtension::Underline,
        TiptapExtension::TextStyle,
        TiptapExtension::TextAlign,
        TiptapExtension::Highlight,
        TiptapExtension::Link,
        TiptapExtension::Placeholder,
    ]
}

fn active_editor_stage() -> bool {
    let active_element_inside_stage = window()
        .and_then(|win| win.document())
        .and_then(|document| document.active_element())
        .and_then(|element| element.closest(EDITOR_STAGE_SELECTOR).ok().flatten())
        .is_some();

    if active_element_inside_stage {
        return true;
    }

    let Some(root) = editor_root_element() else {
        return false;
    };
    let Some(selection) = window().and_then(|win| win.get_selection().ok().flatten()) else {
        return false;
    };

    let anchor_inside = selection
        .anchor_node()
        .map(|node| node_is_within_root(node, &root))
        .unwrap_or(false);
    let focus_inside = selection
        .focus_node()
        .map(|node| node_is_within_root(node, &root))
        .unwrap_or(false);

    anchor_inside || focus_inside
}

fn selection_summary(selection: &TiptapSelectionState) -> String {
    let block = if selection.h1 {
        "一级标题"
    } else if selection.h2 {
        "二级标题"
    } else if selection.h3 {
        "三级标题"
    } else if selection.task_list {
        "Todo 列表"
    } else if selection.ordered_list {
        "有序列表"
    } else if selection.bullet_list {
        "无序列表"
    } else if selection.blockquote {
        "引用块"
    } else if selection.paragraph {
        "段落"
    } else {
        "未定位"
    };

    let mut marks = Vec::new();
    if selection.bold {
        marks.push("Bold");
    }
    if selection.italic {
        marks.push("Italic");
    }
    if selection.underline {
        marks.push("Underline");
    }
    if selection.strike {
        marks.push("Strike");
    }
    if selection.highlight {
        marks.push("Highlight");
    }
    if selection.text_style {
        marks.push("Color");
    }
    if selection.link {
        marks.push("Link");
    }

    let mark_text = if marks.is_empty() {
        "无行内样式".to_string()
    } else {
        marks.join(" / ")
    };

    format!("当前块：{block} | 行内样式：{mark_text}")
}

fn current_block_info_from_index(index: Option<usize>) -> CurrentBlockInfo {
    index
        .map(|value| CurrentBlockInfo {
            index: Some(value),
            block_id: runtime_block_id_from_index(value),
        })
        .unwrap_or(CurrentBlockInfo {
            index: None,
            block_id: None,
        })
}

fn selection_payload(
    selection: &TiptapSelectionState,
    editor_focused: bool,
    current_block_index: Option<usize>,
) -> SelectionPayload {
    SelectionPayload {
        selection: selection.clone(),
        summary: selection_summary(selection),
        editor_focused,
        current_block_index,
        current_block_id: current_block_index.and_then(runtime_block_id_from_index),
    }
}

fn state_payload(
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: String,
    dirty_count: u32,
    hovered_block: Option<HoveredBlockState>,
    editor_focused: bool,
    slash_open: bool,
    turn_into_open: bool,
    color_menu_open: bool,
    more_menu_open: bool,
    read_only: bool,
) -> StatePayload {
    let toolbar_open = toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open);
    StatePayload {
        document_id,
        workspace_id,
        title,
        dirty_count,
        selected_block_index: hovered_block.as_ref().map(|block| block.index),
        editor_focused,
        slash_open,
        toolbar_open,
        read_only,
    }
}

fn status_payload(
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: String,
    dirty_count: u32,
    hovered_block: Option<HoveredBlockState>,
    editor_focused: bool,
    slash_open: bool,
    turn_into_open: bool,
    color_menu_open: bool,
    more_menu_open: bool,
    read_only: bool,
) -> HostStatusPayload {
    let current_block_index = hovered_block.as_ref().map(|block| block.index);
    let current_block_id = current_block_index.and_then(runtime_block_id_from_index);
    HostStatusPayload {
        document_id,
        workspace_id,
        title,
        dirty_count,
        selected_block_index: current_block_index,
        current_block_id,
        editor_focused,
        read_only,
        slash_open,
        toolbar_open: toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open),
    }
}

fn dispatch_runtime_state(
    document_id: Option<String>,
    workspace_id: Option<String>,
    title: String,
    dirty_count: u32,
    hovered_block: Option<HoveredBlockState>,
    editor_focused: bool,
    slash_open: bool,
    turn_into_open: bool,
    color_menu_open: bool,
    more_menu_open: bool,
    read_only: bool,
) {
    let state = state_payload(
        document_id.clone(),
        workspace_id.clone(),
        title.clone(),
        dirty_count,
        hovered_block.clone(),
        editor_focused,
        slash_open,
        turn_into_open,
        color_menu_open,
        more_menu_open,
        read_only,
    );
    dispatch_state_event(&state);
    dispatch_status_event(&status_payload(
        document_id,
        workspace_id,
        title,
        dirty_count,
        hovered_block,
        editor_focused,
        slash_open,
        turn_into_open,
        color_menu_open,
        more_menu_open,
        read_only,
    ));
}

fn send_selection_state(
    selection: &TiptapSelectionState,
    editor_focused: bool,
    current_block_index: Option<usize>,
) {
    dispatch_selection_event(&selection_payload(
        selection,
        editor_focused,
        current_block_index,
    ));
}

fn apply_host_document_payload(
    editor: TiptapEditorHandle,
    payload: HostDocumentPayload,
    set_document_id: WriteSignal<Option<String>>,
    set_workspace_id: WriteSignal<Option<String>>,
    set_title: WriteSignal<String>,
    set_read_only: WriteSignal<bool>,
    set_revision: WriteSignal<Option<i64>>,
    set_conflict_detection_key: WriteSignal<Option<String>>,
    set_dirty_count: WriteSignal<u32>,
    set_html_output: WriteSignal<String>,
    set_document_json: WriteSignal<Value>,
    set_json_output: WriteSignal<String>,
    set_command_feedback: WriteSignal<String>,
) {
    let next_document_id = payload.document_id.clone();
    let next_workspace_id = payload.workspace_id.clone();
    let next_title = payload.title.clone();
    let next_revision = payload.revision;
    let next_conflict_detection_key = payload.conflict_detection_key.clone();
    let next_read_only = payload.read_only.unwrap_or(false);

    set_document_id.set(next_document_id);
    set_workspace_id.set(next_workspace_id);
    if let Some(next_title_value) = next_title {
        set_title.set(next_title_value);
    }
    set_read_only.set(next_read_only);
    set_revision.set(next_revision);
    set_conflict_detection_key.set(next_conflict_detection_key.clone());

    if let Some(content) = payload.content {
        let next_content = TiptapContent::json(content);
        match editor.set_content(next_content) {
            Ok(()) => {
                let _snapshot = sync_editor_outputs(
                    editor,
                    set_html_output,
                    set_document_json,
                    set_json_output,
                );
                set_dirty_count.set(0);
                set_command_feedback.set("宿主文档已同步到编辑器".to_string());
            }
            Err(err) => {
                set_command_feedback.set(format!("宿主文档同步失败：{err}"));
            }
        }
    }
}

fn bridge_ready_payload() -> ReadyPayload {
    ReadyPayload {
        runtime_name: RUNTIME_NAME,
        selectors: BridgeSelectorsPayload {
            root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
            stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
            editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
            toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
            slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
            handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
        },
        supported_commands: vec![
            "replaceContent",
            "setPageOptions",
            "setEditable",
            "undo",
            "redo",
            "focus",
            "requestCurrentBlockId",
        ],
        supports_embedded_mode: true,
    }
}

fn selection_has_rich_marks(selection: &TiptapSelectionState) -> bool {
    selection.bold
        || selection.italic
        || selection.underline
        || selection.strike
        || selection.highlight
        || selection.text_style
        || selection.link
}

fn apply_text_color(editor: TiptapEditorHandle, color: &str) -> Result<&'static str, String> {
    editor
        .set_color(TiptapColorAttributes {
            color: color.to_string(),
        })
        .map(|_| "已更新文字颜色")
        .map_err(|err| format!("命令执行失败：{err}"))
}

fn clear_text_color(editor: TiptapEditorHandle) -> Result<&'static str, String> {
    editor
        .unset_color()
        .map(|_| "已清除文字颜色")
        .map_err(|err| format!("命令执行失败：{err}"))
}

fn apply_highlight_color(
    editor: TiptapEditorHandle,
    color: Option<&str>,
) -> Result<&'static str, String> {
    match color {
        Some(color) => editor
            .set_highlight(Some(TiptapHighlightAttributes {
                color: Some(color.to_string()),
            }))
            .map(|_| "已更新背景色")
            .map_err(|err| format!("命令执行失败：{err}")),
        None => editor
            .unset_highlight()
            .map(|_| "已清除背景色")
            .map_err(|err| format!("命令执行失败：{err}")),
    }
}

fn apply_text_align(
    editor: TiptapEditorHandle,
    alignment: TiptapTextAlign,
) -> Result<&'static str, String> {
    let message = match alignment {
        TiptapTextAlign::Left => "已切到左对齐",
        TiptapTextAlign::Center => "已切到居中",
        TiptapTextAlign::Right => "已切到右对齐",
        TiptapTextAlign::Justify => "已切到两端对齐",
    };
    editor
        .set_text_align(alignment)
        .map(|_| message)
        .map_err(|err| format!("命令执行失败：{err}"))
}

fn run_slash_action(
    editor: TiptapEditorHandle,
    action: SlashActionKind,
) -> Result<&'static str, String> {
    let result = match action {
        SlashActionKind::Paragraph => editor.set_paragraph(),
        SlashActionKind::Heading1 => editor.toggle_heading(TiptapHeadingLevel::H1),
        SlashActionKind::Heading2 => editor.toggle_heading(TiptapHeadingLevel::H2),
        SlashActionKind::Heading3 => editor.toggle_heading(TiptapHeadingLevel::H3),
        SlashActionKind::BulletList => editor.toggle_bullet_list(),
        SlashActionKind::OrderedList => editor.toggle_ordered_list(),
        SlashActionKind::Todo => editor.toggle_task_list(),
        SlashActionKind::Quote => editor.toggle_blockquote(),
        SlashActionKind::CodeBlock => editor.toggle_code_block(Some(TiptapCodeBlockAttributes {
            language: Some("rust".into()),
        })),
        SlashActionKind::Divider => editor.set_horizontal_rule(),
    };

    result
        .map(|_| match action {
            SlashActionKind::Paragraph => "已切到段落",
            SlashActionKind::Heading1 => "已切到一级标题",
            SlashActionKind::Heading2 => "已切到二级标题",
            SlashActionKind::Heading3 => "已切到三级标题",
            SlashActionKind::BulletList => "已切到无序列表",
            SlashActionKind::OrderedList => "已切到有序列表",
            SlashActionKind::Todo => "已切到 Todo 列表",
            SlashActionKind::Quote => "已切到引用块",
            SlashActionKind::CodeBlock => "已切到代码块",
            SlashActionKind::Divider => "已插入分割线",
        })
        .map_err(|err| format!("命令执行失败：{err}"))
}

fn run_block_turn_into_action(
    editor: TiptapEditorHandle,
    block_index: usize,
    action: SlashActionKind,
) -> Result<&'static str, String> {
    let document = editor
        .get_json()
        .map_err(|err| format!("读取当前 JSON 失败：{err}"))?;
    let range = top_level_block_range(&document, block_index)
        .ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;

    match action {
        SlashActionKind::Divider => editor
            .insert_content_at(
                range,
                TiptapContent::json(json!({ "type": "horizontalRule" })),
                None,
            )
            .map(|_| "已把当前块转成分割线")
            .map_err(|err| format!("命令执行失败：{err}")),
        _ => {
            editor
                .set_text_selection(range)
                .map_err(|err| format!("选中当前块失败：{err}"))?;
            editor
                .clear_nodes()
                .map_err(|err| format!("清理当前块样式失败：{err}"))?;

            let message = run_slash_action(editor, action)?;
            let _ = editor.focus();
            let _ = editor.select_textblock_end();
            Ok(message)
        }
    }
}

fn apply_feedback_result<E>(setter: WriteSignal<String>, result: Result<&'static str, E>)
where
    E: Display,
{
    match result {
        Ok(message) => setter.set(message.to_string()),
        Err(err) => setter.set(format!("命令执行失败：{err}")),
    }
}

fn apply_ok_feedback<E>(
    setter: WriteSignal<String>,
    result: Result<(), E>,
    ok_message: &'static str,
) where
    E: Display,
{
    apply_feedback_result(setter, result.map(|_| ok_message));
}

fn normalize_layout_density(value: Option<String>) -> String {
    match value.as_deref() {
        Some("compact") => "compact".to_string(),
        Some("spacious") => "spacious".to_string(),
        _ => "normal".to_string(),
    }
}

#[component]
fn App(mount_options: MountOptions) -> impl IntoView {
    let editor = TiptapEditorHandle::new();
    let initial_document_id = mount_options
        .document_id
        .clone()
        .or_else(current_document_id);
    let initial_workspace_id = mount_options
        .workspace_id
        .clone()
        .or_else(current_workspace_id);
    let persisted_identity = resolve_persisted_document_identity(
        initial_document_id.clone(),
        initial_workspace_id.clone(),
    );
    let persisted = load_persisted_document(&persisted_identity);
    let restored_from_storage = persisted.is_some();
    let restored_html = persisted
        .as_ref()
        .and_then(|document| document.html.clone());
    let initial_title_value = mount_options
        .title
        .clone()
        .or_else(|| {
            persisted
                .as_ref()
                .map(|document| document.title.clone())
        })
        .unwrap_or_else(default_title);
    let initial_editor_content = mount_options
        .html
        .clone()
        .map(TiptapContent::html)
        .or_else(|| {
            mount_options
                .content
                .clone()
                .map(TiptapContent::json)
        })
        .or_else(|| {
            persisted.as_ref().map(|document| {
                document
                    .html
                    .clone()
                    .map(TiptapContent::html)
                    .unwrap_or_else(|| TiptapContent::json(document.content.clone()))
            })
        })
        .unwrap_or_else(initial_content);

    let (document_id, set_document_id) = signal(initial_document_id);
    let (workspace_id, set_workspace_id) = signal(initial_workspace_id);
    let (title, set_title) = signal(initial_title_value);
    let (read_only, set_read_only) = signal(mount_options.read_only.unwrap_or(false));
    let (revision, set_revision) = signal(mount_options.revision);
    let (conflict_detection_key, set_conflict_detection_key) =
        signal(mount_options.conflict_detection_key.clone());
    let (html_output, set_html_output) = signal(String::new());
    let (document_json, set_document_json) = signal(
        persisted
            .as_ref()
            .map(|document| document.content.clone())
            .unwrap_or_else(|| json!({"type": "doc", "content": []})),
    );
    let (json_output, set_json_output) = signal(String::new());
    let (selection_state, set_selection_state) = signal(TiptapSelectionState::default());
    let (dirty_count, set_dirty_count) = signal(0_u32);
    let (editor_focused, set_editor_focused) = signal(false);
    let (text_selection_active, set_text_selection_active) = signal(false);
    let (floating_toolbar_anchor, set_floating_toolbar_anchor) =
        signal(None::<FloatingToolbarAnchor>);
    let (locked_toolbar_anchor, set_locked_toolbar_anchor) = signal(None::<FloatingToolbarAnchor>);
    let (slash_open, set_slash_open) = signal(false);
    let (slash_index, set_slash_index) = signal(0_usize);
    let (turn_into_open, set_turn_into_open) = signal(false);
    let (color_menu_open, set_color_menu_open) = signal(false);
    let (more_menu_open, set_more_menu_open) = signal(false);
    let (hovered_block, set_hovered_block) = signal(None::<HoveredBlockState>);
    let (block_menu_anchor, set_block_menu_anchor) = signal(None::<HoveredBlockState>);
    let (block_menu_open, set_block_menu_open) = signal(false);
    let (block_turn_into_open, set_block_turn_into_open) = signal(false);
    let (pending_drag, set_pending_drag) = signal(None::<PendingDragState>);
    let (dragging_block_index, set_dragging_block_index) = signal(None::<usize>);
    let (dragging_block_anchor, set_dragging_block_anchor) = signal(None::<HoveredBlockState>);
    let (drop_indicator, set_drop_indicator) = signal(None::<DropIndicatorState>);
    let (suppress_handle_click, set_suppress_handle_click) = signal(false);
    let (command_feedback, set_command_feedback) = signal("等待第一次编辑".to_string());
    let initial_editable = mount_options.editable.unwrap_or(!read_only.get_untracked());
    let (editor_editable, set_editor_editable) = signal(initial_editable);
    let initial_page_options = mount_options.page_options.clone();
    let (wide_layout, set_wide_layout) =
        signal(initial_page_options.as_ref().and_then(|opts| opts.wide_layout).unwrap_or(false));
    let (small_text, set_small_text) =
        signal(initial_page_options.as_ref().and_then(|opts| opts.small_text).unwrap_or(false));
    let (layout_density, set_layout_density) = signal(normalize_layout_density(
        initial_page_options
            .as_ref()
            .and_then(|opts| opts.layout_density.clone()),
    ));
    let (show_heading_numbers, set_show_heading_numbers) = signal(
        initial_page_options
            .as_ref()
            .and_then(|opts| opts.show_heading_numbers)
            .unwrap_or(true),
    );
    let (embed_default_block_id, set_embed_default_block_id) = signal(
        initial_page_options
            .as_ref()
            .and_then(|opts| opts.embed_default_block_id.clone()),
    );

    {
        let block_menu_open = block_menu_open;
        Effect::new(move |_| {
            let Some(document) = window().and_then(|win| win.document()) else {
                return;
            };
            let Some(body) = document.body() else {
                return;
            };

            if block_menu_open.get() {
                let _ = body.style().set_property("overflow", "hidden");
            } else {
                let _ = body.style().remove_property("overflow");
            }
        });
    }

    {
        Effect::new(move |_| {
            let Some(stage) = editor_stage_element() else {
                return;
            };
            let height = stage.get_bounding_client_rect().height();
            if height > 0.0 {
                dispatch_runtime_event(HEIGHT_EVENT, &HeightPayload { height });
            }
        });
    }

    {
        let block_menu_open = block_menu_open;
        let set_block_turn_into_open = set_block_turn_into_open;
        Effect::new(move |_| {
            if !block_menu_open.get() {
                set_block_turn_into_open.set(false);
            }
        });
    }

    Effect::new({
        let editor = editor;
        let set_document_id = set_document_id;
        let set_workspace_id = set_workspace_id;
        let set_title = set_title;
        let set_read_only = set_read_only;
        let set_revision = set_revision;
        let set_conflict_detection_key = set_conflict_detection_key;
        let set_dirty_count = set_dirty_count;
        let set_html_output = set_html_output;
        let set_document_json = set_document_json;
        let set_json_output = set_json_output;
        let set_command_feedback = set_command_feedback;
        let set_editor_editable = set_editor_editable;
        let set_wide_layout = set_wide_layout;
        let set_small_text = set_small_text;
        let set_layout_density = set_layout_density;
        let set_show_heading_numbers = set_show_heading_numbers;
        let set_embed_default_block_id = set_embed_default_block_id;
        move |_| {
            let command_listener = Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
                let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
                    return;
                };
                let detail = custom_event.detail();
                let Ok(envelope) = serde_wasm_bindgen::from_value::<HostCommandEnvelope>(detail) else {
                    return;
                };
                if envelope.protocol.as_deref() != Some(PROTOCOL) {
                    return;
                }

                let Some(command_kind) = HostCommandKind::from_payload(envelope.payload.as_ref().unwrap_or(&HostCommandPayload {
                    command: None,
                    document_id: None,
                    workspace_id: None,
                    title: None,
                    content: None,
                    editable: None,
                    page_options: None,
                    block_id: None,
                    block_index: None,
                    text: None,
                    reference_document_id: None,
                    reference_block_id: None,
                    current_block_id: None,
                    selection: None,
                    revision: None,
                    conflict_detection_key: None,
                    read_only: None,
                })) else {
                    return;
                };

                let Some(payload) = envelope.payload else {
                    return;
                };

                match command_kind {
                    HostCommandKind::Undo => {
                        let _ = editor.undo();
                    }
                    HostCommandKind::Redo => {
                        let _ = editor.redo();
                    }
                    HostCommandKind::ReplaceContent | HostCommandKind::Bootstrap => {
                        apply_host_document_payload(
                            editor,
                            HostDocumentPayload {
                                document_id: payload.document_id,
                                workspace_id: payload.workspace_id,
                                title: payload.title,
                                content: payload.content,
                                revision: payload.revision,
                                conflict_detection_key: payload.conflict_detection_key,
                                read_only: payload.read_only.or(payload.editable.map(|editable| !editable)),
                            },
                            set_document_id,
                            set_workspace_id,
                            set_title,
                            set_read_only,
                            set_revision,
                            set_conflict_detection_key,
                            set_dirty_count,
                            set_html_output,
                            set_document_json,
                            set_json_output,
                            set_command_feedback,
                        );
                    }
                    HostCommandKind::SetEditable => {
                        let editable = payload.editable.unwrap_or(true);
                        set_editor_editable.set(editable);
                        set_read_only.set(!editable);
                        dispatch_runtime_state(
                            document_id.get_untracked(),
                            workspace_id.get_untracked(),
                            title.get_untracked(),
                            dirty_count.get_untracked(),
                            hovered_block.get_untracked(),
                            editor_focused.get_untracked(),
                            slash_open.get_untracked(),
                            turn_into_open.get_untracked(),
                            color_menu_open.get_untracked(),
                            more_menu_open.get_untracked(),
                            read_only.get_untracked(),
                        );
                    }
                    HostCommandKind::SetPageOptions => {
                        if let Some(page_options) = payload.page_options {
                            if let Some(value) = page_options.wide_layout {
                                set_wide_layout.set(value);
                            }
                            if let Some(value) = page_options.small_text {
                                set_small_text.set(value);
                            }
                            set_layout_density
                                .set(normalize_layout_density(page_options.layout_density));
                            if let Some(value) = page_options.show_heading_numbers {
                                set_show_heading_numbers.set(value);
                            }
                            set_embed_default_block_id.set(page_options.embed_default_block_id);
                            set_command_feedback.set("页面布局选项已同步".to_string());
                        }
                    }
                    HostCommandKind::Focus => {
                        let _ = editor.focus();
                    }
                    HostCommandKind::RequestCurrentBlockId => {
                        let current_block = current_block_info_from_index(hovered_block.get_untracked().map(|block| block.index));
                        dispatch_status_event(&HostStatusPayload {
                            document_id: document_id.get_untracked(),
                            workspace_id: workspace_id.get_untracked(),
                            title: title.get_untracked(),
                            dirty_count: dirty_count.get_untracked(),
                            selected_block_index: current_block.index,
                            current_block_id: current_block.block_id,
                            editor_focused: editor_focused.get_untracked(),
                            read_only: read_only.get_untracked(),
                            slash_open: slash_open.get_untracked(),
                            toolbar_open: toolbar_overlay_locked(
                                turn_into_open.get_untracked(),
                                color_menu_open.get_untracked(),
                                more_menu_open.get_untracked(),
                            ),
                        });
                    }
                    HostCommandKind::InsertInlineReference | HostCommandKind::InsertEmbedReference => {
                        let reference_text = match (
                            payload.reference_document_id.or(payload.document_id),
                            payload.reference_block_id.or(payload.block_id),
                        ) {
                            (Some(document_id), Some(block_id)) => format!(
                                "[[引用 {}:{}]]",
                                document_id,
                                block_id
                            ),
                            (Some(document_id), None) => format!("[[引用 {}]]", document_id),
                            _ => "[[引用]]".to_string(),
                        };
                        let node = if matches!(command_kind, HostCommandKind::InsertEmbedReference) {
                            json!({
                                "type": "blockquote",
                                "content": [
                                    {
                                        "type": "paragraph",
                                        "content": [
                                            { "type": "text", "text": reference_text }
                                        ]
                                    }
                                ]
                            })
                        } else {
                            json!({
                                "type": "paragraph",
                                "content": [
                                    { "type": "text", "text": reference_text }
                                ]
                            })
                        };
                        let _ = editor.insert_content(TiptapContent::json(node), None);
                    }
                }
            }));

            if let Some(document) = window().and_then(|win| win.document()) {
                let listener_ref = command_listener.as_ref().unchecked_ref();
                let _ = document.add_event_listener_with_callback(COMMAND_EVENT, listener_ref);
                if let Some((mount_id, _, _)) = runtime_mount_context() {
                    let target: EventTarget = document.into();
                    register_runtime_listener(mount_id, target, command_listener);
                }
            }
        }
    });

    {
        let turn_into_open = turn_into_open;
        let color_menu_open = color_menu_open;
        let more_menu_open = more_menu_open;
        let set_locked_toolbar_anchor = set_locked_toolbar_anchor;
        Effect::new(move |_| {
            if !(turn_into_open.get() || color_menu_open.get() || more_menu_open.get()) {
                set_locked_toolbar_anchor.set(None);
            }
        });
    }

    {
        let editor_focused = editor_focused;
        let text_selection_active = text_selection_active;
        let slash_open = slash_open;
        let block_menu_open = block_menu_open;
        let turn_into_open = turn_into_open;
        let color_menu_open = color_menu_open;
        let more_menu_open = more_menu_open;
        let set_turn_into_open = set_turn_into_open;
        let set_color_menu_open = set_color_menu_open;
        let set_more_menu_open = set_more_menu_open;
        Effect::new(move |_| {
            let toolbar_locked = toolbar_overlay_locked(
                turn_into_open.get_untracked(),
                color_menu_open.get_untracked(),
                more_menu_open.get_untracked(),
            );
            if ((!editor_focused.get() || !text_selection_active.get()) && !toolbar_locked)
                || slash_open.get()
                || block_menu_open.get()
            {
                set_turn_into_open.set(false);
                set_color_menu_open.set(false);
                set_more_menu_open.set(false);
            }
        });
    }

    {
        let set_editor_focused = set_editor_focused;
        let set_text_selection_active = set_text_selection_active;
        let set_floating_toolbar_anchor = set_floating_toolbar_anchor;
        let set_slash_open = set_slash_open;
        let set_turn_into_open = set_turn_into_open;
        let set_hovered_block = set_hovered_block;
        let set_block_menu_anchor = set_block_menu_anchor;
        let set_block_menu_open = set_block_menu_open;
        let set_pending_drag = set_pending_drag;
        let set_dragging_block_index = set_dragging_block_index;
        let set_dragging_block_anchor = set_dragging_block_anchor;
        let set_drop_indicator = set_drop_indicator;
        let set_command_feedback = set_command_feedback;
        let set_suppress_handle_click = set_suppress_handle_click;
        let pending_drag = pending_drag;
        let dragging_block_index = dragging_block_index;
        let mouseup_handle = window_event_listener(ev::mouseup, move |event: MouseEvent| {
            if let Some(source_index) = dragging_block_index.try_get_untracked().flatten() {
                let indicator = drop_indicator_from_point(event.client_x(), event.client_y());
                if let Some(indicator) = indicator {
                    match editor.get_html() {
                        Ok(current_html) => match reorder_top_level_block_html(
                            &current_html,
                            source_index,
                            indicator.index,
                            indicator.placement,
                        ) {
                            Ok(next_html) => {
                                apply_html_update(
                                    editor,
                                    &persisted_identity,
                                    next_html,
                                    set_dirty_count,
                                    set_html_output,
                                    set_document_json,
                                    set_json_output,
                                    title,
                                    set_command_feedback,
                                    "已通过块手柄拖拽重排",
                                );
                            }
                            Err(err) => {
                                set_command_feedback.set(err);
                            }
                        },
                        Err(err) => {
                            let _ = set_command_feedback.try_set(format!("读取当前 HTML 失败：{err}"));
                        }
                    }
                } else {
                    let _ = set_command_feedback.try_set("块拖拽已取消".to_string());
                }
                let _ = set_dragging_block_index.try_set(None);
                let _ = set_dragging_block_anchor.try_set(None);
                let _ = set_drop_indicator.try_set(None);
                let _ = set_pending_drag.try_set(None);
                let _ = set_block_menu_open.try_set(false);
                let _ = set_block_menu_anchor.try_set(None);
                let _ = set_hovered_block.try_set(None);
                let _ = set_suppress_handle_click.try_set(true);
            } else if pending_drag.try_get_untracked().flatten().is_some() {
                let _ = set_pending_drag.try_set(None);
            }

            if let Some(target) = event.target() {
                if let Some(element) = target_element(target) {
                    if element
                        .closest(&format!("{HANDLE_SHELL_SELECTOR}, .floating-toolbar"))
                        .ok()
                        .flatten()
                        .is_some()
                    {
                        return;
                    }
                }
            }
            let _ = try_sync_editor_overlay_state(
                set_editor_focused,
                set_text_selection_active,
                set_floating_toolbar_anchor,
                set_slash_open,
                set_turn_into_open,
                set_hovered_block,
                set_block_menu_anchor,
                set_block_menu_open,
            );
        });
        let keyup_handle = window_event_listener(ev::keyup, move |_| {
            let _ = try_sync_editor_overlay_state(
                set_editor_focused,
                set_text_selection_active,
                set_floating_toolbar_anchor,
                set_slash_open,
                set_turn_into_open,
                set_hovered_block,
                set_block_menu_anchor,
                set_block_menu_open,
            );
        });

        on_cleanup(move || {
            drop(mouseup_handle);
            drop(keyup_handle);
        });
    }

    {
        let set_editor_focused = set_editor_focused;
        let set_slash_open = set_slash_open;
        let set_turn_into_open = set_turn_into_open;
        let set_hovered_block = set_hovered_block;
        let set_block_menu_anchor = set_block_menu_anchor;
        let set_block_menu_open = set_block_menu_open;
        let set_pending_drag = set_pending_drag;
        let set_dragging_block_index = set_dragging_block_index;
        let set_dragging_block_anchor = set_dragging_block_anchor;
        let set_drop_indicator = set_drop_indicator;
        let set_command_feedback = set_command_feedback;
        let pending_drag = pending_drag;
        let dragging_block_index = dragging_block_index;
        let dragging_block_anchor = dragging_block_anchor;
        let mousemove_handle = window_event_listener(ev::mousemove, move |event: MouseEvent| {
            if dragging_block_index.try_get_untracked().flatten().is_some() {
                let _ = set_editor_focused.try_set(true);
                let _ = set_drop_indicator.try_set(drop_indicator_from_point(
                    event.client_x(),
                    event.client_y(),
                ));
                return;
            }

            let Some(pending) = pending_drag.try_get_untracked().flatten() else {
                return;
            };

            if event.buttons() & 1 == 0 {
                let _ = set_pending_drag.try_set(None);
                return;
            }

            let dx = (event.client_x() - pending.start_x).abs();
            let dy = (event.client_y() - pending.start_y).abs();
            if dx < 4 && dy < 4 {
                return;
            }

            let _ = set_editor_focused.try_set(true);
            let _ = set_slash_open.try_set(false);
            let _ = set_turn_into_open.try_set(false);
            let _ = set_block_menu_open.try_set(false);
            let _ = set_block_menu_anchor.try_set(None);
            let _ = set_hovered_block.try_set(Some(pending.anchor.clone()));
            let _ = set_dragging_block_index.try_set(Some(pending.index));
            let next_anchor = dragging_block_anchor
                .try_get_untracked()
                .flatten()
                .unwrap_or_else(|| pending.anchor.clone());
            let _ = set_dragging_block_anchor.try_set(Some(next_anchor));
            let _ = set_pending_drag.try_set(None);
            let _ = set_drop_indicator.try_set(drop_indicator_from_point(
                event.client_x(),
                event.client_y(),
            ));
            let _ = set_command_feedback.try_set(format!("开始拖拽：{}", pending.anchor.label));
        });

        on_cleanup(move || drop(mousemove_handle));
    }

    {
        let editor = editor;
        let set_slash_open = set_slash_open;
        let slash_open = slash_open;
        let slash_index = slash_index;
        let set_slash_index = set_slash_index;
        let set_turn_into_open = set_turn_into_open;
        let set_block_menu_open = set_block_menu_open;
        let set_block_menu_anchor = set_block_menu_anchor;
        let set_command_feedback = set_command_feedback;
        let keydown_handle = window_event_listener(ev::keydown, move |event| {
            let focused = active_editor_stage();
            if !focused {
                return;
            }

            if event.key() == "Escape" {
                let _ = set_slash_open.try_set(false);
                let _ = set_turn_into_open.try_set(false);
                let _ = set_block_menu_open.try_set(false);
                let _ = set_block_menu_anchor.try_set(None);
                return;
            }

            if event.key() == "/" && !event.ctrl_key() && !event.meta_key() && !event.alt_key() {
                event.prevent_default();
                let _ = set_slash_open.try_set(true);
                let _ = set_turn_into_open.try_set(false);
                let _ = set_block_menu_open.try_set(false);
                let _ = set_block_menu_anchor.try_set(None);
                let _ = set_slash_index.try_set(0);
                let _ = set_command_feedback.try_set("Slash 菜单已打开".to_string());
                return;
            }

            if !slash_open.try_get_untracked().unwrap_or(false) {
                return;
            }

            match event.key().as_str() {
                "ArrowDown" => {
                    event.prevent_default();
                    let next = (slash_index.try_get_untracked().unwrap_or(0) + 1) % SLASH_ACTIONS.len();
                    let _ = set_slash_index.try_set(next);
                }
                "ArrowUp" => {
                    event.prevent_default();
                    let current = slash_index.try_get_untracked().unwrap_or(0);
                    let next = if current == 0 {
                        SLASH_ACTIONS.len() - 1
                    } else {
                        current - 1
                    };
                    let _ = set_slash_index.try_set(next);
                }
                "Enter" => {
                    event.prevent_default();
                    let action = SLASH_ACTIONS[slash_index.try_get_untracked().unwrap_or(0)].kind;
                    match run_slash_action(editor, action) {
                        Ok(message) => {
                            let _ = set_command_feedback.try_set(message.to_string());
                        }
                        Err(message) => {
                            let _ = set_command_feedback.try_set(message);
                        }
                    }
                    let _ = set_slash_open.try_set(false);
                }
                "Escape" => {
                    event.prevent_default();
                    let _ = set_slash_open.try_set(false);
                    let _ = set_command_feedback.try_set("Slash 菜单已关闭".to_string());
                }
                _ => {}
            }
        });
        on_cleanup(move || drop(keydown_handle));
    }

    let has_task_schema = move || {
        let snapshot = json_output.get();
        snapshot.contains("\"taskList\"") && snapshot.contains("\"taskItem\"")
    };

    let selection_text = move || selection_summary(&selection_state.get());
    let is_embedded = is_embedded_mode();

    let on_link_click = {
        let editor = editor;
        let selection_state = selection_state;
        let set_command_feedback = set_command_feedback;
        move |_| {
            if selection_state.get().link {
                apply_feedback_result(
                    set_command_feedback,
                    editor.unset_link().map(|_| "已移除链接"),
                );
                return;
            }

            let href = window()
                .and_then(|win| win.prompt_with_message("输入链接地址").ok().flatten())
                .filter(|value| !value.trim().is_empty());

            match href {
                Some(value) => apply_feedback_result(
                    set_command_feedback,
                    editor
                        .set_link(TiptapLinkResource {
                            href: value,
                            target: Some("_blank".into()),
                            rel: Some("noopener noreferrer".into()),
                            class: None,
                        })
                        .map(|_| "已插入链接"),
                ),
                None => set_command_feedback.set("链接操作已取消".to_string()),
            }
        }
    };

    view! {
        <style>{SPIKE_STYLE}</style>
        <main
            class=move || if is_embedded { "app-shell app-shell-embedded" } else { "app-shell" }
            data-testid="mnote-leptos-tiptap-host"
            data-mnote-runtime=RUNTIME_NAME
            data-mnote-runtime-bridge="true"
        >
            {move || {
                if is_embedded {
                    ().into_any()
                } else {
                    view! {
                        <section class="hero-bar">
                            <div class="hero-meta">
                                <span class="hero-tag">"P0 / Tiptap-like 主编辑器体验"</span>
                                <h1 class="hero-title">"把 Spike 收敛成能检验的最小产品页壳"</h1>
                                <p class="hero-copy">
                                    "这里继续只做 "
                                    <code>"8123"</code>
                                    " 的 Leptos + Tiptap P0 体验验证，不挪动正式 "
                                    <code>"/documents/[id]"</code>
                                    " 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。"
                                </p>
                            </div>
                            <div class="hero-actions">
                                <div class="hero-chip">
                                    <span>"变更计数"</span>
                                    <strong>{move || dirty_count.get().to_string()}</strong>
                                </div>
                                <div class="hero-chip" id="todo-schema-status">
                                    <span>"Todo Schema"</span>
                                    <strong>{move || if has_task_schema() { "ready" } else { "pending" }}</strong>
                                </div>
                                <div class="hero-chip" id="selection-summary-chip">
                                    <span>"Selection"</span>
                                    <strong>{selection_text}</strong>
                                </div>
                            </div>
                        </section>
                    }.into_any()
                }
            }}

            <section class="editor-card">
                {move || {
                    if is_embedded {
                        ().into_any()
                    } else {
                        view! {
                            <>
                                <div class="editor-topbar">
                                    <div class="editor-crumbs">
                                        <span>"空间 / 主文档 /"</span>
                                        <strong>"Editor Baseline Reset"</strong>
                                    </div>
                                    <div class="editor-crumbs">
                                        <span>"反馈："</span>
                                        <strong id="command-feedback">{move || command_feedback.get()}</strong>
                                    </div>
                                </div>

                                <input
                                    class="title-input"
                                    prop:value=move || title.get()
                                    on:input=move |event| {
                                        let next_title = event_target_value(&event);
                                        set_title.set(next_title.clone());
                                        match persist_document_state(
                                            &runtime_persisted_identity(document_id, workspace_id),
                                            &next_title,
                                            &document_json.get_untracked(),
                                            Some(html_output.get_untracked()),
                                        ) {
                                            Ok(()) => set_command_feedback.set("标题已写入本地草稿".to_string()),
                                            Err(err) => set_command_feedback.set(format!("标题保存失败：{err}")),
                                        }
                                    }
                                    placeholder="给这篇页面起个标题"
                                    aria-label="Spike 页面标题"
                                />

                                <div class="editor-subcopy">
                                    <span class="hint-pill">"输入 “/” 打开真正的最小 slash 菜单"</span>
                                    <span class="hint-pill">"选中文本时会出现最小浮动工具条"</span>
                                    <span class="hint-pill">"Todo 现在走 taskList / taskItem，而不是占位 HTML"</span>
                                    <span class="hint-pill">"把鼠标移到块左侧，点击 ⠿ 打开块菜单并拖拽重排"</span>
                                    <span class="hint-pill">"本页会把 JSON 草稿写入 localStorage，刷新后不丢结构"</span>
                                </div>
                            </>
                        }.into_any()
                    }
                }}

                <div
                    id="editor-stage"
                    class="editor-stage"
                    data-testid="mnote-leptos-tiptap-editor-stage"
                    data-page-wide-layout=move || wide_layout.get().to_string()
                    data-page-show-heading-numbers=move || show_heading_numbers.get().to_string()
                    data-page-embed-default-block-id=move || embed_default_block_id.get().unwrap_or_default()
                    on:click=move |event: MouseEvent| {
                        let Some(target) = event.target() else {
                            return;
                        };

                        if let Some(element) = target_element(target.clone()) {
                            if element.closest(HANDLE_SHELL_SELECTOR).ok().flatten().is_some() {
                                return;
                            }
                        }

                        set_editor_focused.set(true);
                        set_block_menu_open.set(false);
                        set_block_menu_anchor.set(None);
                        set_turn_into_open.set(false);
                        set_hovered_block.set(hovered_block_from_target(target));
                    }
                    on:mousemove=move |event: MouseEvent| {
                        if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
                            return;
                        }

                        let Some(target) = event.target() else {
                            return;
                        };

                        if let Some(element) = target_element(target.clone()) {
                            if element.closest(HANDLE_SHELL_SELECTOR).ok().flatten().is_some() {
                                return;
                            }
                        }

                        set_editor_focused.set(true);
                        if let Some(block) = hovered_block_from_target(target) {
                            set_hovered_block.set(Some(block));
                            return;
                        }

                        if let Some(current_block) = hovered_block.get_untracked() {
                            if pointer_in_handle_corridor(event.client_x(), event.client_y(), &current_block) {
                                return;
                            }
                        }

                        set_hovered_block.set(None);
                    }
                    on:mouseover=move |event: MouseEvent| {
                        if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
                            return;
                        }
                        let Some(target) = event.target() else {
                            return;
                        };
                        if let Some(block) = hovered_block_from_target(target) {
                            set_editor_focused.set(true);
                            set_hovered_block.set(Some(block));
                        }
                    }
                    on:mouseleave=move |_| {
                        if !block_menu_open.get_untracked() && dragging_block_index.get_untracked().is_none() {
                            set_hovered_block.set(None);
                        }
                    }
                    on:dragover=move |event: DragEvent| {
                        if dragging_block_index.get_untracked().is_none() {
                            return;
                        }

                        event.prevent_default();
                        let Some(target) = event.target() else {
                            return;
                        };

                        if let Some(element) = target_element(target.clone()) {
                            if element.closest(HANDLE_SHELL_SELECTOR).ok().flatten().is_some() {
                                return;
                            }
                        }

                        set_drop_indicator.set(drop_indicator_from_target(target, event.client_y()));
                    }
                    on:drop=move |event: DragEvent| {
                        event.prevent_default();
                        let Some(source_index) = dragging_block_index.get_untracked() else {
                            return;
                        };

                        let Some(target) = event.target() else {
                            set_dragging_block_index.set(None);
                            set_dragging_block_anchor.set(None);
                            set_drop_indicator.set(None);
                            return;
                        };

                        let Some(indicator) = drop_indicator_from_target(target, event.client_y()) else {
                            set_dragging_block_index.set(None);
                            set_dragging_block_anchor.set(None);
                            set_drop_indicator.set(None);
                            return;
                        };

                        match editor.get_html() {
                            Ok(current_html) => match reorder_top_level_block_html(
                                &current_html,
                                source_index,
                                indicator.index,
                                indicator.placement,
                            ) {
                                Ok(next_html) => {
                                    apply_html_update(
                                        editor,
                                        &runtime_persisted_identity(document_id, workspace_id),
                                        next_html,
                                        set_dirty_count,
                                        set_html_output,
                                        set_document_json,
                                        set_json_output,
                                        title,
                                        set_command_feedback,
                                        "已通过块手柄拖拽重排",
                                    );
                                }
                                Err(err) => {
                                    set_command_feedback.set(err);
                                }
                            },
                            Err(err) => {
                                set_command_feedback.set(format!("读取当前 HTML 失败：{err}"));
                            }
                        }

                        set_dragging_block_index.set(None);
                        set_dragging_block_anchor.set(None);
                        set_drop_indicator.set(None);
                        set_block_menu_open.set(false);
                        set_block_menu_anchor.set(None);
                        set_hovered_block.set(None);
                    }
                >
                    {move || {
                        let toolbar_locked = toolbar_overlay_locked(
                            turn_into_open.get(),
                            color_menu_open.get(),
                            more_menu_open.get(),
                        );
                        let toolbar_anchor = if toolbar_locked {
                            locked_toolbar_anchor
                                .get()
                                .or_else(|| floating_toolbar_anchor.get())
                        } else {
                            floating_toolbar_anchor.get()
                        };
                        if let Some(anchor) = toolbar_anchor {
                            if ((!editor_focused.get() || !text_selection_active.get()) && !toolbar_locked)
                                || slash_open.get()
                                || block_menu_open.get()
                                || dragging_block_index.get().is_some()
                            {
                                return ().into_any();
                            }
                            view! {
                                <div
                                    class="floating-toolbar"
                                    data-testid="mnote-leptos-tiptap-toolbar"
                                    style:top=format!("{}px", anchor.top)
                                    style:left=format!("{}px", anchor.left)
                                    on:mousedown=move |event: MouseEvent| {
                                        if event_target_matches_selector(
                                            event.target(),
                                            ".toolbar-btn, .toolbar-menu-item, .turn-into-item",
                                        ) {
                                            event.prevent_default();
                                        }
                                        event.stop_propagation();
                                    }
                                    on:click=move |event: MouseEvent| {
                                        event.stop_propagation();
                                    }
                                >
                                    <div class="floating-toolbar-group">
                                        <div class="toolbar-popover-anchor">
                                            <button
                                                class="toolbar-btn"
                                                data-wide="true"
                                                data-active=move || turn_into_open.get().to_string()
                                                data-testid="toolbar-turn-into"
                                                on:click=move |_| {
                                                    let next_open = !turn_into_open.get_untracked();
                                                    if next_open {
                                                        set_locked_toolbar_anchor
                                                            .set(floating_toolbar_anchor.get_untracked());
                                                    }
                                                    set_turn_into_open.set(next_open);
                                                    set_block_menu_open.set(false);
                                                }
                                            >
                                                "Text"
                                            </button>
                                            <div
                                                class="turn-into-panel"
                                                data-testid="turn-into-panel"
                                                style:display=move || if turn_into_open.get() { "grid" } else { "none" }
                                                on:mousedown=move |event: MouseEvent| {
                                                    event.stop_propagation();
                                                }
                                                on:click=move |event: MouseEvent| {
                                                    event.stop_propagation();
                                                }
                                                on:wheel=move |event: WheelEvent| {
                                                    trap_scroll_inside_menu(&event);
                                                }
                                            >
                                                {SLASH_ACTIONS.iter().map(|action| {
                                                    let kind = action.kind;
                                                    let label = action.label;
                                                    let description = action.description;
                                                    let testid = format!("turn-into-{}", action.id);
                                                    view! {
                                                        <button
                                                            class="turn-into-item"
                                                            data-testid=testid
                                                            on:click=move |_| {
                                                                apply_feedback_result(set_command_feedback, run_slash_action(editor, kind));
                                                                set_turn_into_open.set(false);
                                                            }
                                                        >
                                                            {label}
                                                            <span>{description}</span>
                                                        </button>
                                                    }
                                                }).collect_view()}
                                            </div>
                                        </div>
                                    </div>

                                    <div class="toolbar-separator"></div>

                                    <div class="floating-toolbar-group">
                                        <button
                                            class="toolbar-btn"
                                            data-active=move || selection_state.get().bold.to_string()
                                            data-testid="toolbar-bold"
                                            on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_bold(), "已更新行内样式")
                                        >
                                            "B"
                                        </button>
                                        <button
                                            class="toolbar-btn"
                                            data-active=move || selection_state.get().italic.to_string()
                                            data-testid="toolbar-italic"
                                            on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_italic(), "已更新行内样式")
                                        >
                                            "I"
                                        </button>
                                        <button
                                            class="toolbar-btn"
                                            data-active=move || selection_state.get().underline.to_string()
                                            data-testid="toolbar-underline"
                                            on:click=move |_| apply_ok_feedback(
                                                set_command_feedback,
                                                editor.toggle_mark(TiptapMarkName::Underline, None, None),
                                                "已更新行内样式",
                                            )
                                        >
                                            "U"
                                        </button>
                                        <button
                                            class="toolbar-btn"
                                            data-active=move || selection_state.get().strike.to_string()
                                            data-testid="toolbar-strike"
                                            on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_strike(), "已更新行内样式")
                                        >
                                            "S"
                                        </button>
                                        <button
                                            class="toolbar-btn"
                                            data-active="false"
                                            data-testid="toolbar-code"
                                            on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_code(), "已更新行内样式")
                                        >
                                            "</>"
                                        </button>
                                        <button
                                            class="toolbar-btn"
                                            data-active=move || selection_state.get().link.to_string()
                                            data-testid="toolbar-link"
                                            on:click=on_link_click
                                        >
                                            "Link"
                                        </button>
                                        <div class="toolbar-popover-anchor">
                                            <button
                                                class="toolbar-btn"
                                                data-wide="true"
                                                data-active=move || (selection_state.get().text_style
                                                    || selection_state.get().highlight
                                                    || color_menu_open.get()).to_string()
                                                data-testid="toolbar-color"
                                                on:click=move |_| {
                                                    let next_open = !color_menu_open.get_untracked();
                                                    if next_open {
                                                        set_locked_toolbar_anchor
                                                            .set(floating_toolbar_anchor.get_untracked());
                                                    }
                                                    set_color_menu_open.set(next_open);
                                                    set_more_menu_open.set(false);
                                                    set_turn_into_open.set(false);
                                                }
                                            >
                                                "Color"
                                            </button>
                                            <div
                                                class="toolbar-popover-panel"
                                                data-testid="toolbar-color-panel"
                                                style:display=move || if color_menu_open.get() { "grid" } else { "none" }
                                                on:mousedown=move |event: MouseEvent| {
                                                    event.stop_propagation();
                                                }
                                                on:click=move |event: MouseEvent| {
                                                    event.stop_propagation();
                                                }
                                                on:wheel=move |event: WheelEvent| {
                                                    trap_scroll_inside_menu(&event);
                                                }
                                            >
                                                <div class="toolbar-popover-section">
                                                    <div class="toolbar-popover-title">"文字颜色"</div>
                                                    <div class="toolbar-color-grid">
                                                        {TOOLBAR_TEXT_COLORS.iter().map(|option| {
                                                            let value = option.value;
                                                            let label = option.label;
                                                            let testid = format!("toolbar-text-color-{}", option.id);
                                                            view! {
                                                                <button
                                                                    class="toolbar-menu-item toolbar-color-item"
                                                                    data-testid=testid
                                                                    on:click=move |_| {
                                                                        apply_feedback_result(
                                                                            set_command_feedback,
                                                                            apply_text_color(editor, value),
                                                                        );
                                                                        set_color_menu_open.set(false);
                                                                    }
                                                                >
                                                                    <span
                                                                        class="toolbar-color-chip"
                                                                        style:background=value
                                                                    ></span>
                                                                    <strong>{label}</strong>
                                                                </button>
                                                            }
                                                        }).collect_view()}
                                                    </div>
                                                </div>

                                                <div class="toolbar-popover-section">
                                                    <div class="toolbar-popover-title">"背景颜色"</div>
                                                    <div class="toolbar-color-grid">
                                                        {TOOLBAR_HIGHLIGHT_COLORS.iter().map(|option| {
                                                            let value = option.value;
                                                            let label = option.label;
                                                            let testid = format!("toolbar-highlight-color-{}", option.id);
                                                            view! {
                                                                <button
                                                                    class="toolbar-menu-item toolbar-color-item"
                                                                    data-testid=testid
                                                                    on:click=move |_| {
                                                                        apply_feedback_result(
                                                                            set_command_feedback,
                                                                            apply_highlight_color(editor, Some(value)),
                                                                        );
                                                                        set_color_menu_open.set(false);
                                                                    }
                                                                >
                                                                    <span
                                                                        class="toolbar-color-chip"
                                                                        style:background=value
                                                                    ></span>
                                                                    <strong>{label}</strong>
                                                                </button>
                                                            }
                                                        }).collect_view()}
                                                    </div>
                                                </div>

                                                <div class="toolbar-popover-section">
                                                    <button
                                                        class="toolbar-menu-item"
                                                        data-testid="toolbar-clear-color"
                                                        on:click=move |_| {
                                                            apply_feedback_result(
                                                                set_command_feedback,
                                                                clear_text_color(editor),
                                                            );
                                                            let _ = apply_highlight_color(editor, None);
                                                            set_color_menu_open.set(false);
                                                        }
                                                    >
                                                        <strong>"清除颜色"</strong>
                                                        <span>"移除文字色与背景色"</span>
                                                    </button>
                                                </div>
                                            </div>
                                        </div>
                                    </div>

                                    <div class="toolbar-separator"></div>

                                    <div class="floating-toolbar-group">
                                        <div class="toolbar-popover-anchor">
                                            <button
                                                class="toolbar-btn"
                                                data-wide="true"
                                                data-active=move || (selection_state.get().align_center
                                                    || selection_state.get().align_right
                                                    || selection_state.get().align_justify
                                                    || more_menu_open.get()).to_string()
                                                data-testid="toolbar-more"
                                                on:click=move |_| {
                                                    let next_open = !more_menu_open.get_untracked();
                                                    if next_open {
                                                        set_locked_toolbar_anchor
                                                            .set(floating_toolbar_anchor.get_untracked());
                                                    }
                                                    set_more_menu_open.set(next_open);
                                                    set_color_menu_open.set(false);
                                                    set_turn_into_open.set(false);
                                                }
                                            >
                                                "More"
                                            </button>
                                            <div
                                                class="toolbar-popover-panel"
                                                data-testid="toolbar-more-panel"
                                                style:display=move || if more_menu_open.get() { "grid" } else { "none" }
                                                on:mousedown=move |event: MouseEvent| {
                                                    event.stop_propagation();
                                                }
                                                on:click=move |event: MouseEvent| {
                                                    event.stop_propagation();
                                                }
                                                on:wheel=move |event: WheelEvent| {
                                                    trap_scroll_inside_menu(&event);
                                                }
                                            >
                                                <div class="toolbar-popover-section">
                                                    <div class="toolbar-popover-title">"对齐方式"</div>
                                                    <div class="toolbar-more-list">
                                                        {TOOLBAR_ALIGN_OPTIONS.iter().map(|option| {
                                                            let label = option.label;
                                                            let alignment = option.alignment;
                                                            let testid = format!("toolbar-align-{}", option.id);
                                                            let active = match option.alignment {
                                                                TiptapTextAlign::Left => selection_state.get().align_left,
                                                                TiptapTextAlign::Center => selection_state.get().align_center,
                                                                TiptapTextAlign::Right => selection_state.get().align_right,
                                                                TiptapTextAlign::Justify => selection_state.get().align_justify,
                                                            };
                                                            view! {
                                                                <button
                                                                    class="toolbar-menu-item toolbar-more-item"
                                                                    data-active=active.to_string()
                                                                    data-testid=testid
                                                                    on:click=move |_| {
                                                                        apply_feedback_result(
                                                                            set_command_feedback,
                                                                            apply_text_align(editor, alignment),
                                                                        );
                                                                        set_more_menu_open.set(false);
                                                                    }
                                                                >
                                                                    <strong>{label}</strong>
                                                                    <span>"按官方浮动工具条对当前块对齐"</span>
                                                                </button>
                                                            }
                                                        }).collect_view()}
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            }.into_any()
                        } else {
                            ().into_any()
                        }
                    }}

                    {move || {
                        if let Some(indicator) = drop_indicator.get() {
                            let drop_indicator_left = editor_stage_element()
                                .map(|stage| {
                                    let stage_rect = stage.get_bounding_client_rect();
                                    format!("{}px", content_column_left(stage_rect.width()))
                                })
                                .unwrap_or_else(|| "56px".to_string());
                            let drop_indicator_right = editor_stage_element()
                                .map(|stage| {
                                    let stage_rect = stage.get_bounding_client_rect();
                                    let content_left = content_column_left(stage_rect.width());
                                    format!("{}px", content_left)
                                })
                                .unwrap_or_else(|| "56px".to_string());
                            view! {
                                <div
                                    class="block-drop-indicator"
                                    data-testid="block-drop-indicator"
                                    style:top=format!("{}px", indicator.top)
                                    style:left=drop_indicator_left
                                    style:right=drop_indicator_right
                                ></div>
                            }.into_any()
                        } else {
                            ().into_any()
                        }
                    }}

                    {move || {
                        let active_block = if block_menu_open.get() {
                            block_menu_anchor.get()
                        } else if dragging_block_index.get().is_some() {
                            dragging_block_anchor.get()
                        } else {
                            hovered_block.get().or_else(|| {
                                if editor_focused.get() && !text_selection_active.get() && !slash_open.get() {
                                    block_state_from_index(0)
                                } else {
                                    None
                                }
                            })
                        };

                        if let Some(block) = active_block {
                            let dragging_index = dragging_block_index.get();
                            let is_dragging = dragging_index
                                .map(|active| active == block.index)
                                .unwrap_or(false);

                            if !editor_focused.get()
                                || text_selection_active.get()
                                || slash_open.get()
                                || (dragging_index.is_some() && !is_dragging)
                            {
                                return ().into_any();
                            }

                            let anchor_top = format!("{}px", hover_anchor_top(&block));
                            let anchor_transform = hover_anchor_transform(&block);
                            let handle_left = editor_stage_element()
                                .map(|stage| {
                                    let stage_rect = stage.get_bounding_client_rect();
                                    format!("{}px", handle_lane_left(stage_rect.width()))
                                })
                                .unwrap_or_else(|| "12px".to_string());
                            let block_index = block.index;
                            let block_label = block.label.clone();
                            let click_block_label = block_label.clone();
                            let menu_block_label = block_label.clone();
                            let duplicate_block_label = block_label.clone();
                            let delete_block_label = block_label.clone();
                            let insert_block_label = block_label.clone();
                            let click_anchor_block = block.clone();
                            let drag_anchor_block = block.clone();

                            view! {
                                <div
                                    class="block-handle-shell"
                                    data-testid="mnote-leptos-tiptap-handle"
                                    data-dragging=move || if is_dragging { "true" } else { "false" }
                                    style:left=handle_left
                                    style:top=anchor_top
                                    style:transform=anchor_transform
                                >
                                    <button
                                        class="block-handle-insert"
                                        data-testid="block-insert-trigger"
                                        aria-label="Insert block"
                                        title="Insert block"
                                        on:click=move |event: MouseEvent| {
                                            event.prevent_default();
                                            event.stop_propagation();
                                            set_turn_into_open.set(false);
                                            set_block_menu_open.set(false);
                                            set_block_menu_anchor.set(None);
                                            match editor.get_html() {
                                                Ok(current_html) => match insert_top_level_paragraph_after_html(&current_html, block_index) {
                                                    Ok(next_html) => {
                                                        apply_html_update(
                                                            editor,
                                                            &runtime_persisted_identity(document_id, workspace_id),
                                                            next_html,
                                                            set_dirty_count,
                                                            set_html_output,
                                                            set_document_json,
                                                            set_json_output,
                                                            title,
                                                            set_command_feedback,
                                                            format!("已在 {} 后插入新块", insert_block_label),
                                                        );
                                                        set_slash_open.set(false);
                                                        set_slash_index.set(0);
                                                        set_hovered_block.set(block_state_from_index(block_index + 1));
                                                    }
                                                    Err(err) => {
                                                        set_command_feedback.set(err);
                                                    }
                                                },
                                                Err(err) => {
                                                    set_command_feedback.set(format!("读取当前 HTML 失败：{err}"));
                                                }
                                            }
                                        }
                                    >
                                        <span>"+"</span>
                                    </button>

                                    <button
                                        class="block-handle-trigger"
                                        data-testid="block-drag-handle-trigger"
                                        aria-label="Open block menu"
                                        title="Open block menu"
                                        on:mousedown=move |event: MouseEvent| {
                                            if event.button() != 0 {
                                                return;
                                            }
                                            event.stop_propagation();
                                            set_pending_drag.set(Some(PendingDragState {
                                                index: block_index,
                                                anchor: drag_anchor_block.clone(),
                                                start_x: event.client_x(),
                                                start_y: event.client_y(),
                                            }));
                                            set_suppress_handle_click.set(false);
                                        }
                                        on:click=move |event: MouseEvent| {
                                            event.prevent_default();
                                            event.stop_propagation();
                                            if suppress_handle_click.get_untracked() {
                                                set_suppress_handle_click.set(false);
                                                return;
                                            }
                                            set_turn_into_open.set(false);
                                            set_slash_open.set(false);
                                            let same_anchor = block_menu_anchor
                                                .get_untracked()
                                                .map(|current| current.index == block_index)
                                                .unwrap_or(false);
                                            let should_open = !block_menu_open.get_untracked() || !same_anchor;
                                            set_block_menu_open.set(should_open);
                                            set_block_turn_into_open.set(false);
                                            if should_open {
                                                set_block_menu_anchor.set(Some(click_anchor_block.clone()));
                                            } else {
                                                set_block_menu_anchor.set(None);
                                            }
                                            set_command_feedback.set(format!("已定位块菜单：{}", click_block_label));
                                        }
                                    >
                                        {if is_dragging { "⋯" } else { "⠿" }}
                                    </button>

                                    {move || {
                                        if block_menu_open.get()
                                            && block_menu_anchor
                                                .get()
                                                .map(|current| current.index == block_index)
                                                .unwrap_or(false)
                                        {
                                            let current_menu_label = menu_block_label.clone();
                                            let current_duplicate_label = duplicate_block_label.clone();
                                            let current_delete_label = delete_block_label.clone();
                                            let menu_layout = block_menu_layout(&block).unwrap_or(BlockMenuLayout {
                                                max_height: 360.0,
                                                open_upward: false,
                                            });
                                            let menu_top = if menu_layout.open_upward { "auto" } else { "0" };
                                            let menu_bottom = if menu_layout.open_upward { "0" } else { "auto" };
                                            view! {
                                                <div
                                                    class="block-drag-menu"
                                                    data-testid="block-drag-menu"
                                                    style:top=menu_top
                                                    style:bottom=menu_bottom
                                                    style:max-height=format!("{}px", menu_layout.max_height)
                                                    on:mousedown=move |event: MouseEvent| {
                                                        event.stop_propagation();
                                                    }
                                                    on:mouseup=move |event: MouseEvent| {
                                                        event.stop_propagation();
                                                    }
                                                    on:mousemove=move |event: MouseEvent| {
                                                        event.stop_propagation();
                                                    }
                                                    on:click=move |event: MouseEvent| {
                                                        event.stop_propagation();
                                                    }
                                                    on:wheel=move |event: WheelEvent| {
                                                        trap_scroll_inside_menu(&event);
                                                    }
                                                >
                                                    <div class="block-drag-menu-label" data-testid="block-drag-menu-label">
                                                        <strong>{current_menu_label.clone()}</strong>
                                                        <span>{format!("顶层块 #{}", block_index + 1)}</span>
                                                    </div>
                                                    <div class="block-drag-menu-actions">
                                                        {move || {
                                                            if block_turn_into_open.get() {
                                                                view! {
                                                                    <div class="block-drag-menu-section">
                                                                        <button
                                                                            class="block-drag-menu-back"
                                                                            data-testid="block-drag-menu-back"
                                                                            on:click=move |event: MouseEvent| {
                                                                                event.stop_propagation();
                                                                                set_block_turn_into_open.set(false);
                                                                            }
                                                                        >
                                                                            "← 返回"
                                                                        </button>
                                                                        {SLASH_ACTIONS
                                                                            .iter()
                                                                            .filter(|action| action.kind != SlashActionKind::Divider)
                                                                            .map(|action| {
                                                                                let kind = action.kind;
                                                                                let label = action.label;
                                                                                let testid = format!("block-drag-menu-item-{}", action.id);
                                                                                let action_menu_label = current_menu_label.clone();
                                                                                view! {
                                                                                    <button
                                                                                        class="block-drag-menu-item"
                                                                                        data-testid=testid
                                                                                        on:click=move |event: MouseEvent| {
                                                                                            event.stop_propagation();
                                                                                            match run_block_turn_into_action(editor, block_index, kind) {
                                                                                                Ok(message) => {
                                                                                                    set_block_menu_open.set(false);
                                                                                                    set_block_menu_anchor.set(None);
                                                                                                    let (html, snapshot, json_text) = read_editor_snapshot(editor);
                                                                                                    set_dirty_count.update(|count| *count += 1);
                                                                                                    set_html_output.set(html.clone());
                                                                                                    set_document_json.set(snapshot.clone());
                                                                                                    set_json_output.set(json_text);
                                                                                                    match persist_document_state(
                                                                                                        &runtime_persisted_identity(document_id, workspace_id),
                                                                                                        &title.get_untracked(),
                                                                                                        &snapshot,
                                                                                                        Some(html),
                                                                                                    ) {
                                                                                                        Ok(()) => {
                                                                                                            set_command_feedback.set(format!(
                                                                                                                "{}：{} -> {}，并已写入本地草稿",
                                                                                                                message,
                                                                                                                action_menu_label.clone(),
                                                                                                                label,
                                                                                                            ));
                                                                                                        }
                                                                                                        Err(err) => {
                                                                                                            set_command_feedback.set(format!(
                                                                                                                "{}：{} -> {}，但本地保存失败：{}",
                                                                                                                message,
                                                                                                                action_menu_label.clone(),
                                                                                                                label,
                                                                                                                err,
                                                                                                            ));
                                                                                                        }
                                                                                                    }
                                                                                                }
                                                                                                Err(err) => {
                                                                                                    set_command_feedback.set(err);
                                                                                                }
                                                                                            }
                                                                                        }
                                                                                    >
                                                                                        {label}
                                                                                    </button>
                                                                                }
                                                                            })
                                                                            .collect_view()}
                                                                    </div>
                                                                }.into_any()
                                                            } else {
                                                                let duplicate_feedback_label = current_duplicate_label.clone();
                                                                let delete_feedback_label = current_delete_label.clone();
                                                                view! {
                                                                    <>
                                                                        <div class="block-drag-menu-section">
                                                                            <button
                                                                                class="block-drag-menu-item"
                                                                                data-testid="block-drag-menu-item-turn-into"
                                                                                on:click=move |event: MouseEvent| {
                                                                                    event.stop_propagation();
                                                                                    set_block_turn_into_open.set(true);
                                                                                }
                                                                            >
                                                                                "Turn Into"
                                                                            </button>
                                                                        </div>
                                                                        <div class="block-drag-menu-divider"></div>
                                                                        <div class="block-drag-menu-section">
                                                                            <button
                                                                                class="block-drag-menu-item"
                                                                                data-testid="block-drag-menu-item-duplicate"
                                                                                on:click=move |event: MouseEvent| {
                                                                                    event.stop_propagation();
                                                                                    match editor.get_html() {
                                                                                        Ok(current_html) => match duplicate_top_level_block_html(&current_html, block_index) {
                                                                                            Ok(next_html) => {
                                                                                                apply_html_update(
                                                                                                    editor,
                                                                                                    &runtime_persisted_identity(document_id, workspace_id),
                                                                                                    next_html,
                                                                                                    set_dirty_count,
                                                                                                    set_html_output,
                                                                                                    set_document_json,
                                                                                                    set_json_output,
                                                                                                    title,
                                                                                                    set_command_feedback,
                                                                                                    format!("已复制 {}", duplicate_feedback_label.clone()),
                                                                                                );
                                                                                                set_block_menu_open.set(false);
                                                                                                set_block_menu_anchor.set(None);
                                                                                            }
                                                                                            Err(err) => set_command_feedback.set(err),
                                                                                        },
                                                                                        Err(err) => set_command_feedback.set(format!("读取当前 HTML 失败：{err}")),
                                                                                    }
                                                                                }
                                                                            >
                                                                                "Duplicate"
                                                                            </button>
                                                                            <button
                                                                                class="block-drag-menu-item"
                                                                                data-testid="block-drag-menu-item-delete"
                                                                                on:click=move |event: MouseEvent| {
                                                                                    event.stop_propagation();
                                                                                    match editor.get_html() {
                                                                                        Ok(current_html) => match delete_top_level_block_html(&current_html, block_index) {
                                                                                            Ok(next_html) => {
                                                                                                apply_html_update(
                                                                                                    editor,
                                                                                                    &runtime_persisted_identity(document_id, workspace_id),
                                                                                                    next_html,
                                                                                                    set_dirty_count,
                                                                                                    set_html_output,
                                                                                                    set_document_json,
                                                                                                    set_json_output,
                                                                                                    title,
                                                                                                    set_command_feedback,
                                                                                                    format!("已删除 {}", delete_feedback_label.clone()),
                                                                                                );
                                                                                                set_block_menu_open.set(false);
                                                                                                set_block_menu_anchor.set(None);
                                                                                                set_hovered_block.set(None);
                                                                                            }
                                                                                            Err(err) => set_command_feedback.set(err),
                                                                                        },
                                                                                        Err(err) => set_command_feedback.set(format!("读取当前 HTML 失败：{err}")),
                                                                                    }
                                                                                }
                                                                            >
                                                                                "Delete"
                                                                            </button>
                                                                        </div>
                                                                    </>
                                                                }.into_any()
                                                            }
                                                        }}
                                                    </div>
                                                </div>
                                            }.into_any()
                                        } else {
                                            ().into_any()
                                        }
                                    }}
                                </div>
                            }.into_any()
                        } else {
                            ().into_any()
                        }
                    }}

                    {move || {
                        if slash_open.get() {
                            view! {
                                <div class="slash-menu" data-testid="mnote-leptos-tiptap-slash-menu">
                                    <div class="slash-list">
                                        {SLASH_ACTIONS.iter().enumerate().map(|(index, action)| {
                                            let is_selected = move || slash_index.get() == index;
                                            let kind = action.kind;
                                            let label = action.label;
                                            let description = action.description;
                                            let testid = format!("slash-item-{}", action.id);
                                            view! {
                                                <button
                                                    class="slash-item"
                                                    data-active=move || is_selected().to_string()
                                                    data-testid=testid
                                                    on:click=move |_| {
                                                        apply_feedback_result(set_command_feedback, run_slash_action(editor, kind));
                                                        set_slash_open.set(false);
                                                    }
                                                >
                                                    {label}
                                                    <span>{description}</span>
                                                </button>
                                            }
                                        }).collect_view()}
                                    </div>
                                </div>
                            }
                                .into_any()
                        } else {
                            ().into_any()
                        }
                    }}

                    <TiptapEditor
                        id="mnote-leptos-tiptap-spike"
                        editor=editor
                        initial_content=initial_editor_content.clone()
                        placeholder="输入 “/” 打开命令菜单；试试 heading / list / todo / quote / code block / divider"
                        disabled=move || !editor_editable.get()
                        extensions=p0_extensions()
                        on_ready=move |_| {
                            if restored_from_storage {
                                let restored_content = restored_html
                                    .clone()
                                    .map(TiptapContent::html)
                                    .unwrap_or_else(|| {
                                        let restored_json = document_json.get_untracked();
                                        let payload = restored_json
                                            .get("content")
                                            .and_then(Value::as_array)
                                            .cloned()
                                            .map(Value::Array)
                                            .unwrap_or(restored_json);
                                        TiptapContent::json(payload)
                                    });
                                if let Err(err) = editor.set_content(restored_content) {
                                    set_command_feedback.set(format!("恢复本地草稿失败：{err}"));
                                    return;
                                }
                            }
                            let snapshot =
                                sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
                            dispatch_ready_event(&ReadyPayload {
                                runtime_name: RUNTIME_NAME,
                                selectors: BridgeSelectorsPayload {
                                    root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
                                    stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
                                    editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
                                    toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
                                    slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
                                    handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
                                },
                                supported_commands: vec![
                                    "replaceContent",
                                    "setEditable",
                                    "undo",
                                    "redo",
                                    "focus",
                                    "requestCurrentBlockId",
                                ],
                                supports_embedded_mode: true,
                            });
                            dispatch_runtime_state(
                                document_id.get_untracked(),
                                workspace_id.get_untracked(),
                                title.get_untracked(),
                                dirty_count.get_untracked(),
                                hovered_block.get_untracked(),
                                editor_focused.get_untracked(),
                                slash_open.get_untracked(),
                                turn_into_open.get_untracked(),
                                color_menu_open.get_untracked(),
                                more_menu_open.get_untracked(),
                                read_only.get_untracked(),
                            );
                            match persist_document_state(
                                &runtime_persisted_identity(document_id, workspace_id),
                                &title.get_untracked(),
                                &snapshot,
                                Some(html_output.get_untracked()),
                            ) {
                                Ok(()) => {
                                    if restored_from_storage {
                                        set_command_feedback.set("编辑器已准备就绪，并已恢复本地草稿".to_string());
                                    } else {
                                        set_command_feedback.set("编辑器已准备就绪，当前内容已写入本地草稿".to_string());
                                    }
                                }
                                Err(err) => {
                                    set_command_feedback.set(format!("编辑器已准备就绪，但草稿初始化失败：{err}"));
                                }
                            }
                        }
                        on_change=move |_| {
                            set_dirty_count.update(|count| *count += 1);
                            let snapshot =
                                sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
                            dispatch_change_event(&ChangePayload {
                                document_id: document_id.get_untracked(),
                                workspace_id: workspace_id.get_untracked(),
                                title: title.get_untracked(),
                                content: snapshot.clone(),
                                meta: ChangeMetaPayload {
                                    dirty_count: dirty_count.get_untracked(),
                                    editor_focused: editor_focused.get_untracked(),
                                    slash_open: slash_open.get_untracked(),
                                    toolbar_open: toolbar_overlay_locked(
                                        turn_into_open.get_untracked(),
                                        color_menu_open.get_untracked(),
                                        more_menu_open.get_untracked(),
                                    ),
                                    selected_block_index: hovered_block.get_untracked().map(|block| block.index),
                                    revision: revision.get_untracked(),
                                    conflict_detection_key: conflict_detection_key.get_untracked(),
                                    read_only: read_only.get_untracked(),
                                },
                            });
                            dispatch_runtime_state(
                                document_id.get_untracked(),
                                workspace_id.get_untracked(),
                                title.get_untracked(),
                                dirty_count.get_untracked(),
                                hovered_block.get_untracked(),
                                editor_focused.get_untracked(),
                                slash_open.get_untracked(),
                                turn_into_open.get_untracked(),
                                color_menu_open.get_untracked(),
                                more_menu_open.get_untracked(),
                                read_only.get_untracked(),
                            );
                            match persist_document_state(
                                &runtime_persisted_identity(document_id, workspace_id),
                                &title.get_untracked(),
                                &snapshot,
                                Some(html_output.get_untracked()),
                            ) {
                                Ok(()) => {
                                    set_command_feedback.set("内容已变更，已同步观察值并写入本地草稿".to_string());
                                }
                                Err(err) => {
                                    set_command_feedback.set(format!("内容已变更，但本地保存失败：{err}"));
                                }
                            }
                        }
                        on_selection_change=move |selection: TiptapSelectionState| {
                            let selection_clone = selection.clone();
                            set_selection_state.set(selection_clone.clone());
                            let toolbar_locked = toolbar_overlay_locked(
                                turn_into_open.get_untracked(),
                                color_menu_open.get_untracked(),
                                more_menu_open.get_untracked(),
                            );
                            let has_text_selection = if has_active_text_selection() {
                                sync_text_selection_overlay(
                                    set_text_selection_active,
                                    set_floating_toolbar_anchor,
                                )
                            } else if toolbar_locked {
                                text_selection_active.get_untracked()
                            } else {
                                set_text_selection_active.set(false);
                                set_floating_toolbar_anchor.set(None);
                                false
                            };
                            if has_text_selection {
                                if !toolbar_locked {
                                    set_turn_into_open.set(false);
                                }
                                set_block_menu_open.set(false);
                                set_block_menu_anchor.set(None);
                                set_hovered_block.set(None);
                            }
                            send_selection_state(
                                &selection_clone,
                                editor_focused.get_untracked(),
                                hovered_block.get_untracked().map(|block| block.index),
                            );
                        }
                        attr:class="editor-surface"
                        attr:data-testid="mnote-leptos-tiptap-editor-root"
                        attr:data-small-text=move || small_text.get().to_string()
                        attr:data-layout-density=move || layout_density.get()
                        attr:data-show-heading-numbers=move || show_heading_numbers.get().to_string()
                        attr:data-embed-default-block-id=move || embed_default_block_id.get().unwrap_or_default()
                    />
                </div>

                {move || {
                    if is_embedded {
                        ().into_any()
                    } else {
                        view! {
                            <div class="footer-strip">
                                <span>
                                    "当前 P0 边界："
                                    <code>"paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider"</code>
                                </span>
                                <span>
                                    "运行时扩展不再走 "
                                    <code>"TiptapExtension::all_enabled()"</code>
                                    "，这里已冻结为最小集合；当前 reload 走 "
                                    <code>"localStorage JSON"</code>
                                    "，块菜单支持复制、删除、turn into 与顶层拖拽。"
                                </span>
                            </div>
                        }.into_any()
                    }
                }}
            </section>

            {move || {
                if is_embedded {
                    ().into_any()
                } else {
                    view! {
                        <details class="debug-drawer">
                            <summary>
                                <span>"调试抽屉：保留 HTML / JSON / Selection 观测，但不再主导页面"</span>
                                <span>"展开 / 收起"</span>
                            </summary>
                            <div class="debug-grid">
                                <section class="debug-card">
                                    <h3>"Selection"</h3>
                                    <pre id="editor-selection-summary">{selection_text}</pre>
                                </section>
                                <section class="debug-card">
                                    <h3>"HTML"</h3>
                                    <pre id="editor-html-output">{move || html_output.get()}</pre>
                                </section>
                                <section class="debug-card">
                                    <h3>"JSON"</h3>
                                    <pre id="editor-json-output">{move || json_output.get()}</pre>
                                </section>
                            </div>
                        </details>
                    }.into_any()
                }
            }}
        </main>
    }
}

fn initial_content() -> TiptapContent {
    TiptapContent::json(serde_json::json!({
        "type": "doc",
        "content": []
    }))
}

pub fn standalone_main() {
    console_error_panic_hook::set_once();
    mount_to_body(|| {
        view! { <App mount_options=MountOptions::default()/> }
    });
}
