import {
  $suggestions,
  $suggestionUi,
  createBlockHandle,
  DEFAULT_BLOCK_HANDLE_LABELS,
  EditorState,
  EditorView,
  Plugin,
  Schema,
  TextSelection,
  pluginSuggestion,
  removeSuggestMark,
  store,
  storePlugin,
  suggestionKeymap,
  suggestionsMark,
  type Node as PMNode,
  type Suggestion,
  type Transaction,
} from '@learnitive/prosemirror-editor';
import { createLearnitiveMathField } from '@learnitive/latex-mathlive';
import {
  getLatexEditorCommandDefinition,
  type SourceTextEdit,
} from '@learnitive/latex-source';
import type {
  VisualBlock,
  VisualInline,
} from '@learnitive/project-semantic-index';
import { createNativeLearnitiveLatexVisualEditor } from './native-learnitive-editor';

export type LatexVisualAction =
  | { kind: 'citation'; id: string; from: number; to: number; command: string; targets: readonly string[]; sourceCoordinates?: 'base' | 'current' }
  | { kind: 'reference'; id: string; from: number; to: number; command: string; target: string; sourceCoordinates?: 'base' | 'current' }
  | { kind: 'footnote'; id: string; from: number; to: number; bodyFrom: number; bodyTo: number; text: string; editable: boolean; sourceCoordinates?: 'base' | 'current' }
  | { kind: 'math'; id: string; from: number; to: number; bodyFrom: number; bodyTo: number; value: string; mathKind: 'inline' | 'display'; environment?: string; editable: boolean; sourceCoordinates?: 'base' | 'current' }
  | { kind: 'figure'; id: string; sourceFrom?: number; sourceTo?: number; sourceCoordinates?: 'base' | 'current' }
  | { kind: 'table'; id: string; row?: number; column?: number; sourceFrom?: number; sourceTo?: number; sourceCoordinates?: 'base' | 'current' }
  | {
      kind: 'table-structure';
      id: string;
      operation:
        | 'add-row-above'
        | 'add-row-below'
        | 'add-column-left'
        | 'add-column-right'
        | 'delete-row'
        | 'delete-column'
        | 'delete-table';
      row?: number;
      column?: number;
    }
  | {
      kind: 'list-structure';
      id: string;
      operation: 'delete-item' | 'delete-list';
      itemId?: string;
    }
  | {
      kind: 'table-boundary';
      id: string;
      direction: 'before' | 'after';
    }
  | {
      kind: 'block-boundary';
      id: string;
      sourceFrom: number;
      sourceTo: number;
      direction: 'before' | 'after';
    }
  | {
      kind: 'slash-menu';
      id: string;
      sourceFrom: number;
      sourceTo: number;
      placement: 'before' | 'after' | 'replace';
      anchor: { left: number; top: number; bottom: number; right: number };
      query?: string;
      selectedIndex?: number;
      synthetic?: boolean;
      /** Base-source caret used by inline commands such as /newline. */
      inlineSourceAt?: number;
      /** Exact typed suggestion text that must be replaced in current source. */
      sourceQueryText?: string;
      /** Whether the suggestion caret is at the end of its editable host. */
      inlineAtEnd?: boolean;
      inlineContext?: LatexVisualInlineSourceContext;
      sourceCoordinates?: 'base' | 'current';
    }
  | { kind: 'slash-select'; query: string; selectedIndex: number }
  | { kind: 'slash-close' }
  | {
      kind: 'heading-exit';
      id: string;
      headingTo: number;
    }
  | {
      kind: 'paragraph-break';
      id: string;
      sourceFrom: number;
      sourceTo: number;
      atEnd: boolean;
      inlineContext?: LatexVisualInlineSourceContext;
    }
  | {
      kind: 'list-item-break';
      id: string;
      sourceFrom: number;
      sourceTo: number;
      itemTo: number;
      atEnd: boolean;
      inlineContext?: LatexVisualInlineSourceContext;
    }
  | {
      kind: 'list-exit';
      id: string;
      itemFrom: number;
      itemTo: number;
      listTo: number;
    }
  | {
      kind: 'hard-break';
      id: string;
      sourceFrom: number;
      sourceTo: number;
      atEnd: boolean;
      inlineContext?: LatexVisualInlineSourceContext;
      /** Typed slash text to replace; absent for Shift+Enter. */
      replaceSourceText?: string;
    }
  | {
      kind: 'delete-hard-break';
      id: string;
      sourceFrom: number;
      sourceTo: number;
    }
  | {
      kind: 'materialize-text';
      id: string;
      sourceAt: number;
      text: string;
    }
  | {
      kind: 'move-block';
      id: string;
      sourceFrom: number;
      sourceTo: number;
      targetId: string;
      targetFrom: number;
      targetTo: number;
      placement: 'before' | 'after';
    }
  | {
      kind: 'format-selection';
      id: string;
      bodyFrom: number;
      bodyTo: number;
      relativeFrom: number;
      relativeTo: number;
      format: 'bold' | 'italic' | 'underline' | 'monospace' | 'link';
      selectedText: string;
      sourceFrom: number;
      sourceTo: number;
      visualKind?: string;
      actionData?: unknown;
      anchor?: { left: number; top: number; bottom: number; right: number };
      sourceCoordinates?: 'base' | 'current';
    }
  | {
      kind: 'ai-suggest-selection';
      selection: LatexVisualSourceSelection;
      anchor?: { left: number; top: number; bottom: number; right: number };
    };

export type LatexVisualInlineSourceContext = {
  id: string;
  sourceFrom: number;
  sourceTo: number;
  bodyFrom: number;
  bodyTo: number;
  visualKind: string;
  inlineCommand?: string;
};

const schema = new Schema({
  nodes: {
    doc: { content: 'block*' },
    text: { group: 'inline' },
    visual_heading: {
      group: 'block',
      content: 'text*',
      attrs: { id: {}, sourceFrom: {}, sourceTo: {}, bodyFrom: {}, bodyTo: {}, level: { default: 2 } },
      toDOM(node: PMNode) {
        const level = Math.min(6, Math.max(1, Number(node.attrs.level) || 2));
        return [`h${level}`, { class: 'interactive-visual-heading', 'data-source-id': node.attrs.id }, 0];
      },
    },
    visual_paragraph: {
      group: 'block',
      content: 'text*',
      attrs: {
        id: {}, sourceFrom: {}, sourceTo: {}, bodyFrom: {}, bodyTo: {},
        synthetic: { default: false },
      },
      toDOM(node: PMNode) {
        return [
          'p',
          {
            class: node.attrs.synthetic
              ? 'interactive-visual-paragraph interactive-visual-synthetic-paragraph'
              : 'interactive-visual-paragraph',
            'data-source-id': node.attrs.id,
            ...(node.attrs.synthetic ? { 'data-visual-synthetic': 'true' } : {}),
          },
          0,
        ];
      },
    },
    visual_rich_paragraph: {
      group: 'block',
      content: 'visual_inline*',
      attrs: { id: {}, sourceFrom: {}, sourceTo: {} },
      toDOM(node: PMNode) {
        return ['p', { class: 'interactive-visual-paragraph interactive-visual-rich-paragraph', 'data-source-id': node.attrs.id }, 0];
      },
    },
    visual_source_text: {
      inline: true,
      group: 'visual_inline',
      content: 'text*',
      selectable: false,
      attrs: {
        id: {},
        sourceFrom: {},
        sourceTo: {},
        bodyFrom: {},
        bodyTo: {},
        visualKind: { default: 'text' },
        inlineCommand: { default: '' },
        action: { default: '' },
        actionData: { default: null },
        editable: { default: true },
        synthetic: { default: false },
      },
      toDOM(node: PMNode) {
        const action = String(node.attrs.action || '');
        const visualKind = String(node.attrs.visualKind || 'text');
        const attrs: Record<string, string> = {
          class: node.attrs.synthetic
            ? `interactive-visual-inline interactive-visual-inline-${visualKind} interactive-visual-synthetic-inline`
            : `interactive-visual-inline interactive-visual-inline-${visualKind}`,
          'data-source-id': String(node.attrs.id),
        };
        if (node.attrs.synthetic) attrs['data-visual-synthetic-inline'] = 'true';
        if (action) {
          attrs['data-visual-action'] = action;
          attrs['contenteditable'] = 'false';
          attrs['role'] = 'button';
          attrs['tabindex'] = '0';
        }
        if (!node.attrs.editable) attrs['contenteditable'] = 'false';
        return ['span', attrs, 0];
      },
    },
    visual_hard_break: {
      inline: true,
      group: 'visual_inline',
      atom: true,
      selectable: false,
      attrs: {
        id: {},
        sourceFrom: {},
        sourceTo: {},
        sourceText: { default: '\\newline' },
      },
      toDOM(node: PMNode) {
        return [
          'br',
          {
            class: 'interactive-visual-hard-break',
            'data-source-id': String(node.attrs.id),
            'data-source-break': String(node.attrs.sourceText || '\\newline'),
          },
        ];
      },
    },
    visual_math: {
      inline: true,
      group: 'visual_inline',
      atom: true,
      selectable: true,
      attrs: {
        id: {},
        sourceFrom: {},
        sourceTo: {},
        bodyFrom: {},
        bodyTo: {},
        mathKind: { default: 'inline' },
        value: { default: '' },
        environment: { default: '' },
        editable: { default: true },
      },
      toDOM(node: PMNode) {
        const display = node.attrs.mathKind === 'display';
        return [
          display ? 'span' : 'span',
          {
            class: display
              ? 'interactive-visual-math interactive-visual-math-display'
              : 'interactive-visual-math interactive-visual-math-inline',
            'data-source-id': String(node.attrs.id),
            'data-visual-math': 'true',
            contenteditable: 'false',
          },
          String(node.attrs.value || ''),
        ];
      },
    },
    visual_list: {
      group: 'block',
      // Keep the list schema generatable. visual_list_item carries required
      // source-mapping attributes, so using `+` here makes ProseMirror reject
      // the entire schema at application startup because it cannot synthesize
      // a valid default item. Parsed Learnitive lists still always provide their
      // concrete items, and the structural guard prevents item insertion/removal
      // from drifting away from canonical LaTeX source mappings.
      content: 'visual_list_item*',
      isolating: true,
      attrs: {
        id: {},
        sourceFrom: {},
        sourceTo: {},
        ordered: { default: false },
        environment: { default: 'itemize' },
      },
      toDOM(node: PMNode) {
        return [
          'div',
          {
            class: 'interactive-visual-list-wrap',
            'data-source-id': String(node.attrs.id),
          },
          [
            'div',
            { class: 'interactive-visual-list-toolbar', contenteditable: 'false' },
            [
              'details',
              { class: 'interactive-visual-list-actions' },
              ['summary', { class: 'interactive-visual-list-actions-summary', title: 'List actions', 'aria-label': 'List actions' }, '⋮'],
              [
                'div',
                { class: 'interactive-visual-list-actions-menu' },
                ['button', { type: 'button', 'data-visual-list-command': 'delete-item' }, 'Delete item'],
                ['button', { type: 'button', 'data-visual-list-command': 'delete-list', class: 'interactive-visual-list-destructive' }, 'Delete list'],
              ],
            ],
          ],
          [
            node.attrs.ordered ? 'ol' : 'ul',
            {
              class: 'interactive-visual-list',
              'data-list-environment': String(node.attrs.environment),
            },
            0,
          ],
        ];
      },
    },
    visual_list_item: {
      content: 'visual_inline*',
      attrs: { id: {}, sourceFrom: {}, sourceTo: {}, bodyFrom: {}, bodyTo: {} },
      toDOM(node: PMNode) {
        return ['li', { class: 'interactive-visual-list-item', 'data-source-id': String(node.attrs.id) }, 0];
      },
    },
    visual_figure: {
      group: 'block',
      atom: true,
      selectable: true,
      attrs: {
        id: {}, sourceFrom: {}, sourceTo: {}, asset: { default: '' }, assetUrl: { default: '' }, caption: { default: '' }, label: { default: '' },
      },
      toDOM(node: PMNode) {
        const asset = String(node.attrs.asset || '');
        const url = String(node.attrs.assetUrl || '');
        const visual = url && /\.(?:png|svg)$/i.test(asset)
          ? ['img', { src: url, alt: String(node.attrs.caption || asset), class: 'interactive-visual-figure-image' }]
          : ['div', { class: 'interactive-visual-figure-placeholder' }, asset || 'Figure'];
        return [
          'figure',
          {
            class: 'interactive-visual-figure',
            contenteditable: 'false',
            'data-source-id': node.attrs.id,
            'data-visual-action': 'figure',
            role: 'button',
            tabindex: '0',
          },
          visual,
          ['figcaption', {}, String(node.attrs.caption || asset || 'Figure')],
          ...(node.attrs.label ? [['div', { class: 'interactive-visual-meta' }, `#${String(node.attrs.label)}`]] : []),
        ];
      },
    },
    visual_table: {
      group: 'block',
      content: 'visual_table_row+',
      isolating: true,
      attrs: { id: {}, sourceFrom: {}, sourceTo: {}, caption: { default: '' }, label: { default: '' }, columns: { default: '' }, editable: { default: false } },
      toDOM(node: PMNode) {
        return [
          'div',
          { class: 'interactive-visual-table-wrap', 'data-source-id': node.attrs.id },
          [
            'div',
            { class: 'interactive-visual-table-toolbar', contenteditable: 'false' },
            ['button', { type: 'button', class: 'interactive-visual-table-meta-button', 'data-visual-action': 'table' }, String(node.attrs.caption || 'Table')],
            ...(node.attrs.editable
              ? [[
                  'details',
                  { class: 'interactive-visual-table-actions' },
                  ['summary', { class: 'interactive-visual-table-actions-summary', title: 'Table actions', 'aria-label': 'Table actions' }, '⋮'],
                  [
                    'div',
                    { class: 'interactive-visual-table-actions-menu' },
                    ['button', { type: 'button', 'data-visual-table-command': 'add-row-above' }, 'Add row above'],
                    ['button', { type: 'button', 'data-visual-table-command': 'add-row-below' }, 'Add row below'],
                    ['button', { type: 'button', 'data-visual-table-command': 'add-column-left' }, 'Add column left'],
                    ['button', { type: 'button', 'data-visual-table-command': 'add-column-right' }, 'Add column right'],
                    ['hr'],
                    ['button', { type: 'button', 'data-visual-table-command': 'delete-row' }, 'Delete row'],
                    ['button', { type: 'button', 'data-visual-table-command': 'delete-column' }, 'Delete column'],
                    ['button', { type: 'button', 'data-visual-table-command': 'delete-table', class: 'interactive-visual-table-destructive' }, 'Delete table'],
                  ],
                ]]
              : []),
          ],
          ['table', { class: 'interactive-visual-table' }, ['tbody', {}, 0]],
          ...(node.attrs.label ? [['div', { class: 'interactive-visual-meta', contenteditable: 'false' }, `#${String(node.attrs.label)}`]] : []),
        ];
      },
    },
    visual_table_row: {
      // Keep the row schema generatable. visual_table_cell carries required
      // source-mapping attributes, so using `+` here makes ProseMirror reject
      // the entire schema at application startup because it cannot synthesize
      // a valid default cell. Parsed Learnitive tables still always provide their
      // concrete cells, and the editor's structural guard prevents cell
      // insertion/removal from drifting away from LaTeX source mappings.
      content: 'visual_table_cell*',
      toDOM() {
        return ['tr', {}, 0];
      },
    },
    visual_table_cell: {
      content: 'visual_inline*',
      attrs: { id: {}, sourceFrom: {}, sourceTo: {}, bodyFrom: {}, bodyTo: {}, row: { default: -1 }, column: { default: -1 } },
      toDOM(node: PMNode) {
        return [
          'td',
          {
            'data-source-id': node.attrs.id,
            'data-table-row': String(node.attrs.row ?? -1),
            'data-table-column': String(node.attrs.column ?? -1),
          },
          0,
        ];
      },
    },
    visual_theorem: {
      group: 'block',
      content: 'text*',
      attrs: { id: {}, sourceFrom: {}, sourceTo: {}, bodyFrom: {}, bodyTo: {}, environment: {}, title: { default: '' }, editable: { default: false } },
      toDOM(node: PMNode) {
        return [
          'section',
          { class: 'interactive-visual-theorem', 'data-source-id': node.attrs.id, ...(node.attrs.editable ? {} : { contenteditable: 'false' }) },
          ['div', { class: 'interactive-visual-theorem-title', contenteditable: 'false' }, String(node.attrs.title || node.attrs.environment)],
          ['div', { class: 'interactive-visual-theorem-body' }, 0],
        ];
      },
    },
    raw_latex: {
      group: 'block',
      atom: true,
      selectable: true,
      attrs: { id: {}, sourceFrom: {}, sourceTo: {}, raw: {}, reason: { default: '' } },
      toDOM(node: PMNode) {
        return [
          'div',
          { class: 'interactive-visual-raw', contenteditable: 'false', 'data-source-id': node.attrs.id },
          ['div', { class: 'interactive-visual-raw-label' }, 'Raw LaTeX'],
          ['pre', {}, String(node.attrs.raw)],
        ];
      },
    },
  },
  marks: {
    slash_command: suggestionsMark({
      markName: 'slash_command',
      className: 'text-pop',
      trigger: '/',
    }),
  },
});

