import {
  collection,
  createBlockHandle,
  DEFAULT_BLOCK_HANDLE_LABELS,
  Decoration,
  DecorationSet,
  EditorState,
  EditorView,
  Fragment,
  Plugin,
  PluginKey,
  Schema,
  Selection,
  NodeSelection,
  TextSelection,
  resolve,
  setupBase,
  setupBold,
  setupCode,
  setupDropGapCursor,
  setupHardBreak,
  setupHeading,
  setupHistory,
  setupItalic,
  setupLink,
  setupList,
  setupMath,
  setupParagraph,
  setupPlaceholder,
  setupTable,
  setupTrailingNode,
  setupUnderline,
  type Node as PMNode,
  type NodeSpec,
  type Mark,
  type CollectionType,
  type Transaction,
} from '@learnitive/prosemirror-editor';
import { createLearnitiveMathField, type MathLiveField } from '@learnitive/latex-mathlive';
import {
  computeMinimalSourceTextEdit,
  detectLatexLineEnding,
  findLatexMathRegions,
  normalizeLatexLineEndings,
  parseLatexTabular,
  type LatexLineEnding,
  type SourceTextEdit,
} from '@learnitive/latex-source';
import type { VisualBlock, VisualInline } from '@learnitive/project-semantic-index';
import type {
  LatexVisualAction,
  LatexVisualReviewDecoration,
  LatexVisualSourceCursor,
  LatexVisualSourceSelection,
} from './index';

export const LEARNITIVE_NATIVE_LATEX_VISUAL = true;

const RAW_BLOCK = 'latex_raw_block';
const RAW_INLINE = 'latex_raw_inline';
const TEXT_ENVIRONMENT = 'latex_text_environment';
const INLINE_MATH = 'math_inline';
const DISPLAY_MATH = 'math_display';
const VISUAL_ASSET_ERROR_PREFIX = 'interactive-asset-error:';

const rawExtension = collection({
  id: 'latex-native-raw',
  nodes: {
    [RAW_BLOCK]: {
      group: 'block',
      atom: true,
      selectable: true,
      attrs: {
        latex: { default: '' },
        displayLatex: { default: '' },
        sourceId: { default: '' },
        sourceFrom: { default: -1 },
        sourceTo: { default: -1 },
        serialize: { default: true },
        tight: { default: false },
        label: { default: 'Preserved LaTeX' },
        visualKind: { default: 'raw' },
        assetUrl: { default: '' },
        assetTargets: { default: [] },
        assetUrls: { default: [] },
        assetStatuses: { default: [] },
        complexFigure: { default: false },
        caption: { default: '' },
        labelText: { default: '' },
        summary: { default: '' },
        immutable: { default: true },
      },
      parseDOM: [{ tag: 'div[data-interactive-latex-raw-block]' }],
      toDOM(node: PMNode) {
        const visualKind = String(node.attrs.visualKind ?? 'raw');
        const displayLatex = String(node.attrs.displayLatex ?? '');
        const canonicalLatex = String(node.attrs.latex ?? '');
        const latex = displayLatex.trim().length
          ? displayLatex
          : canonicalLatex.trim().length
            ? canonicalLatex
            : 'Protected LaTeX source is unavailable.';
        const label = String(node.attrs.label ?? 'Preserved LaTeX');
        const sourceId = String(node.attrs.sourceId ?? '');
        const summary = String(node.attrs.summary ?? '');
        const caption = String(node.attrs.caption ?? '');
        const labelText = String(node.attrs.labelText ?? '');
        if (visualKind === 'trivia') {
          return [
            'div',
            {
              class: 'interactive-visual-source-trivia',
              'data-interactive-source-trivia': 'true',
              'data-source-id': sourceId,
              'aria-hidden': 'true',
              contenteditable: 'false',
            },
          ];
        }
        // Keep the complete protected source in the Visual affordance. CSS
        // owns viewport height/scrolling; Source remains the only edit path.
        const preview = latex;
        const assetTargetsForNavigation = Array.isArray(node.attrs.assetTargets)
          ? node.attrs.assetTargets.map((value) => String(value))
          : [];
        const complexFigureForNavigation = Boolean(node.attrs.complexFigure) || assetTargetsForNavigation.length > 1;
        const navigationLabel = visualKind === 'figure'
          ? (complexFigureForNavigation ? 'complex figure' : 'figure')
          : label.replace(/\s*·\s*Protected LaTeX$/u, '').toLowerCase();
        const baseAttrs = {
          class: [
            'interactive-visual-raw-block',
            'interactive-visual-protected-clickable',
            visualKind === 'figure' ? 'interactive-visual-figure' : '',
            visualKind === 'theorem' ? 'interactive-visual-theorem' : '',
            visualKind === 'heading' ? 'interactive-visual-heading interactive-visual-raw-heading' : '',
            visualKind === 'table' ? 'interactive-visual-protected-table' : '',
            visualKind === 'preamble' ? 'interactive-visual-document-setup' : '',
          ].filter(Boolean).join(' '),
          'data-interactive-latex-raw-block': 'true',
          'data-source-id': sourceId,
          'data-visual-kind': visualKind,
          contenteditable: 'false',
          'data-immutable': node.attrs.immutable ? 'true' : 'false',
          role: 'button',
          tabindex: '0',
          'aria-label': `Open ${navigationLabel || 'protected LaTeX'} in Source`,
          title: 'Open exact LaTeX source',
        };

        if (visualKind === 'figure') {
          const assetUrl = String(node.attrs.assetUrl ?? '');
          const assetTargets = Array.isArray(node.attrs.assetTargets)
            ? node.attrs.assetTargets.map((value) => String(value))
            : [];
          const assetUrls = Array.isArray(node.attrs.assetUrls)
            ? node.attrs.assetUrls.map((value) => String(value))
            : [];
          const assetStatuses = Array.isArray(node.attrs.assetStatuses)
            ? node.attrs.assetStatuses.map((value) => String(value))
            : [];
          const alignedAssetUrls = assetTargets.length
            ? assetTargets.map((_, index) => assetUrls[index] ?? '')
            : (assetUrl ? [assetUrl] : []);
          const complexFigure = Boolean(node.attrs.complexFigure) || assetTargets.length > 1;
          const figureLabel = complexFigure ? 'Complex figure · Protected LaTeX' : 'Figure · Protected LaTeX';
          return [
            'div',
            baseAttrs,
            ['div', { class: 'interactive-visual-protected-header' },
              ['span', {}, figureLabel],
              ['span', { class: 'interactive-visual-protected-action', 'aria-hidden': 'true' }, 'Open in Source →'],
            ],
            assetTargets.length
              ? ['div', { class: 'interactive-visual-figure-preview-grid' },
                  ...assetTargets.map((target, index) => {
                    const url = alignedAssetUrls[index] ?? '';
                    const status = assetStatuses[index] ?? (url ? 'ready' : 'loading');
                    const statusCopy = status === 'missing'
                      ? 'asset not found'
                      : status === 'unsupported'
                        ? 'browser preview unavailable; source preserved'
                        : status === 'error'
                          ? 'preview failed; source preserved'
                          : 'loading preview…';
                    return url
                      ? [
                          'img',
                          {
                            src: url,
                            alt: caption || labelText || target || `Figure graphic ${index + 1}`,
                            class: 'interactive-visual-figure-image',
                          },
                        ]
                      : [
                          'div',
                          { class: 'interactive-visual-figure-placeholder interactive-visual-figure-asset-placeholder', 'data-preview-status': status },
                          `${target} · ${statusCopy}`,
                        ];
                  }),
                ]
              : ['div', { class: 'interactive-visual-figure-placeholder' }, 'Figure source preserved · preview unavailable'],
            ...(caption ? [['div', { class: 'interactive-visual-protected-caption' }, caption]] : []),
            ...(summary ? [['div', { class: 'interactive-visual-protected-summary' }, summary]] : []),
            ...(labelText ? [['div', { class: 'interactive-visual-meta' }, `Label: ${labelText}`]] : []),
            ['div', { class: 'interactive-visual-protected-source-label' }, 'Complete LaTeX'],
            ['pre', { class: 'interactive-visual-protected-source interactive-visual-protected-figure-source' }, preview || String(node.attrs.latex ?? '') || 'Protected figure source is unavailable.'],
            ['div', { class: 'interactive-visual-protected-footer' }, 'Protected · Source-only'],
          ];
        }

        return [
          'div',
          baseAttrs,
          ['div', { class: 'interactive-visual-protected-header' },
            ['span', {}, label],
            ['span', { class: 'interactive-visual-protected-action', 'aria-hidden': 'true' }, 'Open in Source →'],
          ],
          ...(summary ? [['div', { class: 'interactive-visual-protected-summary' }, summary]] : []),
          ['pre', { class: 'interactive-visual-protected-source' }, preview || String(node.attrs.latex ?? '') || 'Protected LaTeX source is unavailable.'],
          ['div', { class: 'interactive-visual-protected-footer' }, node.attrs.immutable ? 'Protected · Source-only' : 'Preserved exactly'],
        ];
      },
    } satisfies NodeSpec,
    [TEXT_ENVIRONMENT]: {
      group: 'block',
      content: 'inline*',
      attrs: {
        sourceId: { default: '' },
        environment: { default: '' },
        prefix: { default: '' },
        suffix: { default: '' },
      },
      parseDOM: [{ tag: 'section[data-interactive-latex-text-environment]' }],
      toDOM(node: PMNode) {
        const environment = String(node.attrs.environment ?? 'environment');
        return [
          'section',
          {
            class: 'interactive-visual-text-environment',
            'data-interactive-latex-text-environment': 'true',
            'data-source-id': String(node.attrs.sourceId ?? ''),
          },
          [
            'div',
            { class: 'interactive-visual-text-environment-header', contenteditable: 'false' },
            environment,
          ],
          ['div', { class: 'interactive-visual-text-environment-body' }, 0],
        ];
      },
    } satisfies NodeSpec,
    [RAW_INLINE]: {
      inline: true,
      group: 'inline',
      atom: true,
      selectable: true,
      attrs: {
        latex: { default: '' },
        sourceId: { default: '' },
        label: { default: 'LaTeX' },
        visualKind: { default: 'raw' },
        command: { default: '' },
        targets: { default: [] },
        target: { default: '' },
        bodyFromRelative: { default: 0 },
        bodyToRelative: { default: 0 },
        footnoteText: { default: '' },
        editable: { default: false },
      },
      parseDOM: [{ tag: 'span[data-interactive-latex-raw-inline]' }],
      toDOM(node: PMNode) {
        const latex = String(node.attrs.latex ?? '');
        const visualKind = String(node.attrs.visualKind ?? 'raw');
        if (visualKind === 'trivia') {
          return [
            'span',
            {
              class: 'interactive-visual-source-trivia-inline',
              'data-interactive-latex-raw-inline': 'true',
              'data-source-id': String(node.attrs.sourceId ?? ''),
              'aria-hidden': 'true',
              contenteditable: 'false',
            },
          ];
        }
        const normalized = latex.replace(/\s+/gu, ' ').trim();
        const preview = normalized.length > 72 ? `${normalized.slice(0, 69)}…` : normalized;
        const label = preview || String(node.attrs.label ?? 'LaTeX');
        return [
          'span',
          {
            class: [
              'interactive-visual-raw-inline',
              'interactive-visual-inline',
              `interactive-visual-inline-${visualKind}`,
            ].join(' '),
            'data-interactive-latex-raw-inline': 'true',
            'data-source-id': String(node.attrs.sourceId ?? ''),
            title: `${latex || String(node.attrs.label ?? 'LaTeX')} · Open in Source`,
            contenteditable: 'false',
            role: 'button',
            tabindex: '0',
            'aria-label': `Open ${visualKind || 'protected LaTeX'} in Source`,
          },
          label,
        ];
      },
    } satisfies NodeSpec,
  },
});

type SourceMapEntry = {
  pmFrom: number;
  pmTo: number;
  sourceFrom: number;
  sourceTo: number;
  sourceBoundaries?: readonly number[];
  id?: string;
};

type SerializedNativeDocument = {
  source: string;
  maps: SourceMapEntry[];
};

type DocumentShell = {
  prefix: string;
  suffix: string;
  bodyStart: number;
  preambleEnd: number;
  bodyEnd: number;
};

type NativeSourceTriviaGap = {
  from: number;
  to: number;
  text: string;
  leftId?: string;
  rightId?: string;
};

type NativeSourceLayout = {
  leadingTrivia: string;
  trailingTrivia: string;
  interBlockTrivia: readonly NativeSourceTriviaGap[];
  originalNodeIds: ReadonlySet<string>;
  originalNodeOrder: readonly string[];
};

type NativeProjection = {
  doc: PMNode;
  sourceLayout: NativeSourceLayout;
};

type NativeOptions = {
  host: HTMLElement;
  blocks: readonly VisualBlock[];
  source: string;
  getSource?: () => string | undefined;
  assetUrls?: ReadonlyMap<string, string>;
  onEdits: (edits: SourceTextEdit[]) => void;
  onNativeEdits?: (
    edits: SourceTextEdit[],
    meta: { historyReplay: boolean; baseSource: string; nextSource: string },
  ) => boolean | void;
  onUndo?: () => void;
  onRedo?: () => void;
  onAction?: (action: LatexVisualAction) => void;
  getSlashOptionCount?: (query: string) => number;
  onNavigateSource?: (location: { id: string; sourceFrom: number; sourceTo: number }) => void;
  onCursorChange?: (cursor: LatexVisualSourceCursor | undefined) => void;
  onSelectionChange?: (selection: LatexVisualSourceSelection | undefined) => void;
};

const nativeReviewDecorationKey = new PluginKey<DecorationSet>('interactive-native-review-decorations');

function createNativeReviewDecorationPlugin(): Plugin {
  return new Plugin({
    key: nativeReviewDecorationKey,
    state: {
      init: () => DecorationSet.empty,
      apply(transaction, current) {
        const replacement = transaction.getMeta(nativeReviewDecorationKey) as DecorationSet | undefined;
        return replacement ?? current.map(transaction.mapping, transaction.doc);
      },
    },
    props: {
      decorations(state) {
        return nativeReviewDecorationKey.getState(state) ?? DecorationSet.empty;
      },
    },
  });
}

function reviewDecorationClasses(decoration: LatexVisualReviewDecoration): string {
  return [
    decoration.kind === 'comment' ? 'interactive-visual-comment-anchor' : 'interactive-visual-change-anchor',
    decoration.kind === 'change' ? `interactive-visual-change-${decoration.changeType ?? 'replace'}` : '',
    decoration.status === 'resolved' ? 'interactive-visual-comment-resolved' : '',
    decoration.status === 'outdated' ? 'interactive-visual-comment-outdated' : '',
  ].filter(Boolean).join(' ');
}

function setupLatexTable() {
  const extension = setupTable();
  const tableSpec = extension.nodes?.table;
  if (tableSpec) {
    extension.nodes = {
      ...extension.nodes,
      table: {
        ...tableSpec,
        attrs: {
          ...(tableSpec.attrs ?? {}),
          latexId: { default: '' },
          columns: { default: '' },
          rawBeforeTabular: { default: '' },
          rawAfterTabular: { default: '' },
          rawOriginal: { default: '' },
          initialFingerprint: { default: '' },
          initialColumns: { default: '' },
          tableAlign: { default: 'left' },
          initialTableAlign: { default: 'left' },
          tableAlignStyle: { default: 'none' },
          hasHorizontalRules: { default: true },
        },
      },
    };
  }
  const withIdentity = withVisualNodeIdentity(extension, 'table', 'interactive-visual-table', 'latexId');
  const identified = withIdentity.nodes?.table;
  if (identified?.toDOM) {
    const originalToDOM = identified.toDOM;
    withIdentity.nodes = {
      ...withIdentity.nodes,
      table: {
        ...identified,
        toDOM(node: PMNode) {
          const output = originalToDOM(node);
          if (!Array.isArray(output) || typeof output[0] !== 'string') return output;
          const currentAttrs =
            output.length > 1 &&
            output[1] !== null &&
            typeof output[1] === 'object' &&
            !Array.isArray(output[1])
              ? (output[1] as Record<string, unknown>)
              : undefined;
          const attrs = {
            ...(currentAttrs ?? {}),
            'data-table-align': String(node.attrs.tableAlign ?? 'left'),
          };
          return currentAttrs
            ? [output[0], attrs, ...output.slice(2)]
            : [output[0], attrs, ...output.slice(1)];
        },
      },
    };
  }
  return withIdentity;
}

function setupLatexMath() {
  const extension = setupMath();
  const withAttrs = (spec: NodeSpec | undefined): NodeSpec | undefined =>
    spec
      ? {
          ...spec,
          atom: true,
          selectable: true,
          attrs: {
            ...(spec.attrs ?? {}),
            latexId: { default: '' },
            delimiter: { default: '' },
            environment: { default: '' },
            originalLatex: { default: '' },
            initialValue: { default: '' },
            editable: { default: true },
          },
        }
      : undefined;
  const inline = withAttrs(extension.nodes?.[INLINE_MATH]);
  const display = withAttrs(extension.nodes?.[DISPLAY_MATH]);
  extension.nodes = {
    ...extension.nodes,
    ...(inline ? { [INLINE_MATH]: inline } : {}),
    ...(display ? { [DISPLAY_MATH]: display } : {}),
  };
  return extension;
}

function withLatexCommand<T extends CollectionType>(
  extension: T,
  markName: string,
  defaultCommand: string,
): T {
  const mark = extension.marks?.[markName];
  if (mark) {
    const originalToDOM = mark.toDOM;
    extension.marks = {
      ...extension.marks,
      [markName]: {
        ...mark,
        attrs: {
          ...(mark.attrs ?? {}),
          latexCommand: { default: defaultCommand },
        },
        ...(originalToDOM
          ? {
              toDOM(markInstance: Mark, inline: boolean) {
                const output = originalToDOM(markInstance, inline);
                if (!Array.isArray(output) || typeof output[0] !== 'string') return output;
                const currentAttrs =
                  output.length > 1 &&
                  output[1] !== null &&
                  typeof output[1] === 'object' &&
                  !Array.isArray(output[1])
                    ? (output[1] as Record<string, unknown>)
                    : undefined;
                const attrs = {
                  ...(currentAttrs ?? {}),
                  class: [
                    String(currentAttrs?.class ?? ''),
                    'interactive-visual-inline',
                    `interactive-visual-inline-${markName === 'code' ? 'monospace' : markName}`,
                  ].filter(Boolean).join(' '),
                };
                return currentAttrs
                  ? [output[0], attrs, ...output.slice(2)]
                  : [output[0], attrs, ...output.slice(1)];
              },
            }
          : {}),
      },
    };
  }
  return extension;
}

function setupLatexHardBreak() {
  // setupHardBreak() intentionally returns a narrowly inferred node shape.
  // BF4 extends that valid ProseMirror NodeSpec with a Learnitive-only attribute,
  // so widen the collection to the public CollectionType/NodeSpec contract
  // before adding attrs instead of mutating the narrow inferred literal type.
  const extension: CollectionType = setupHardBreak();
  const hardBreak: NodeSpec | undefined = extension.nodes?.hard_break;
  if (hardBreak) {
    extension.nodes = {
      ...extension.nodes,
      hard_break: {
        ...hardBreak,
        attrs: {
          ...(hardBreak.attrs ?? {}),
          latexSource: { default: '\\newline{}' },
        },
      },
    };
  }
  return withVisualNodeIdentity(extension, 'hard_break', 'interactive-visual-hard-break');
}

function withVisualNodeIdentity<T extends CollectionType>(
  extension: T,
  nodeName: string,
  className: string,
  idAttr = 'sourceId',
): T {
  const spec = extension.nodes?.[nodeName];
  if (!spec) return extension;
  const originalToDOM = spec.toDOM;
  extension.nodes = {
    ...extension.nodes,
    [nodeName]: {
      ...spec,
      attrs: {
        ...(spec.attrs ?? {}),
        [idAttr]: { default: '' },
      },
      ...(originalToDOM
        ? {
            toDOM(node: PMNode) {
              const output = originalToDOM(node);
              if (!Array.isArray(output) || typeof output[0] !== 'string') return output;
              const currentAttrs =
                output.length > 1 &&
                output[1] !== null &&
                typeof output[1] === 'object' &&
                !Array.isArray(output[1])
                  ? (output[1] as Record<string, unknown>)
                  : undefined;
              const attrs = {
                ...(currentAttrs ?? {}),
                class: [String(currentAttrs?.class ?? ''), className].filter(Boolean).join(' '),
                'data-source-id': String(node.attrs[idAttr] ?? ''),
              };
              return currentAttrs
                ? [output[0], attrs, ...output.slice(2)]
                : [output[0], attrs, ...output.slice(1)];
            },
          }
        : {}),
    },
  };
  return extension;
}

function setupLatexParagraph() {
  return withVisualNodeIdentity(setupParagraph(), 'paragraph', 'interactive-visual-paragraph');
}

function setupLatexHeading() {
  const extension = withVisualNodeIdentity(setupHeading(), 'heading', 'interactive-visual-heading');
  const spec = extension.nodes?.heading;
  if (spec) {
    extension.nodes = {
      ...extension.nodes,
      heading: {
        ...spec,
        attrs: {
          ...(spec.attrs ?? {}),
          sourceSuffix: { default: '' },
          latexCommand: { default: '' },
        },
      },
    };
  }
  return extension;
}