function requiredNodeType(name: string) {
  const type = schema.nodes[name];
  if (!type) throw new Error(`Missing Learnitive visual schema node: ${name}`);
  return type;
}

function inlineToNode(inline: VisualInline): PMNode {
  if (inline.kind === 'text') {
    return requiredNodeType('visual_source_text').create(
      {
        id: inline.id,
        sourceFrom: inline.range.from,
        sourceTo: inline.range.to,
        bodyFrom: inline.bodyRange.from,
        bodyTo: inline.bodyRange.to,
        visualKind: 'text',
      },
      inline.text ? schema.text(inline.text) : undefined,
    );
  }
  if (inline.kind === 'styled-text') {
    return requiredNodeType('visual_source_text').create(
      {
        id: inline.id,
        sourceFrom: inline.range.from,
        sourceTo: inline.range.to,
        bodyFrom: inline.bodyRange.from,
        bodyTo: inline.bodyRange.to,
        visualKind: inline.style,
        inlineCommand: inline.command,
        actionData: inline.href ? { href: inline.href } : null,
        editable: true,
      },
      inline.text ? schema.text(inline.text) : undefined,
    );
  }
  if (inline.kind === 'raw') {
    return requiredNodeType('visual_source_text').create(
      {
        id: inline.id,
        sourceFrom: inline.range.from,
        sourceTo: inline.range.to,
        bodyFrom: inline.range.from,
        bodyTo: inline.range.to,
        visualKind: 'raw',
        editable: false,
      },
      inline.text ? schema.text(inline.text) : undefined,
    );
  }
  if (inline.kind === 'hard-break') {
    return requiredNodeType('visual_hard_break').create({
      id: inline.id,
      sourceFrom: inline.range.from,
      sourceTo: inline.range.to,
      sourceText: inline.sourceText,
    });
  }
  if (inline.kind === 'math') {
    return requiredNodeType('visual_math').create({
      id: inline.id,
      sourceFrom: inline.range.from,
      sourceTo: inline.range.to,
      bodyFrom: inline.bodyRange.from,
      bodyTo: inline.bodyRange.to,
      mathKind: inline.mathKind,
      value: inline.mathLiveValue,
      environment: inline.environment ?? '',
      editable: inline.editable,
    });
  }
  if (inline.kind === 'citation') {
    return requiredNodeType('visual_source_text').create(
      {
        id: inline.id,
        sourceFrom: inline.range.from,
        sourceTo: inline.range.to,
        bodyFrom: inline.range.from,
        bodyTo: inline.range.to,
        visualKind: 'citation',
        action: 'citation',
        editable: false,
      },
      schema.text(`[${inline.targets.join(', ')}]`),
    );
  }
  if (inline.kind === 'reference') {
    return requiredNodeType('visual_source_text').create(
      {
        id: inline.id,
        sourceFrom: inline.range.from,
        sourceTo: inline.range.to,
        bodyFrom: inline.range.from,
        bodyTo: inline.range.to,
        visualKind: 'reference',
        action: 'reference',
        editable: false,
      },
      schema.text(`↗ ${inline.target}`),
    );
  }
  return requiredNodeType('visual_source_text').create(
    {
      id: inline.id,
      sourceFrom: inline.range.from,
      sourceTo: inline.range.to,
      bodyFrom: inline.bodyRange.from,
      bodyTo: inline.bodyRange.to,
      visualKind: 'footnote',
      action: 'footnote',
      editable: false,
    },
    schema.text(`† ${inline.text}`),
  );
}

function blockToNode(block: VisualBlock, assetUrl?: string): PMNode {
  if (block.kind === 'raw') {
    return requiredNodeType('raw_latex').create({
      id: block.id,
      sourceFrom: block.range.from,
      sourceTo: block.range.to,
      raw: block.text,
      reason: block.reason ?? '',
    });
  }
  if (block.kind === 'text-environment') {
    return requiredNodeType('raw_latex').create({
      id: block.id,
      sourceFrom: block.range.from,
      sourceTo: block.range.to,
      raw: `${block.prefix}${block.text}${block.suffix}`,
      reason: `The ${block.environment} environment is editable in the native Visual editor and preserved here as source-only LaTeX.`,
    });
  }
  if (block.kind === 'rich-paragraph') {
    return requiredNodeType('visual_rich_paragraph').create(
      { id: block.id, sourceFrom: block.range.from, sourceTo: block.range.to },
      block.inlines.map(inlineToNode),
    );
  }
  if (block.kind === 'list') {
    if (!block.editable || block.items.length === 0) {
      return requiredNodeType('raw_latex').create({
        id: block.id,
        sourceFrom: block.range.from,
        sourceTo: block.range.to,
        raw: block.text,
        reason: 'This list contains nested or unsupported LaTeX and is preserved as source-only.',
      });
    }
    return requiredNodeType('visual_list').create(
      {
        id: block.id,
        sourceFrom: block.range.from,
        sourceTo: block.range.to,
        ordered: block.ordered,
        environment: block.environment,
      },
      block.items.map((item) =>
        requiredNodeType('visual_list_item').create(
          {
            id: item.id,
            sourceFrom: item.range.from,
            sourceTo: item.range.to,
            bodyFrom: item.bodyRange.from,
            bodyTo: item.bodyRange.to,
          },
          item.inlines.length > 0
            ? item.inlines.map(inlineToNode)
            : [
                requiredNodeType('visual_source_text').create({
                  id: `${item.id}:empty-text`,
                  sourceFrom: item.bodyRange.from,
                  sourceTo: item.bodyRange.to,
                  bodyFrom: item.bodyRange.from,
                  bodyTo: item.bodyRange.to,
                  visualKind: 'text',
                  editable: true,
                }),
              ],
        ),
      ),
    );
  }
  if (block.kind === 'figure') {
    return requiredNodeType('visual_figure').create({
      id: block.id,
      sourceFrom: block.range.from,
      sourceTo: block.range.to,
      asset: block.asset ?? '',
      assetUrl: assetUrl ?? '',
      caption: block.caption ?? '',
      label: block.label ?? '',
    });
  }
  if (block.kind === 'table') {
    const byRow = new Map<number, Array<NonNullable<typeof block.cells>[number]>>();
    for (const cell of block.cells ?? []) {
      const list = byRow.get(cell.row) ?? [];
      list.push(cell);
      byRow.set(cell.row, list);
    }
    const rows = [...byRow.entries()]
      .sort(([a], [b]) => a - b)
      .map(([, cells]) =>
        requiredNodeType('visual_table_row').create(
          undefined,
          cells
            .slice()
            .sort((a, b) => a.column - b.column)
            .map((cell) =>
              requiredNodeType('visual_table_cell').create(
                {
                  id: cell.id,
                  sourceFrom: cell.range.from,
                  sourceTo: cell.range.to,
                  bodyFrom: cell.bodyRange.from,
                  bodyTo: cell.bodyRange.to,
                  row: cell.row,
                  column: cell.column,
                },
                cell.inlines.map(inlineToNode),
              ),
            ),
        ),
      );
    if (rows.length === 0) {
      return requiredNodeType('raw_latex').create({
        id: block.id,
        sourceFrom: block.range.from,
        sourceTo: block.range.to,
        raw: block.text,
        reason: 'This table uses LaTeX constructs that are preserved as source-only in this visual release.',
      });
    }
    return requiredNodeType('visual_table').create(
      {
        id: block.id,
        sourceFrom: block.range.from,
        sourceTo: block.range.to,
        caption: block.caption ?? '',
        label: block.label ?? '',
        columns: block.columns ?? '',
        editable: block.editable,
      },
      rows,
    );
  }
  if (block.kind === 'theorem') {
    return requiredNodeType('visual_theorem').create(
      {
        id: block.id,
        sourceFrom: block.range.from,
        sourceTo: block.range.to,
        bodyFrom: block.bodyRange.from,
        bodyTo: block.bodyRange.to,
        environment: block.environment,
        title: block.title ?? '',
        editable: block.editable,
      },
      block.text ? schema.text(block.text) : undefined,
    );
  }

  const attrs = {
    id: block.id,
    sourceFrom: block.range.from,
    sourceTo: block.range.to,
    bodyFrom: block.bodyRange.from,
    bodyTo: block.bodyRange.to,
    ...(block.kind === 'heading' ? { level: block.level ?? 2 } : {}),
  };
  const type = requiredNodeType(block.kind === 'heading' ? 'visual_heading' : 'visual_paragraph');
  return type.create(attrs, block.text ? schema.text(block.text) : undefined);
}

export function createLatexVisualDocument(
  blocks: readonly VisualBlock[],
  assetUrls: ReadonlyMap<string, string> = new Map(),
): PMNode {
  return requiredNodeType('doc').create(
    undefined,
    blocks.map((block) => blockToNode(block, assetUrls.get(block.id))),
  );
}