function setupLatexList() {
  const extension = withVisualNodeIdentity(setupList(), 'list', 'interactive-visual-list-item');
  const spec = extension.nodes?.list;
  if (spec) {
    extension.nodes = {
      ...extension.nodes,
      list: {
        ...spec,
        attrs: {
          ...(spec.attrs ?? {}),
          latexListId: { default: '' },
        },
      },
    };
  }
  return extension;
}

let nativeVisualId = 0;
function nextNativeVisualId(nodeType: string): string {
  nativeVisualId += 1;
  return `bf4:${nodeType}:${nativeVisualId}`;
}

function setupNativeVisualIdentity() {
  return collection({
    id: 'latex-native-identity',
    plugin: {
      uniqueVisualIds: () =>
        new Plugin({
          appendTransaction(transactions, _oldState, newState) {
            if (!transactions.some((transaction) => transaction.docChanged)) return null;
            const seenSourceIds = new Set<string>();
            const seenLatexIds = new Set<string>();
            let tr = newState.tr;
            let changed = false;

            // Learnitive's list implementation is intentionally flat: each list
            // item is represented by a `list` node and contiguous siblings form
            // one rendered list. Keep a separate LaTeX run identity so native
            // Enter/Tab/Shift+Tab can keep emitting one LaTeX environment even
            // when a newly-created Learnitive item starts with the schema default
            // (`latexListId === ''`). Imported adjacent environments already
            // have distinct ids and are therefore never merged here.
            const normalizeListRuns = (parent: PMNode, parentPos: number) => {
              let childPos = parentPos;
              let activeKind: 'itemize' | 'enumerate' | undefined;
              let activeRunId = '';
              for (let index = 0; index < parent.childCount; index += 1) {
                const child = parent.child(index);
                if (child.type.name === 'list') {
                  const kind = listEnvironment(child);
                  let runId = String(child.attrs.latexListId ?? '');
                  if (!runId) {
                    if (activeKind === kind && activeRunId) {
                      runId = activeRunId;
                    } else {
                      runId = nextNativeVisualId('latex-list-run');
                    }
                    tr = tr.setNodeMarkup(childPos, undefined, {
                      ...child.attrs,
                      latexListId: runId,
                    });
                    changed = true;
                  }
                  activeKind = kind;
                  activeRunId = runId;
                } else {
                  activeKind = undefined;
                  activeRunId = '';
                }

                if (child.childCount > 0) normalizeListRuns(child, childPos + 1);
                childPos += child.nodeSize;
              }
            };

            normalizeListRuns(newState.doc, 0);

            newState.doc.descendants((node, pos) => {
              const usesSourceId = Object.prototype.hasOwnProperty.call(node.attrs, 'sourceId');
              const usesLatexId = node.type.name === 'table' && Object.prototype.hasOwnProperty.call(node.attrs, 'latexId');
              const attrName = usesLatexId ? 'latexId' : usesSourceId ? 'sourceId' : undefined;
              if (!attrName) return true;
              const seen = attrName === 'latexId' ? seenLatexIds : seenSourceIds;
              const current = String(node.attrs[attrName] ?? '');
              if (current && !seen.has(current)) {
                seen.add(current);
                return true;
              }
              const replacement = nextNativeVisualId(node.type.name);
              seen.add(replacement);
              tr = tr.setNodeMarkup(pos, undefined, { ...node.attrs, [attrName]: replacement });
              changed = true;
              return true;
            });
            return changed ? tr : null;
          },
        }),
    },
  });
}

function setupImmutableLatexProtection() {
  return collection({
    id: 'latex-native-immutable-protection',
    plugin: {
      immutableProtection: () =>
        new Plugin({
          filterTransaction(transaction, state) {
            if (!transaction.docChanged) return true;
            const immutable = new Map<string, string>();
            state.doc.descendants((node) => {
              if (node.type.name !== RAW_BLOCK || !node.attrs.immutable) return true;
              const id = String(node.attrs.sourceId ?? '');
              if (id) immutable.set(id, String(node.attrs.latex ?? ''));
              return false;
            });
            if (!immutable.size) return true;
            const remaining = new Map<string, string>();
            transaction.doc.descendants((node) => {
              if (node.type.name !== RAW_BLOCK || !node.attrs.immutable) return true;
              const id = String(node.attrs.sourceId ?? '');
              if (id) remaining.set(id, String(node.attrs.latex ?? ''));
              return false;
            });
            for (const [id, latex] of immutable) {
              if (remaining.get(id) !== latex) return false;
            }
            return true;
          },
        }),
    },
  });
}

function immutableTopLatex(raw: string): boolean {
  return /^\\(?:maketitle|tableofcontents|listoffigures|listoftables|printbibliography|bibliography|bibliographystyle|appendix|frontmatter|mainmatter|backmatter)\b/u.test(raw.trim());
}