function structuralSignature(doc: PMNode): string[] {
  const result: string[] = [];
  doc.descendants((node) => {
    if (node.isText) return true;
    if (
      (node.type.name === 'visual_paragraph' || node.type.name === 'visual_source_text') &&
      node.attrs.synthetic === true
    ) {
      return false;
    }
    result.push([
      node.type.name,
      String(node.attrs.id ?? ''),
      String(node.attrs.sourceFrom ?? ''),
      String(node.attrs.sourceTo ?? ''),
      String(node.attrs.bodyFrom ?? ''),
      String(node.attrs.bodyTo ?? ''),
    ].join(':'));
    return true;
  });
  return result;
}

function protectedVisualNodes(doc: PMNode): Map<string, PMNode> {
  const result = new Map<string, PMNode>();
  doc.descendants((node) => {
    if (node.isText) return true;
    const id = String(node.attrs.id ?? '');
    if (!id) return true;
    const protectedNode =
      node.type.name === 'raw_latex' ||
      node.type.name === 'visual_figure' ||
      node.type.name === 'visual_math' ||
      node.type.name === 'visual_hard_break' ||
      (node.type.name === 'visual_source_text' &&
        (node.attrs.editable === false || Boolean(node.attrs.action))) ||
      (node.type.name === 'visual_theorem' && node.attrs.editable === false);
    if (protectedNode) result.set(id, node);
    return true;
  });
  return result;
}

/**
 * R14.22: Visual typing is allowed whenever the transaction preserves the
 * source-mapped node graph and leaves protected/source-only nodes untouched.
 * This intentionally validates the transaction against the current document,
 * not the editor's initial snapshot, so ordinary ProseMirror text/composition
 * transactions are not rejected by a frozen whole-document signature.
 */
export function isLatexVisualTransactionSourceSafe(
  previous: PMNode,
  next: PMNode,
): boolean {
  const beforeStructure = structuralSignature(previous);
  const afterStructure = structuralSignature(next);
  if (beforeStructure.length !== afterStructure.length) return false;
  for (let index = 0; index < beforeStructure.length; index += 1) {
    if (beforeStructure[index] !== afterStructure[index]) return false;
  }

  const beforeProtected = protectedVisualNodes(previous);
  const afterProtected = protectedVisualNodes(next);
  if (beforeProtected.size !== afterProtected.size) return false;
  for (const [id, before] of beforeProtected) {
    const after = afterProtected.get(id);
    if (!after || !before.eq(after)) return false;
  }
  return true;
}

function sourceEditableNodes(doc: PMNode): Map<string, PMNode> {
  const result = new Map<string, PMNode>();
  doc.descendants((node) => {
    if (node.isText) return true;
    const id = String(node.attrs.id ?? '');
    if (!id || node.attrs.bodyFrom === undefined || node.attrs.bodyTo === undefined) return true;
    if (
      (node.type.name === 'visual_paragraph' || node.type.name === 'visual_source_text') &&
      node.attrs.synthetic === true
    ) return true;
    if (node.type.name === 'visual_theorem' && !node.attrs.editable) return true;
    if (node.type.name === 'visual_source_text' && !node.attrs.editable) return true;
    // List items/table cells contain source-mapped inline children over the
    // same body range. Mapping both parent and child produces duplicate canonical edits.
    if (node.type.name === 'visual_list_item' || node.type.name === 'visual_table_cell') return true;
    result.set(id, node);
    return true;
  });
  return result;
}

function sourceTextWithoutSyntheticSlash(node: PMNode): string {
  let text = '';
  node.descendants((child) => {
    if (!child.isText) return true;
    const syntheticSlash = child.marks.some(
      (mark) => mark.type.name === 'slash_command' && mark.attrs.synthetic === true,
    );
    if (!syntheticSlash) text += child.text ?? '';
    return false;
  });
  return text;
}

export function deriveVisualSourceEdits(previous: PMNode, next: PMNode): SourceTextEdit[] {
  const before = sourceEditableNodes(previous);
  const edits: SourceTextEdit[] = [];
  for (const [id, node] of sourceEditableNodes(next)) {
    const old = before.get(id);
    if (!old) continue;
    const oldText = sourceTextWithoutSyntheticSlash(old);
    const nextText = sourceTextWithoutSyntheticSlash(node);
    if (oldText === nextText) continue;
    edits.push({
      from: Number(node.attrs.bodyFrom),
      to: Number(node.attrs.bodyTo),
      text: nextText,
    });
  }
  return edits;
}

function actionForInline(inline: VisualInline): LatexVisualAction | undefined {
  if (inline.kind === 'citation') return { kind: 'citation', id: inline.id, from: inline.range.from, to: inline.range.to, command: inline.command, targets: inline.targets };
  if (inline.kind === 'reference') return { kind: 'reference', id: inline.id, from: inline.range.from, to: inline.range.to, command: inline.command, target: inline.target };
  if (inline.kind === 'footnote') return { kind: 'footnote', id: inline.id, from: inline.range.from, to: inline.range.to, bodyFrom: inline.bodyRange.from, bodyTo: inline.bodyRange.to, text: inline.text, editable: inline.editable };
  if (inline.kind === 'math') return { kind: 'math', id: inline.id, from: inline.range.from, to: inline.range.to, bodyFrom: inline.bodyRange.from, bodyTo: inline.bodyRange.to, value: inline.mathLiveValue, mathKind: inline.mathKind, ...(inline.environment ? { environment: inline.environment } : {}), editable: inline.editable };
  return undefined;
}

function actionForId(blocks: readonly VisualBlock[], id: string): LatexVisualAction | undefined {
  for (const block of blocks) {
    if (block.id === id && block.kind === 'figure') return { kind: 'figure', id };
    if (block.id === id && block.kind === 'table') return { kind: 'table', id };
    if (block.kind === 'table') {
      const cell = block.cells?.find((candidate) => candidate.id === id);
      if (cell) return { kind: 'table', id: block.id, row: cell.row, column: cell.column };
    }
    if (block.kind === 'rich-paragraph' || block.kind === 'text-environment') {
      const inline = block.inlines.find((candidate) => candidate.id === id);
      if (inline) return actionForInline(inline);
    }
    if (block.kind === 'list') {
      for (const item of block.items) {
        const inline = item.inlines.find((candidate) => candidate.id === id);
        if (inline) return actionForInline(inline);
      }
    }
  }
  return undefined;
}

export type LatexVisualSourceSelection = {
  id: string;
  bodyFrom: number;
  bodyTo: number;
  relativeFrom: number;
  relativeTo: number;
  selectedText: string;
  sourceFrom: number;
  sourceTo: number;
  visualKind?: string;
  actionData?: unknown;
  sourceCoordinates?: 'base' | 'current';
};

/**
 * Review annotations projected onto the live Visual PM document. Source
 * coordinates refer to the source snapshot currently represented by Visual
 * mode (canonical source when Track Changes is off, review projection when it
 * is on). Native BF4 maps these through ProseMirror transactions so comments
 * and pending changes remain attached while nearby text is edited.
 */
export type LatexVisualReviewDecoration = {
  id: string;
  from: number;
  to: number;
  status: 'open' | 'resolved' | 'outdated';
  kind: 'comment' | 'change';
  changeType?: 'insert' | 'delete' | 'replace';
  originalText?: string;
  proposedText?: string;
};

function sourceMappedSelection(state: EditorState): LatexVisualSourceSelection | undefined {
  const { from, to, empty } = state.selection;
  if (empty || from === to) return undefined;
  const $from = state.doc.resolve(from);
  const $to = state.doc.resolve(to);

  const mappedDepth = ($pos: typeof $from) => {
    for (let depth = $pos.depth; depth > 0; depth -= 1) {
      const node = $pos.node(depth);
      if (
        node.attrs.bodyFrom !== undefined &&
        node.attrs.bodyTo !== undefined &&
        node.attrs.editable !== false
      ) {
        return depth;
      }
    }
    return -1;
  };
  const fromDepth = mappedDepth($from);
  const toDepth = mappedDepth($to);
  if (fromDepth < 0 || toDepth < 0) return undefined;
  const fromNode = $from.node(fromDepth);
  const toNode = $to.node(toDepth);
  const id = String(fromNode.attrs.id ?? '');
  if (!id || id !== String(toNode.attrs.id ?? '')) return undefined;

  const relativeFrom = Math.max(0, from - $from.start(fromDepth));
  const relativeTo = Math.max(relativeFrom, to - $to.start(toDepth));
  return {
    id,
    bodyFrom: Number(fromNode.attrs.bodyFrom),
    bodyTo: Number(fromNode.attrs.bodyTo),
    relativeFrom,
    relativeTo,
    selectedText: state.doc.textBetween(from, to, '\n', '\n'),
    sourceFrom: Number(fromNode.attrs.sourceFrom ?? fromNode.attrs.bodyFrom),
    sourceTo: Number(fromNode.attrs.sourceTo ?? fromNode.attrs.bodyTo),
    visualKind: String(fromNode.attrs.visualKind ?? ''),
    actionData: fromNode.attrs.actionData ?? undefined,
  };
}

function topLevelSourceElement(view: EditorView, eventTarget?: EventTarget | null): HTMLElement | null {
  if (eventTarget instanceof Element) {
    let element: Element | null = eventTarget.closest('[data-source-id]');
    while (element?.parentElement && element.parentElement !== view.dom) {
      element = element.parentElement.closest('[data-source-id]');
    }
    if (element instanceof HTMLElement && element.parentElement === view.dom) return element;
  }
  const domAt = view.domAtPos(view.state.selection.from).node;
  let element = domAt instanceof Element ? domAt : domAt.parentElement;
  while (element && element.parentElement !== view.dom) element = element.parentElement;
  return element instanceof HTMLElement && element.dataset.sourceId ? element : null;
}

function visualBlockAtClientPoint(
  view: EditorView,
  host: HTMLElement,
  event: MouseEvent,
): HTMLElement | null {
  const direct = event.target instanceof Element
    ? topLevelSourceElement(view, event.target)
    : null;
  if (direct) return direct;

  // Match Learnitive's block-handle behavior: when the pointer is in the left
  // gutter, probe horizontally into the editor at the same Y coordinate.
  // This makes the + appear on hover without first selecting/clicking a block.
  const editorRect = view.dom.getBoundingClientRect();
  const hostRect = host.getBoundingClientRect();
  if (event.clientY < hostRect.top || event.clientY > hostRect.bottom) return null;
  const probeX = Math.min(editorRect.right - 4, editorRect.left + Math.min(36, Math.max(12, editorRect.width * 0.08)));
  const hit = document.elementFromPoint(probeX, event.clientY);
  return hit instanceof Element ? topLevelSourceElement(view, hit) : null;
}

function blockRangeById(blocks: readonly VisualBlock[], id: string) {
  return blocks.find((block) => block.id === id)?.range;
}

/**
 * Returns the source unit that should move when a Visual block is dragged.
 * Headings own their semantic subtree: everything until the next heading at
 * the same or a higher level. This keeps section content attached to the
 * heading instead of moving only the \section{...} command.
 */
export function visualMoveRangeForBlock(
  blocks: readonly VisualBlock[],
  id: string,
): { from: number; to: number } | undefined {
  const ordered = [...blocks].sort(
    (a, b) => a.range.from - b.range.from || a.range.to - b.range.to,
  );
  const index = ordered.findIndex((block) => block.id === id);
  if (index < 0) return undefined;
  const block = ordered[index]!;
  if (block.kind !== 'heading') return block.range;

  const level = block.level ?? 2;
  let to = block.range.to;
  for (let cursor = index + 1; cursor < ordered.length; cursor += 1) {
    const candidate = ordered[cursor]!;
    if (candidate.kind === 'heading' && (candidate.level ?? 2) <= level) {
      // Keep whitespace/comments immediately before the next same/higher
      // heading with that next unit, not with the unit being moved.
      to = candidate.range.from;
      break;
    }
    to = Math.max(to, candidate.range.to);
  }
  return { from: block.range.from, to };
}

function rectData(rect: DOMRect) {
  return { left: rect.left, top: rect.top, bottom: rect.bottom, right: rect.right };
}

export type LatexVisualSourceCursor = {
  offset: number;
  id: string;
  nodeType: string;
  visualKind?: string;
  sourceCoordinates?: 'base' | 'current';
};

function mappedVisualSourcePoint(
  state: EditorState,
  position: number,
): LatexVisualSourceCursor | undefined {
  const $pos = state.doc.resolve(position);
  for (let depth = $pos.depth; depth > 0; depth -= 1) {
    const node = $pos.node(depth);
    const bodyFrom = Number(node.attrs.bodyFrom);
    const bodyTo = Number(node.attrs.bodyTo);
    if (
      !Number.isFinite(bodyFrom) ||
      !Number.isFinite(bodyTo) ||
      node.attrs.editable === false
    ) {
      continue;
    }
    const relative = Math.max(0, position - $pos.start(depth));
    const sourceLength = Math.max(0, bodyTo - bodyFrom);
    return {
      offset: bodyFrom + Math.min(relative, sourceLength),
      id: String(node.attrs.id ?? ''),
      nodeType: node.type.name,
      ...(node.attrs.visualKind
        ? { visualKind: String(node.attrs.visualKind) }
        : {}),
    };
  }
  return undefined;
}

function visualPositionForSourceOffset(
  doc: PMNode,
  offset: number,
): number | undefined {
  let best:
    | { position: number; priority: number; boundary: number; sourceSpan: number }
    | undefined;
  doc.descendants((node, pos) => {
    if (node.isText) return true;
    const from = Number(node.attrs.bodyFrom);
    const to = Number(node.attrs.bodyTo);
    if (
      !Number.isFinite(from) ||
      !Number.isFinite(to) ||
      offset < from ||
      offset > to ||
      node.attrs.editable === false
    ) {
      return true;
    }

    // Several structural nodes intentionally share a source body range with
    // their editable inline child (notably list items and table cells). The
    // old first-match mapping stopped on the parent, so an Enter-created empty
    // list item could receive a structural caret that was not a typing target.
    // Prefer the most specific editable text host while still retaining block
    // fallbacks for plain paragraphs and genuinely childless structures.
    const priority =
      node.type.name === 'visual_source_text'
        ? 4
        : node.type.name === 'visual_paragraph'
          ? 3
          : node.type.name === 'visual_list_item' ||
              node.type.name === 'visual_table_cell'
            ? 1
            : 2;
    const boundary = offset === from ? 2 : offset === to ? 0 : 1;
    const sourceSpan = Math.max(0, to - from);
    const relative = Math.max(0, Math.min(node.content.size, offset - from));
    const candidate = {
      position: pos + 1 + relative,
      priority,
      boundary,
      sourceSpan,
    };
    if (
      !best ||
      candidate.priority > best.priority ||
      (candidate.priority === best.priority && candidate.boundary > best.boundary) ||
      (candidate.priority === best.priority &&
        candidate.boundary === best.boundary &&
        candidate.sourceSpan < best.sourceSpan)
    ) {
      best = candidate;
    }
    return true;
  });
  return best?.position;
}

function withVisualAssetUrls(
  node: PMNode,
  assetUrls: ReadonlyMap<string, string>,
): PMNode {
  const isFigure = node.type.name === 'visual_figure';
  const id = isFigure ? String(node.attrs.id ?? '') : '';
  const nextAssetUrl = isFigure ? (assetUrls.get(id) ?? '') : undefined;
  const attrsChanged = isFigure && String(node.attrs.assetUrl ?? '') !== nextAssetUrl;
  const nextAttrs = attrsChanged ? { ...node.attrs, assetUrl: nextAssetUrl } : node.attrs;

  if (node.isText) return node;
  if (node.childCount === 0) {
    return attrsChanged ? node.type.create(nextAttrs, undefined, node.marks) : node;
  }

  const children: PMNode[] = [];
  let childChanged = false;
  node.forEach((child) => {
    const nextChild = withVisualAssetUrls(child, assetUrls);
    children.push(nextChild);
    childChanged ||= nextChild !== child;
  });
  if (!attrsChanged && !childChanged) return node;
  return node.type.create(nextAttrs, children, node.marks);
}

function ancestorAtPosition(
  state: EditorState,
  position: number,
  names: readonly string[],
): { node: PMNode; depth: number } | undefined {
  const $pos = state.doc.resolve(position);
  for (let depth = $pos.depth; depth > 0; depth -= 1) {
    const node = $pos.node(depth);
    if (names.includes(node.type.name)) return { node, depth };
  }
  return undefined;
}

function isPlainStructuralPoint(point: LatexVisualSourceCursor): boolean {
  return (
    point.nodeType === 'visual_paragraph' ||
    point.nodeType === 'visual_list_item' ||
    point.nodeType === 'visual_table_cell' ||
    (point.nodeType === 'visual_source_text' &&
      (!point.visualKind ||
        point.visualKind === 'text' ||
        point.visualKind === 'bold' ||
        point.visualKind === 'italic' ||
        point.visualKind === 'underline' ||
        point.visualKind === 'monospace' ||
        point.visualKind === 'link'))
  );
}

function inlineSourceContext(
  state: EditorState,
  from: number,
  to: number,
): LatexVisualInlineSourceContext | undefined {
  const inline = sameAncestorId(state, from, to, ['visual_source_text']);
  if (!inline || inline.node.attrs.editable === false) return undefined;
  const sourceFrom = Number(inline.node.attrs.sourceFrom);
  const sourceTo = Number(inline.node.attrs.sourceTo);
  const bodyFrom = Number(inline.node.attrs.bodyFrom);
  const bodyTo = Number(inline.node.attrs.bodyTo);
  if (
    !Number.isFinite(sourceFrom) ||
    !Number.isFinite(sourceTo) ||
    !Number.isFinite(bodyFrom) ||
    !Number.isFinite(bodyTo)
  ) {
    return undefined;
  }
  return {
    id: String(inline.node.attrs.id ?? ''),
    sourceFrom,
    sourceTo,
    bodyFrom,
    bodyTo,
    visualKind: String(inline.node.attrs.visualKind ?? 'text'),
    ...(inline.node.attrs.inlineCommand
      ? { inlineCommand: String(inline.node.attrs.inlineCommand) }
      : {}),
  };
}

function editableStructuralHost(
  state: EditorState,
  from: number,
  to: number,
) {
  return (
    sameAncestorId(state, from, to, ['visual_list_item']) ??
    sameAncestorId(state, from, to, ['visual_table_cell']) ??
    sameAncestorId(state, from, to, ['visual_rich_paragraph']) ??
    sameAncestorId(state, from, to, ['visual_paragraph'])
  );
}

function hardBreakAtSourceBoundary(
  state: EditorState,
  sourceOffset: number,
  direction: 'before' | 'after',
): PMNode | undefined {
  let match: PMNode | undefined;
  state.doc.descendants((node) => {
    if (match || node.type.name !== 'visual_hard_break') return !match;
    const sourceFrom = Number(node.attrs.sourceFrom);
    const sourceTo = Number(node.attrs.sourceTo);
    if (!Number.isFinite(sourceFrom) || !Number.isFinite(sourceTo)) return true;
    if (
      (direction === 'before' && sourceTo === sourceOffset) ||
      (direction === 'after' && sourceFrom === sourceOffset)
    ) {
      match = node;
      return false;
    }
    return true;
  });
  return match;
}

function isAtHostEnd(
  state: EditorState,
  position: number,
  host: { node: PMNode; depth: number } | undefined,
): boolean {
  if (!host) return false;
  const $pos = state.doc.resolve(position);
  if ($pos.depth < host.depth) return false;
  return position >= $pos.start(host.depth) + host.node.content.size;
}

function isAtHostStart(
  state: EditorState,
  position: number,
  host: { node: PMNode; depth: number } | undefined,
): boolean {
  if (!host) return false;
  const $pos = state.doc.resolve(position);
  if ($pos.depth < host.depth) return false;
  return position <= $pos.start(host.depth);
}

function tableColumnKinds(spec: string): string[] {
  const result: string[] = [];

  const skipBalancedGroup = (start: number): number => {
    if (spec[start] !== '{') return start;
    let depth = 0;
    for (let index = start; index < spec.length; index += 1) {
      const char = spec[index];
      if (char === '{' && spec[index - 1] !== '\\') depth += 1;
      if (char === '}' && spec[index - 1] !== '\\') {
        depth -= 1;
        if (depth === 0) return index;
      }
    }
    return spec.length - 1;
  };

  for (let index = 0; index < spec.length; index += 1) {
    const char = spec[index] ?? '';
    if (char === 'l' || char === 'c' || char === 'r' || char === 'X') {
      result.push(char);
      continue;
    }
    if (char === 'p' || char === 'm' || char === 'b') {
      let groupStart = index + 1;
      while (/\s/u.test(spec[groupStart] ?? '')) groupStart += 1;
      if (spec[groupStart] === '{') {
        result.push(char);
        index = skipBalancedGroup(groupStart);
      }
      continue;
    }
    // Column modifiers carry braced payloads whose contents may contain
    // letters such as `l` in `\linewidth`. Skip the payload completely so it
    // cannot be mistaken for another table column.
    if (char === '@' || char === '>' || char === '<' || char === '!') {
      let groupStart = index + 1;
      while (/\s/u.test(spec[groupStart] ?? '')) groupStart += 1;
      if (spec[groupStart] === '{') index = skipBalancedGroup(groupStart);
    }
  }
  return result;
}

function tableCellSupportsHardBreak(table: PMNode, cell: PMNode): boolean {
  const column = Number(cell.attrs.column);
  if (!Number.isFinite(column) || column < 0) return false;
  const kind = tableColumnKinds(String(table.attrs.columns ?? ''))[column];
  return kind === 'p' || kind === 'm' || kind === 'b';
}

function orderedTableCells(
  state: EditorState,
  table: { node: PMNode; depth: number },
  selection: TextSelection,
): Array<{ pos: number; node: PMNode }> {
  const tablePos = selection.$from.before(table.depth);
  const tableEnd = tablePos + table.node.nodeSize;
  const cells: Array<{ pos: number; node: PMNode }> = [];
  state.doc.descendants((node, pos) => {
    if (pos <= tablePos || pos >= tableEnd) return true;
    if (node.type.name === 'visual_table_cell') cells.push({ pos: pos + 1, node });
    return true;
  });
  cells.sort((left, right) => {
    const rowDelta = Number(left.node.attrs.row) - Number(right.node.attrs.row);
    return rowDelta || Number(left.node.attrs.column) - Number(right.node.attrs.column);
  });
  return cells;
}

function moveWithinTable(
  view: EditorView,
  selection: TextSelection,
  table: { node: PMNode; depth: number },
  cell: { node: PMNode; depth: number },
  direction: 'previous' | 'next',
): { moved: boolean; atBoundary: boolean } {
  const cells = orderedTableCells(view.state, table, selection);
  const currentId = String(cell.node.attrs.id ?? '');
  const index = cells.findIndex((candidate) => String(candidate.node.attrs.id ?? '') === currentId);
  if (index < 0) return { moved: false, atBoundary: false };
  const target = cells[index + (direction === 'previous' ? -1 : 1)];
  if (!target) return { moved: false, atBoundary: true };
  view.dispatch(
    view.state.tr
      .setSelection(TextSelection.create(view.state.doc, target.pos))
      .scrollIntoView(),
  );
  return { moved: true, atBoundary: false };
}

function sameAncestorId(
  state: EditorState,
  from: number,
  to: number,
  names: readonly string[],
): { node: PMNode; depth: number } | undefined {
  const left = ancestorAtPosition(state, from, names);
  const right = ancestorAtPosition(state, to, names);
  if (!left || !right) return undefined;
  const leftId = String(left.node.attrs.id ?? '');
  const rightId = String(right.node.attrs.id ?? '');
  if (!leftId || leftId !== rightId) return undefined;
  return left;
}

function createSelectionToolbar(
  owner: HTMLElement,
  onFormat: (format: 'bold' | 'italic' | 'underline' | 'monospace' | 'link') => void,
  onAiSuggest: () => void,
) {
  const toolbar = document.createElement('div');
  toolbar.className = 'interactive-visual-selection-toolbar hidden';
  toolbar.setAttribute('role', 'toolbar');
  toolbar.setAttribute('aria-label', 'Text formatting');
  const title = (id: string, fallback: string) =>
    getLatexEditorCommandDefinition(id)?.label ?? fallback;
  const items: Array<[string, string, 'bold' | 'italic' | 'underline' | 'monospace' | 'link']> = [
    ['B', title('bold', 'Bold'), 'bold'],
    ['I', title('italic', 'Italic'), 'italic'],
    ['U', title('underline', 'Underline'), 'underline'],
    ['</>', title('typewriter', 'Typewriter'), 'monospace'],
    ['↗', title('hyperlink', 'Hyperlink'), 'link'],
  ];
  for (const [label, title, format] of items) {
    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', () => onFormat(format));
    toolbar.appendChild(button);
  }
  const aiButton = document.createElement('button');
  aiButton.type = 'button';
  aiButton.className = 'interactive-visual-selection-button interactive-ai-selection-button';
  aiButton.textContent = 'AI';
  aiButton.title = 'AI Suggest';
  aiButton.setAttribute('aria-label', 'AI Suggest');
  aiButton.addEventListener('mousedown', (event) => event.preventDefault());
  aiButton.addEventListener('click', onAiSuggest);
  toolbar.appendChild(aiButton);
  owner.appendChild(toolbar);
  return toolbar;
}

const VISUAL_INTERNAL_ASSET_REFRESH = 'learnitiveVisualInternalAssetRefresh';