function sourceLooksLikePlainEditableProse(raw: string): boolean {
  if (!raw.trim()) return false;
  // Recovery is deliberately conservative: real TeX syntax, comments and math
  // remain on the semantic/protected path. The only TeX spellings accepted here
  // are the exact literal encodings emitted by Learnitive's own Visual serializer.
  // Blank-line paragraph separators are handled separately below.
  const withoutCanonicalLiterals = raw
    .replaceAll('\\textbackslash{}', '')
    .replaceAll('\\textasciitilde{}', '')
    .replaceAll('\\textasciicircum{}', '')
    .replace(/\\[%&#_$\{\}]/gu, '');
  return !/[\\%$&{}#_^~]/u.test(withoutCanonicalLiterals);
}

function plainEditableProseRanges(
  source: string,
  from: number,
  to: number,
): readonly { from: number; to: number }[] | undefined {
  if (to <= from) return [];
  const raw = source.slice(from, to);
  if (!raw.trim()) return [];
  if (!sourceLooksLikePlainEditableProse(raw)) return undefined;

  const ranges: { from: number; to: number }[] = [];
  const separator = /(?:\r?\n[ \t]*){2,}/gu;
  let cursor = 0;
  const push = (localFrom: number, localTo: number) => {
    let trimmedFrom = localFrom;
    let trimmedTo = localTo;
    while (trimmedFrom < trimmedTo && /\s/u.test(raw[trimmedFrom] ?? '')) trimmedFrom += 1;
    while (trimmedTo > trimmedFrom && /\s/u.test(raw[trimmedTo - 1] ?? '')) trimmedTo -= 1;
    if (trimmedTo > trimmedFrom) ranges.push({ from: from + trimmedFrom, to: from + trimmedTo });
  };
  while (true) {
    const match = separator.exec(raw);
    if (!match) break;
    push(cursor, match.index);
    cursor = match.index + match[0].length;
  }
  push(cursor, raw.length);
  return ranges.length ? ranges : undefined;
}

function normalizePlainEditableProjectionBlocks(
  source: string,
  blocks: readonly VisualBlock[],
): VisualBlock[] {
  const normalized: VisualBlock[] = [];
  for (const block of blocks) {
    if (block.kind !== 'raw') {
      normalized.push(block);
      continue;
    }
    const ranges = plainEditableProseRanges(source, block.range.from, block.range.to);
    if (!ranges?.length) {
      normalized.push(block);
      continue;
    }
    for (const range of ranges) {
      normalized.push({
        id: `${block.id}:recovered-prose:${range.from}:${range.to}`,
        kind: 'paragraph',
        file: block.file,
        range,
        bodyRange: range,
        text: source.slice(range.from, range.to),
      });
    }
  }
  return normalized;
}

function protectedLatexLabel(raw: string): { label: string; visualKind: string; summary?: string } {
  const trimmed = raw.trim();
  if (/\\begin\s*\{tikzpicture\}/u.test(trimmed)) {
    return { label: 'TikZ · Protected LaTeX', visualKind: 'tikz', summary: 'TikZ drawing source preserved exactly' };
  }
  if (/\\(?:documentclass|usepackage|RequirePackage|newcommand|renewcommand|providecommand|DeclareMathOperator|title|author|date|name|address)\b/u.test(trimmed)) {
    return { label: 'Document setup · Protected LaTeX', visualKind: 'preamble', summary: 'Preamble, metadata and custom definitions are preserved exactly' };
  }
  if (/\\(?:bibliography|bibliographystyle|printbibliography|addbibresource)\b/u.test(trimmed)) {
    return { label: 'Bibliography · Protected LaTeX', visualKind: 'bibliography', summary: 'Bibliography source preserved exactly' };
  }
  if (/\\(?:vfill|hfill|pagebreak|newpage|clearpage|cleardoublepage|columnbreak|newcolumn)\b/u.test(trimmed)) {
    return { label: 'Page layout · Protected LaTeX', visualKind: 'layout', summary: 'Page/column layout preserved exactly' };
  }
  if (/^(?:%[^\r\n]*(?:\r?\n|$)|\s)*\\(?:maketitle|tableofcontents|listoffigures|listoftables)\b/u.test(trimmed)) {
    return { label: 'Document structure · Protected LaTeX', visualKind: 'structure', summary: 'Document-class structure preserved exactly' };
  }
  if (/^\\label\s*\{/u.test(trimmed)) {
    return { label: 'Label · Protected LaTeX', visualKind: 'label', summary: 'Cross-reference anchor preserved exactly' };
  }
  return { label: 'Protected LaTeX', visualKind: 'raw', summary: 'Source-only LaTeX preserved exactly' };
}

function extensions() {
  return [
    setupBase(),
    setupLatexParagraph(),
    setupLatexHeading(),
    setupLatexList(),
    setupLatexHardBreak(),
    withLatexCommand(setupBold(), 'bold', 'textbf'),
    withLatexCommand(setupItalic(), 'italic', 'textit'),
    withLatexCommand(setupUnderline(), 'underline', 'underline'),
    withLatexCommand(setupCode(), 'code', 'texttt'),
    withLatexCommand(setupLink({ openOnClick: 'meta' }), 'link', 'href'),
    setupLatexMath(),
    setupLatexTable(),
    rawExtension,
    setupDropGapCursor(),
    setupPlaceholder({
      placeholder: "Press '/' for commands",
      blockPlaceholder: "Press '/' for commands",
    }),
    setupNativeVisualIdentity(),
    setupImmutableLatexProtection(),
    setupHistory(),
    setupTrailingNode({
      notAfter: ['paragraph'],
    }),
  ];
}

function splitDocumentShell(source: string): DocumentShell {
  const begin = /\\begin\s*\{document\}/u.exec(source);
  const endMatches = [...source.matchAll(/\\end\s*\{document\}/gu)];
  const end = endMatches.at(-1);
  if (!begin || !end || end.index < begin.index + begin[0].length) {
    return { prefix: '', suffix: '', bodyStart: 0, preambleEnd: 0, bodyEnd: source.length };
  }
  const bodyStart = begin.index + begin[0].length;
  return {
    prefix: source.slice(0, bodyStart),
    suffix: source.slice(end.index),
    bodyStart,
    preambleEnd: begin.index,
    bodyEnd: end.index,
  };
}

function sourceSlice(source: string, range: { from: number; to: number }): string {
  const from = Math.max(0, Math.min(source.length, range.from));
  const to = Math.max(from, Math.min(source.length, range.to));
  return source.slice(from, to);
}

function decodeSimpleLatexText(value: string): string {
  return value
    .replaceAll('\\textbackslash{}', '\\')
    .replace(/\\([%&#_$\{\}])/gu, '$1')
    .replaceAll('\\textasciitilde{}', '~')
    .replaceAll('\\textasciicircum{}', '^');
}

function escapeLatexCharacter(char: string): string {
  switch (char) {
    case '\\':
      return '\\textbackslash{}';
    case '{':
      return '\\{';
    case '}':
      return '\\}';
    case '%':
      return '\\%';
    case '&':
      return '\\&';
    case '#':
      return '\\#';
    case '_':
      return '\\_';
    case '$':
      return '\\$';
    case '~':
      return '\\textasciitilde{}';
    case '^':
      return '\\textasciicircum{}';
    default:
      return char;
  }
}

function escapedTextWithBoundaries(text: string): { text: string; boundaries: number[] } {
  const parts: string[] = [];
  const boundaries = [0];
  let length = 0;
  for (const char of text) {
    const escaped = escapeLatexCharacter(char);
    parts.push(escaped);
    length += escaped.length;
    boundaries.push(length);
  }
  return { text: parts.join(''), boundaries };
}

function markForInline(schema: Schema, inline: Extract<VisualInline, { kind: 'styled-text' }>): Mark | undefined {
  if (inline.style === 'bold') return schema.marks.bold?.create({ latexCommand: inline.command });
  if (inline.style === 'italic') return schema.marks.italic?.create({ latexCommand: inline.command });
  if (inline.style === 'underline') return schema.marks.underline?.create({ latexCommand: inline.command });
  if (inline.style === 'monospace') return schema.marks.code?.create({ latexCommand: inline.command });
  if (inline.style === 'link') {
    return schema.marks.link?.create({ href: inline.href ?? '', title: null, latexCommand: inline.command });
  }
  return undefined;
}

function rawInlineLabel(inline: VisualInline): string {
  if (inline.kind === 'citation') return `Citation: ${inline.targets.join(', ')}`;
  if (inline.kind === 'reference') return `Reference: ${inline.target}`;
  if (inline.kind === 'footnote') return 'Footnote';
  if (inline.kind === 'raw') return 'LaTeX';
  return 'LaTeX';
}

function inlineIsSourceTrivia(inline: VisualInline): boolean {
  return inline.kind === 'raw' && inline.reason === 'LaTeX comment preserved as invisible source trivia.';
}

function sourceIsOnlyTrivia(raw: string): boolean {
  return /^(?:(?:[ \t\r\n]+)|(?:%[^\r\n]*(?:\r?\n|$)))*$/u.test(raw);
}

/**
 * Protected boxes are a lossless Source affordance. Standalone comment-only
 * spans never become manuscript nodes (see sourceIsOnlyTrivia), but once a
 * meaningful construct is protected its displayed payload must be the complete
 * original LaTeX, including comments and spacing inside that construct. This
 * guarantees that a failed/unsupported preview can never become an empty box.
 */
function protectedDisplayLatex(raw: string): string {
  // The protected/source-only affordance is the lossless fallback. Display the
  // exact bytes owned by the protected source range instead of a prettified or
  // reconstructed variant; this includes comments, indentation and blank lines.
  return raw;
}

function richInlineIsVisuallyEditable(source: string, inline: VisualInline): boolean {
  if (inline.kind === 'styled-text') return decodeSimpleLatexText(inline.text).trim().length > 0;
  // Math has a dedicated native Visual projection even when a particular
  // expression is too complex for safe MathLive editing. In that case it must
  // remain a read-only equation preview, not cause the containing paragraph or
  // display equation to collapse into Protected LaTeX.
  if (inline.kind === 'math') return true;
  if (inline.kind !== 'text') return false;
  const raw = sourceSlice(source, inline.range);
  if (!raw.trim()) return false;
  // Be conservative: comments and control sequences belong in a protected
  // source box unless the semantic parser has already claimed them as a known
  // editable inline node. This keeps source-only runs such as comments plus
  // \maketitle from leaking into Visual as a row of tiny chips.
  const withoutComments = raw.replace(/(^|[^\\])%[^\r\n]*/gu, '$1');
  const withoutCanonicalLiterals = withoutComments
    .replaceAll('\\textbackslash{}', '')
    .replaceAll('\\textasciitilde{}', '')
    .replaceAll('\\textasciicircum{}', '')
    .replace(/\\[%&#_$\{\}]/gu, '');
  return withoutComments.trim().length > 0 && !/\\[A-Za-z@]+/u.test(withoutCanonicalLiterals);
}

function inlineNodes(schema: Schema, source: string, inlines: readonly VisualInline[]): PMNode[] {
  const nodes: PMNode[] = [];
  for (const inline of inlines) {
    if (inline.kind === 'text') {
      const value = decodeSimpleLatexText(inline.text);
      if (value) nodes.push(schema.text(value));
      continue;
    }
    if (inline.kind === 'hard-break') {
      const hardBreak = schema.nodes.hard_break;
      if (hardBreak) nodes.push(hardBreak.create({ latexSource: inline.sourceText }));
      continue;
    }
    if (inline.kind === 'styled-text') {
      const value = decodeSimpleLatexText(inline.text);
      const mark = markForInline(schema, inline);
      if (value) nodes.push(schema.text(value, mark ? [mark] : undefined));
      continue;
    }
    if (inline.kind === 'math' && inline.mathKind === 'inline') {
      const type = schema.nodes[INLINE_MATH];
      if (type) {
        const content = inline.mathLiveValue ? schema.text(inline.mathLiveValue) : undefined;
        nodes.push(
          type.create(
            {
              latexId: inline.id,
              delimiter: inline.delimiter,
              environment: inline.environment ?? '',
              originalLatex: sourceSlice(source, inline.range),
              initialValue: inline.mathLiveValue,
              editable: inline.editable,
            },
            content,
          ),
        );
      }
      continue;
    }
    const raw = schema.nodes[RAW_INLINE];
    if (raw) {
      nodes.push(
        raw.create({
          latex: sourceSlice(source, inline.range),
          sourceId: inline.id,
          label: rawInlineLabel(inline),
          visualKind: inlineIsSourceTrivia(inline) ? 'trivia' : inline.kind,
          command: inline.kind === 'citation' || inline.kind === 'reference' ? inline.command : '',
          targets: inline.kind === 'citation' ? [...inline.targets] : [],
          target: inline.kind === 'reference' ? inline.target : '',
          bodyFromRelative: inline.kind === 'footnote' ? inline.bodyRange.from - inline.range.from : 0,
          bodyToRelative: inline.kind === 'footnote' ? inline.bodyRange.to - inline.range.from : 0,
          footnoteText: inline.kind === 'footnote' ? inline.text : '',
          editable: inline.kind === 'footnote' ? inline.editable : false,
        }),
      );
    }
  }
  return nodes;
}

function simpleTextNode(schema: Schema, text: string): PMNode[] {
  const decoded = decodeSimpleLatexText(text);
  return decoded ? [schema.text(decoded)] : [];
}

function paragraphNode(schema: Schema, content: readonly PMNode[], sourceId = ''): PMNode {
  return schema.nodes.paragraph!.create({ sourceId }, content);
}

function displayMathNode(schema: Schema, source: string, inline: Extract<VisualInline, { kind: 'math' }>): PMNode | undefined {
  const type = schema.nodes[DISPLAY_MATH];
  if (!type) return undefined;
  const content = inline.mathLiveValue ? schema.text(inline.mathLiveValue) : undefined;
  return type.create(
    {
      latexId: inline.id,
      delimiter: inline.delimiter,
      environment: inline.environment ?? '',
      originalLatex: sourceSlice(source, inline.range),
      initialValue: inline.mathLiveValue,
      editable: inline.editable,
    },
    content,
  );
}

function richParagraphNodes(schema: Schema, source: string, block: Extract<VisualBlock, { kind: 'rich-paragraph' }>): PMNode[] {
  const result: PMNode[] = [];
  let current: PMNode[] = [];
  let paragraphIndex = 0;
  const flush = () => {
    if (current.length || result.length === 0) {
      const id = paragraphIndex === 0 ? block.id : `${block.id}:paragraph:${paragraphIndex}`;
      result.push(paragraphNode(schema, current, id));
      paragraphIndex += 1;
    }
    current = [];
  };
  for (const inline of block.inlines) {
    if (inline.kind === 'math' && inline.mathKind === 'display') {
      if (current.length) flush();
      const math = displayMathNode(schema, source, inline);
      if (math) result.push(math);
      continue;
    }
    current.push(...inlineNodes(schema, source, [inline]));
  }
  if (current.length || result.length === 0) flush();
  return result;
}

function listNodes(schema: Schema, source: string, block: Extract<VisualBlock, { kind: 'list' }>): PMNode[] {
  if (!block.editable || !schema.nodes.list) {
    const raw = schema.nodes[RAW_BLOCK]!;
    return [
      raw.create({
        latex: sourceSlice(source, block.range),
        sourceId: block.id,
        sourceFrom: block.range.from,
        sourceTo: block.range.to,
        label: block.environment === 'enumerate' ? 'Enumerated list · Protected LaTeX' : 'Itemized list · Protected LaTeX',
        visualKind: 'list',
        summary: 'List structure preserved exactly',
        immutable: true,
      }),
    ];
  }
  return block.items.map((item) => {
    const paragraph = paragraphNode(schema, inlineNodes(schema, source, item.inlines));
    return schema.nodes.list!.create(
      {
        kind: block.ordered ? 'ordered' : 'bullet',
        listKind: block.ordered ? 'ordered' : 'bullet',
        tight: true,
        ...(block.ordered ? { order: 1 } : {}),
        sourceId: item.id,
        latexListId: block.id,
      },
      [paragraph],
    );
  });
}

function tableCellFingerprint(cell: PMNode): string {
  const parts: string[] = [];
  cell.forEach((child) => {
    if (child.isText) {
      const marks = child.marks.map((mark) => {
        const attrs = Object.fromEntries(
          Object.entries(mark.attrs).filter(([key]) => key !== 'sourceId' && key !== 'latexId'),
        );
        return `${mark.type.name}:${JSON.stringify(attrs)}`;
      });
      parts.push(`text:${JSON.stringify(child.text ?? '')}:${marks.join(',')}`);
      return;
    }
    if (child.type.name === 'hard_break') {
      parts.push(`hard-break:${String(child.attrs.latexSource ?? '\\newline{}')}`);
      return;
    }
    if (child.type.name === RAW_INLINE) {
      parts.push(`raw:${String(child.attrs.latex ?? '')}`);
      return;
    }
    if (child.type.name === INLINE_MATH) {
      parts.push(`math:${child.textContent}:${String(child.attrs.delimiter ?? '')}:${String(child.attrs.environment ?? '')}`);
      return;
    }
    parts.push(`${child.type.name}:${child.textContent}`);
  });
  return parts.join('\u241d');
}

function tableFingerprint(node: PMNode): string {
  const rows: string[] = [];
  node.forEach((row) => {
    const cells: string[] = [];
    row.forEach((cell) => {
      cells.push(tableCellFingerprint(cell));
    });
    rows.push(cells.join('\u241f'));
  });
  return rows.join('\u241e');
}

function splitTableSource(source: string): { before: string; after: string } {
  const model = parseLatexTabular(source);
  if (!model) return { before: '', after: '' };
  return {
    before: source.slice(0, model.from),
    after: source.slice(model.to),
  };
}


type NativeTableAlign = 'left' | 'center' | 'right';
type NativeTableAlignStyle = 'none' | 'centering' | 'center-env' | 'flushleft' | 'flushright';

function detectTableAlignment(rawSource: string): { align: NativeTableAlign; style: NativeTableAlignStyle } {
  const parts = splitTableSource(rawSource);
  if (/\\begin\s*\{center\}\s*$/u.test(parts.before) && /^\s*\\end\s*\{center\}/u.test(parts.after)) {
    return { align: 'center', style: 'center-env' };
  }
  if (/\\begin\s*\{flushright\}\s*$/u.test(parts.before) && /^\s*\\end\s*\{flushright\}/u.test(parts.after)) {
    return { align: 'right', style: 'flushright' };
  }
  if (/\\begin\s*\{flushleft\}\s*$/u.test(parts.before) && /^\s*\\end\s*\{flushleft\}/u.test(parts.after)) {
    return { align: 'left', style: 'flushleft' };
  }
  if (/\\centering\b/u.test(parts.before)) return { align: 'center', style: 'centering' };
  return { align: 'left', style: 'none' };
}

function stripCanonicalTableAlignment(before: string, after: string): { before: string; after: string } {
  let nextBefore = before
    .replace(/\\begin\s*\{(?:center|flushleft|flushright)\}\s*$/u, '')
    .replace(/(?:^|\n)[ \t]*\\centering[ \t]*(?=\n|$)/u, '\n');
  let nextAfter = after.replace(/^\s*\\end\s*\{(?:center|flushleft|flushright)\}/u, '');
  nextBefore = nextBefore.replace(/[ \t]+$/gmu, '').replace(/\n{3,}$/u, '\n\n');
  nextAfter = nextAfter.replace(/^\n{3,}/u, '\n\n');
  return { before: nextBefore, after: nextAfter };
}

function canonicalTableAlignmentParts(
  before: string,
  after: string,
  align: NativeTableAlign,
  lineEnding: LatexLineEnding,
): { before: string; after: string } {
  const rawStripped = stripCanonicalTableAlignment(before, after);
  const stripped = {
    before: normalizeLatexLineEndings(rawStripped.before, lineEnding),
    after: normalizeLatexLineEndings(rawStripped.after, lineEnding),
  };
  const environment = align === 'center' ? 'center' : align === 'right' ? 'flushright' : 'flushleft';
  const prefix = stripped.before.endsWith('\n') || !stripped.before ? '' : lineEnding;
  const suffix = stripped.after.startsWith('\n') || !stripped.after ? '' : lineEnding;
  return {
    before: `${stripped.before}${prefix}\\begin{${environment}}${lineEnding}`,
    after: `${lineEnding}\\end{${environment}}${suffix}${stripped.after}`,
  };
}

function tableSpecShape(spec: string): { tokens: string[]; fullGrid: boolean } | undefined {
  const model = parseLatexTabular(`\\begin{tabular}{${spec}}\n\\end{tabular}`);
  if (!model?.columns.length) return undefined;
  const tokens = model.columns.map((column) => column.text.trim());
  const compact = spec.replace(/\s/gu, '');
  const fullGrid = compact === `|${tokens.map((token) => token.replace(/\s/gu, '')).join('|')}|`;
  return { tokens, fullGrid };
}

function safelyEditableTableSource(rawSource: string): boolean {
  // BF4 edits only table syntax that can round-trip through Learnitive's simple
  // inline-cell model without discarding author-owned rule/layout commands.
  // Complex tables remain protected LaTeX atoms and keep their bytes intact.
  if (/\\(?:toprule|midrule|bottomrule|cmidrule|cline|hhline|specialrule|addlinespace|multirow|multicolumn|rowcolor|cellcolor|arrayrulecolor|noalign)\b/u.test(rawSource)) {
    return false;
  }
  const model = parseLatexTabular(rawSource);
  if (!model) return false;
  // Decorator/inter-column material cannot be reconstructed reliably after a
  // native Learnitive column insertion/deletion, so keep those tables Source-only.
  if (/[@><!]/u.test(model.spec)) return false;
  const shape = tableSpecShape(model.spec);
  if (!shape) return false;
  if (!shape.tokens.every((token) => /^[lcr]$/u.test(token.trim()))) return false;
  // A partial vertical-rule pattern (for example `l|c`) is author-owned.
  // Full grids and rule-free specs have deterministic resize semantics.
  return !model.spec.includes('|') || shape.fullGrid;
}

function tableNode(schema: Schema, source: string, block: Extract<VisualBlock, { kind: 'table' }>): PMNode {
  const rawSource = sourceSlice(source, block.range);
  if (
    !block.editable ||
    !block.cells?.length ||
    !block.rowCount ||
    !block.columnCount ||
    !safelyEditableTableSource(rawSource)
  ) {
    return schema.nodes[RAW_BLOCK]!.create({
      latex: rawSource,
      sourceId: block.id,
      sourceFrom: block.range.from,
      sourceTo: block.range.to,
      label: 'Complex LaTeX table',
      visualKind: 'table',
      summary: [
        block.rowCount && block.columnCount ? `${block.rowCount} rows × ${block.columnCount} columns` : '',
        'Custom LaTeX preserved',
      ].filter(Boolean).join(' • '),
      immutable: true,
    });
  }
  const rows: PMNode[] = [];
  for (let rowIndex = 0; rowIndex < block.rowCount; rowIndex += 1) {
    const cells: PMNode[] = [];
    for (let columnIndex = 0; columnIndex < block.columnCount; columnIndex += 1) {
      const cell = block.cells.find((candidate) => candidate.row === rowIndex && candidate.column === columnIndex);
      const content = cell ? inlineNodes(schema, source, cell.inlines) : [];
      cells.push(schema.nodes.table_cell!.create(null, content));
    }
    rows.push(schema.nodes.table_row!.create(null, cells));
  }
  const parts = splitTableSource(rawSource);
  const alignment = detectTableAlignment(rawSource);
  const table = schema.nodes.table!.create(
    {
      latexId: block.id,
      columns: block.columns ?? '',
      rawBeforeTabular: parts.before,
      rawAfterTabular: parts.after,
      rawOriginal: rawSource,
      initialColumns: block.columns ?? '',
      tableAlign: alignment.align,
      initialTableAlign: alignment.align,
      tableAlignStyle: alignment.style,
      hasHorizontalRules: /\\hline/u.test(rawSource),
    },
    rows,
  );
  return table.type.create(
    {
      ...table.attrs,
      initialFingerprint: tableFingerprint(table),
    },
    table.content,
  );
}

function visualAssetPreview(value: string): { url: string; status: string } {
  if (!value) return { url: '', status: 'loading' };
  if (value.startsWith(VISUAL_ASSET_ERROR_PREFIX)) {
    return { url: '', status: value.slice(VISUAL_ASSET_ERROR_PREFIX.length) || 'error' };
  }
  return { url: value, status: 'ready' };
}

function blockNodes(
  schema: Schema,
  source: string,
  block: VisualBlock,
  assetUrls?: ReadonlyMap<string, string>,
): PMNode[] {
  if (block.kind === 'heading') {
    const level = Math.max(1, Math.min(6, block.level ?? 2));
    const command = block.command || headingCommand(level);
    const starred = Boolean(block.starred);
    const raw = sourceSlice(source, block.range);
    const body = sourceSlice(source, block.bodyRange);
    const sourceSuffix = block.suffix ?? '';
    const coreRaw = sourceSuffix && raw.endsWith(sourceSuffix) ? raw.slice(0, -sourceSuffix.length) : raw;
    const commandSource = `\\${command}${starred ? '*' : ''}`;
    const simple = !starred && coreRaw === `${commandSource}{${body}}` && !/\\[A-Za-z@]+/u.test(body);
    if (!simple) {
      return [
        schema.nodes[RAW_BLOCK]!.create({
          latex: raw,
          displayLatex: protectedDisplayLatex(raw),
          sourceId: block.id,
          sourceFrom: block.range.from,
          sourceTo: block.range.to,
          label: `${command}${starred ? '*' : ''}: ${block.text} · Protected LaTeX`,
          visualKind: 'heading',
          summary: 'Section title preserved exactly; edit it in Source',
          immutable: true,
        }),
      ];
    }
    return [
      schema.nodes.heading!.create(
        { level, sourceId: block.id, sourceSuffix, latexCommand: command },
        simpleTextNode(schema, body),
      ),
    ];
  }
  if (block.kind === 'paragraph') {
    return [paragraphNode(schema, simpleTextNode(schema, block.text), block.id)];
  }
  if (block.kind === 'text-environment') {
    return [
      schema.nodes[TEXT_ENVIRONMENT]!.create(
        {
          sourceId: block.id,
          environment: block.environment,
          prefix: block.prefix,
          suffix: block.suffix,
        },
        inlineNodes(schema, source, block.inlines),
      ),
    ];
  }
  if (block.kind === 'rich-paragraph') {
    const meaningful = block.inlines.filter(
      (inline) => !inlineIsSourceTrivia(inline) && (inline.kind !== 'text' || inline.text.trim().length > 0),
    );
    const blockSource = sourceSlice(source, block.range);
    if (meaningful.length === 0 && block.inlines.some(inlineIsSourceTrivia)) {
      // A comment-only semantic block is source trivia, not manuscript content.
      // buildNativeProjection() preserves its exact bytes in the sidecar layout
      // instead of inserting an invisible top-level ProseMirror atom that would
      // disturb selection, Enter behavior, node counts, and block navigation.
      return [];
    }
    const editableInline = meaningful.some((inline) => richInlineIsVisuallyEditable(source, inline));
    if (meaningful.length > 0 && !editableInline) {
      const protectedMeta = protectedLatexLabel(blockSource);
      return [
        schema.nodes[RAW_BLOCK]!.create({
          latex: blockSource,
          displayLatex: protectedDisplayLatex(blockSource),
          sourceId: block.id,
          sourceFrom: block.range.from,
          sourceTo: block.range.to,
          label: protectedMeta.label,
          visualKind: protectedMeta.visualKind,
          summary: protectedMeta.summary ?? 'Source-only LaTeX preserved exactly',
          immutable: true,
        }),
      ];
    }
    return richParagraphNodes(schema, source, block);
  }
  if (block.kind === 'list') return listNodes(schema, source, block);
  if (block.kind === 'table') return [tableNode(schema, source, block)];
  if (block.kind === 'raw') {
    const raw = sourceSlice(source, block.range);
    if (sourceLooksLikePlainEditableProse(raw)) {
      return [paragraphNode(schema, simpleTextNode(schema, raw), block.id)];
    }
    const protectedMeta = protectedLatexLabel(raw);
    return [
      schema.nodes[RAW_BLOCK]!.create({
        latex: raw,
        displayLatex: protectedDisplayLatex(raw),
        sourceId: block.id,
        sourceFrom: block.range.from,
        sourceTo: block.range.to,
        label: protectedMeta.label,
        visualKind: protectedMeta.visualKind,
        summary: protectedMeta.summary ?? 'Source-only LaTeX preserved exactly',
        immutable: true,
      }),
    ];
  }
  return [
    schema.nodes[RAW_BLOCK]!.create({
      latex: sourceSlice(source, block.range),
      displayLatex: protectedDisplayLatex(sourceSlice(source, block.range)),
      sourceId: block.id,
      sourceFrom: block.range.from,
      sourceTo: block.range.to,
      label:
        block.kind === 'figure'
          ? 'Figure'
          : block.kind === 'theorem'
            ? `${block.environment} theorem`
            : 'Preserved LaTeX',
      visualKind: block.kind,
      ...(block.kind === 'figure'
        ? (() => {
            const targets = block.assets?.map((asset) => asset.target) ?? (block.asset ? [block.asset] : []);
            const previews = targets.map((target) => visualAssetPreview(assetUrls?.get(target) ?? ''));
            const urls = previews.map((preview) => preview.url);
            const statuses = previews.map((preview) => preview.status);
            const blockPreview = visualAssetPreview(assetUrls?.get(block.id) ?? '');
            return {
              assetUrl: urls.find(Boolean) ?? blockPreview.url,
              assetTargets: targets,
              assetUrls: urls,
              assetStatuses: statuses,
              complexFigure: Boolean(block.complex),
              caption: block.caption ?? '',
              labelText: block.label ?? '',
              summary: [
                targets.length ? `${targets.length} graphic${targets.length === 1 ? '' : 's'}` : '',
                block.complex ? 'Complex layout preserved' : '',
                block.graphicsOptions ?? '',
              ].filter(Boolean).join(' • '),
              immutable: true,
            };
          })()
        : { immutable: true }),
      ...(block.kind === 'theorem'
        ? { summary: block.title ? `${block.environment}: ${block.title}` : block.environment }
        : {}),
    }),
  ];
}

function topLevelSourceIdentity(node: PMNode): string {
  const sourceId = String(node.attrs.sourceId ?? '');
  if (sourceId) return sourceId;
  const latexId = String(node.attrs.latexId ?? '');
  return latexId;
}

function buildNativeProjection(
  schema: Schema,
  source: string,
  blocks: readonly VisualBlock[],
  assetUrls?: ReadonlyMap<string, string>,
): NativeProjection {
  const shell = splitDocumentShell(source);
  const bodyEnd = shell.bodyEnd;
  const normalizedBlocks = normalizePlainEditableProjectionBlocks(source, blocks);
  const semanticSorted = [...normalizedBlocks]
    .filter((block) => block.range.from >= shell.bodyStart && block.range.to <= bodyEnd)
    .sort((left, right) => left.range.from - right.range.from || left.range.to - right.range.to);

  // A semantic-index miss must not turn Visual-authored prose into Protected
  // LaTeX after Source -> Visual. Recover plain, TeX-free paragraph ranges in
  // otherwise unclaimed gaps before projection; the normal gap sidecar then
  // preserves their exact blank-line separators byte-for-byte.
  const recoveredGapBlocks: VisualBlock[] = [];
  const projectionFile = semanticSorted[0]?.file ?? normalizedBlocks[0]?.file;
  if (projectionFile) {
    let gapCursor = shell.bodyStart;
    const recoverGap = (from: number, to: number) => {
      const ranges = plainEditableProseRanges(source, from, to);
      if (!ranges?.length) return;
      for (const range of ranges) {
        recoveredGapBlocks.push({
          id: `${projectionFile}:recovered-gap-prose:${range.from}:${range.to}`,
          kind: 'paragraph',
          file: projectionFile,
          range,
          bodyRange: range,
          text: source.slice(range.from, range.to),
        });
      }
    };
    for (const block of semanticSorted) {
      if (block.range.from > gapCursor) recoverGap(gapCursor, block.range.from);
      gapCursor = Math.max(gapCursor, block.range.to);
    }
    if (gapCursor < bodyEnd) recoverGap(gapCursor, bodyEnd);
  }

  const sorted = [...semanticSorted, ...recoveredGapBlocks]
    .sort((left, right) => left.range.from - right.range.from || left.range.to - right.range.to);
  const nodes: PMNode[] = [];
  const interBlockTrivia: NativeSourceTriviaGap[] = [];
  const originalNodeOrder: string[] = [];
  let leadingTrivia = '';
  let trailingTrivia = '';
  let lastSerializableId: string | undefined;
  let serializableNodeCount = 0;

  const registerNodes = (projected: readonly PMNode[]) => {
    for (const node of projected) {
      nodes.push(node);
      if (node.type.name === RAW_BLOCK && node.attrs.serialize === false) continue;
      serializableNodeCount += 1;
      const id = topLevelSourceIdentity(node);
      if (id) {
        originalNodeOrder.push(id);
        lastSerializableId = id;
      }
    }
  };

  // The preamble is meaningful project source but is not safely editable in the
  // manuscript projection. Show one immutable proxy while the exact preamble
  // remains owned by the document-shell serializer. It intentionally does not
  // participate in manuscript source layout or block separators.
  if (shell.preambleEnd > 0) {
    const preamble = source.slice(0, shell.preambleEnd);
    if (/\S/u.test(preamble)) {
      nodes.push(
        schema.nodes[RAW_BLOCK]!.create({
          // Keep the canonical bytes on the node as well as displayLatex. The
          // proxy is serialize:false, so this cannot duplicate the preamble on
          // save, while guaranteeing that no renderer/update path can produce
          // an empty Document setup box.
          latex: preamble,
          displayLatex: protectedDisplayLatex(preamble),
          sourceId: `preamble:0:${shell.preambleEnd}`,
          sourceFrom: 0,
          sourceTo: shell.preambleEnd,
          serialize: false,
          label: 'Document setup · Protected LaTeX',
          visualKind: 'preamble',
          summary: 'Document class, packages, metadata and custom definitions are preserved exactly',
          immutable: true,
        }),
      );
    }
  }

  const protectedGapNode = (from: number, to: number): PMNode | undefined => {
    if (to <= from) return undefined;
    const gap = source.slice(from, to);
    if (!gap || !/\S/u.test(gap)) return undefined;
    const protectedMeta = protectedLatexLabel(gap);
    return schema.nodes[RAW_BLOCK]!.create({
      latex: gap,
      displayLatex: protectedDisplayLatex(gap),
      sourceId: `gap:${from}:${to}`,
      sourceFrom: from,
      sourceTo: to,
      label: protectedMeta.label,
      visualKind: protectedMeta.visualKind,
      summary: protectedMeta.summary ?? '',
      immutable: true,
      tight: true,
    });
  };

  const preserveGapBefore = (
    from: number,
    to: number,
    rightId: string | undefined,
  ) => {
    if (to <= from) return;
    const gap = source.slice(from, to);
    if (!gap) return;
    if (!sourceIsOnlyTrivia(gap)) {
      // Plain unclaimed prose should already have been materialized as
      // recoveredGapBlocks above. Keep this single-span safety net for unusual
      // ordering/overlap cases, but never collapse multiple paragraphs into one
      // ProseMirror paragraph.
      const proseRanges = plainEditableProseRanges(source, from, to);
      if (proseRanges?.length === 1) {
        const prose = proseRanges[0]!;
        registerNodes([paragraphNode(schema, simpleTextNode(schema, source.slice(prose.from, prose.to)), `recovered-prose:${prose.from}:${prose.to}`)]);
        return;
      }
      const protectedNode = protectedGapNode(from, to);
      if (protectedNode) registerNodes([protectedNode]);
      return;
    }
    if (!lastSerializableId) {
      leadingTrivia += gap;
      return;
    }
    if (rightId) {
      interBlockTrivia.push({
        from,
        to,
        text: gap,
        leftId: lastSerializableId,
        rightId,
      });
      return;
    }
    // A pure-trivia gap with no right manuscript node is document-body tail.
    // Keep it outside ProseMirror so an invisible atom cannot steal selection.
    trailingTrivia += gap;
  };

  let cursor = shell.bodyStart;
  for (const block of sorted) {
    if (block.range.from < cursor) continue;
    const blockSource = sourceSlice(source, block.range);
    const projected = blockNodes(schema, source, block, assetUrls);

    // Comment-only semantic segments are source trivia. Leave cursor untouched
    // so the next gap captures their bytes together with surrounding whitespace.
    if (projected.length === 0 && sourceIsOnlyTrivia(blockSource)) continue;

    const firstId = projected.map(topLevelSourceIdentity).find(Boolean);
    preserveGapBefore(cursor, block.range.from, firstId);

    if (projected.length > 0) {
      registerNodes(projected);
    } else if (/\S/u.test(blockSource)) {
      // Fail closed: a meaningful semantic block must never disappear merely
      // because a projector returned no native node.
      const protectedMeta = protectedLatexLabel(blockSource);
      registerNodes([
        schema.nodes[RAW_BLOCK]!.create({
          latex: blockSource,
          displayLatex: protectedDisplayLatex(blockSource),
          sourceId: block.id,
          sourceFrom: block.range.from,
          sourceTo: block.range.to,
          label: protectedMeta.label,
          visualKind: protectedMeta.visualKind,
          summary: protectedMeta.summary ?? 'Source-only LaTeX preserved exactly',
          immutable: true,
        }),
      ]);
    }
    cursor = block.range.to;
  }

  if (cursor < bodyEnd) {
    const tail = source.slice(cursor, bodyEnd);
    if (sourceIsOnlyTrivia(tail)) {
      trailingTrivia += tail;
    } else {
      // Use the same conservative prose recovery at the document tail. This is
      // the most common location for freshly typed Visual text, and previously
      // it bypassed preserveGapBefore() and could be boxed wholesale after a
      // Source -> Visual round-trip when the semantic index missed the span.
      const proseRanges = plainEditableProseRanges(source, cursor, bodyEnd);
      if (proseRanges?.length) {
        for (const prose of proseRanges) {
          registerNodes([
            paragraphNode(
              schema,
              simpleTextNode(schema, source.slice(prose.from, prose.to)),
              `recovered-tail-prose:${prose.from}:${prose.to}`,
            ),
          ]);
        }
      } else {
        const protectedNode = protectedGapNode(cursor, bodyEnd);
        if (protectedNode) registerNodes([protectedNode]);
      }
    }
  }

  // Expose the document closing wrapper as a compact protected footer. It is
  // source-owned by the shell serializer (serialize:false), but keeping it
  // visible makes the bottom of complex imported documents fail closed rather
  // than silently omitting meaningful structural LaTeX.
  if (/\S/u.test(shell.suffix)) {
    nodes.push(
      schema.nodes[RAW_BLOCK]!.create({
        latex: shell.suffix,
        displayLatex: protectedDisplayLatex(shell.suffix),
        sourceId: `document-tail:${shell.bodyEnd}:${source.length}`,
        sourceFrom: shell.bodyEnd,
        sourceTo: source.length,
        serialize: false,
        tight: true,
        label: 'Document end · Protected LaTeX',
        visualKind: 'document-tail',
        summary: 'Closing document structure is preserved exactly',
        immutable: true,
      }),
    );
  }

  if (serializableNodeCount === 0) nodes.push(paragraphNode(schema, []));

  return {
    doc: schema.topNodeType.create(null, nodes),
    sourceLayout: {
      leadingTrivia,
      trailingTrivia,
      interBlockTrivia,
      originalNodeIds: new Set(originalNodeOrder),
      originalNodeOrder,
    },
  };
}

function wrapMarkedText(mark: Mark, _inner: string): { prefix: string; suffix: string } {
  const command = String(mark.attrs.latexCommand ?? '');
  if (mark.type.name === 'bold') return { prefix: `\\${command || 'textbf'}{`, suffix: '}' };
  if (mark.type.name === 'italic') return { prefix: `\\${command || 'textit'}{`, suffix: '}' };
  if (mark.type.name === 'underline') return { prefix: `\\${command || 'underline'}{`, suffix: '}' };
  if (mark.type.name === 'code') return { prefix: `\\${command || 'texttt'}{`, suffix: '}' };
  if (mark.type.name === 'link') {
    const href = String(mark.attrs.href ?? '');
    if (command === 'url') return { prefix: '\\url{', suffix: '}' };
    return { prefix: `\\href{${href}}{`, suffix: '}' };
  }
  return { prefix: '', suffix: '' };
}

function serializeTextNode(
  node: PMNode,
  pmFrom: number,
  sourceFrom: number,
): { text: string; maps: SourceMapEntry[] } {
  const plainText = node.text ?? '';
  const urlLiteral = node.marks.some(
    (mark) => mark.type.name === 'link' && String(mark.attrs.latexCommand ?? '') === 'url',
  );
  const escaped = urlLiteral
    ? { text: plainText, boundaries: Array.from({ length: plainText.length + 1 }, (_, index) => index) }
    : escapedTextWithBoundaries(plainText);
  let prefix = '';
  let suffix = '';
  for (const mark of node.marks) {
    const wrapper = wrapMarkedText(mark, escaped.text);
    prefix += wrapper.prefix;
    suffix = wrapper.suffix + suffix;
  }
  const sourceBodyFrom = sourceFrom + prefix.length;
  return {
    text: `${prefix}${escaped.text}${suffix}`,
    maps: [
      {
        pmFrom,
        pmTo: pmFrom + (node.text?.length ?? 0),
        sourceFrom: sourceBodyFrom,
        sourceTo: sourceBodyFrom + escaped.text.length,
        sourceBoundaries: escaped.boundaries.map((value) => sourceBodyFrom + value),
      },
    ],
  };
}

function serializeMath(node: PMNode, lineEnding: LatexLineEnding): string {
  const value = node.textContent;
  const initialValue = String(node.attrs.initialValue ?? '');
  const original = String(node.attrs.originalLatex ?? '');
  if (original && value === initialValue) return original;
  if (node.type.name === INLINE_MATH) {
    const delimiter = String(node.attrs.delimiter ?? '$');
    if (delimiter === '\\(') return `\\(${value}\\)`;
    return `$${value}$`;
  }
  const environment = String(node.attrs.environment ?? '');
  if (environment) {
    return `\\begin{${environment}}${lineEnding}${value}${lineEnding}\\end{${environment}}`;
  }
  return `\\[${lineEnding}${value}${lineEnding}\\]`;
}

function serializeInlineContent(
  node: PMNode,
  pmContentStart: number,
  sourceStart: number,
  options: { hardBreakLatex?: string; lineEnding?: LatexLineEnding } = {},
) {
  let text = '';
  const maps: SourceMapEntry[] = [];
  let pmOffset = pmContentStart;
  node.forEach((child) => {
    const childSourceStart = sourceStart + text.length;
    if (child.isText) {
      const serialized = serializeTextNode(child, pmOffset, childSourceStart);
      text += serialized.text;
      maps.push(...serialized.maps);
    } else if (child.type.name === 'hard_break') {
      const latex = options.hardBreakLatex ?? String(child.attrs.latexSource ?? '\\newline{}');
      text += latex;
      maps.push({
        pmFrom: pmOffset,
        pmTo: pmOffset + child.nodeSize,
        sourceFrom: childSourceStart,
        sourceTo: childSourceStart + latex.length,
      });
    } else if (child.type.name === RAW_INLINE) {
      const latex = String(child.attrs.latex ?? '');
      text += latex;
      maps.push({
        pmFrom: pmOffset,
        pmTo: pmOffset + child.nodeSize,
        sourceFrom: childSourceStart,
        sourceTo: childSourceStart + latex.length,
        id: String(child.attrs.sourceId ?? ''),
      });
    } else if (child.type.name === INLINE_MATH) {
      const latex = serializeMath(child, options.lineEnding ?? '\n');
      text += latex;
      maps.push({
        pmFrom: pmOffset,
        pmTo: pmOffset + child.nodeSize,
        sourceFrom: childSourceStart,
        sourceTo: childSourceStart + latex.length,
        id: String(child.attrs.latexId ?? ''),
      });
    }
    pmOffset += child.nodeSize;
  });
  return { text, maps };
}

function headingCommand(level: number): string {
  if (level <= 1) return 'chapter';
  if (level === 2) return 'section';
  if (level === 3) return 'subsection';
  if (level === 4) return 'subsubsection';
  return 'paragraph';
}

function tableColumnSpecForWidth(node: PMNode, width: number): string {
  const raw = String(node.attrs.columns ?? '');
  const safeWidth = Math.max(1, width);
  const shape = raw ? tableSpecShape(raw) : undefined;
  if (shape?.tokens.length === safeWidth) return raw;
  if (shape) {
    const resized = shape.tokens.slice(0, safeWidth);
    while (resized.length < safeWidth) resized.push('l');
    return shape.fullGrid ? `|${resized.join('|')}|` : resized.join('');
  }
  return `|${Array.from({ length: safeWidth }, () => 'l').join('|')}|`;
}

function tableColumnSpec(node: PMNode): string {
  return tableColumnSpecForWidth(node, node.firstChild?.childCount ?? 1);
}

function tableCellAlignments(spec: string, width: number): readonly ('l' | 'c' | 'r')[] {
  const model = parseLatexTabular(`\\begin{tabular}{${spec}}\n\\end{tabular}`);
  const alignments = model?.columns.map((column) => {
    const first = column.text.trim()[0];
    return first === 'c' || first === 'r' ? first : 'l';
  });
  if (alignments?.length === width) return alignments;
  return Array.from({ length: width }, () => 'l' as const);
}

function hasDirectHardBreak(node: PMNode): boolean {
  for (let index = 0; index < node.childCount; index += 1) {
    if (node.child(index).type.name === 'hard_break') return true;
  }
  return false;
}

function serializeTable(
  node: PMNode,
  pmFrom: number,
  sourceFrom: number,
  lineEnding: LatexLineEnding,
) {
  const fingerprint = tableFingerprint(node);
  const original = String(node.attrs.rawOriginal ?? '');
  if (
    original &&
    fingerprint === String(node.attrs.initialFingerprint ?? '') &&
    String(node.attrs.columns ?? '') === String(node.attrs.initialColumns ?? '') &&
    String(node.attrs.tableAlign ?? 'left') === String(node.attrs.initialTableAlign ?? 'left')
  ) {
    return {
      text: original,
      maps: [
        {
          pmFrom,
          pmTo: pmFrom + node.nodeSize,
          sourceFrom,
          sourceTo: sourceFrom + original.length,
          id: String(node.attrs.latexId ?? ''),
        },
      ] satisfies SourceMapEntry[],
    };
  }
  let before = String(node.attrs.rawBeforeTabular ?? '');
  let after = String(node.attrs.rawAfterTabular ?? '');
  const tableAlign = String(node.attrs.tableAlign ?? 'left') as NativeTableAlign;
  const initialTableAlign = String(node.attrs.initialTableAlign ?? 'left') as NativeTableAlign;
  if (tableAlign !== initialTableAlign) {
    const aligned = canonicalTableAlignmentParts(before, after, tableAlign, lineEnding);
    before = aligned.before;
    after = aligned.after;
  }
  const lines: string[] = [];
  const spec = tableColumnSpec(node);
  const width = node.firstChild?.childCount ?? 1;
  const alignments = tableCellAlignments(spec, width);
  lines.push(`\\begin{tabular}{${spec}}`);
  if (node.attrs.hasHorizontalRules !== false) lines.push('\\hline');
  node.forEach((row) => {
    const cells: string[] = [];
    row.forEach((cell, _offset, cellIndex) => {
      const hardBreak = hasDirectHardBreak(cell);
      const serialized = serializeInlineContent(
        cell,
        0,
        0,
        hardBreak
          ? { hardBreakLatex: '\\\\', lineEnding }
          : { lineEnding },
      );
      if (hardBreak) {
        cells.push(`\\shortstack[${alignments[cellIndex] ?? 'l'}]{${serialized.text}}`);
      } else {
        cells.push(serialized.text);
      }
    });
    lines.push(`${cells.join(' & ')} \\\\`);
    if (node.attrs.hasHorizontalRules !== false) lines.push('\\hline');
  });
  lines.push('\\end{tabular}');
  const text = `${before}${lines.join(lineEnding)}${after}`;
  return {
    text,
    maps: [
      {
        pmFrom,
        pmTo: pmFrom + node.nodeSize,
        sourceFrom,
        sourceTo: sourceFrom + text.length,
        id: String(node.attrs.latexId ?? ''),
      },
    ] satisfies SourceMapEntry[],
  };
}

function listEnvironment(node: PMNode): 'itemize' | 'enumerate' {
  return node.attrs.listKind === 'ordered' || node.attrs.kind === 'ordered' ? 'enumerate' : 'itemize';
}

function serializeListItem(
  node: PMNode,
  pmFrom: number,
  sourceFrom: number,
  lineEnding: LatexLineEnding,
): { text: string; maps: SourceMapEntry[] } {
  let text = '\\item ';
  const maps: SourceMapEntry[] = [];
  let childPos = pmFrom + 1;
  let index = 0;
  while (index < node.childCount) {
    const child = node.child(index);
    if (child.type.name === 'paragraph') {
      const serialized = serializeInlineContent(
        child,
        childPos + 1,
        sourceFrom + text.length,
        { lineEnding },
      );
      text += serialized.text;
      maps.push(...serialized.maps);
      childPos += child.nodeSize;
      index += 1;
      continue;
    }
    if (child.type.name === 'list') {
      const environment = listEnvironment(child);
      const groupId = String(child.attrs.latexListId ?? '');
      const group: PMNode[] = [child];
      let groupSize = child.nodeSize;
      let cursor = index + 1;
      while (cursor < node.childCount) {
        const candidate = node.child(cursor);
        if (candidate.type.name !== 'list' || listEnvironment(candidate) !== environment) break;
        const candidateGroupId = String(candidate.attrs.latexListId ?? '');
        if ((groupId || candidateGroupId) && groupId !== candidateGroupId) break;
        group.push(candidate);
        groupSize += candidate.nodeSize;
        cursor += 1;
      }
      const nested = serializeConsecutiveListNodes(
        group,
        childPos,
        sourceFrom + text.length + lineEnding.length,
        environment,
        lineEnding,
      );
      text += `${lineEnding}${nested.text}`;
      maps.push(...nested.maps);
      childPos += groupSize;
      index = cursor;
      continue;
    }
    if (child.type.name === RAW_BLOCK) {
      text += String(child.attrs.latex ?? '');
    }
    childPos += child.nodeSize;
    index += 1;
  }
  const id = String(node.attrs.sourceId ?? '');
  if (id) {
    maps.push({
      pmFrom,
      pmTo: pmFrom + node.nodeSize,
      sourceFrom,
      sourceTo: sourceFrom + text.length,
      id,
    });
  }
  return { text, maps };
}

function serializeConsecutiveListNodes(
  nodes: readonly PMNode[],
  pmFrom: number,
  sourceFrom: number,
  environment: 'itemize' | 'enumerate',
  lineEnding: LatexLineEnding,
) {
  let text = `\\begin{${environment}}${lineEnding}`;
  const maps: SourceMapEntry[] = [];
  let pmPos = pmFrom;
  nodes.forEach((node, index) => {
    if (index > 0) text += lineEnding;
    const item = serializeListItem(node, pmPos, sourceFrom + text.length, lineEnding);
    text += item.text;
    maps.push(...item.maps);
    pmPos += node.nodeSize;
  });
  text += `${lineEnding}\\end{${environment}}`;
  return { text, maps };
}

function serializeNativeDocument(
  doc: PMNode,
  sourceTemplate: string,
  sourceLayout: NativeSourceLayout,
): SerializedNativeDocument {
  const shell = splitDocumentShell(sourceTemplate);
  const lineEnding = detectLatexLineEnding(sourceTemplate);
  const blockSeparator = `${lineEnding}${lineEnding}`;
  type SerializedPart = {
    text: string;
    maps: SourceMapEntry[];
    ids: readonly string[];
    /** Node identity existed in the source projection that created this view. */
    original: boolean;
    /** Protected source gap already contains its own boundary spacing. */
    tight: boolean;
    prefixTrivia: string;
    suffixTrivia: string;
  };

  const currentTopLevelIds: string[] = [];
  doc.forEach((node) => {
    if (node.type.name === RAW_BLOCK && node.attrs.serialize === false) return;
    const id = topLevelSourceIdentity(node);
    if (id) currentTopLevelIds.push(id);
  });
  const currentIdSet = new Set(currentTopLevelIds);
  const originalOrderIndex = new Map(
    sourceLayout.originalNodeOrder.map((id, index) => [id, index] as const),
  );
  const prefixTriviaById = new Map<string, NativeSourceTriviaGap[]>();
  const suffixTriviaById = new Map<string, NativeSourceTriviaGap[]>();
  const orphanTrivia: NativeSourceTriviaGap[] = [];

  const appendGap = (
    target: Map<string, NativeSourceTriviaGap[]>,
    id: string,
    gap: NativeSourceTriviaGap,
  ) => {
    const entries = target.get(id) ?? [];
    entries.push(gap);
    target.set(id, entries);
  };

  for (const gap of sourceLayout.interBlockTrivia) {
    const rightIndex = gap.rightId ? originalOrderIndex.get(gap.rightId) : undefined;
    let rightAnchor: string | undefined;
    if (rightIndex !== undefined) {
      for (let index = rightIndex; index < sourceLayout.originalNodeOrder.length; index += 1) {
        const candidate = sourceLayout.originalNodeOrder[index];
        if (candidate && currentIdSet.has(candidate)) {
          rightAnchor = candidate;
          break;
        }
      }
    }
    if (rightAnchor) {
      appendGap(prefixTriviaById, rightAnchor, gap);
      continue;
    }

    const leftIndex = gap.leftId ? originalOrderIndex.get(gap.leftId) : undefined;
    let leftAnchor: string | undefined;
    if (leftIndex !== undefined) {
      for (let index = leftIndex; index >= 0; index -= 1) {
        const candidate = sourceLayout.originalNodeOrder[index];
        if (candidate && currentIdSet.has(candidate)) {
          leftAnchor = candidate;
          break;
        }
      }
    }
    if (leftAnchor) {
      appendGap(suffixTriviaById, leftAnchor, gap);
      continue;
    }
    orphanTrivia.push(gap);
  }

  const orderedTrivia = (entries: readonly NativeSourceTriviaGap[] | undefined): string =>
    [...(entries ?? [])]
      .sort((left, right) => left.from - right.from || left.to - right.to)
      .map((entry) => entry.text)
      .join('');
  const triviaForIds = (
    ids: readonly string[],
    source: Map<string, NativeSourceTriviaGap[]>,
  ): string => ids.map((id) => orderedTrivia(source.get(id))).join('');

  const bodyLeading = `${sourceLayout.leadingTrivia}${orderedTrivia(orphanTrivia)}`;
  const bodyTrailing = sourceLayout.trailingTrivia;
  const parts: SerializedPart[] = [];
  const staticMaps: SourceMapEntry[] = [];
  let bodyLength = bodyLeading.length;
  let pmPos = 0;
  let index = 0;

  const separatorBefore = (part: Pick<SerializedPart, 'original' | 'tight' | 'prefixTrivia'>): string => {
    const previous = parts.at(-1);
    if (!previous) {
      // A brand-new first manuscript node in an otherwise empty/tightly wrapped
      // document gets one structural newline. Existing source-backed nodes keep
      // their exact original adjacency to \\begin{document}.
      return !part.original && !bodyLeading ? lineEnding : '';
    }
    if (previous.tight || part.tight || previous.suffixTrivia || part.prefixTrivia) return '';
    // Existing source-backed neighbors had no trivia gap in the original source;
    // retain that exact adjacency. New Visual blocks use a canonical blank line.
    if (previous.original && part.original) return '';
    return blockSeparator;
  };

  const metadataForIds = (ids: readonly string[], tight: boolean) => ({
    ids,
    original: ids.some((id) => sourceLayout.originalNodeIds.has(id)),
    tight,
    prefixTrivia: triviaForIds(ids, prefixTriviaById),
    suffixTrivia: triviaForIds(ids, suffixTriviaById),
  });

  const sourceStartFor = (part: Pick<SerializedPart, 'original' | 'tight' | 'prefixTrivia'>): number =>
    shell.prefix.length + bodyLength + separatorBefore(part).length + part.prefixTrivia.length;

  const pushPart = (part: SerializedPart) => {
    const separator = separatorBefore(part);
    bodyLength += separator.length + part.prefixTrivia.length + part.text.length + part.suffixTrivia.length;
    parts.push(part);
  };

  while (index < doc.childCount) {
    const node = doc.child(index);

    if (node.type.name === RAW_BLOCK && node.attrs.serialize === false) {
      const id = String(node.attrs.sourceId ?? '');
      const sourceFrom = Number(node.attrs.sourceFrom);
      const sourceTo = Number(node.attrs.sourceTo);
      if (id && Number.isFinite(sourceFrom) && Number.isFinite(sourceTo) && sourceFrom >= 0 && sourceTo >= sourceFrom) {
        staticMaps.push({
          pmFrom: pmPos,
          // Non-serializing proxies are Visual-only widgets. Keep their source
          // map zero-width in PM so manuscript carets cannot map backwards into
          // the preamble while mapForId() still exposes the full Source range.
          pmTo: pmPos,
          sourceFrom,
          sourceTo,
          id,
        });
      }
      pmPos += node.nodeSize;
      index += 1;
      continue;
    }

    if (node.type.name === 'list') {
      const environment = listEnvironment(node);
      const group: PMNode[] = [node];
      const ids: string[] = [];
      const firstId = topLevelSourceIdentity(node);
      if (firstId) ids.push(firstId);
      let groupSize = node.nodeSize;
      let cursor = index + 1;
      while (cursor < doc.childCount) {
        const candidate = doc.child(cursor);
        if (candidate.type.name !== 'list' || listEnvironment(candidate) !== environment) break;
        const groupId = String(node.attrs.latexListId ?? '');
        const candidateGroupId = String(candidate.attrs.latexListId ?? '');
        if ((groupId || candidateGroupId) && groupId !== candidateGroupId) break;
        group.push(candidate);
        const candidateId = topLevelSourceIdentity(candidate);
        if (candidateId) ids.push(candidateId);
        groupSize += candidate.nodeSize;
        cursor += 1;
      }
      const meta = metadataForIds(ids, false);
      const sourceStart = sourceStartFor(meta);
      const serialized = serializeConsecutiveListNodes(group, pmPos, sourceStart, environment, lineEnding);
      pushPart({ ...serialized, ...meta });
      pmPos += groupSize;
      index = cursor;
      continue;
    }

    const id = topLevelSourceIdentity(node);
    const ids = id ? [id] : [];
    const tight = node.type.name === RAW_BLOCK && Boolean(node.attrs.tight);
    const meta = metadataForIds(ids, tight);
    const sourceStart = sourceStartFor(meta);

    if (node.type.name === 'paragraph') {
      const serialized = serializeInlineContent(node, pmPos + 1, sourceStart, { lineEnding });
      // Empty top-level paragraphs are editor structure, not persistent LaTeX
      // content. Once content is typed they materialize as normal new blocks.
      if (serialized.text.length === 0) {
        pmPos += node.nodeSize;
        index += 1;
        continue;
      }
      const sourceId = String(node.attrs.sourceId ?? '');
      pushPart({
        text: serialized.text,
        maps: sourceId
          ? [
              ...serialized.maps,
              { pmFrom: pmPos, pmTo: pmPos + node.nodeSize, sourceFrom: sourceStart, sourceTo: sourceStart + serialized.text.length, id: sourceId },
            ]
          : serialized.maps,
        ...meta,
      });
    } else if (node.type.name === 'heading') {
      const command = String(node.attrs.latexCommand ?? '') || headingCommand(Number(node.attrs.level) || 2);
      const prefix = `\\${command}{`;
      const body = serializeInlineContent(node, pmPos + 1, sourceStart + prefix.length, { lineEnding });
      const sourceSuffix = String(node.attrs.sourceSuffix ?? '');
      const text = `${prefix}${body.text}}${sourceSuffix}`;
      const sourceId = String(node.attrs.sourceId ?? '');
      pushPart({
        text,
        maps: sourceId
          ? [...body.maps, { pmFrom: pmPos, pmTo: pmPos + node.nodeSize, sourceFrom: sourceStart, sourceTo: sourceStart + text.length, id: sourceId }]
          : body.maps,
        ...meta,
      });
    } else if (node.type.name === TEXT_ENVIRONMENT) {
      const prefix = String(node.attrs.prefix ?? '');
      const suffix = String(node.attrs.suffix ?? '');
      const body = serializeInlineContent(node, pmPos + 1, sourceStart + prefix.length, { lineEnding });
      const text = `${prefix}${body.text}${suffix}`;
      const sourceId = String(node.attrs.sourceId ?? '');
      pushPart({
        text,
        maps: sourceId
          ? [
              ...body.maps,
              { pmFrom: pmPos, pmTo: pmPos + node.nodeSize, sourceFrom: sourceStart, sourceTo: sourceStart + text.length, id: sourceId },
            ]
          : body.maps,
        ...meta,
      });
    } else if (node.type.name === DISPLAY_MATH) {
      const text = serializeMath(node, lineEnding);
      pushPart({
        text,
        maps: [
          {
            pmFrom: pmPos,
            pmTo: pmPos + node.nodeSize,
            sourceFrom: sourceStart,
            sourceTo: sourceStart + text.length,
            id: String(node.attrs.latexId ?? ''),
          },
        ],
        ...meta,
      });
    } else if (node.type.name === 'table') {
      pushPart({ ...serializeTable(node, pmPos, sourceStart, lineEnding), ...meta });
    } else if (node.type.name === RAW_BLOCK) {
      const text = String(node.attrs.latex ?? '');
      pushPart({
        text,
        maps: [
          {
            pmFrom: pmPos,
            pmTo: pmPos + node.nodeSize,
            sourceFrom: sourceStart,
            sourceTo: sourceStart + text.length,
            id: String(node.attrs.sourceId ?? ''),
          },
        ],
        ...meta,
      });
    } else {
      const text = node.textContent;
      pushPart({ text, maps: [], ...meta });
    }
    pmPos += node.nodeSize;
    index += 1;
  }

  let body = bodyLeading;
  const maps: SourceMapEntry[] = [...staticMaps];
  let previous: SerializedPart | undefined;
  for (const part of parts) {
    let separator = '';
    if (!previous) {
      separator = !part.original && !bodyLeading ? lineEnding : '';
    } else if (!previous.tight && !part.tight && !previous.suffixTrivia && !part.prefixTrivia && !(previous.original && part.original)) {
      separator = blockSeparator;
    }
    body += `${separator}${part.prefixTrivia}${part.text}${part.suffixTrivia}`;
    maps.push(...part.maps);
    previous = part;
  }
  body += bodyTrailing;
  if (parts.length > 0 && !bodyTrailing && !parts.at(-1)?.original) body += lineEnding;

  const source = `${shell.prefix}${body}${shell.suffix}`;
  for (const map of maps) {
    map.sourceFrom = Math.max(0, Math.min(source.length, map.sourceFrom));
    map.sourceTo = Math.max(map.sourceFrom, Math.min(source.length, map.sourceTo));
  }
  return { source, maps };
}

function mapPmToSource(maps: readonly SourceMapEntry[], position: number): number | undefined {
  let nearest: { distance: number; offset: number } | undefined;
  for (const map of maps) {
    if (position >= map.pmFrom && position <= map.pmTo) {
      if (map.sourceBoundaries && map.pmTo > map.pmFrom) {
        const index = Math.max(0, Math.min(map.sourceBoundaries.length - 1, position - map.pmFrom));
        return map.sourceBoundaries[index];
      }
      const span = Math.max(1, map.pmTo - map.pmFrom);
      const ratio = Math.max(0, Math.min(1, (position - map.pmFrom) / span));
      return Math.round(map.sourceFrom + ratio * (map.sourceTo - map.sourceFrom));
    }
    const leftDistance = Math.abs(position - map.pmFrom);
    if (!nearest || leftDistance < nearest.distance) nearest = { distance: leftDistance, offset: map.sourceFrom };
    const rightDistance = Math.abs(position - map.pmTo);
    if (!nearest || rightDistance < nearest.distance) nearest = { distance: rightDistance, offset: map.sourceTo };
  }
  return nearest?.offset;
}

function mapSourceToPm(maps: readonly SourceMapEntry[], offset: number): number | undefined {
  let nearest: { distance: number; position: number } | undefined;
  for (const map of maps) {
    if (offset >= map.sourceFrom && offset <= map.sourceTo) {
      if (map.sourceBoundaries?.length) {
        let bestIndex = 0;
        let bestDistance = Number.POSITIVE_INFINITY;
        map.sourceBoundaries.forEach((boundary, index) => {
          const distance = Math.abs(boundary - offset);
          if (distance < bestDistance) {
            bestDistance = distance;
            bestIndex = index;
          }
        });
        return map.pmFrom + bestIndex;
      }
      const span = Math.max(1, map.sourceTo - map.sourceFrom);
      const ratio = Math.max(0, Math.min(1, (offset - map.sourceFrom) / span));
      return Math.round(map.pmFrom + ratio * (map.pmTo - map.pmFrom));
    }
    const leftDistance = Math.abs(offset - map.sourceFrom);
    if (!nearest || leftDistance < nearest.distance) nearest = { distance: leftDistance, position: map.pmFrom };
    const rightDistance = Math.abs(offset - map.sourceTo);
    if (!nearest || rightDistance < nearest.distance) nearest = { distance: rightDistance, position: map.pmTo };
  }
  return nearest?.position;
}

function mapForId(maps: readonly SourceMapEntry[], id: string): { from: number; to: number } | undefined {
  const matches = maps.filter((map) => map.id === id);
  if (!matches.length) return undefined;
  return {
    from: Math.min(...matches.map((map) => map.sourceFrom)),
    to: Math.max(...matches.map((map) => map.sourceTo)),
  };
}

function sourceRangesOverlap(
  leftFrom: number,
  leftTo: number,
  rightFrom: number,
  rightTo: number,
): boolean {
  if (leftFrom === leftTo) return leftFrom >= rightFrom && leftFrom <= rightTo;
  if (rightFrom === rightTo) return rightFrom >= leftFrom && rightFrom <= leftTo;
  return leftFrom < rightTo && leftTo > rightFrom;
}

function buildNativeReviewDecorationSet(
  doc: PMNode,
  maps: readonly SourceMapEntry[],
  decorations: readonly LatexVisualReviewDecoration[],
): DecorationSet {
  const result: Decoration[] = [];

  for (const decoration of decorations) {
    const className = reviewDecorationClasses(decoration);
    const attrs = {
      class: className,
      'data-review-thread-id': decoration.id,
    };

    // Protected LaTeX atoms are reviewed atomically. If a comment/change
    // overlaps one of these source-backed nodes, decorate the whole PM node
    // rather than manufacturing an editable text range inside raw syntax.
    const protectedMap = maps.find((map) => {
      if (!sourceRangesOverlap(decoration.from, decoration.to, map.sourceFrom, map.sourceTo)) return false;
      const node = doc.nodeAt(map.pmFrom);
      return Boolean(
        node &&
        (node.type.name === RAW_BLOCK || node.type.name === RAW_INLINE || node.type.name === DISPLAY_MATH || node.type.name === INLINE_MATH),
      );
    });
    if (protectedMap) {
      const node = doc.nodeAt(protectedMap.pmFrom);
      if (node) {
        result.push(Decoration.node(protectedMap.pmFrom, protectedMap.pmFrom + node.nodeSize, attrs));
        continue;
      }
    }

    const from = mapSourceToPm(maps, decoration.from);
    const to = mapSourceToPm(maps, decoration.to);
    if (from === undefined || to === undefined) continue;
    const safeFrom = Math.max(0, Math.min(doc.content.size, Math.min(from, to)));
    const safeTo = Math.max(safeFrom, Math.min(doc.content.size, Math.max(from, to)));

    if (decoration.kind === 'change' && decoration.changeType === 'delete') {
      const deletedText = decoration.originalText ?? '';
      result.push(
        Decoration.widget(
          safeFrom,
          () => {
            const structuralKind = /\\begin\s*\{figure\}/u.test(deletedText)
              ? 'Figure'
              : /\\begin\s*\{(?:table|tabular|tabularx|longtable)\}/u.test(deletedText)
                ? 'Table'
                : /\\(?:begin\s*\{(?:equation\*?|align\*?|gather\*?)\}|\[)/u.test(deletedText)
                  ? 'Equation'
                  : /\n|\\(?:begin|section|subsection|chapter|item)\b/u.test(deletedText)
                    ? 'LaTeX block'
                    : undefined;
            const dom = document.createElement(structuralKind ? 'div' : 'span');
            dom.className = `${className} interactive-visual-change-deletion-widget${structuralKind ? ' interactive-visual-change-deletion-widget-block interactive-visual-change-deletion-protected' : ''}`;
            dom.dataset.reviewThreadId = decoration.id;
            dom.contentEditable = 'false';
            if (structuralKind) {
              const header = document.createElement('div');
              header.className = 'interactive-visual-protected-header';
              header.textContent = `${structuralKind} · pending deletion`;
              const preview = document.createElement('pre');
              preview.className = 'interactive-visual-protected-source';
              preview.textContent = deletedText.slice(0, 1200);
              dom.append(header, preview);
            } else {
              dom.textContent = deletedText || 'Deleted content';
            }
            dom.setAttribute('aria-label', 'Pending deletion');
            return dom;
          },
          { side: -1, key: `review-delete:${decoration.id}` },
        ),
      );
      continue;
    }

    if (safeTo > safeFrom) {
      const inclusive = decoration.kind === 'change';
      result.push(
        Decoration.inline(safeFrom, safeTo, attrs, {
          inclusiveStart: inclusive,
          inclusiveEnd: inclusive,
        }),
      );
    }
  }

  return DecorationSet.create(doc, result);
}

function selectionRect(view: EditorView): { left: number; top: number; bottom: number; right: number } {
  const coords = view.coordsAtPos(view.state.selection.head);
  return { left: coords.left, top: coords.top, bottom: coords.bottom, right: coords.right };
}

function slashAtSelection(view: EditorView): { from: number; to: number; query: string } | undefined {
  const selection = view.state.selection;
  if (!selection.empty || !selection.$from.parent.isTextblock) return undefined;
  const text = selection.$from.parent.textBetween(0, selection.$from.parentOffset, '\n', '\uFFFC');
  const match = /(?:^|\s)\/([^\s/]*)$/u.exec(text);
  if (!match) return undefined;
  const query = match[1] ?? '';
  const slashIndex = text.length - query.length - 1;
  const from = selection.from - (text.length - slashIndex);
  return { from, to: selection.from, query };
}

function focusAfterNode(view: EditorView, nodePos: number, nodeSize: number): void {
  const target = nodePos + nodeSize;
  let tr = view.state.tr;
  let selection = Selection.near(tr.doc.resolve(Math.min(target, tr.doc.content.size)), 1);
  if (!selection.$from.parent.inlineContent) {
    const $node = tr.doc.resolve(nodePos);
    const insertIndex = $node.index() + 1;
    const paragraph = view.state.schema.nodes.paragraph?.createAndFill();
    if (paragraph && $node.parent.canReplaceWith(insertIndex, insertIndex, paragraph.type)) {
      tr = tr.insert(target, paragraph);
      selection = TextSelection.create(tr.doc, target + 1);
    }
  }
  view.focus();
  view.dispatch(tr.setSelection(selection).scrollIntoView());
}

function focusBeforeNode(view: EditorView, nodePos: number): void {
  const target = Math.max(0, Math.min(view.state.doc.content.size, nodePos));
  let tr = view.state.tr;
  let selection = Selection.near(tr.doc.resolve(target), -1);
  if (!selection.$from.parent.inlineContent) {
    const $node = tr.doc.resolve(target);
    const insertIndex = $node.index();
    const paragraph = view.state.schema.nodes.paragraph?.createAndFill();
    if (paragraph && $node.parent.canReplaceWith(insertIndex, insertIndex, paragraph.type)) {
      tr = tr.insert(target, paragraph);
      selection = TextSelection.create(tr.doc, target + 1);
    }
  }
  view.focus();
  view.dispatch(tr.setSelection(selection).scrollIntoView());
}

function isHistoryReplayTransaction(transaction: Transaction): boolean {
  const meta = (transaction as unknown as { meta?: Record<string, unknown> }).meta;
  return Object.keys(meta ?? {}).some((key) => key === 'history$' || key.startsWith('history$'));
}

export function createNativeLearnitiveLatexVisualEditor(options: NativeOptions) {
  const resolved = resolve(extensions(), true);
  const schema = new Schema({ nodes: resolved.nodes, marks: resolved.marks });
  const reviewDecorationPlugin = createNativeReviewDecorationPlugin();
  const editorPlugins = [...resolved.resolvePlugins({ schema }), reviewDecorationPlugin];
  let sourceTemplate = options.source;
  let sourceBaseline = options.source;
  let projection = buildNativeProjection(schema, sourceTemplate, options.blocks, options.assetUrls);
  let sourceLayout = projection.sourceLayout;
  let currentSerialized: SerializedNativeDocument = { source: options.source, maps: [] };
  let currentReviewDecorations: readonly LatexVisualReviewDecoration[] = [];
  let slashSession: { from: number; to: number; query: string; selectedIndex: number } | undefined;
  let suppressEmit = true;
  const state = EditorState.create({
    doc: projection.doc,
    schema,
    plugins: editorPlugins,
  });

  // Learnitive-style contextual table control. Keep the manuscript quiet: one
  // compact table icon + chevron appears above the active native table and all
  // mutations remain PM commands/transactions.
  const tableCommands = setupTable().command;
  const tableToolbar = document.createElement('div');
  tableToolbar.className = 'interactive-visual-native-table-toolbar';
  tableToolbar.hidden = true;
  tableToolbar.setAttribute('contenteditable', 'false');
  const tableActions = document.createElement('details');
  tableActions.className = 'interactive-visual-table-actions';
  const tableActionsSummary = document.createElement('summary');
  tableActionsSummary.className = 'interactive-visual-table-actions-summary';
  tableActionsSummary.title = 'Table actions';
  tableActionsSummary.setAttribute('aria-label', 'Table actions');

  const makeSvg = (paths: readonly string[], size: number) => {
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('width', String(size));
    svg.setAttribute('height', String(size));
    svg.setAttribute('fill', 'none');
    svg.setAttribute('stroke', 'currentColor');
    svg.setAttribute('stroke-width', '2');
    svg.setAttribute('stroke-linecap', 'round');
    svg.setAttribute('stroke-linejoin', 'round');
    svg.setAttribute('aria-hidden', 'true');
    paths.forEach((d) => {
      const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      path.setAttribute('d', d);
      svg.append(path);
    });
    return svg;
  };
  tableActionsSummary.append(
    makeSvg(['M3 3h18v18H3z', 'M3 9h18', 'M3 15h18', 'M9 3v18', 'M15 3v18'], 16),
    makeSvg(['m6 9 6 6 6-6'], 12),
  );
  const tableActionsMenu = document.createElement('div');
  tableActionsMenu.className = 'interactive-visual-table-actions-menu';
  tableActions.append(tableActionsSummary, tableActionsMenu);
  tableToolbar.append(tableActions);
  options.host.append(tableToolbar);

  const activeTable = (currentView: EditorView): { node: PMNode; pos: number } | undefined => {
    const $from = currentView.state.selection.$from;
    for (let depth = $from.depth; depth > 0; depth -= 1) {
      const node = $from.node(depth);
      if (node.type.name === 'table') return { node, pos: $from.before(depth) };
    }
    return undefined;
  };

  const activeTableColumn = (currentView: EditorView): number | undefined => {
    const $from = currentView.state.selection.$from;
    for (let depth = $from.depth; depth > 0; depth -= 1) {
      if ($from.node(depth).type.name === 'table_cell' && depth > 0) {
        return $from.index(depth - 1);
      }
    }
    return undefined;
  };

  const activeTableCoordinates = (currentView: EditorView): { row: number; column: number } | undefined => {
    const $from = currentView.state.selection.$from;
    for (let depth = $from.depth; depth > 1; depth -= 1) {
      if ($from.node(depth).type.name === 'table_cell') {
        return { row: $from.index(depth - 2), column: $from.index(depth - 1) };
      }
    }
    return undefined;
  };

  const spreadsheetRows = (text: string): string[][] | undefined => {
    if (!text.includes('\t')) return undefined;
    const normalized = text.replace(/\r\n?/gu, '\n').replace(/\n$/u, '');
    const rows = normalized.split('\n').map((line) => line.split('\t'));
    return rows.length > 0 && rows.some((row) => row.length > 1) ? rows : undefined;
  };

  const pasteSpreadsheetIntoTable = (currentView: EditorView, text: string): boolean => {
    const active = activeTable(currentView);
    const coordinates = activeTableCoordinates(currentView);
    const pasted = spreadsheetRows(text);
    if (!active || !coordinates || !pasted) return false;

    const originalRows: PMNode[][] = [];
    active.node.forEach((row) => {
      const cells: PMNode[] = [];
      row.forEach((cell) => {
        cells.push(cell);
      });
      originalRows.push(cells);
    });
    const currentWidth = Math.max(1, ...originalRows.map((row) => row.length));
    const pastedWidth = Math.max(1, ...pasted.map((row) => row.length));
    const nextWidth = Math.max(currentWidth, coordinates.column + pastedWidth);
    const nextHeight = Math.max(originalRows.length, coordinates.row + pasted.length);
    const rows = Array.from({ length: nextHeight }, (_, rowIndex) => {
      const originalRow = originalRows[rowIndex];
      const rowCells = Array.from({ length: nextWidth }, (_, columnIndex) => {
        const pastedRow = pasted[rowIndex - coordinates.row];
        const pastedValue = pastedRow?.[columnIndex - coordinates.column];
        const originalCell = originalRow?.[columnIndex];
        if (pastedValue !== undefined) {
          return schema.nodes.table_cell!.create(
            originalCell?.attrs ?? null,
            simpleTextNode(schema, pastedValue),
          );
        }
        if (originalCell) return originalCell;
        return schema.nodes.table_cell!.create(null, []);
      });
      return schema.nodes.table_row!.create(originalRow ? active.node.child(rowIndex).attrs : null, rowCells);
    });
    const next = active.node.type.create(
      {
        ...active.node.attrs,
        columns: tableColumnSpecForWidth(active.node, nextWidth),
      },
      rows,
    );
    currentView.dispatch(
      currentView.state.tr
        .replaceWith(active.pos, active.pos + active.node.nodeSize, next)
        .setMeta('uiEvent', 'paste')
        .scrollIntoView(),
    );
    return true;
  };

  const setCurrentTableColumnAlignment = (alignment: 'l' | 'c' | 'r') => {
    const active = activeTable(view);
    const column = activeTableColumn(view);
    if (!active || column === undefined) return;
    const spec = tableColumnSpec(active.node);
    const shape = tableSpecShape(spec);
    if (!shape || column < 0 || column >= shape.tokens.length) return;
    const tokens = [...shape.tokens];
    if (!tokens.every((token) => /^[lcr]$/u.test(token.trim()))) return;
    tokens[column] = alignment;
    const columns = shape.fullGrid ? `|${tokens.join('|')}|` : tokens.join('');
    view.dispatch(
      view.state.tr.setNodeMarkup(active.pos, undefined, { ...active.node.attrs, columns }).scrollIntoView(),
    );
    view.focus();
    tableActions.open = false;
  };

  const setCurrentTableAlignment = (alignment: NativeTableAlign) => {
    const active = activeTable(view);
    if (!active || active.node.attrs.tableAlign === alignment) {
      tableActions.open = false;
      view.focus();
      return;
    }
    view.dispatch(
      view.state.tr.setNodeMarkup(active.pos, undefined, {
        ...active.node.attrs,
        tableAlign: alignment,
      }).scrollIntoView(),
    );
    view.focus();
    tableActions.open = false;
  };

  const emitCurrentTableMetadata = () => {
    const active = activeTable(view);
    if (!active) return;
    const id = String(active.node.attrs.latexId ?? '');
    const range = id ? mapForId(currentSerialized.maps, id) : undefined;
    if (!id || !range) return;
    options.onAction?.({
      kind: 'table',
      id,
      sourceFrom: range.from,
      sourceTo: range.to,
      sourceCoordinates: 'current',
    });
    tableActions.open = false;
  };

  const appendMenuLabel = (label: string) => {
    const item = document.createElement('div');
    item.className = 'interactive-visual-table-menu-label';
    item.textContent = label;
    tableActionsMenu.append(item);
  };
  const appendSeparator = () => tableActionsMenu.append(document.createElement('hr'));
  const appendMenuButton = (
    label: string,
    handler: () => void,
    options_: { destructive?: boolean; alignment?: () => boolean } = {},
  ) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = label;
    if (options_.destructive) button.classList.add('interactive-visual-table-destructive');
    if (options_.alignment) button.dataset.tableAlignmentOption = 'true';
    button.addEventListener('pointerdown', (event) => event.preventDefault());
    button.addEventListener('click', handler);
    if (options_.alignment) {
      tableActions.addEventListener('toggle', () => {
        button.dataset.active = options_.alignment?.() ? 'true' : 'false';
      });
    }
    tableActionsMenu.append(button);
    return button;
  };

  appendMenuButton('Table settings / CSV…', emitCurrentTableMetadata);
  appendSeparator();
  appendMenuLabel('Rows and columns');
  for (const [label, command] of [
    ['Add row above', tableCommands.addRowAbove],
    ['Add row below', tableCommands.addRowBelow],
    ['Add column left', tableCommands.addColumnLeft],
    ['Add column right', tableCommands.addColumnRight],
  ] as const) {
    appendMenuButton(label, () => {
      command(view.state, view.dispatch);
      view.focus();
      tableActions.open = false;
    });
  }

  appendSeparator();
  appendMenuLabel('Table alignment');
  for (const [label, alignment] of [
    ['Left', 'left'],
    ['Center', 'center'],
    ['Right', 'right'],
  ] as const) {
    appendMenuButton(label, () => setCurrentTableAlignment(alignment), {
      alignment: () => activeTable(view)?.node.attrs.tableAlign === alignment,
    });
  }

  appendSeparator();
  appendMenuLabel('Column alignment');
  for (const [label, alignment] of [
    ['Left', 'l'],
    ['Center', 'c'],
    ['Right', 'r'],
  ] as const) {
    appendMenuButton(label, () => setCurrentTableColumnAlignment(alignment));
  }

  appendSeparator();
  appendMenuButton('Delete row', () => {
    tableCommands.deleteRow(view.state, view.dispatch);
    view.focus();
    tableActions.open = false;
  });
  appendMenuButton('Delete column', () => {
    tableCommands.deleteColumn(view.state, view.dispatch);
    view.focus();
    tableActions.open = false;
  });
  appendMenuButton('Delete table', () => {
    tableCommands.deleteTable(view.state, view.dispatch);
    view.focus();
    tableActions.open = false;
  }, { destructive: true });

  const updateTableToolbar = (currentView: EditorView) => {
    const active = activeTable(currentView);
    tableToolbar.hidden = !active;
    if (!active) {
      tableActions.open = false;
      return;
    }
    const dom = currentView.nodeDOM(active.pos);
    if (dom instanceof HTMLElement) {
      const hostRect = options.host.getBoundingClientRect();
      const rect = dom.getBoundingClientRect();
      tableToolbar.style.top = `${Math.max(0, rect.top - hostRect.top + options.host.scrollTop - 34)}px`;
      tableToolbar.style.left = `${Math.max(0, rect.right - hostRect.left + options.host.scrollLeft - 60)}px`;
    }
  };

  // Preserve Learnitive's list action affordance, but perform deletion directly
  // against the live flat-list ProseMirror structure. Normal Enter/Backspace
  // remains entirely Learnitive-owned.
  const listToolbar = document.createElement('div');
  listToolbar.className = 'interactive-visual-list-toolbar interactive-visual-native-list-toolbar';
  listToolbar.hidden = true;
  listToolbar.setAttribute('contenteditable', 'false');
  listToolbar.style.opacity = '1';
  const listActions = document.createElement('details');
  listActions.className = 'interactive-visual-list-actions';
  const listActionsSummary = document.createElement('summary');
  listActionsSummary.className = 'interactive-visual-list-actions-summary';
  listActionsSummary.title = 'List actions';
  listActionsSummary.setAttribute('aria-label', 'List actions');
  listActionsSummary.textContent = '⋮';
  const listActionsMenu = document.createElement('div');
  listActionsMenu.className = 'interactive-visual-list-actions-menu';
  listActions.append(listActionsSummary, listActionsMenu);
  listToolbar.append(listActions);
  options.host.append(listToolbar);

  type ActiveList = { node: PMNode; from: number; to: number; depth: number };
  const activeList = (currentView: EditorView): ActiveList | undefined => {
    const $from = currentView.state.selection.$from;
    for (let depth = $from.depth; depth > 0; depth -= 1) {
      const node = $from.node(depth);
      if (node.type.name === 'list') {
        const from = $from.before(depth);
        return { node, from, to: from + node.nodeSize, depth };
      }
    }
    return undefined;
  };

  const deleteNativeListRange = (from: number, to: number) => {
    const paragraph = view.state.schema.nodes.paragraph?.createAndFill();
    const coversDocument = from === 0 && to === view.state.doc.content.size;
    const tr = coversDocument && paragraph
      ? view.state.tr.replaceWith(from, to, paragraph)
      : view.state.tr.delete(from, to);
    view.dispatch(tr.scrollIntoView());
  };

  const deleteCurrentListItem = () => {
    const active = activeList(view);
    if (!active) return;
    deleteNativeListRange(active.from, active.to);
    view.focus();
    listActions.open = false;
  };

  const deleteCurrentListEnvironment = () => {
    const active = activeList(view);
    if (!active) return;
    const $from = view.state.doc.resolve(active.from + 1);
    const parentDepth = Math.max(0, active.depth - 1);
    const parent = $from.node(parentDepth);
    const activeIndex = $from.index(parentDepth);
    const environment = listEnvironment(active.node);
    const groupId = String(active.node.attrs.latexListId ?? '');
    const matches = (node: PMNode) =>
      node.type.name === 'list' &&
      listEnvironment(node) === environment &&
      (!(groupId || String(node.attrs.latexListId ?? '')) || String(node.attrs.latexListId ?? '') === groupId);

    let first = activeIndex;
    while (first > 0 && matches(parent.child(first - 1))) first -= 1;
    let last = activeIndex;
    while (last + 1 < parent.childCount && matches(parent.child(last + 1))) last += 1;

    const parentStart = parentDepth === 0 ? 0 : $from.start(parentDepth);
    let deleteFrom = parentStart;
    for (let index = 0; index < first; index += 1) deleteFrom += parent.child(index).nodeSize;
    let deleteTo = deleteFrom;
    for (let index = first; index <= last; index += 1) deleteTo += parent.child(index).nodeSize;
    deleteNativeListRange(deleteFrom, deleteTo);
    view.focus();
    listActions.open = false;
  };

  for (const [label, handler] of [
    ['Delete item', deleteCurrentListItem],
    ['Delete list', deleteCurrentListEnvironment],
  ] as const) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = label;
    if (label === 'Delete list') button.className = 'interactive-visual-list-destructive';
    button.addEventListener('pointerdown', (event) => event.preventDefault());
    button.addEventListener('click', handler);
    listActionsMenu.append(button);
  }

  const updateListToolbar = (currentView: EditorView) => {
    const active = activeList(currentView);
    listToolbar.hidden = !active;
    if (!active) {
      listActions.open = false;
      return;
    }
    const dom = currentView.nodeDOM(active.from);
    if (dom instanceof HTMLElement) {
      const hostRect = options.host.getBoundingClientRect();
      const rect = dom.getBoundingClientRect();
      listToolbar.style.top = `${Math.max(0, rect.top - hostRect.top + options.host.scrollTop)}px`;
      listToolbar.style.right = '0.15rem';
    }
  };

  const notifySelection = (view: EditorView) => {
    const selection = view.state.selection;
    const head = mapPmToSource(currentSerialized.maps, selection.head);
    if (head !== undefined) {
      options.onCursorChange?.({
        offset: head,
        id: 'bf4-native',
        nodeType: selection.$head.parent.type.name,
        visualKind: selection.$head.parent.type.name,
        sourceCoordinates: 'current',
      });
    } else {
      options.onCursorChange?.(undefined);
    }
    if (selection.empty) {
      options.onSelectionChange?.(undefined);
    } else {
      const from = mapPmToSource(currentSerialized.maps, selection.from);
      const to = mapPmToSource(currentSerialized.maps, selection.to);
      if (from !== undefined && to !== undefined && to > from) {
        options.onSelectionChange?.({
          id: 'bf4-native-selection',
          bodyFrom: from,
          bodyTo: to,
          relativeFrom: 0,
          relativeTo: to - from,
          selectedText: currentSerialized.source.slice(from, to),
          sourceFrom: from,
          sourceTo: to,
          visualKind: selection.$from.parent.type.name,
          sourceCoordinates: 'current',
        });
      } else {
        options.onSelectionChange?.(undefined);
      }
    }
    updateTableToolbar(view);
    updateListToolbar(view);
  };

  const updateSlash = (view: EditorView) => {
    const found = slashAtSelection(view);
    if (!found) {
      if (slashSession) options.onAction?.({ kind: 'slash-close' });
      slashSession = undefined;
      return;
    }
    const sourceFrom = mapPmToSource(currentSerialized.maps, found.from);
    const sourceTo = mapPmToSource(currentSerialized.maps, found.to);
    if (sourceFrom === undefined || sourceTo === undefined) return;
    const optionCount = Math.max(0, options.getSlashOptionCount?.(found.query) ?? 0);
    const selectedIndex = optionCount > 0
      ? Math.max(0, Math.min((slashSession?.selectedIndex ?? 0), optionCount - 1))
      : 0;
    slashSession = { ...found, selectedIndex };
    options.onAction?.({
      kind: 'slash-menu',
      id: 'bf4-native-slash',
      sourceFrom,
      sourceTo,
      placement: 'replace',
      anchor: selectionRect(view),
      query: found.query,
      selectedIndex,
      sourceQueryText: `/${found.query}`,
      inlineAtEnd: true,
      inlineSourceAt: sourceTo,
      sourceCoordinates: 'current',
    });
  };

  let view: EditorView;
  const emitMathEditor = (mathNode: PMNode, anchor: HTMLElement) => {
    const id = String(mathNode.attrs.latexId ?? '');
    const mapped = id ? mapForId(currentSerialized.maps, id) : undefined;
    if (!id || !mapped) return;
    const region = findLatexMathRegions(currentSerialized.source).find(
      (candidate) =>
        (mapped.from >= candidate.from && mapped.from < candidate.to) ||
        (candidate.from >= mapped.from && candidate.to <= mapped.to),
    );
    if (!region) return;
    options.onAction?.({
      kind: 'math',
      id,
      from: region.from,
      to: region.to,
      bodyFrom: region.contentFrom,
      bodyTo: region.contentTo,
      value: mathNode.textContent,
      mathKind: region.kind,
      ...(region.environment ? { environment: region.environment } : {}),
      editable: Boolean(mathNode.attrs.editable),
      sourceCoordinates: 'current',
    });
    // Keep the modal placement stable even when the NodeView is selected by
    // ProseMirror immediately after the click.
    anchor.dataset.learnitiveMathRequested = 'true';
  };
  const renderProtectedRawBlock = (dom: HTMLDivElement, node: PMNode) => {
    const visualKind = String(node.attrs.visualKind ?? 'raw');
    const canonicalLatex = String(node.attrs.latex ?? '');
    const displayLatex = String(node.attrs.displayLatex ?? '');
    const latex = displayLatex.trim().length
      ? displayLatex
      : canonicalLatex.trim().length
        ? canonicalLatex
        : 'Protected LaTeX source is unavailable.';
    const label = String(node.attrs.label ?? 'Preserved LaTeX');
    const sourceId = String(node.attrs.sourceId ?? '');
    const summary = String(node.attrs.summary ?? '');
    const caption = String(node.attrs.caption ?? '');
    const labelText = String(node.attrs.labelText ?? '');
    const assetTargets = Array.isArray(node.attrs.assetTargets)
      ? node.attrs.assetTargets.map((value) => String(value))
      : [];
    const assetUrls = Array.isArray(node.attrs.assetUrls)
      ? node.attrs.assetUrls.map((value) => String(value))
      : [];
    const assetStatuses = Array.isArray(node.attrs.assetStatuses)
      ? node.attrs.assetStatuses.map((value) => String(value))
      : [];
    const complexFigure = Boolean(node.attrs.complexFigure) || assetTargets.length > 1;
    const navigationLabel = visualKind === 'figure'
      ? (complexFigure ? 'complex figure' : 'figure')
      : label.replace(/\s*·\s*Protected LaTeX$/u, '').toLowerCase();

    dom.className = [
      'interactive-visual-raw-block',
      'interactive-visual-protected-clickable',
      visualKind === 'figure' ? 'interactive-visual-figure' : '',
      visualKind === 'theorem' ? 'interactive-visual-theorem' : '',
      visualKind === 'heading' ? 'interactive-visual-heading interactive-visual-raw-heading' : '',
      visualKind === 'table' ? 'interactive-visual-protected-table' : '',
      visualKind === 'preamble' ? 'interactive-visual-document-setup' : '',
    ].filter(Boolean).join(' ');
    dom.dataset.learnitiveLatexRawBlock = 'true';
    dom.dataset.sourceId = sourceId;
    dom.dataset.visualKind = visualKind;
    dom.dataset.immutable = node.attrs.immutable ? 'true' : 'false';
    dom.contentEditable = 'false';
    dom.setAttribute('role', 'button');
    dom.tabIndex = 0;
    dom.setAttribute('aria-label', `Open ${navigationLabel || 'protected LaTeX'} in Source`);
    dom.title = 'Open exact LaTeX source';
    dom.replaceChildren();

    const header = document.createElement('div');
    header.className = 'interactive-visual-protected-header';
    const title = document.createElement('span');
    title.textContent = visualKind === 'figure'
      ? (complexFigure ? 'Complex figure · Protected LaTeX' : 'Figure · Protected LaTeX')
      : label;
    const action = document.createElement('span');
    action.className = 'interactive-visual-protected-action';
    action.setAttribute('aria-hidden', 'true');
    action.textContent = 'Open in Source →';
    header.append(title, action);
    dom.append(header);

    if (summary) {
      const summaryElement = document.createElement('div');
      summaryElement.className = 'interactive-visual-protected-summary';
      summaryElement.textContent = summary;
      dom.append(summaryElement);
    }

    // For figures, the lossless source is the primary fallback and must be
    // immediately visible even while previews are loading or unavailable.
    if (visualKind === 'figure') {
      const sourceLabel = document.createElement('div');
      sourceLabel.className = 'interactive-visual-protected-source-label';
      sourceLabel.textContent = 'Complete LaTeX';
      const source = document.createElement('pre');
      source.className = 'interactive-visual-protected-source interactive-visual-protected-figure-source';
      source.textContent = latex;
      dom.append(sourceLabel, source);
    }

    if (visualKind === 'figure') {
      if (assetTargets.length > 0) {
        const grid = document.createElement('div');
        grid.className = 'interactive-visual-figure-preview-grid';
        assetTargets.forEach((target, index) => {
          const url = assetUrls[index] ?? '';
          const status = assetStatuses[index] ?? (url ? 'ready' : 'loading');
          if (url) {
            const image = document.createElement('img');
            image.src = url;
            image.alt = caption || labelText || target || `Figure graphic ${index + 1}`;
            image.className = 'interactive-visual-figure-image';
            grid.append(image);
            return;
          }
          const placeholder = document.createElement('div');
          placeholder.className = 'interactive-visual-figure-placeholder interactive-visual-figure-asset-placeholder';
          placeholder.dataset.previewStatus = status;
          const statusCopy = status === 'missing'
            ? 'asset not found'
            : status === 'unsupported'
              ? 'browser preview unavailable; source preserved'
              : status === 'error'
                ? 'preview failed; source preserved'
                : 'loading preview…';
          placeholder.textContent = `${target || `Graphic ${index + 1}`} · ${statusCopy}`;
          grid.append(placeholder);
        });
        dom.append(grid);
      } else {
        const placeholder = document.createElement('div');
        placeholder.className = 'interactive-visual-figure-placeholder';
        placeholder.textContent = 'Figure source preserved · preview unavailable';
        dom.append(placeholder);
      }
      if (caption) {
        const captionElement = document.createElement('div');
        captionElement.className = 'interactive-visual-protected-caption';
        captionElement.textContent = caption;
        dom.append(captionElement);
      }
      if (labelText) {
        const meta = document.createElement('div');
        meta.className = 'interactive-visual-meta';
        meta.textContent = `Label: ${labelText}`;
        dom.append(meta);
      }
    }

    if (visualKind !== 'figure') {
      const source = document.createElement('pre');
      source.className = 'interactive-visual-protected-source';
      // textContent is intentional: protected source is displayed literally and
      // can never be interpreted as HTML or collapse into an empty child tree.
      source.textContent = latex;
      dom.append(source);
    }

    const footer = document.createElement('div');
    footer.className = 'interactive-visual-protected-footer';
    footer.textContent = node.attrs.immutable ? 'Protected · Source-only' : 'Preserved exactly';
    dom.append(footer);
  };

  const rawBlockNodeView = (node: PMNode) => {
    const dom = document.createElement('div');
    let currentNode = node;
    renderProtectedRawBlock(dom, currentNode);
    return {
      dom,
      ignoreMutation: () => true,
      update(next: PMNode) {
        if (next.type !== currentNode.type) return false;
        currentNode = next;
        renderProtectedRawBlock(dom, currentNode);
        return true;
      },
    };
  };

  const nodeViews = {
    [RAW_BLOCK]: rawBlockNodeView,
    [INLINE_MATH]: (node: PMNode) => {
      const dom = document.createElement('span');
      dom.className = 'interactive-visual-math interactive-visual-math-inline interactive-visual-math-protected interactive-visual-math-editable';
      dom.contentEditable = 'false';
      dom.dataset.sourceId = String(node.attrs.latexId ?? '');
      dom.title = 'Double-click to edit equation';
      let currentNode = node;
      let field: MathLiveField | undefined;
      try {
        field = createLearnitiveMathField({
          parent: dom,
          value: node.textContent,
          readOnly: true,
          ariaLabel: 'Inline equation. Double-click to edit.',
          className: 'interactive-visual-math-field interactive-visual-math-field-inline',
        });
      } catch {
        dom.textContent = `$${node.textContent}$`;
      }
      const activate = (event: MouseEvent) => {
        event.preventDefault();
        event.stopPropagation();
        emitMathEditor(currentNode, dom);
      };
      dom.addEventListener('dblclick', activate);
      return {
        dom,
        stopEvent: (event: Event) => event.type === 'dblclick',
        ignoreMutation: () => true,
        update(next: PMNode) {
          if (next.type !== currentNode.type) return false;
          currentNode = next;
          dom.dataset.sourceId = String(next.attrs.latexId ?? '');
          if (field && field.value !== next.textContent) {
            field.value = next.textContent;
          }
          return true;
        },
        destroy() {
          dom.removeEventListener('dblclick', activate);
          field?.remove();
        },
      };
    },
    [DISPLAY_MATH]: (node: PMNode) => {
      const dom = document.createElement('div');
      dom.className = 'interactive-visual-math interactive-visual-math-display interactive-visual-math-protected';
      dom.contentEditable = 'false';
      dom.dataset.sourceId = String(node.attrs.latexId ?? '');
      const header = document.createElement('div');
      header.className = 'interactive-visual-protected-header interactive-visual-math-header';
      const title = document.createElement('span');
      title.textContent = 'Equation';
      const editButton = document.createElement('button');
      editButton.type = 'button';
      editButton.className = 'interactive-visual-protected-action';
      editButton.textContent = 'Edit';
      editButton.setAttribute('aria-label', 'Edit equation');
      const fieldHost = document.createElement('div');
      fieldHost.className = 'interactive-visual-math-field-host';
      header.append(title, editButton);
      dom.append(header, fieldHost);
      let currentNode = node;
      let field: MathLiveField | undefined;
      try {
        field = createLearnitiveMathField({
          parent: fieldHost,
          value: node.textContent,
          readOnly: true,
          ariaLabel: 'Display equation. Use Edit to modify.',
          className: 'interactive-visual-math-field interactive-visual-math-field-display',
        });
      } catch {
        // Rendering failure must not remove the edit affordance. The app-level
        // MathLive editor is a separate, recoverable surface.
        fieldHost.textContent = `\\[${node.textContent}\\]`;
      }
      const activate = (event: Event) => {
        event.preventDefault();
        event.stopPropagation();
        emitMathEditor(currentNode, dom);
      };
      editButton.addEventListener('click', activate);
      dom.addEventListener('dblclick', activate);
      return {
        dom,
        stopEvent: (event: Event) =>
          (event.target instanceof Node && editButton.contains(event.target)) ||
          event.type === 'dblclick',
        ignoreMutation: () => true,
        update(next: PMNode) {
          if (next.type !== currentNode.type) return false;
          currentNode = next;
          dom.dataset.sourceId = String(next.attrs.latexId ?? '');
          if (field && field.value !== next.textContent) {
            field.value = next.textContent;
          }
          return true;
        },
        destroy() {
          editButton.removeEventListener('click', activate);
          dom.removeEventListener('dblclick', activate);
          field?.remove();
        },
      };
    },
  };

  view = new EditorView(options.host, {
    state,
    nodeViews,
    attributes: {
      class: 'interactive-latex-visual-editor',
      spellcheck: 'true',
      tabindex: '0',
      'data-interactive-native-visual': 'true',
    },
    dispatchTransaction(transaction) {
      const previousSerialized = currentSerialized;
      const result = view.state.applyTransaction(transaction);
      const next = result.state;
      view.updateState(next);
      currentSerialized = serializeNativeDocument(next.doc, sourceTemplate, sourceLayout);
      const docChanged = result.transactions.some((candidate) => candidate.docChanged);
      const historyReplay = result.transactions.some(isHistoryReplayTransaction);
      if (!suppressEmit && docChanged && currentSerialized.source !== previousSerialized.source) {
        // Collaboration V2: emit only the source splice produced by this PM
        // transaction. The old whole-document replacement destroyed unrelated
        // Yjs item identity and made every remote cursor vulnerable to drift.
        const edit = computeMinimalSourceTextEdit(
          previousSerialized.source,
          currentSerialized.source,
        );
        if (edit) {
          const accepted = options.onNativeEdits
            ? options.onNativeEdits([edit], {
                historyReplay,
                baseSource: previousSerialized.source,
                nextSource: currentSerialized.source,
              })
            : (options.onEdits([edit]), true);
          if (accepted !== false) {
            sourceTemplate = currentSerialized.source;
            sourceBaseline = currentSerialized.source;
          }
        }
      }
      notifySelection(view);
      updateSlash(view);
    },
    handlePaste(currentView, event) {
      const text = event.clipboardData?.getData('text/plain') ?? '';
      if (!text || !pasteSpreadsheetIntoTable(currentView, text)) return false;
      event.preventDefault();
      return true;
    },
    handleClickOn(_currentView, _pos, node, _nodePos, _event, direct) {
      if (!direct) return false;
      if (node.type.name === 'table') {
        const id = String(node.attrs.latexId ?? '');
        const range = id ? mapForId(currentSerialized.maps, id) : undefined;
        if (id && range) {
          options.onAction?.({
            kind: 'table',
            id,
            sourceFrom: range.from,
            sourceTo: range.to,
            sourceCoordinates: 'current',
          });
          return true;
        }
      }
      if (node.type.name === RAW_INLINE || node.type.name === RAW_BLOCK) {
        const id = String(node.attrs.sourceId ?? '');
        const range = id ? mapForId(currentSerialized.maps, id) : undefined;
        if (!id || !range) return false;
        options.onNavigateSource?.({ id, sourceFrom: range.from, sourceTo: range.to });
        return true;
      }
      return false;
    },
    handleKeyDown(currentView, event) {
      const selection = currentView.state.selection;
      if (selection instanceof NodeSelection) {
        const selected = selection.node;
        if ((selected.type.name === RAW_BLOCK || selected.type.name === RAW_INLINE) && (event.key === 'Enter' || event.key === ' ')) {
          const id = String(selected.attrs.sourceId ?? '');
          const range = id ? mapForId(currentSerialized.maps, id) : undefined;
          if (id && range) {
            event.preventDefault();
            options.onNavigateSource?.({ id, sourceFrom: range.from, sourceTo: range.to });
            return true;
          }
        }
        const protectedNode =
          selected.type.name === RAW_BLOCK ||
          selected.type.name === DISPLAY_MATH ||
          selected.type.name === INLINE_MATH;
        if (protectedNode && event.key === 'Enter') {
          event.preventDefault();
          focusAfterNode(currentView, selection.from, selected.nodeSize);
          return true;
        }
        if (protectedNode && (event.key === 'ArrowDown' || event.key === 'ArrowRight')) {
          event.preventDefault();
          focusAfterNode(currentView, selection.from, selected.nodeSize);
          return true;
        }
        if (protectedNode && event.key === 'ArrowUp') {
          event.preventDefault();
          focusBeforeNode(currentView, selection.from);
          return true;
        }
        if (protectedNode && event.key === 'ArrowLeft') {
          event.preventDefault();
          const before = Selection.near(currentView.state.doc.resolve(selection.from), -1);
          currentView.dispatch(currentView.state.tr.setSelection(before).scrollIntoView());
          currentView.focus();
          return true;
        }
      }
      if (!slashSession) return false;
      const count = Math.max(0, options.getSlashOptionCount?.(slashSession.query) ?? 0);
      if (event.key === 'Escape') {
        event.preventDefault();
        slashSession = undefined;
        options.onAction?.({ kind: 'slash-close' });
        return true;
      }
      if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && count > 0) {
        event.preventDefault();
        const delta = event.key === 'ArrowDown' ? 1 : -1;
        slashSession.selectedIndex = (slashSession.selectedIndex + delta + count) % count;
        updateSlash(currentView);
        return true;
      }
      if (event.key === 'Enter' && count > 0) {
        event.preventDefault();
        options.onAction?.({
          kind: 'slash-select',
          query: slashSession.query,
          selectedIndex: slashSession.selectedIndex,
        });
        return true;
      }
      return false;
    },
  });

  // Native Learnitive selection formatting. Formatting changes are PM mark
  // transactions; only the Link button delegates to Learnitive's URL dialog.
  const selectionToolbar = document.createElement('div');
  selectionToolbar.className = 'interactive-visual-selection-toolbar hidden';
  selectionToolbar.setAttribute('role', 'toolbar');
  selectionToolbar.setAttribute('aria-label', 'Text formatting');
  const formatButtons = [
    ['B', 'Bold', 'bold'],
    ['I', 'Italic', 'italic'],
    ['U', 'Underline', 'underline'],
    ['</>', 'Typewriter', 'code'],
    ['↗', 'Link', 'link'],
  ] as const;
  const applyNativeFormat = (format: (typeof formatButtons)[number][2]) => {
    const selection = view.state.selection;
    if (!(selection instanceof TextSelection) || selection.empty) return;
    if (format === 'link') {
      const sourceFrom = mapPmToSource(currentSerialized.maps, selection.from);
      const sourceTo = mapPmToSource(currentSerialized.maps, selection.to);
      if (sourceFrom === undefined || sourceTo === undefined || sourceTo <= sourceFrom) return;
      options.onAction?.({
        kind: 'format-selection',
        id: 'bf4-native-selection',
        bodyFrom: sourceFrom,
        bodyTo: sourceTo,
        relativeFrom: 0,
        relativeTo: sourceTo - sourceFrom,
        format: 'link',
        selectedText: currentSerialized.source.slice(sourceFrom, sourceTo),
        sourceFrom,
        sourceTo,
        visualKind: selection.$from.parent.type.name,
        sourceCoordinates: 'current',
      });
      return;
    }
    const mark = view.state.schema.marks[format];
    if (!mark) return;
    const active = view.state.doc.rangeHasMark(selection.from, selection.to, mark);
    const tr = active
      ? view.state.tr.removeMark(selection.from, selection.to, mark)
      : view.state.tr.addMark(selection.from, selection.to, mark.create());
    view.focus();
    view.dispatch(tr.scrollIntoView());
  };
  for (const [label, title, format] of formatButtons) {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'interactive-visual-selection-button';
    button.textContent = label;
    button.title = title;
    button.setAttribute('aria-label', title);
    button.addEventListener('mousedown', (event) => event.preventDefault());
    button.addEventListener('click', () => applyNativeFormat(format));
    selectionToolbar.append(button);
  }
  const aiSuggestButton = document.createElement('button');
  aiSuggestButton.type = 'button';
  aiSuggestButton.className = 'interactive-visual-selection-button interactive-ai-selection-button';
  aiSuggestButton.textContent = 'AI';
  aiSuggestButton.title = 'AI Suggest';
  aiSuggestButton.setAttribute('aria-label', 'AI Suggest');
  aiSuggestButton.addEventListener('mousedown', (event) => event.preventDefault());
  aiSuggestButton.addEventListener('click', () => {
    const selection = view.state.selection;
    if (!(selection instanceof TextSelection) || selection.empty) return;
    const sourceFrom = mapPmToSource(currentSerialized.maps, selection.from);
    const sourceTo = mapPmToSource(currentSerialized.maps, selection.to);
    if (sourceFrom === undefined || sourceTo === undefined || sourceTo <= sourceFrom) return;
    const rect = selectionToolbar.getBoundingClientRect();
    options.onAction?.({
      kind: 'ai-suggest-selection',
      selection: {
        id: 'bf4-native-selection',
        bodyFrom: sourceFrom,
        bodyTo: sourceTo,
        relativeFrom: 0,
        relativeTo: sourceTo - sourceFrom,
        selectedText: currentSerialized.source.slice(sourceFrom, sourceTo),
        sourceFrom,
        sourceTo,
        visualKind: selection.$from.parent.type.name,
        sourceCoordinates: 'current',
      },
      anchor: { left: rect.left, top: rect.top, bottom: rect.bottom, right: rect.right },
    });
  });
  selectionToolbar.append(aiSuggestButton);
  options.host.append(selectionToolbar);

  const updateSelectionToolbar = () => {
    const selection = view.state.selection;
    const domSelection = window.getSelection();
    if (
      !(selection instanceof TextSelection) ||
      selection.empty ||
      !domSelection ||
      domSelection.rangeCount === 0 ||
      domSelection.isCollapsed ||
      !view.dom.contains(domSelection.anchorNode)
    ) {
      selectionToolbar.classList.add('hidden');
      return;
    }
    const rect = domSelection.getRangeAt(0).getBoundingClientRect();
    selectionToolbar.style.left = `${rect.left + rect.width / 2}px`;
    selectionToolbar.style.top = `${Math.max(8, rect.top - 8)}px`;
    selectionToolbar.classList.remove('hidden');
  };
  let selectionToolbarFrame = 0;
  const selectionChange = () => {
    if (selectionToolbarFrame) cancelAnimationFrame(selectionToolbarFrame);
    selectionToolbarFrame = requestAnimationFrame(() => {
      selectionToolbarFrame = 0;
      updateSelectionToolbar();
    });
  };
  document.addEventListener('selectionchange', selectionChange);

  // Reuse Learnitive's block-handle presentation, but move native PM nodes rather
  // than editing LaTeX ranges. The + button opens Learnitive's existing LaTeX
  // insertion palette at the current serialized block boundary.
  const blockHandle = createBlockHandle({
    ...DEFAULT_BLOCK_HANDLE_LABELS,
    addBlockLabel: 'Add LaTeX block',
    dragHandleLabel: 'Drag LaTeX block to move',
  });
  blockHandle.wrapper.classList.add('interactive-visual-block-handle', 'hidden');
  blockHandle.dragButton.draggable = true;
  options.host.append(blockHandle.wrapper);
  const visualBlockSelector = [
    '.interactive-visual-heading',
    '.interactive-visual-paragraph',
    '.interactive-visual-list-item',
    '.interactive-visual-table',
    '.interactive-visual-math-display',
    '.interactive-visual-raw-block',
  ].join(',');
  let hoveredBlock: HTMLElement | null = null;
  let draggedBlock: HTMLElement | null = null;
  let blockHandleHideTimer: number | undefined;
  const cancelBlockHandleHide = () => {
    if (blockHandleHideTimer !== undefined) {
      window.clearTimeout(blockHandleHideTimer);
      blockHandleHideTimer = undefined;
    }
  };
  const positionBlockHandle = (block: HTMLElement | null) => {
    cancelBlockHandleHide();
    hoveredBlock = block;
    if (!block) {
      blockHandle.wrapper.classList.add('hidden');
      return;
    }
    const rect = block.getBoundingClientRect();
    blockHandle.wrapper.style.left = `${Math.max(6, rect.left - 48)}px`;
    blockHandle.wrapper.style.top = `${Math.max(6, rect.top + Math.min(12, Math.max(0, rect.height / 2 - 12)))}px`;
    blockHandle.wrapper.classList.remove('hidden');
  };
  const scheduleBlockHandleHide = () => {
    cancelBlockHandleHide();
    blockHandleHideTimer = window.setTimeout(() => {
      blockHandleHideTimer = undefined;
      positionBlockHandle(null);
    }, 180);
  };
  const blockAtEvent = (event: PointerEvent | DragEvent): HTMLElement | null => {
    const target = event.target;
    if (!(target instanceof Element)) return null;
    const block =
      target.closest<HTMLElement>('.interactive-visual-list-item, .interactive-visual-table') ??
      target.closest<HTMLElement>(visualBlockSelector);
    if (block?.dataset.immutable === 'true') return null;
    return block && options.host.contains(block) ? block : null;
  };
  // Keep the Learnitive gutter affordance stable while the pointer travels from a
  // manuscript block into the left gutter. The old mousemove implementation
  // hid the handle as soon as the pointer crossed empty host padding, making
  // both + and drag practically impossible to click.
  const pointerMove = (event: PointerEvent) => {
    const block = blockAtEvent(event);
    if (block && block !== hoveredBlock) positionBlockHandle(block);
    else if (block) cancelBlockHandleHide();
  };
  const pointerLeave = (event: PointerEvent) => {
    const related = event.relatedTarget;
    if (related instanceof Node && blockHandle.wrapper.contains(related)) {
      cancelBlockHandleHide();
      return;
    }
    scheduleBlockHandleHide();
  };
  options.host.addEventListener('pointermove', pointerMove);
  options.host.addEventListener('pointerleave', pointerLeave);
  blockHandle.wrapper.addEventListener('pointerenter', cancelBlockHandleHide);
  blockHandle.wrapper.addEventListener('pointerleave', (event) => {
    const related = event.relatedTarget;
    if (related instanceof Node && options.host.contains(related)) {
      cancelBlockHandleHide();
      return;
    }
    scheduleBlockHandleHide();
  });
  blockHandle.plusButton.addEventListener('pointerdown', (event) => event.preventDefault());
  blockHandle.dragButton.addEventListener('pointerdown', (event) => {
    if (event.pointerType === 'mouse' && event.button !== 0) return;
    cancelBlockHandleHide();
  });
  const visualHostScroll = () => {
    updateTableToolbar(view);
    updateListToolbar(view);
    if (hoveredBlock) positionBlockHandle(hoveredBlock);
  };
  options.host.addEventListener('scroll', visualHostScroll, { passive: true });

  const currentRangeForBlock = (block: HTMLElement) => {
    const id = block.dataset.sourceId ?? '';
    return id ? { id, range: mapForId(currentSerialized.maps, id) } : undefined;
  };
  blockHandle.plusButton.addEventListener('click', () => {
    if (!hoveredBlock) return;
    const current = currentRangeForBlock(hoveredBlock);
    if (!current?.range) return;
    const rect = hoveredBlock.getBoundingClientRect();
    options.onAction?.({
      kind: 'slash-menu',
      id: current.id,
      sourceFrom: current.range.from,
      sourceTo: current.range.to,
      placement: 'after',
      anchor: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom },
      query: '',
      selectedIndex: 0,
      synthetic: true,
      sourceCoordinates: 'current',
    });
  });

  const topLevelNodeForBlock = (block: HTMLElement): { node: PMNode; from: number; to: number } | undefined => {
    let inner: number;
    try {
      inner = view.posAtDOM(block, 0);
    } catch {
      return undefined;
    }
    const clamped = Math.max(0, Math.min(view.state.doc.content.size, inner));
    const $pos = view.state.doc.resolve(clamped);
    if ($pos.depth >= 1) {
      const node = $pos.node(1);
      const from = $pos.before(1);
      return { node, from, to: from + node.nodeSize };
    }
    const node = view.state.doc.nodeAt(clamped);
    return node ? { node, from: clamped, to: clamped + node.nodeSize } : undefined;
  };
  const dragStart = (event: DragEvent) => {
    draggedBlock = hoveredBlock;
    if (!draggedBlock || !event.dataTransfer) return;
    const source = topLevelNodeForBlock(draggedBlock);
    if (!source) {
      draggedBlock = null;
      return;
    }

    // Use ProseMirror's native drag contract, matching Learnitive's block handle:
    // select the actual PM node, serialize the selected Slice to the clipboard
    // payload, and let EditorView's built-in drop handling map/move it. Never
    // place a Learnitive sentinel in text/plain — if a browser/default drop path
    // wins, sentinel text must not be insertable into the manuscript.
    view.focus();
    const selection = NodeSelection.create(view.state.doc, source.from);
    view.dispatch(view.state.tr.setSelection(selection));
    const slice = view.state.selection.content();
    const clipboard = view.serializeForClipboard(slice);
    event.dataTransfer.clearData();
    event.dataTransfer.setData('text/html', clipboard.dom.innerHTML);
    event.dataTransfer.setData('text/plain', clipboard.text);
    event.dataTransfer.effectAllowed = 'copyMove';
    try {
      event.dataTransfer.setDragImage(draggedBlock, 0, 0);
    } catch {
      // jsdom/older browsers may not implement custom drag images.
    }
    view.dragging = { slice, move: !event.ctrlKey && !event.metaKey };
    draggedBlock.classList.add('interactive-visual-dragging');
  };
  const dragEnd = () => {
    draggedBlock?.classList.remove('interactive-visual-dragging');
    draggedBlock = null;
  };
  blockHandle.dragButton.addEventListener('dragstart', dragStart);
  blockHandle.dragButton.addEventListener('dragend', dragEnd);

  currentSerialized = serializeNativeDocument(view.state.doc, sourceTemplate, sourceLayout);
  // Keep edits relative to the exact source snapshot supplied by the app.
  // Every normal native keystroke is emitted as the smallest canonical source
  // splice, preserving unrelated Yjs item identity during collaboration.
  suppressEmit = false;
  notifySelection(view);

  const replaceProjection = (
    blocks: readonly VisualBlock[],
    assetUrls?: ReadonlyMap<string, string>,
    selection?: { anchorSource: number; headSource?: number; focus?: boolean },
  ): boolean => {
    // Structural/external source refreshes must not reset a live Visual caret
    // to ProseMirror's default selection at document start. Capture the current
    // selection in canonical source coordinates before rebuilding the PM doc,
    // then map it into the fresh projection. This is projection-level
    // reconciliation only; ordinary typing stays on the existing EditorView.
    const previousSelection = view.state.selection;
    const preserveFocus = view.hasFocus();
    const previousScrollTop = options.host.scrollTop;
    const previousScrollLeft = options.host.scrollLeft;
    const previousNodeId = previousSelection instanceof NodeSelection
      ? String(previousSelection.node.attrs.sourceId ?? previousSelection.node.attrs.latexId ?? '')
      : '';
    const implicitSourceSelection = selection
      ? undefined
      : {
          anchorSource: mapPmToSource(currentSerialized.maps, previousSelection.anchor),
          headSource: mapPmToSource(currentSerialized.maps, previousSelection.head),
        };

    const externalSource = options.getSource?.() ?? currentSerialized.source;
    sourceTemplate = externalSource;
    sourceBaseline = externalSource;
    projection = buildNativeProjection(schema, externalSource, blocks, assetUrls ?? options.assetUrls);
    sourceLayout = projection.sourceLayout;
    const doc = projection.doc;
    const serialized = serializeNativeDocument(doc, externalSource, sourceLayout);
    let pmSelection: Selection | undefined;

    if (!selection && previousSelection instanceof NodeSelection && previousNodeId) {
      const nodeMap = serialized.maps.find((map) => map.id === previousNodeId);
      if (nodeMap) {
        try {
          pmSelection = NodeSelection.create(doc, nodeMap.pmFrom);
        } catch {
          pmSelection = undefined;
        }
      }
    }

    const requestedAnchor = selection?.anchorSource ?? implicitSourceSelection?.anchorSource;
    const requestedHead = selection?.headSource ?? implicitSourceSelection?.headSource ?? requestedAnchor;
    if (!pmSelection && requestedAnchor !== undefined && requestedHead !== undefined) {
      const anchor = mapSourceToPm(serialized.maps, requestedAnchor);
      const head = mapSourceToPm(serialized.maps, requestedHead);
      if (anchor !== undefined && head !== undefined) {
        try {
          pmSelection = TextSelection.create(doc, anchor, head);
        } catch {
          try {
            pmSelection = Selection.near(doc.resolve(head));
          } catch {
            pmSelection = undefined;
          }
        }
      }
    }

    // Source maps should normally recover the exact selection. If a custom/raw
    // projection temporarily has no map (for example while an external source
    // edit changes the surrounding structure), fail near the previous PM
    // position rather than allowing EditorState's default selection at the
    // beginning of the document. This is deliberately only a projection
    // refresh fallback; ordinary keyboard editing never uses it.
    if (!pmSelection) {
      const fallbackHead = Math.max(0, Math.min(doc.content.size, previousSelection.head));
      try {
        pmSelection = Selection.near(doc.resolve(fallbackHead));
      } catch {
        pmSelection = Selection.atStart(doc);
      }
    }

    // Keep the existing EditorState, plugins and unaffected NodeViews alive.
    // Recreating EditorState (and then replacing the complete PM document)
    // for every remote character made Visual collaboration progressively
    // expensive and destroyed useful transaction mapping. Prefer the smallest
    // ProseMirror slice that transforms the current projection into the freshly
    // parsed projection; retain a full-document fallback only for uncommon
    // schema/boundary cases where a minimal replace cannot reproduce it.
    let projectionTransaction = view.state.tr;
    const diffStart = view.state.doc.content.findDiffStart(doc.content);
    if (diffStart !== null) {
      const diffEnd = view.state.doc.content.findDiffEnd(doc.content);
      const oldEnd = diffEnd?.a ?? view.state.doc.content.size;
      const newEnd = diffEnd?.b ?? doc.content.size;
      try {
        const candidate = view.state.tr.replace(
          diffStart,
          oldEnd,
          doc.slice(diffStart, newEnd),
        );
        projectionTransaction = candidate.doc.eq(doc)
          ? candidate
          : view.state.tr.replaceWith(0, view.state.doc.content.size, doc.content);
      } catch {
        projectionTransaction = view.state.tr.replaceWith(0, view.state.doc.content.size, doc.content);
      }
    }
    projectionTransaction = projectionTransaction
      .setMeta('addToHistory', false)
      .setMeta('learnitiveCollaborationProjection', true);
    try {
      const nextSelection = pmSelection instanceof NodeSelection
        ? NodeSelection.create(projectionTransaction.doc, pmSelection.from)
        : TextSelection.create(
            projectionTransaction.doc,
            pmSelection?.anchor ?? 0,
            pmSelection?.head ?? pmSelection?.anchor ?? 0,
          );
      projectionTransaction = projectionTransaction.setSelection(nextSelection);
    } catch {
      const fallbackHead = Math.max(
        0,
        Math.min(projectionTransaction.doc.content.size, pmSelection?.head ?? previousSelection.head),
      );
      projectionTransaction = projectionTransaction.setSelection(
        Selection.near(projectionTransaction.doc.resolve(fallbackHead)),
      );
    }
    suppressEmit = true;
    view.dispatch(projectionTransaction);
    currentSerialized = serialized;
    suppressEmit = false;
    if (currentReviewDecorations.length) {
      const reviewSet = buildNativeReviewDecorationSet(
        view.state.doc,
        currentSerialized.maps,
        currentReviewDecorations,
      );
      view.dispatch(
        view.state.tr
          .setMeta(nativeReviewDecorationKey, reviewSet)
          .setMeta('addToHistory', false),
      );
    }
    if (!selection) {
      if (preserveFocus) view.focus();
      options.host.scrollTop = previousScrollTop;
      options.host.scrollLeft = previousScrollLeft;
    } else if (selection.focus || preserveFocus) {
      // A collaboration projection may supply an explicit CRDT-relative
      // selection. Preserve the editor's existing focus state, but never
      // steal focus back from a dialog/toolbar merely because a peer typed.
      view.focus();
    }
    notifySelection(view);
    return selection ? Boolean(pmSelection) : true;
  };

  const focusSourceOffset = (offset: number): boolean => {
    const position = mapSourceToPm(currentSerialized.maps, offset);
    if (position === undefined) return false;
    try {
      const selection = Selection.near(view.state.doc.resolve(position));
      view.dispatch(view.state.tr.setSelection(selection).scrollIntoView());
      view.focus();
      return true;
    } catch {
      return false;
    }
  };

  const revealSourceOffset = (offset: number) => {
    const position = mapSourceToPm(currentSerialized.maps, offset);
    if (position === undefined) return;
    try {
      view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(position))).scrollIntoView());
    } catch {
      // Source-only regions remain safely protected rather than guessing.
    }
  };

  const updateMathValue = (id: string, value: string) => {
    let found: { node: PMNode; pos: number } | undefined;
    view.state.doc.descendants((node, pos) => {
      if (
        !found &&
        (node.type.name === INLINE_MATH || node.type.name === DISPLAY_MATH) &&
        String(node.attrs.latexId ?? '') === id
      ) {
        found = { node, pos };
        return false;
      }
      return true;
    });
    if (!found) return;
    const content = value ? schema.text(value) : Fragment.empty;
    view.dispatch(
      view.state.tr.replaceWith(
        found.pos,
        found.pos + found.node.nodeSize,
        found.node.type.create({ ...found.node.attrs }, content),
      ),
    );
  };

  const focusAfterProtectedNode = (id: string): boolean => {
    let found: { node: PMNode; pos: number } | undefined;
    view.state.doc.descendants((node, pos) => {
      const sourceId = String(node.attrs.sourceId ?? node.attrs.latexId ?? '');
      if (
        !found &&
        sourceId === id &&
        (node.type.name === DISPLAY_MATH || node.type.name === RAW_BLOCK)
      ) {
        found = { node, pos };
        return false;
      }
      return true;
    });
    if (!found) return false;
    focusAfterNode(view, found.pos, found.node.nodeSize);
    return true;
  };

  const replaceTableData = (id: string, rows: readonly (readonly string[])[]): boolean => {
    const normalized = rows
      .filter((row) => row.length > 0)
      .map((row) => [...row]);
    if (!normalized.length) return false;
    const width = Math.max(...normalized.map((row) => row.length));
    if (!width) return false;
    normalized.forEach((row) => {
      while (row.length < width) row.push('');
    });
    let found: { node: PMNode; pos: number } | undefined;
    view.state.doc.descendants((node, pos) => {
      if (!found && node.type.name === 'table' && String(node.attrs.latexId ?? '') === id) {
        found = { node, pos };
        return false;
      }
      return true;
    });
    if (!found) return false;
    const tableRows = normalized.map((values) =>
      schema.nodes.table_row!.create(
        null,
        values.map((value) => schema.nodes.table_cell!.create(null, simpleTextNode(schema, value))),
      ),
    );
    const next = found.node.type.create(
      { ...found.node.attrs, columns: tableColumnSpecForWidth(found.node, width) },
      tableRows,
    );
    view.dispatch(
      view.state.tr.replaceWith(found.pos, found.pos + found.node.nodeSize, next).scrollIntoView(),
    );
    view.focus();
    return true;
  };

  type NativeSlashSession = { from: number; to: number; query: string; selectedIndex: number };

  const splitNativeSlashParagraph = (active: NativeSlashSession) => {
    const $from = view.state.doc.resolve(active.from);
    if (
      $from.depth !== 1 ||
      $from.parent.type.name !== 'paragraph' ||
      active.to > $from.end()
    ) {
      return undefined;
    }
    const blockPos = $from.before(1);
    const paragraph = $from.parent;
    const contentStart = blockPos + 1;
    const fromOffset = active.from - contentStart;
    const toOffset = active.to - contentStart;
    if (
      fromOffset < 0 ||
      toOffset < fromOffset ||
      toOffset > paragraph.content.size
    ) {
      return undefined;
    }
    const before = paragraph.content.cut(0, fromOffset);
    const after = paragraph.content.cut(toOffset, paragraph.content.size);
    const meaningful = (fragment: Fragment) =>
      fragment.size > 0 &&
      fragment.textBetween(0, fragment.size, '\n', '\uFFFC').trim().length > 0;
    const beforeNode = meaningful(before)
      ? paragraph.type.create({ ...paragraph.attrs }, before)
      : undefined;
    const afterNode = meaningful(after)
      ? paragraph.type.create(
          { ...paragraph.attrs, sourceId: nextNativeVisualId('paragraph') },
          after,
        )
      : undefined;
    return { blockPos, paragraph, beforeNode, afterNode };
  };

  const replaceSlashParagraphWithBlocks = (
    active: NativeSlashSession,
    middle: PMNode,
    selectionOffsetInMiddle: number,
    selectionKind: 'text' | 'node' = 'text',
  ): { tr: Transaction; middlePos: number } | undefined => {
    const parts = splitNativeSlashParagraph(active);
    if (!parts) return undefined;
    const replacements = [parts.beforeNode, middle, parts.afterNode].filter(
      (node): node is PMNode => Boolean(node),
    );
    const middlePos = parts.blockPos + (parts.beforeNode?.nodeSize ?? 0);
    let tr = view.state.tr.replaceWith(
      parts.blockPos,
      parts.blockPos + parts.paragraph.nodeSize,
      Fragment.fromArray(replacements),
    );
    if (selectionKind === 'node') {
      tr = tr.setSelection(NodeSelection.create(tr.doc, middlePos));
    } else {
      const requested = middlePos + selectionOffsetInMiddle;
      const safe = Math.max(0, Math.min(tr.doc.content.size, requested));
      try {
        tr = tr.setSelection(TextSelection.create(tr.doc, safe));
      } catch {
        tr = tr.setSelection(Selection.near(tr.doc.resolve(safe), 1));
      }
    }
    return { tr, middlePos };
  };

  const insertNativeTableAtSlash = (rows: number, columns: number): boolean => {
    const active = slashSession;
    const tableType = schema.nodes.table;
    const rowType = schema.nodes.table_row;
    const cellType = schema.nodes.table_cell;
    if (!active || !tableType || !rowType || !cellType) return false;
    const safeRows = Math.max(1, Math.min(20, Math.floor(rows)));
    const safeColumns = Math.max(1, Math.min(20, Math.floor(columns)));
    const parts = splitNativeSlashParagraph(active);
    if (!parts) return false;
    const spec = `|${Array.from({ length: safeColumns }, () => 'l').join('|')}|`;
    const tableRows = Array.from({ length: safeRows }, () =>
      rowType.create(
        null,
        Array.from({ length: safeColumns }, () => cellType.create(null, Fragment.empty)),
      ),
    );
    const table = tableType.create(
      {
        latexId: nextNativeVisualId('table'),
        columns: spec,
        rawBeforeTabular: '\\begin{center}\n',
        rawAfterTabular: '\n\\end{center}',
        rawOriginal: '',
        initialFingerprint: '',
        initialColumns: spec,
        tableAlign: 'center',
        initialTableAlign: 'center',
        tableAlignStyle: 'center-env',
        hasHorizontalRules: true,
      },
      tableRows,
    );
    const replacement = replaceSlashParagraphWithBlocks(active, table, 3);
    if (!replacement) return false;
    slashSession = undefined;
    view.dispatch(replacement.tr.scrollIntoView());
    view.focus();
    return true;
  };

  const openNativeMathEditorById = (id: string) => {
    const mapped = mapForId(currentSerialized.maps, id);
    if (!mapped) return;
    const region = findLatexMathRegions(currentSerialized.source).find(
      (candidate) =>
        (mapped.from >= candidate.from && mapped.from < candidate.to) ||
        (candidate.from >= mapped.from && candidate.to <= mapped.to),
    );
    if (!region) return;
    let nodeValue = '';
    let nodePos: number | undefined;
    view.state.doc.descendants((node, pos) => {
      if (
        nodeValue === '' &&
        (node.type.name === INLINE_MATH || node.type.name === DISPLAY_MATH) &&
        String(node.attrs.latexId ?? '') === id
      ) {
        nodeValue = node.textContent;
        nodePos = pos;
        return false;
      }
      return true;
    });
    const dom = nodePos === undefined ? undefined : view.nodeDOM(nodePos);
    options.onAction?.({
      kind: 'math',
      id,
      from: region.from,
      to: region.to,
      bodyFrom: region.contentFrom,
      bodyTo: region.contentTo,
      value: nodeValue || region.content,
      mathKind: region.kind,
      ...(region.environment ? { environment: region.environment } : {}),
      editable: true,
      sourceCoordinates: 'current',
    });
    if (dom instanceof HTMLElement) dom.dataset.learnitiveMathRequested = 'true';
  };

  /**
   * BF4.4 native slash execution.
   *
   * Supported Visual structures are transformed directly at the live
   * ProseMirror selection. This deliberately bypasses the legacy
   * PM -> LaTeX offset -> source mutation -> reparse path which could insert a
   * heading/list in the following paragraph when source block boundaries were
   * ambiguous. Block commands also split a non-empty top-level paragraph at
   * the live slash position, preserving the PM content on both sides.
   */
  const executeNativeSlashCommand = (command: string): boolean => {
    const active = slashSession;
    if (!active) return false;
    const $from = view.state.doc.resolve(active.from);
    if (!$from.parent.isTextblock || active.to > $from.end()) return false;
    const depth = $from.depth;

    // Inline math is safe in any editable textblock, including list items and
    // native table cells. It must not fall back to a reconstructed LaTeX offset.
    if (command === 'inline-math') {
      const mathType = schema.nodes[INLINE_MATH];
      if (!mathType) return false;
      const id = nextNativeVisualId('math-inline');
      const math = mathType.create(
        {
          latexId: id,
          delimiter: '\\(',
          environment: '',
          originalLatex: '',
          initialValue: 'E=mc^2',
          editable: true,
        },
        schema.text('E=mc^2'),
      );
      let tr = view.state.tr.replaceWith(active.from, active.to, math);
      slashSession = undefined;
      tr = tr.setSelection(TextSelection.create(tr.doc, active.from + math.nodeSize));
      view.dispatch(tr.scrollIntoView());
      view.focus();
      openNativeMathEditorById(id);
      return true;
    }

    if (depth !== 1) return false;
    const blockPos = $from.before(depth);
    const originalParent = $from.parent;
    const targetOffset = Math.max(0, active.from - (blockPos + 1));

    const finish = (tr: Transaction, selectionPos: number) => {
      slashSession = undefined;
      const safe = Math.max(0, Math.min(tr.doc.content.size, selectionPos));
      let nextTr = tr;
      try {
        nextTr = nextTr.setSelection(TextSelection.create(nextTr.doc, safe));
      } catch {
        nextTr = nextTr.setSelection(Selection.near(nextTr.doc.resolve(safe)));
      }
      view.dispatch(nextTr.scrollIntoView());
      view.focus();
    };

    if (command === 'paragraph') {
      const tr = view.state.tr.delete(active.from, active.to);
      finish(tr, active.from);
      return true;
    }

    const headingLevel = command === 'section'
      ? 2
      : command === 'subsection'
        ? 3
        : command === 'subsubsection'
          ? 4
          : undefined;
    if (headingLevel !== undefined) {
      const heading = schema.nodes.heading;
      if (!heading || originalParent.type.name !== 'paragraph') return false;
      const wholeParagraph = targetOffset === 0 && active.to === blockPos + 1 + originalParent.content.size;
      if (wholeParagraph) {
        let tr = view.state.tr.delete(active.from, active.to);
        const current = tr.doc.nodeAt(blockPos);
        if (!current?.isTextblock) return false;
        tr = tr.setNodeMarkup(blockPos, heading, {
          ...current.attrs,
          level: headingLevel,
        });
        const next = tr.doc.nodeAt(blockPos);
        const pos = blockPos + 1 + Math.min(targetOffset, next?.content.size ?? 0);
        finish(tr, pos);
        return true;
      }
      const headingNode = heading.create({
        ...originalParent.attrs,
        sourceId: nextNativeVisualId('heading'),
        level: headingLevel,
      });
      const replacement = replaceSlashParagraphWithBlocks(active, headingNode, 1);
      if (!replacement) return false;
      slashSession = undefined;
      view.dispatch(replacement.tr.scrollIntoView());
      view.focus();
      return true;
    }

    if (command === 'bullet-list' || command === 'numbered-list') {
      const listType = schema.nodes.list;
      if (!listType || originalParent.type.name !== 'paragraph') return false;
      const ordered = command === 'numbered-list';
      const runId = nextNativeVisualId('latex-list-run');
      const itemParagraph = originalParent.type.create({
        ...originalParent.attrs,
        sourceId: nextNativeVisualId('list-item'),
      });
      const list = listType.create(
        {
          kind: ordered ? 'ordered' : 'bullet',
          listKind: ordered ? 'ordered' : 'bullet',
          tight: true,
          ...(ordered ? { order: 1 } : {}),
          sourceId: nextNativeVisualId('list'),
          latexListId: runId,
        },
        [itemParagraph],
      );
      const replacement = replaceSlashParagraphWithBlocks(active, list, 2);
      if (!replacement) return false;
      slashSession = undefined;
      view.dispatch(replacement.tr.scrollIntoView());
      view.focus();
      return true;
    }

    if (command === 'display-math' || command === 'equation') {
      const mathType = schema.nodes[DISPLAY_MATH];
      if (!mathType || originalParent.type.name !== 'paragraph') return false;
      const id = nextNativeVisualId('math-display');
      const environment = command === 'equation' ? 'equation' : '';
      const math = mathType.create(
        {
          latexId: id,
          delimiter: environment ? 'environment' : '\\[',
          environment,
          originalLatex: '',
          initialValue: 'E=mc^2',
          editable: true,
        },
        schema.text('E=mc^2'),
      );
      const replacement = replaceSlashParagraphWithBlocks(active, math, 0, 'node');
      if (!replacement) return false;
      slashSession = undefined;
      view.dispatch(replacement.tr.scrollIntoView());
      view.focus();
      openNativeMathEditorById(id);
      return true;
    }

    return false;
  };

  const dismissSlashSuggestion = () => {
    slashSession = undefined;
    options.onAction?.({ kind: 'slash-close' });
  };

  return {
    native: true as const,
    view,
    destroy() {
      tableToolbar.remove();
      listToolbar.remove();
      document.removeEventListener('selectionchange', selectionChange);
      if (selectionToolbarFrame) cancelAnimationFrame(selectionToolbarFrame);
      selectionToolbar.remove();
      cancelBlockHandleHide();
      options.host.removeEventListener('pointermove', pointerMove);
      options.host.removeEventListener('pointerleave', pointerLeave);
      options.host.removeEventListener('scroll', visualHostScroll);
      blockHandle.dragButton.removeEventListener('dragstart', dragStart);
      blockHandle.dragButton.removeEventListener('dragend', dragEnd);
      blockHandle.wrapper.remove();
      view.destroy();
    },
    replaceProjection,
    updateAssetUrls(assetUrls: ReadonlyMap<string, string>) {
      options.assetUrls = assetUrls;
      let tr = view.state.tr;
      view.state.doc.descendants((node, pos) => {
        if (node.type.name !== RAW_BLOCK || String(node.attrs.visualKind ?? '') !== 'figure') return true;
        const id = String(node.attrs.sourceId ?? '');
        const targets = Array.isArray(node.attrs.assetTargets)
          ? node.attrs.assetTargets.map((value) => String(value))
          : [];
        const previews = targets.map((target) => visualAssetPreview(assetUrls.get(target) ?? ''));
        const nextAssetUrls = previews.map((preview) => preview.url);
        const nextAssetStatuses = previews.map((preview) => preview.status);
        const blockPreview = visualAssetPreview(assetUrls.get(id) ?? '');
        const nextAssetUrl = nextAssetUrls.find(Boolean) ?? blockPreview.url;
        const currentUrls = Array.isArray(node.attrs.assetUrls) ? node.attrs.assetUrls.map((value) => String(value)) : [];
        const currentStatuses = Array.isArray(node.attrs.assetStatuses) ? node.attrs.assetStatuses.map((value) => String(value)) : [];
        if (
          String(node.attrs.assetUrl ?? '') === nextAssetUrl &&
          JSON.stringify(currentUrls) === JSON.stringify(nextAssetUrls) &&
          JSON.stringify(currentStatuses) === JSON.stringify(nextAssetStatuses)
        ) return false;
        tr = tr.setNodeMarkup(pos, undefined, {
          ...node.attrs,
          assetUrl: nextAssetUrl,
          assetUrls: nextAssetUrls,
          assetStatuses: nextAssetStatuses,
        });
        return false;
      });
      if (tr.docChanged) {
        suppressEmit = true;
        view.dispatch(tr.setMeta('addToHistory', false));
        suppressEmit = false;
      }
    },
    revealSourceOffset,
    focusSourceOffset,
    insertSyntheticParagraphAtSourceOffset(offset: number) {
      focusSourceOffset(offset);
    },
    insertSyntheticInlineAtSourceOffset(offset: number) {
      focusSourceOffset(offset);
    },
    updateMathValue,
    executeNativeSlashCommand,
    insertNativeTableAtSlash,
    focusAfterProtectedNode,
    replaceTableData,
    updateReviewDecorations(decorations: readonly LatexVisualReviewDecoration[]) {
      currentReviewDecorations = decorations.map((decoration) => ({ ...decoration }));
      const set = buildNativeReviewDecorationSet(view.state.doc, currentSerialized.maps, currentReviewDecorations);
      view.dispatch(
        view.state.tr
          .setMeta(nativeReviewDecorationKey, set)
          .setMeta('addToHistory', false),
      );
    },
    dismissSlashSuggestion,
    sourceRangeForId(id: string) {
      return mapForId(currentSerialized.maps, id);
    },
    getSourceSelection() {
      const selection = view.state.selection;
      const anchor = mapPmToSource(currentSerialized.maps, selection.anchor);
      const head = mapPmToSource(currentSerialized.maps, selection.head);
      return anchor === undefined || head === undefined ? undefined : { anchor, head };
    },
    currentSource() {
      return currentSerialized.source;
    },
  };
}