export function createLegacyLatexVisualEditor(options: {
  host: HTMLElement;
  blocks: readonly VisualBlock[];
  assetUrls?: ReadonlyMap<string, string>;
  onEdits: (edits: SourceTextEdit[]) => 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;
}): {
  native: false;
  view: EditorView;
  destroy: () => void;
  replaceProjection: (
    blocks: readonly VisualBlock[],
    assetUrls?: ReadonlyMap<string, string>,
    selection?: { anchorSource: number; headSource?: number; focus?: boolean },
  ) => boolean;
  updateAssetUrls: (assetUrls: ReadonlyMap<string, string>) => void;
  revealSourceOffset: (offset: number) => void;
  focusSourceOffset: (offset: number) => boolean;
  insertSyntheticParagraphAtSourceOffset: (offset: number) => void;
  insertSyntheticInlineAtSourceOffset: (offset: number) => void;
  updateMathValue: (id: string, value: string) => void;
  /** BF4.4: execute a safely-supported Visual slash command at the live PM caret. */
  executeNativeSlashCommand: (command: string) => boolean;
  insertNativeTableAtSlash: (rows: number, columns: number) => boolean;
  dismissSlashSuggestion: () => void;
} {
  let sourceBaselineDoc = createLatexVisualDocument(options.blocks, options.assetUrls);
  let view: EditorView;
  let syntheticParagraphCounter = 0;
  const guard = new Plugin({
    filterTransaction(transaction: Transaction, state: EditorState) {
      if (!transaction.docChanged) return true;
      if (transaction.getMeta(VISUAL_INTERNAL_ASSET_REFRESH)) return true;
      return isLatexVisualTransactionSourceSafe(state.doc, transaction.doc);
    },
  });
  const slashSuggestionPlugin = pluginSuggestion({
    markName: 'slash_command',
    trigger: '/',
    endOnWhitespace: false,
  });
  const state = EditorState.create({
    doc: sourceBaselineDoc,
    schema,
    plugins: [storePlugin(), guard, suggestionKeymap(), slashSuggestionPlugin],
  });
  let updateSelectionToolbar = () => {};
  let syncSlashSuggestion = () => {};
  view = new EditorView(options.host, {
    state,
    nodeViews: {
      visual_math(node: PMNode) {
        const display = node.attrs.mathKind === 'display';
        const dom = document.createElement('span');
        dom.className = [
          'interactive-visual-math',
          display ? 'interactive-visual-math-display' : 'interactive-visual-math-inline',
          node.attrs.editable
            ? 'interactive-visual-math-editable'
            : 'interactive-visual-math-protected',
        ].join(' ');
        dom.dataset.sourceId = String(node.attrs.id);
        dom.dataset.visualMath = 'true';
        dom.dataset.visualAction = 'math';
        dom.dataset.mathValue = String(node.attrs.value || '');
        dom.dataset.mathEditable = node.attrs.editable ? 'true' : 'false';
        dom.contentEditable = 'false';
        dom.setAttribute('role', 'button');
        dom.setAttribute('tabindex', '0');
        dom.setAttribute(
          'aria-label',
          node.attrs.editable ? 'Edit equation' : 'Source-only equation',
        );
        let field: HTMLElement | undefined;
        try {
          const mathField = createLearnitiveMathField({
            parent: dom,
            value: String(node.attrs.value || ''),
            readOnly: true,
            ariaLabel: display ? 'Display equation preview' : 'Inline equation preview',
            className: display
              ? 'interactive-visual-math-field interactive-visual-math-field-display'
              : 'interactive-visual-math-field interactive-visual-math-field-inline',
          });
          mathField.style.pointerEvents = 'none';
          mathField.tabIndex = -1;
          field = mathField;
        } catch (reason) {
          // R14.14: MathLive is an enhancement, never a Visual-mode fatal
          // dependency. Preserve the canonical equation as a source-backed
          // fallback chip when the custom element cannot initialize.
          console.error('Visual MathLive preview unavailable', reason);
          const fallback = document.createElement('code');
          fallback.className = 'interactive-visual-math-fallback';
          fallback.textContent = `$${String(node.attrs.value || '')}$`;
          fallback.title = 'Equation preview unavailable; click to edit or use Source';
          dom.appendChild(fallback);
          field = fallback;
        }
        if (!node.attrs.editable) {
          dom.title = 'This equation contains structural LaTeX and must be edited in Source mode.';
        } else {
          dom.title = 'Edit equation';
        }
        return {
          dom,
          stopEvent(event: Event) {
            return Boolean(field) && (event.target === field || field.contains(event.target as Node));
          },
          ignoreMutation() {
            return true;
          },
          destroy() {
            field?.remove();
          },
        };
      },
    },
    editable: () => true,
    // R14.18: Learnitive's suggestion plugin can miss the native `/` text-input
    // edge on some browsers/IME paths. Insert the same suggestion mark that
    // the left + path uses, so typed slash and synthetic slash share one
    // deterministic lifecycle without owning a separate Visual command model.
    handleTextInput(currentView, from, to, text) {
      const syntheticInline = ancestorAtPosition(
        currentView.state,
        from,
        ['visual_source_text'],
      );
      const syntheticParagraph = ancestorAtPosition(
        currentView.state,
        from,
        ['visual_paragraph'],
      );
      const synthetic = syntheticInline?.node.attrs.synthetic === true
        ? syntheticInline
        : syntheticParagraph?.node.attrs.synthetic === true
          ? syntheticParagraph
          : undefined;
      if (synthetic) {
        options.onAction?.({
          kind: 'materialize-text',
          id: String(synthetic.node.attrs.id),
          sourceAt: Number(synthetic.node.attrs.bodyFrom),
          text,
        });
        return true;
      }

      if (text !== '/' || from !== to) return false;
      const selection = currentView.state.selection;
      if (!selection.empty) return false;
      const fromPoint = mappedVisualSourcePoint(currentView.state, from);
      const toPoint = mappedVisualSourcePoint(currentView.state, to);
      if (
        !fromPoint ||
        !toPoint ||
        !isPlainStructuralPoint(fromPoint) ||
        !isPlainStructuralPoint(toPoint) ||
        !editableStructuralHost(currentView.state, from, to)
      ) {
        return false;
      }
      const mark = schema.marks.slash_command?.create({
        trigger: '/',
        synthetic: false,
      });
      if (!mark) return false;
      const inherited = selection.$from.marks().filter(
        (candidate) => candidate.type !== mark.type,
      );
      currentView.dispatch(
        currentView.state.tr
          .replaceWith(from, to, schema.text('/', [mark, ...inherited]))
          .addStoredMark(mark),
      );
      return true;
    },
    handlePaste(currentView, event) {
      const clipboard = event.clipboardData?.getData('text/plain');
      if (!clipboard || !/[\r\n]/.test(clipboard)) return false;
      const selection = currentView.state.selection;
      if (!(selection instanceof TextSelection)) return false;
      const fromPoint = mappedVisualSourcePoint(currentView.state, selection.from);
      const toPoint = mappedVisualSourcePoint(currentView.state, selection.to);
      if (
        !fromPoint ||
        !toPoint ||
        fromPoint.id !== toPoint.id ||
        !isPlainStructuralPoint(fromPoint) ||
        !isPlainStructuralPoint(toPoint)
      ) {
        return false;
      }
      // ProseMirror's default multi-line paste creates new PM block nodes.
      // Those nodes have no canonical LaTeX source mapping and are correctly
      // rejected by the structural guard, which used to make paste look broken.
      // Keep the paste inside the existing source-backed unit; the next semantic
      // refresh may split the resulting LaTeX lines into their natural blocks.
      currentView.dispatch(
        currentView.state.tr.insertText(
          clipboard.replace(/\r\n?/g, '\n'),
          selection.from,
          selection.to,
        ),
      );
      return true;
    },
    handleKeyDown(currentView, event) {
      const selection = currentView.state.selection;
      if (!(selection instanceof TextSelection)) return false;

      if (
        event.key === 'Tab' &&
        !event.isComposing &&
        !event.metaKey &&
        !event.ctrlKey &&
        !event.altKey
      ) {
        const cell = ancestorAtPosition(currentView.state, selection.from, ['visual_table_cell']);
        const table = ancestorAtPosition(currentView.state, selection.from, ['visual_table']);
        if (cell && table) {
          const movement = moveWithinTable(
            currentView,
            selection,
            table,
            cell,
            event.shiftKey ? 'previous' : 'next',
          );
          if (movement.moved) return true;
          if (!event.shiftKey && movement.atBoundary) {
            options.onAction?.({
              kind: 'table-structure',
              id: String(table.node.attrs.id ?? ''),
              operation: 'add-row-below',
              row: Number(cell.node.attrs.row),
              column: 0,
            });
            return true;
          }
          // Shift+Tab in the first cell stays inside the table instead of
          // moving browser focus to unrelated chrome.
          if (event.shiftKey && movement.atBoundary) return true;
        }
      }

      if (
        (event.key === 'ArrowDown' || event.key === 'ArrowUp') &&
        !event.isComposing &&
        !event.metaKey &&
        !event.ctrlKey &&
        !event.altKey &&
        !event.shiftKey &&
        selection.empty
      ) {
        const cell = ancestorAtPosition(currentView.state, selection.from, ['visual_table_cell']);
        const table = ancestorAtPosition(currentView.state, selection.from, ['visual_table']);
        if (cell && table) {
          const cells = orderedTableCells(currentView.state, table, selection);
          const rows = cells.map((candidate) => Number(candidate.node.attrs.row)).filter(Number.isFinite);
          const maxRow = rows.length > 0 ? Math.max(...rows) : -1;
          const row = Number(cell.node.attrs.row);
          if (
            event.key === 'ArrowDown' &&
            row === maxRow &&
            isAtHostEnd(currentView.state, selection.to, cell)
          ) {
            options.onAction?.({
              kind: 'table-boundary',
              id: String(table.node.attrs.id ?? ''),
              direction: 'after',
            });
            return true;
          }
          if (
            event.key === 'ArrowUp' &&
            row === 0 &&
            isAtHostStart(currentView.state, selection.from, cell)
          ) {
            options.onAction?.({
              kind: 'table-boundary',
              id: String(table.node.attrs.id ?? ''),
              direction: 'before',
            });
            return true;
          }
        }
      }

      if (
        (event.key === 'Backspace' || event.key === 'Delete') &&
        !event.isComposing &&
        !event.metaKey &&
        !event.ctrlKey &&
        !event.altKey &&
        !event.shiftKey &&
        !selection.empty
      ) {
        const list = sameAncestorId(
          currentView.state,
          selection.from,
          selection.to,
          ['visual_list'],
        );
        if (list) {
          const normalizeListSelection = (value: string) => value.replace(/\s+/gu, '');
          const selectedText = normalizeListSelection(currentView.state.doc.textBetween(
            selection.from,
            selection.to,
            '\n',
            '\n',
          ));
          const listText = normalizeListSelection(list.node.textContent);
          if (listText && selectedText === listText) {
            options.onAction?.({
              kind: 'list-structure',
              id: String(list.node.attrs.id ?? ''),
              operation: 'delete-list',
            });
            return true;
          }
        }
      }

      if (
        (event.key === 'Backspace' || event.key === 'Delete') &&
        !event.isComposing &&
        !event.metaKey &&
        !event.ctrlKey &&
        !event.altKey &&
        !event.shiftKey &&
        selection.empty
      ) {
        const listItem = ancestorAtPosition(currentView.state, selection.from, ['visual_list_item']);
        const list = ancestorAtPosition(currentView.state, selection.from, ['visual_list']);
        if (
          event.key === 'Backspace' &&
          listItem &&
          list &&
          list.node.childCount === 1 &&
          listItem.node.textContent.trim().length === 0
        ) {
          options.onAction?.({
            kind: 'list-structure',
            id: String(list.node.attrs.id ?? ''),
            operation: 'delete-list',
            itemId: String(listItem.node.attrs.id ?? ''),
          });
          return true;
        }

        const sourcePoint = mappedVisualSourcePoint(currentView.state, selection.from);
        const immediate = event.key === 'Backspace'
          ? selection.$from.nodeBefore
          : selection.$from.nodeAfter;
        const adjacent = immediate?.type.name === 'visual_hard_break'
          ? immediate
          : sourcePoint
            ? hardBreakAtSourceBoundary(
                currentView.state,
                sourcePoint.offset,
                event.key === 'Backspace' ? 'before' : 'after',
              )
            : undefined;
        if (adjacent) {
          options.onAction?.({
            kind: 'delete-hard-break',
            id: String(adjacent.attrs.id ?? ''),
            sourceFrom: Number(adjacent.attrs.sourceFrom),
            sourceTo: Number(adjacent.attrs.sourceTo),
          });
          return true;
        }
      }

      if (
        event.key !== 'Enter' ||
        event.isComposing ||
        event.metaKey ||
        event.ctrlKey ||
        event.altKey
      ) {
        return false;
      }
      const fromPoint = mappedVisualSourcePoint(currentView.state, selection.from);
      const toPoint = mappedVisualSourcePoint(currentView.state, selection.to);
      if (!fromPoint || !toPoint || !isPlainStructuralPoint(fromPoint) || !isPlainStructuralPoint(toPoint)) {
        return false;
      }

      const inlineContext = inlineSourceContext(
        currentView.state,
        selection.from,
        selection.to,
      );
      // A selection spanning multiple independently mapped rich inlines cannot
      // be structurally replaced without also rewriting wrapper syntax. Keep
      // that case source-only rather than risking an unbalanced command.
      if (
        !selection.empty &&
        (fromPoint.nodeType === 'visual_source_text' || toPoint.nodeType === 'visual_source_text') &&
        !inlineContext
      ) {
        return false;
      }

      const listItem = sameAncestorId(
        currentView.state,
        selection.from,
        selection.to,
        ['visual_list_item'],
      );
      const tableCell = sameAncestorId(
        currentView.state,
        selection.from,
        selection.to,
        ['visual_table_cell'],
      );
      const richParagraph = sameAncestorId(
        currentView.state,
        selection.from,
        selection.to,
        ['visual_rich_paragraph'],
      );
      const heading = sameAncestorId(
        currentView.state,
        selection.from,
        selection.to,
        ['visual_heading'],
      );
      const paragraph = sameAncestorId(
        currentView.state,
        selection.from,
        selection.to,
        ['visual_paragraph'],
      );
      const structuralHost = listItem ?? tableCell ?? richParagraph ?? paragraph;

      if (tableCell) {
        const table = ancestorAtPosition(currentView.state, selection.from, ['visual_table']);
        if (!table) return false;
        if (event.shiftKey && tableCellSupportsHardBreak(table.node, tableCell.node)) {
          options.onAction?.({
            kind: 'hard-break',
            id: String(tableCell.node.attrs.id ?? fromPoint.id),
            sourceFrom: fromPoint.offset,
            sourceTo: toPoint.offset,
            atEnd: isAtHostEnd(currentView.state, selection.to, tableCell),
            ...(inlineContext ? { inlineContext } : {}),
          });
          return true;
        }
        // Simple l/c/r cells cannot safely contain arbitrary paragraph breaks.
        // Enter (and Shift+Enter in those simple cells) therefore navigates to
        // the next cell. At the final cell Enter leaves the table; Tab retains
        // its spreadsheet-style "add row" behavior.
        const movement = moveWithinTable(currentView, selection, table, tableCell, 'next');
        if (movement.moved) return true;
        if (movement.atBoundary) {
          options.onAction?.({
            kind: 'table-boundary',
            id: String(table.node.attrs.id ?? ''),
            direction: 'after',
          });
          return true;
        }
        return true;
      }

      if (event.shiftKey) {
        if (!structuralHost) return false;
        options.onAction?.({
          kind: 'hard-break',
          id: String(structuralHost.node.attrs.id ?? fromPoint.id),
          sourceFrom: fromPoint.offset,
          sourceTo: toPoint.offset,
          atEnd: isAtHostEnd(currentView.state, selection.to, structuralHost),
          ...(inlineContext ? { inlineContext } : {}),
        });
        return true;
      }

      if (heading) {
        options.onAction?.({
          kind: 'heading-exit',
          id: String(heading.node.attrs.id),
          headingTo: Number(heading.node.attrs.sourceTo),
        });
        return true;
      }

      if (listItem) {
        const item = listItem.node;
        if (selection.empty && item.textContent.trim().length === 0) {
          const list = ancestorAtPosition(
            currentView.state,
            selection.from,
            ['visual_list'],
          );
          if (!list) return false;
          options.onAction?.({
            kind: 'list-exit',
            id: String(item.attrs.id),
            itemFrom: Number(item.attrs.sourceFrom),
            itemTo: Number(item.attrs.sourceTo),
            listTo: Number(list.node.attrs.sourceTo),
          });
          return true;
        }
        options.onAction?.({
          kind: 'list-item-break',
          id: String(item.attrs.id),
          sourceFrom: fromPoint.offset,
          sourceTo: toPoint.offset,
          itemTo: Number(item.attrs.sourceTo),
          atEnd: toPoint.offset >= Number(item.attrs.bodyTo),
          ...(inlineContext ? { inlineContext } : {}),
        });
        return true;
      }

      if (paragraph || richParagraph) {
        const paragraphHost = richParagraph ?? paragraph;
        if (!paragraphHost) return false;
        // Synthetic paragraphs represent a real canonical insertion point
        // (for example immediately after \newline{}). Enter must materialize
        // a canonical paragraph break there; swallowing the key left the
        // caret trapped in an empty visual-only paragraph.
        options.onAction?.({
          kind: 'paragraph-break',
          id: String(paragraphHost.node.attrs.id),
          sourceFrom: fromPoint.offset,
          sourceTo: toPoint.offset,
          atEnd: isAtHostEnd(currentView.state, selection.to, paragraphHost),
          ...(inlineContext ? { inlineContext } : {}),
        });
        return true;
      }
      return false;
    },
    dispatchTransaction(transaction: Transaction) {
      const result = view.state.applyTransaction(transaction);
      const next = result.state;
      const internalAssetRefresh = Boolean(
        transaction.getMeta(VISUAL_INTERNAL_ASSET_REFRESH),
      );
      const edits = internalAssetRefresh
        ? []
        : deriveVisualSourceEdits(sourceBaselineDoc, next.doc);
      // Update ProseMirror first. Source synchronization can synchronously
      // publish React/store updates; calling it before updateState can cause a
      // re-entrant render to leave the DOM on the old state and make Visual
      // mode appear read-only even though EditorView.editable is true.
      view.updateState(next);
      if (edits.length > 0) options.onEdits(edits);
      window.requestAnimationFrame(updateSelectionToolbar);
      window.requestAnimationFrame(syncSlashSuggestion);
    },
    attributes: {
      class: 'interactive-latex-visual-editor',
      spellcheck: 'true',
      tabindex: '0',
    },
  });
  // Be defensive against browser/extension code that mutates contenteditable
  // between creation and first focus. ProseMirror still remains the owner of
  // subsequent editable state updates.
  view.dom.contentEditable = 'true';
  view.dom.setAttribute('aria-readonly', 'false');
  const ensureEditableFocus = (event: PointerEvent) => {
    const target = event.target;
    if (!(target instanceof Element)) return;
    if (target.closest('[contenteditable="false"],button,input,select,textarea,[role="button"]')) return;
    // Some browser/React remount paths leave focus on the scroll host rather
    // than ProseMirror. Put it back on the actual editing surface before the
    // native selection/input event sequence continues.
    view.dom.contentEditable = 'true';
    view.focus();
  };
  options.host.addEventListener('pointerdown', ensureEditableFocus);

  // R14.11: reuse the original Learnitive block-handle builder so Visual mode has
  // the same left-side "+" and grip interaction without giving the Markdown
  // editor ownership of LaTeX serialization.
  const handle = createBlockHandle({
    ...DEFAULT_BLOCK_HANDLE_LABELS,
    addBlockLabel: 'Add LaTeX block',
    dragHandleLabel: 'Drag LaTeX block to move',
  });
  handle.wrapper.classList.add('interactive-visual-block-handle', 'hidden');
  handle.dragButton.draggable = true;
  options.host.appendChild(handle.wrapper);
  let hoveredBlock: HTMLElement | null = null;
  let draggedBlock: HTMLElement | null = null;

  const positionHandle = (block: HTMLElement | null) => {
    hoveredBlock = block;
    if (!block) {
      handle.wrapper.classList.add('hidden');
      return;
    }
    const rect = block.getBoundingClientRect();
    handle.wrapper.style.left = `${Math.max(6, rect.left - 48)}px`;
    handle.wrapper.style.top = `${Math.max(6, rect.top + Math.min(12, Math.max(0, rect.height / 2 - 12)))}px`;
    handle.wrapper.classList.remove('hidden');
  };

  let handleHideTimer: ReturnType<typeof setTimeout> | undefined;
  const cancelHandleHide = () => {
    if (handleHideTimer) clearTimeout(handleHideTimer);
    handleHideTimer = undefined;
  };
  const scheduleHandleHide = () => {
    cancelHandleHide();
    handleHideTimer = setTimeout(() => {
      if (handle.wrapper.matches(':hover') || options.host.matches(':hover')) return;
      positionHandle(null);
    }, 140);
  };
  const mouseMove = (event: MouseEvent) => {
    cancelHandleHide();
    const block = visualBlockAtClientPoint(view, options.host, event);
    if (block !== hoveredBlock) positionHandle(block);
  };
  const mouseLeave = (event: MouseEvent) => {
    const related = event.relatedTarget;
    if (related instanceof Node && handle.wrapper.contains(related)) return;
    scheduleHandleHide();
  };
  const handleMouseEnter = () => cancelHandleHide();
  const handleMouseLeave = () => {
    if (!options.host.matches(':hover')) scheduleHandleHide();
  };
  options.host.addEventListener('mousemove', mouseMove);
  options.host.addEventListener('mouseleave', mouseLeave);
  handle.wrapper.addEventListener('mouseenter', handleMouseEnter);
  handle.wrapper.addEventListener('mouseleave', handleMouseLeave);

  type SlashContext = {
    id: string;
    sourceFrom: number;
    sourceTo: number;
    placement: 'before' | 'after' | 'replace';
  };
  let slashContext: SlashContext | undefined;
  let lastSlashSignature = '';

  const slashContextForBlock = (
    block: HTMLElement,
    placement: 'before' | 'after' | 'replace',
  ): SlashContext | undefined => {
    const id = block.dataset.sourceId;
    if (!id) return undefined;
    const range = blockRangeById(options.blocks, id);
    if (!range) return undefined;
    return { id, sourceFrom: range.from, sourceTo: range.to, placement };
  };

  const dismissSlashSuggestion = () => {
    const active = store.get(view.state, $suggestions).get(view);
    if (active?.markName === 'slash_command') {
      removeSuggestMark({
        markName: 'slash_command',
        selection: view.state.selection,
      })(view.state, view.dispatch, view);
    }
    slashContext = undefined;
    if (lastSlashSignature) {
      lastSlashSignature = '';
      options.onAction?.({ kind: 'slash-close' });
    }
  };

  const openNativeSlash = (
    block: HTMLElement,
    placement: 'before' | 'after' | 'replace',
    preserveSelection: boolean,
  ) => {
    const context = slashContextForBlock(block, placement);
    if (!context) return;
    dismissSlashSuggestion();
    slashContext = context;

    let selection = view.state.selection;
    if (!preserveSelection) {
      const pos = view.posAtDOM(block, 0);
      const node = pos >= 0 ? view.state.doc.nodeAt(pos) : null;
      if (node?.isTextblock) {
        selection = TextSelection.create(
          view.state.doc,
          Math.min(view.state.doc.content.size, pos + 1 + node.content.size),
        );
      }
    }
    if (!selection.empty || !selection.$from.parent.isTextblock) {
      options.onAction?.({
        kind: 'slash-menu',
        ...context,
        anchor: rectData(block.getBoundingClientRect()),
        query: '',
        selectedIndex: 0,
        synthetic: true,
      });
      return;
    }

    const mark = schema.marks.slash_command?.create({
      trigger: '/',
      synthetic: true,
    });
    if (!mark) return;
    const inherited = selection.$from.marks().filter((candidate) => candidate.type !== mark.type);
    const tr = view.state.tr
      .setSelection(selection)
      .replaceWith(
        selection.from,
        selection.to,
        schema.text('/', [mark, ...inherited]),
      )
      .addStoredMark(mark)
      .scrollIntoView();
    view.focus();
    view.dispatch(tr);
  };

  syncSlashSuggestion = () => {
    const active = store.get(view.state, $suggestions).get(view);
    if (!active || active.markName !== 'slash_command' || !active.show) {
      if (lastSlashSignature) {
        lastSlashSignature = '';
        slashContext = undefined;
        options.onAction?.({ kind: 'slash-close' });
      }
      return;
    }
    const query = active.text.startsWith('/') ? active.text.slice(1) : active.text;
    const optionCount = Math.max(0, options.getSlashOptionCount?.(query) ?? 1);
    const existingUi = store.get(view.state, $suggestionUi);
    const nextUi = new Map(existingUi);
    const currentHandlers = nextUi.get(view) ?? {};
    nextUi.set(view, {
      ...currentHandlers,
      slash_command: {
        optionCount,
        onSelect: (selected: Suggestion) => {
          options.onAction?.({
            kind: 'slash-select',
            query: selected.text.startsWith('/') ? selected.text.slice(1) : selected.text,
            selectedIndex: selected.selectedIndex,
          });
        },
      },
    });
    store.set(view.state, $suggestionUi, nextUi);

    const block = topLevelSourceElement(view);
    const context = slashContext ?? (block ? slashContextForBlock(
      block,
      block.textContent?.replace(active.text, '').trim() ? 'after' : 'replace',
    ) : undefined);
    if (!context) return;
    const anchorElement = active.anchorEl();
    const anchorRect = anchorElement?.getBoundingClientRect?.() ?? block?.getBoundingClientRect();
    if (!anchorRect) return;
    const selection = view.state.selection;
    const suggestionStart = Math.max(0, selection.from - active.text.length);
    const inlinePoint = mappedVisualSourcePoint(view.state, suggestionStart);
    const structuralHost = editableStructuralHost(
      view.state,
      suggestionStart,
      selection.from,
    );
    const suggestionInlineContext = inlineSourceContext(
      view.state,
      suggestionStart,
      selection.from,
    );
    const signature = `${active.text}|${active.selectedIndex}|${context.id}|${context.placement}|${active.refresh}`;
    if (signature === lastSlashSignature) return;
    lastSlashSignature = signature;
    options.onAction?.({
      kind: 'slash-menu',
      ...context,
      anchor: rectData(anchorRect as DOMRect),
      query,
      selectedIndex: Math.max(0, Math.min(active.selectedIndex, Math.max(0, optionCount - 1))),
      synthetic: active.synthetic,
      ...(inlinePoint ? { inlineSourceAt: inlinePoint.offset } : {}),
      sourceQueryText: active.synthetic ? '' : active.text,
      inlineAtEnd: isAtHostEnd(view.state, selection.from, structuralHost),
      ...(suggestionInlineContext ? { inlineContext: suggestionInlineContext } : {}),
    });
  };

  handle.plusButton.addEventListener('mousedown', (event) => event.preventDefault());
  handle.plusButton.addEventListener('click', (event) => {
    event.preventDefault();
    if (!hoveredBlock) return;
    openNativeSlash(hoveredBlock, event.altKey ? 'before' : 'after', false);
  });

  const clearDropTargets = () => {
    for (const item of view.dom.querySelectorAll('.interactive-visual-drop-target')) {
      item.classList.remove(
        'interactive-visual-drop-target',
        'interactive-visual-drop-before',
        'interactive-visual-drop-after',
      );
      if (item instanceof HTMLElement) delete item.dataset.dropPlacement;
    }
  };

  const autoScrollDrag = (clientY: number) => {
    const rect = options.host.getBoundingClientRect();
    const threshold = Math.min(72, Math.max(40, rect.height * 0.12));
    if (clientY < rect.top + threshold) {
      options.host.scrollBy({ top: -30, behavior: 'auto' });
    } else if (clientY > rect.bottom - threshold) {
      options.host.scrollBy({ top: 30, behavior: 'auto' });
    }
  };

  const markDropTarget = (target: HTMLElement, clientY: number) => {
    clearDropTargets();
    const rect = target.getBoundingClientRect();
    const placement = clientY < rect.top + rect.height / 2 ? 'before' : 'after';
    target.classList.add(
      'interactive-visual-drop-target',
      placement === 'before' ? 'interactive-visual-drop-before' : 'interactive-visual-drop-after',
    );
    target.dataset.dropPlacement = placement;
    return placement;
  };

  const emitMove = (
    source: HTMLElement,
    target: HTMLElement,
    placement: 'before' | 'after',
  ) => {
    if (target === source) return;
    const id = source.dataset.sourceId;
    const targetId = target.dataset.sourceId;
    if (!id || !targetId) return;
    const sourceRange = visualMoveRangeForBlock(options.blocks, id);
    const targetRange = visualMoveRangeForBlock(options.blocks, targetId);
    if (!sourceRange || !targetRange) return;
    if (
      targetRange.from >= sourceRange.from &&
      targetRange.to <= sourceRange.to
    ) return;
    options.onAction?.({
      kind: 'move-block',
      id,
      sourceFrom: sourceRange.from,
      sourceTo: sourceRange.to,
      targetId,
      targetFrom: targetRange.from,
      targetTo: targetRange.to,
      placement,
    });
  };

  const dragStart = (event: DragEvent) => {
    draggedBlock = hoveredBlock;
    if (!draggedBlock) return;
    event.dataTransfer?.setData('text/plain', draggedBlock.dataset.sourceId ?? '');
    if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
    draggedBlock.classList.add('interactive-visual-dragging');
  };
  const dragEnd = () => {
    draggedBlock?.classList.remove('interactive-visual-dragging');
    draggedBlock = null;
    clearDropTargets();
  };
  handle.dragButton.addEventListener('dragstart', dragStart);
  handle.dragButton.addEventListener('dragend', dragEnd);

  const dragOver = (event: DragEvent) => {
    if (!draggedBlock) return;
    autoScrollDrag(event.clientY);
    const target = topLevelSourceElement(view, event.target);
    if (!target || target === draggedBlock) return;
    event.preventDefault();
    markDropTarget(target, event.clientY);
    if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
  };
  const drop = (event: DragEvent) => {
    if (!draggedBlock) return;
    const target = topLevelSourceElement(view, event.target);
    if (!target || target === draggedBlock) return;
    event.preventDefault();
    const placement = (target.dataset.dropPlacement === 'after' ? 'after' : 'before') as 'before' | 'after';
    emitMove(draggedBlock, target, placement);
    dragEnd();
  };

  // HTML5 drag is unreliable on touch screens. Keep the same source-range
  // move semantics, but use pointer tracking for touch/pen and the same
  // Learnitive-style auto-scroll/drop indicator.
  let pointerDrag:
    | {
        pointerId: number;
        source: HTMLElement;
        startX: number;
        startY: number;
        active: boolean;
        target?: HTMLElement;
        placement?: 'before' | 'after';
      }
    | undefined;
  const pointerDown = (event: PointerEvent) => {
    if (event.pointerType === 'mouse' || !hoveredBlock) return;
    pointerDrag = {
      pointerId: event.pointerId,
      source: hoveredBlock,
      startX: event.clientX,
      startY: event.clientY,
      active: false,
    };
    handle.dragButton.setPointerCapture?.(event.pointerId);
    event.preventDefault();
  };
  const pointerMove = (event: PointerEvent) => {
    const drag = pointerDrag;
    if (!drag || drag.pointerId !== event.pointerId) return;
    if (!drag.active && Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 6) return;
    drag.active = true;
    drag.source.classList.add('interactive-visual-dragging');
    autoScrollDrag(event.clientY);
    const hit = document.elementFromPoint(event.clientX, event.clientY);
    const target = topLevelSourceElement(view, hit);
    if (!target || target === drag.source) {
      drag.target = undefined;
      drag.placement = undefined;
      clearDropTargets();
      return;
    }
    drag.target = target;
    drag.placement = markDropTarget(target, event.clientY);
    event.preventDefault();
  };
  const finishPointerDrag = (event: PointerEvent) => {
    const drag = pointerDrag;
    if (!drag || drag.pointerId !== event.pointerId) return;
    if (drag.active && drag.target && drag.placement) {
      emitMove(drag.source, drag.target, drag.placement);
    }
    drag.source.classList.remove('interactive-visual-dragging');
    clearDropTargets();
    try { handle.dragButton.releasePointerCapture?.(event.pointerId); } catch { /* capture already released */ }
    pointerDrag = undefined;
  };
  handle.dragButton.addEventListener('pointerdown', pointerDown);
  handle.dragButton.addEventListener('pointermove', pointerMove);
  handle.dragButton.addEventListener('pointerup', finishPointerDrag);
  handle.dragButton.addEventListener('pointercancel', finishPointerDrag);

  view.dom.addEventListener('dragover', dragOver);
  view.dom.addEventListener('drop', drop);

  // R14.12 P0: Visual and Source share LatexSourceService history. Keep the
  // ProseMirror projection out of a second independent undo stack and route
  // the platform-standard shortcuts to the canonical document history.
  const historyKeydown = (event: KeyboardEvent) => {
    if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
    if (event.key.toLowerCase() !== 'z') return;
    event.preventDefault();
    event.stopPropagation();
    if (event.shiftKey) options.onRedo?.();
    else options.onUndo?.();
  };
  view.dom.addEventListener('keydown', historyKeydown, true);

  let activeMappedSelection: LatexVisualSourceSelection | undefined;
  const selectionToolbar = createSelectionToolbar(
    options.host,
    (format) => {
      const selected = activeMappedSelection;
      if (!selected) return;
      const rect = selectionToolbar.getBoundingClientRect();
      options.onAction?.({ kind: 'format-selection', ...selected, format, anchor: rectData(rect) });
    },
    () => {
      const selected = activeMappedSelection;
      if (!selected) return;
      const rect = selectionToolbar.getBoundingClientRect();
      options.onAction?.({ kind: 'ai-suggest-selection', selection: selected, anchor: rectData(rect) });
    },
  );
  updateSelectionToolbar = () => {
    const cursorPosition = view.state.selection instanceof TextSelection
      ? view.state.selection.head
      : view.state.selection.to;
    options.onCursorChange?.(
      mappedVisualSourcePoint(view.state, cursorPosition),
    );
    activeMappedSelection = sourceMappedSelection(view.state);
    options.onSelectionChange?.(activeMappedSelection);
    if (!activeMappedSelection) {
      selectionToolbar.classList.add('hidden');
      return;
    }
    const selection = window.getSelection();
    if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
      selectionToolbar.classList.add('hidden');
      return;
    }
    const rect = selection.getRangeAt(0).getBoundingClientRect();
    if (!rect.width && !rect.height) {
      selectionToolbar.classList.add('hidden');
      return;
    }
    selectionToolbar.style.left = `${rect.left + rect.width / 2}px`;
    selectionToolbar.style.top = `${Math.max(8, rect.top - 8)}px`;
    selectionToolbar.classList.remove('hidden');
  };

  const click = (event: Event) => {
    const listCommandTarget = event.target instanceof Element
      ? event.target.closest<HTMLElement>('[data-visual-list-command]')
      : null;
    if (listCommandTarget) {
      const operation = listCommandTarget.dataset.visualListCommand as Extract<LatexVisualAction, { kind: 'list-structure' }>['operation'] | undefined;
      const listElement = listCommandTarget.closest<HTMLElement>('.interactive-visual-list-wrap');
      const id = listElement?.dataset.sourceId;
      if (!operation || !id) return;
      const list = ancestorAtPosition(view.state, view.state.selection.from, ['visual_list']);
      const item = ancestorAtPosition(view.state, view.state.selection.from, ['visual_list_item']);
      if (operation === 'delete-item' && (!list || String(list.node.attrs.id ?? '') !== id || !item)) return;
      options.onAction?.({
        kind: 'list-structure',
        id,
        operation,
        ...(item ? { itemId: String(item.node.attrs.id ?? '') } : {}),
      });
      listCommandTarget.closest('details')?.removeAttribute('open');
      return;
    }

    const commandTarget = event.target instanceof Element
      ? event.target.closest<HTMLElement>('[data-visual-table-command]')
      : null;
    if (commandTarget) {
      const operation = commandTarget.dataset.visualTableCommand as Extract<LatexVisualAction, { kind: 'table-structure' }>['operation'] | undefined;
      const id = commandTarget.closest<HTMLElement>('[data-source-id]')?.dataset.sourceId;
      if (!operation || !id) return;
      const cell = ancestorAtPosition(view.state, view.state.selection.from, ['visual_table_cell']);
      const row = Number(cell?.node.attrs.row);
      const column = Number(cell?.node.attrs.column);
      options.onAction?.({
        kind: 'table-structure',
        id,
        operation,
        ...(Number.isFinite(row) && row >= 0 ? { row } : {}),
        ...(Number.isFinite(column) && column >= 0 ? { column } : {}),
      });
      commandTarget.closest('details')?.removeAttribute('open');
      return;
    }
    const target = event.target instanceof Element ? event.target.closest('[data-visual-action]') : null;
    if (!target) return;
    const id = target.closest('[data-source-id]')?.getAttribute('data-source-id');
    if (!id) return;
    const action = actionForId(options.blocks, id);
    if (!action) return;
    if (action.kind === 'math') {
      const sourceElement = target.closest<HTMLElement>('[data-source-id]');
      const currentValue = sourceElement?.dataset.mathValue;
      options.onAction?.(
        currentValue === undefined ? action : { ...action, value: currentValue },
      );
      return;
    }
    if (action.kind === 'table') {
      const cell = ancestorAtPosition(view.state, view.state.selection.from, ['visual_table_cell']);
      const row = Number(cell?.node.attrs.row);
      const column = Number(cell?.node.attrs.column);
      options.onAction?.(
        Number.isFinite(row) && row >= 0 && Number.isFinite(column) && column >= 0
          ? { ...action, row, column }
          : action,
      );
      return;
    }
    options.onAction?.(action);
  };
  const doubleClick = (event: Event) => {
    const target = event.target instanceof Element ? event.target.closest('[data-source-id]') : null;
    const id = target?.getAttribute('data-source-id');
    if (!id) return;
    const block = options.blocks.find((candidate) =>
      candidate.id === id ||
      ((candidate.kind === 'rich-paragraph' || candidate.kind === 'text-environment') && candidate.inlines.some((inline) => inline.id === id)) ||
      (candidate.kind === 'list' && candidate.items.some((item) => item.id === id || item.inlines.some((inline) => inline.id === id))),
    );
    if (!block) return;
    const range = block.kind === 'rich-paragraph' || block.kind === 'text-environment'
      ? block.inlines.find((inline) => inline.id === id)?.range ?? block.range
      : block.kind === 'list'
        ? block.items.find((item) => item.id === id)?.range ??
          block.items.flatMap((item) => item.inlines).find((inline) => inline.id === id)?.range ??
          block.range
        : block.range;
    options.onNavigateSource?.({ id, sourceFrom: range.from, sourceTo: range.to });
  };
  const revealSourceOffset = (offset: number) => {
    const block = options.blocks.find((candidate) => offset >= candidate.range.from && offset <= candidate.range.to);
    if (!block) return;
    const element = [...options.host.querySelectorAll<HTMLElement>('[data-source-id]')].find(
      (candidate) => candidate.dataset.sourceId === block.id,
    );
    element?.scrollIntoView({ block: 'center' });
    element?.classList.add('interactive-visual-source-reveal');
    window.setTimeout(() => element?.classList.remove('interactive-visual-source-reveal'), 900);
  };

  const focusSourceOffset = (offset: number): boolean => {
    const position = visualPositionForSourceOffset(view.state.doc, offset);
    if (position === undefined) return false;
    view.dispatch(
      view.state.tr
        .setSelection(TextSelection.create(view.state.doc, position))
        .scrollIntoView(),
    );
    view.focus();
    return true;
  };

  const insertSyntheticParagraphAtSourceOffset = (offset: number) => {
    // BF3 semantic projection preserves canonical blank-line caret rows as
    // real zero-length paragraph nodes. Prefer that durable source-backed host and
    // keep the synthetic paragraph only as a fallback for legacy/unsupported
    // projections where no editable source position exists.
    const mappedPosition = visualPositionForSourceOffset(view.state.doc, offset);
    if (mappedPosition !== undefined) {
      view.dispatch(
        view.state.tr
          .setSelection(TextSelection.create(view.state.doc, mappedPosition))
          .scrollIntoView(),
      );
      view.focus();
      return;
    }
    let existingPosition: number | undefined;
    view.state.doc.descendants((node, pos) => {
      if (
        existingPosition === undefined &&
        node.type.name === 'visual_paragraph' &&
        node.attrs.synthetic === true &&
        Number(node.attrs.bodyFrom) === offset
      ) {
        existingPosition = pos + 1;
        return false;
      }
      return existingPosition === undefined;
    });
    if (existingPosition !== undefined) {
      view.dispatch(
        view.state.tr
          .setSelection(TextSelection.create(view.state.doc, existingPosition))
          .scrollIntoView(),
      );
      view.focus();
      return;
    }

    let insertAt = 0;
    for (let index = 0; index < view.state.doc.childCount; index += 1) {
      const child = view.state.doc.child(index);
      const childFrom = Number(child.attrs.sourceFrom);
      if (Number.isFinite(childFrom) && childFrom >= offset) break;
      insertAt += child.nodeSize;
    }
    const synthetic = requiredNodeType('visual_paragraph').create({
      id: `synthetic-paragraph:${offset}:${syntheticParagraphCounter++}`,
      sourceFrom: offset,
      sourceTo: offset,
      bodyFrom: offset,
      bodyTo: offset,
      synthetic: true,
    });
    const transaction = view.state.tr.insert(insertAt, synthetic);
    transaction.setSelection(TextSelection.create(transaction.doc, insertAt + 1));
    view.dispatch(transaction.scrollIntoView());
    view.focus();
  };

  const insertSyntheticInlineAtSourceOffset = (offset: number) => {
    let parentPosition: number | undefined;
    let parentNode: PMNode | undefined;
    let bestDistance = Number.POSITIVE_INFINITY;
    view.state.doc.descendants((node, pos) => {
      if (node.isText) return true;
      if (
        node.type.name !== 'visual_rich_paragraph' &&
        node.type.name !== 'visual_list_item' &&
        node.type.name !== 'visual_table_cell'
      ) {
        return true;
      }
      const from = Number(node.attrs.sourceFrom);
      const to = Number(node.attrs.sourceTo);
      if (!Number.isFinite(from) || !Number.isFinite(to) || from > offset) return true;
      const distance = offset <= to ? 0 : offset - to;
      if (distance <= bestDistance) {
        bestDistance = distance;
        parentPosition = pos;
        parentNode = node;
      }
      return true;
    });
    if (parentPosition === undefined || !parentNode || bestDistance > 2) {
      insertSyntheticParagraphAtSourceOffset(offset);
      return;
    }
    const synthetic = requiredNodeType('visual_source_text').create({
      id: `synthetic-inline:${offset}:${syntheticParagraphCounter++}`,
      sourceFrom: offset,
      sourceTo: offset,
      bodyFrom: offset,
      bodyTo: offset,
      visualKind: 'text',
      editable: true,
      synthetic: true,
    });
    const targetParent = parentNode;
    const insertAt = parentPosition + 1 + targetParent.content.size;
    const transaction = view.state.tr.insert(insertAt, synthetic);
    transaction.setSelection(TextSelection.create(transaction.doc, insertAt + 1));
    view.dispatch(transaction.scrollIntoView());
    view.focus();
  };

  const updateMathValue = (id: string, value: string) => {
    for (const element of options.host.querySelectorAll<HTMLElement>('[data-visual-math="true"]')) {
      if (element.dataset.sourceId !== id) continue;
      const field = element.querySelector('math-field') as (HTMLElement & { value?: string }) | null;
      if (field && 'value' in field) field.value = value;
      element.dataset.mathValue = value;
      break;
    }
  };

  const replaceProjection = (
    blocks: readonly VisualBlock[],
    assetUrls: ReadonlyMap<string, string> = options.assetUrls ?? new Map(),
    requestedSelection?: { anchorSource: number; headSource?: number; focus?: boolean },
  ): boolean => {
    if (view.isDestroyed) return false;
    const nextDoc = createLatexVisualDocument(blocks, assetUrls);

    const previousSelection = view.state.selection;
    const preservedAnchorSource = mappedVisualSourcePoint(view.state, previousSelection.anchor)?.offset;
    const preservedHeadSource = mappedVisualSourcePoint(view.state, previousSelection.head)?.offset;
    const explicitAnchor = requestedSelection?.anchorSource;
    const explicitHead = requestedSelection?.headSource ?? explicitAnchor;
    const anchorSource = explicitAnchor ?? preservedAnchorSource;
    const headSource = explicitHead ?? preservedHeadSource;
    const fallbackAnchor = previousSelection.anchor;
    const fallbackHead = previousSelection.head;
    const wasFocused = view.hasFocus();
    const scrollTop = options.host.scrollTop;
    const scrollLeft = options.host.scrollLeft;

    options.blocks = blocks;
    options.assetUrls = assetUrls;
    sourceBaselineDoc = nextDoc;

    // Even when the projection is structurally identical, an explicit source
    // selection belongs to the structural mutation and must be installed in
    // the same synchronous operation rather than on a later animation frame.
    if (nextDoc.eq(view.state.doc)) {
      if (anchorSource === undefined || headSource === undefined) return false;
      const anchor = visualPositionForSourceOffset(view.state.doc, anchorSource);
      const head = visualPositionForSourceOffset(view.state.doc, headSource);
      if (anchor === undefined || head === undefined) return false;
      view.dispatch(
        view.state.tr
          .setSelection(TextSelection.create(view.state.doc, anchor, head))
          .setMeta('addToHistory', false)
          .scrollIntoView(),
      );
      if (requestedSelection?.focus ?? wasFocused) view.focus();
      return true;
    }

    let nextState = EditorState.create({
      schema,
      plugins: view.state.plugins,
      doc: nextDoc,
    });
    let selectionMapped = false;
    try {
      const anchor =
        anchorSource === undefined
          ? Math.max(1, Math.min(fallbackAnchor, nextDoc.content.size))
          : visualPositionForSourceOffset(nextDoc, anchorSource);
      const head =
        headSource === undefined
          ? Math.max(1, Math.min(fallbackHead, nextDoc.content.size))
          : visualPositionForSourceOffset(nextDoc, headSource);
      if (anchor !== undefined && head !== undefined) {
        nextState = nextState.apply(
          nextState.tr
            .setSelection(TextSelection.create(nextState.doc, anchor, head))
            .setMeta('addToHistory', false),
        );
        selectionMapped = anchorSource !== undefined && headSource !== undefined;
      }
    } catch {
      // A structural source refresh can make a preserved caret unmappable.
      // Explicit structural selections report false so the caller can fail
      // closed instead of manufacturing a synthetic caret.
    }

    view.updateState(nextState);
    view.dom.contentEditable = 'true';
    view.dom.setAttribute('aria-readonly', 'false');
    options.host.scrollTop = scrollTop;
    options.host.scrollLeft = scrollLeft;
    if (requestedSelection?.focus ?? wasFocused) view.focus();
    window.requestAnimationFrame(() => {
      if (view.isDestroyed) return;
      options.host.scrollTop = scrollTop;
      options.host.scrollLeft = scrollLeft;
      updateSelectionToolbar();
      syncSlashSuggestion();
    });
    return requestedSelection ? selectionMapped : true;
  };

  const updateAssetUrls = (assetUrls: ReadonlyMap<string, string>) => {
    if (view.isDestroyed) return;
    options.assetUrls = assetUrls;
    sourceBaselineDoc = withVisualAssetUrls(sourceBaselineDoc, assetUrls);

    let transaction = view.state.tr;
    view.state.doc.descendants((node, pos) => {
      if (node.type.name !== 'visual_figure') return true;
      const id = String(node.attrs.id ?? '');
      const nextAssetUrl = assetUrls.get(id) ?? '';
      if (String(node.attrs.assetUrl ?? '') === nextAssetUrl) return false;
      transaction = transaction.setNodeMarkup(
        pos,
        undefined,
        { ...node.attrs, assetUrl: nextAssetUrl },
        node.marks,
      );
      return false;
    });
    if (!transaction.docChanged) return;
    transaction.setMeta(VISUAL_INTERNAL_ASSET_REFRESH, true);
    transaction.setMeta('addToHistory', false);
    view.dispatch(transaction);
  };

  const keydown = (event: Event) => {
    if (!(event instanceof KeyboardEvent)) return;
    if ((event.key === 'Enter' || event.key === ' ') && event.target instanceof Element && event.target.closest('[data-visual-action]')) {
      click(event);
      return;
    }
    // Typed '/' is owned exclusively by Learnitive's suggestion plugin. The left
    // + button still opens the same suggestion state programmatically.
    if (event.key === 'Escape') {
      window.requestAnimationFrame(syncSlashSuggestion);
    }
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      const boundaryTarget = event.target instanceof Element
        ? event.target.closest<HTMLElement>('.interactive-visual-figure,[data-visual-math="true"]')
        : null;
      const id = boundaryTarget?.dataset.sourceId;
      if (id && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) {
        const action = actionForId(options.blocks, id);
        const direction = event.key === 'ArrowDown' ? 'after' : 'before';
        if (action?.kind === 'math' && action.mathKind === 'display') {
          // Only top-level display math owns a document block boundary. Math
          // nested inside lists/tables must keep navigation inside its host.
          const topLevelMath = options.blocks.some(
            (block) =>
              (block.kind === 'rich-paragraph' || block.kind === 'text-environment') &&
              block.inlines.some((inline) => inline.id === id),
          );
          if (topLevelMath) {
            event.preventDefault();
            options.onAction?.({
              kind: 'block-boundary',
              id,
              sourceFrom: action.from,
              sourceTo: action.to,
              direction,
            });
            return;
          }
        }
        if (action?.kind === 'figure') {
          const range = blockRangeById(options.blocks, id);
          if (range) {
            event.preventDefault();
            options.onAction?.({
              kind: 'block-boundary',
              id,
              sourceFrom: range.from,
              sourceTo: range.to,
              direction,
            });
            return;
          }
        }
      }
      window.requestAnimationFrame(syncSlashSuggestion);
    }
  };
  options.host.addEventListener('click', click);
  options.host.addEventListener('keydown', keydown);
  options.host.addEventListener('dblclick', doubleClick);
  document.addEventListener('selectionchange', updateSelectionToolbar);
  return {
    native: false as const,
    view,
    replaceProjection,
    updateAssetUrls,
    revealSourceOffset,
    focusSourceOffset,
    insertSyntheticParagraphAtSourceOffset,
    insertSyntheticInlineAtSourceOffset,
    updateMathValue,
    executeNativeSlashCommand: () => false,
    insertNativeTableAtSlash: () => false,
    dismissSlashSuggestion,
    destroy: () => {
      options.host.removeEventListener('click', click);
      options.host.removeEventListener('keydown', keydown);
      options.host.removeEventListener('dblclick', doubleClick);
      options.host.removeEventListener('mousemove', mouseMove);
      options.host.removeEventListener('mouseleave', mouseLeave);
      handle.wrapper.removeEventListener('mouseenter', handleMouseEnter);
      handle.wrapper.removeEventListener('mouseleave', handleMouseLeave);
      cancelHandleHide();
      view.dom.removeEventListener('dragover', dragOver);
      view.dom.removeEventListener('drop', drop);
      handle.dragButton.removeEventListener('dragstart', dragStart);
      handle.dragButton.removeEventListener('dragend', dragEnd);
      handle.dragButton.removeEventListener('pointerdown', pointerDown);
      handle.dragButton.removeEventListener('pointermove', pointerMove);
      handle.dragButton.removeEventListener('pointerup', finishPointerDrag);
      handle.dragButton.removeEventListener('pointercancel', finishPointerDrag);
      view.dom.removeEventListener('keydown', historyKeydown, true);
      document.removeEventListener('selectionchange', updateSelectionToolbar);
      const existingUi = store.get(view.state, $suggestionUi);
      if (existingUi.has(view)) {
        const nextUi = new Map(existingUi);
        nextUi.delete(view);
        store.set(view.state, $suggestionUi, nextUi);
      }
      handle.wrapper.remove();
      selectionToolbar.remove();
      options.host.removeEventListener('pointerdown', ensureEditableFocus);
      view.destroy();
    },
  };
}


export function createLatexVisualEditor(options: Parameters<typeof createLegacyLatexVisualEditor>[0] & {
  source?: string;
  getSource?: () => string | undefined;
  onNativeEdits?: (
    edits: SourceTextEdit[],
    meta: { historyReplay: boolean; baseSource: string; nextSource: string },
  ) => boolean | void;
}): ReturnType<typeof createLegacyLatexVisualEditor> | ReturnType<typeof createNativeLearnitiveLatexVisualEditor> {
  if (typeof options.source === 'string') {
    return createNativeLearnitiveLatexVisualEditor({
      ...options,
      source: options.source,
      getSource: options.getSource,
    });
  }
  return createLegacyLatexVisualEditor(options);
}
