import { useCoreServices } from '@learnitive/context';
import {
  createLatexVisualEditor,
  type LatexVisualAction,
} from '@learnitive/latex-visual';
import {
  applySourceTextEdits,
  CANONICAL_LATEX_HARD_BREAK,
  CANONICAL_LATEX_PARAGRAPH_SEPARATOR,
  canEditLatexMathWithMathLive,
  computeMinimalSourceTextEdit,
  captureSourceAnchor,
  resolveSourceAnchor,
  type SourceAnchor,
  createLatexInlineSplitMutation,
  createLatexListStructureMutation,
  createLatexMathReplacementEdit,
  createSourceBackedBlockDeletion,
  createLatexTableStructureMutation,
  createLatexTableTemplate,
  canonicalOffsetToEditorOffset,
  detectLatexLineEnding,
  editorOffsetToCanonicalOffset,
  hasMixedLatexLineEndings,
  findLatexMathRegions,
  normalizeLatexLineEndings,
  latexMathRegionToMathLiveValue,
  type LatexMathRegion,
  type SourceMutationOrigin,
  type SourceTextEdit,
} from '@learnitive/latex-source';
import { projectFilePath } from '@learnitive/project-storage';
import {
  parseLatexFile,
  resolveAssetTarget,
  type VisualBlock,
} from '@learnitive/project-semantic-index';
import type { AiSuggestionAction } from '@learnitive/service-core';
import {
  Button,
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  Input,
  Label,
  toast,
} from '@learnitive/ui-components';
import { WsPath } from '@learnitive/ws-path';
import { atom, useAtomValue } from 'jotai';
import { AlertTriangle, GitCompareArrows, MessageSquarePlus, Redo2, Undo2 } from 'lucide-react';
import React from 'react';
import { CitationPickerDialog } from './citation-picker-dialog';
import { CitationContextDialog } from './citation-context-dialog';
import { CitationSufficiencyDialog } from './citation-sufficiency-dialog';
import {
  LatexVisualSlashMenu,
  getLatexVisualSlashCommandAt,
  getLatexVisualSlashOptionCount,
  type LatexVisualInsertCommand,
} from './latex-visual-slash-menu';
import { LatexVisualLinkEditor } from './latex-visual-link-editor';
import { MathLiveLoadingOverlay, MathLiveLocalBoundary } from './mathlive-local-boundary';
import { MathLiveEquationEditor } from './mathlive-equation-editor';
import { LatexTableGridPicker } from './latex-table-grid-picker';
import { LatexVisualReferencePicker } from './latex-visual-reference-picker';
import type { LatexDocumentController } from '../latex-document-controller';
import type { PaperMemoryManuscriptSelection } from '../paper-memory-manuscript';
import type { ReviewCommentTarget } from './review-comment-dialog';

const VISUAL_IMAGE_ACCEPT =
  '.png,.jpg,.jpeg,.pdf,image/png,image/jpeg,application/pdf';
const BUSYTEX_DIRECT_IMAGE_FORMATS = new Set(['png', 'jpg', 'jpeg', 'pdf']);
const INACTIVE_VISUAL_VERSION_ATOM = atom(0);
const SCROLL_OWNERSHIP_KEYS = new Set([
  'ArrowUp',
  'ArrowDown',
  'PageUp',
  'PageDown',
  'Home',
  'End',
  ' ',
]);

function relativeProjectTarget(fromFile: string, targetFile: string): string {
  const from = fromFile.split('/').slice(0, -1);
  const target = targetFile.split('/');
  let common = 0;
  while (common < from.length && common < target.length && from[common] === target[common]) {
    common += 1;
  }
  return [...from.slice(common).map(() => '..'), ...target.slice(common)].join('/') || targetFile;
}

function hasLatexPackage(source: string, packageName: string): boolean {
  const packagePattern = /\\(?:usepackage|RequirePackage)(?:\[[^\]]*\])?\{([^}]*)\}/gu;
  for (const match of source.matchAll(packagePattern)) {
    const packages = (match[1] ?? '').split(',').map((value) => value.trim());
    if (packages.includes(packageName)) return true;
  }
  return false;
}

function ensureLatexPackage(source: string, packageName: string): {
  source: string;
  insertionOffset: number;
  insertedLength: number;
} {
  if (hasLatexPackage(source, packageName)) {
    return { source, insertionOffset: -1, insertedLength: 0 };
  }
  const beginDocument = source.search(/\\begin\s*\{document\}/u);
  if (beginDocument < 0) {
    return { source, insertionOffset: -1, insertedLength: 0 };
  }
  const prefix = source.slice(0, beginDocument);
  const separator = prefix.endsWith('\n') ? '' : '\n';
  const insertion = `${separator}\\usepackage{${packageName}}\n`;
  return {
    source: source.slice(0, beginDocument) + insertion + source.slice(beginDocument),
    insertionOffset: beginDocument,
    insertedLength: insertion.length,
  };
}


function sourceRangeForVisualId(
  blocks: readonly VisualBlock[],
  id: string,
): { from: number; to: number } | undefined {
  const seen = new Set<object>();
  const visit = (value: unknown): { from: number; to: number } | undefined => {
    if (!value || typeof value !== 'object') return undefined;
    if (seen.has(value as object)) return undefined;
    seen.add(value as object);
    if (Array.isArray(value)) {
      for (const item of value) {
        const found = visit(item);
        if (found) return found;
      }
      return undefined;
    }
    const record = value as Record<string, unknown>;
    if (record.id === id) {
      const range = record.range as { from?: unknown; to?: unknown } | undefined;
      if (typeof range?.from === 'number' && typeof range.to === 'number') {
        return { from: range.from, to: range.to };
      }
    }
    for (const child of Object.values(record)) {
      const found = visit(child);
      if (found) return found;
    }
    return undefined;
  };
  return visit(blocks);
}

function sourceOffsetForLine(source: string, line: number): number {
  if (line <= 1) return 0;
  let offset = 0;
  let currentLine = 1;
  while (currentLine < line) {
    const next = source.indexOf('\n', offset);
    if (next < 0) return source.length;
    offset = next + 1;
    currentLine += 1;
  }
  return offset;
}

function nextNonWhitespaceOffset(source: string, start: number): number {
  let offset = Math.max(0, Math.min(source.length, start));
  while (offset < source.length && /\s/.test(source[offset] ?? '')) offset += 1;
  return offset;
}

function mapOffsetAcrossLineEndingNormalization(
  source: string,
  normalizedSource: string,
  offset: number,
): number {
  const editorOffset = canonicalOffsetToEditorOffset(source, offset);
  return editorOffsetToCanonicalOffset(normalizedSource, editorOffset);
}

function uniqueSourceMatch(source: string, needle: string): number | undefined {
  if (!needle) return undefined;
  const first = source.indexOf(needle);
  if (first < 0) return undefined;
  return source.indexOf(needle, first + 1) < 0 ? first : undefined;
}

/**
 * A native Visual projection can intentionally omit or regroup protected
 * source-only regions. In that case its serialized baseline is not byte-for-
 * byte identical to the canonical project source even though the editable
 * paragraph itself is exact. Rebase a transaction-local splice by requiring a
 * unique exact context around the edited characters. This never guesses when
 * the context is ambiguous; callers fall back to authoritative reconciliation.
 */
function rebaseVisualEditByContext(
  edit: SourceTextEdit,
  baseSource: string,
  currentSource: string,
): SourceTextEdit | undefined {
  const removed = baseSource.slice(edit.from, edit.to);
  for (const contextSize of [160, 96, 64, 40, 24, 12]) {
    const leftFrom = Math.max(0, edit.from - contextSize);
    const rightTo = Math.min(baseSource.length, edit.to + contextSize);
    const left = baseSource.slice(leftFrom, edit.from);
    const right = baseSource.slice(edit.to, rightTo);
    const needle = `${left}${removed}${right}`;
    const match = uniqueSourceMatch(currentSource, needle);
    if (match === undefined) continue;
    const from = match + left.length;
    return { ...edit, from, to: from + removed.length };
  }
  return undefined;
}

/**
 * Rebase one Visual source splice over a remote source change that landed
 * between the PM transaction and its canonical commit. Normal collaboration
 * stays synchronized before the next browser event; this is the final race
 * guard for a remote insertion/deletion immediately before a local keystroke.
 */
function rebaseVisualEdit(
  edit: SourceTextEdit,
  baseSource: string,
  currentSource: string,
): SourceTextEdit | undefined {
  if (baseSource === currentSource) return edit;
  const remote = computeMinimalSourceTextEdit(baseSource, currentSource);
  if (!remote) return edit;
  const removed = remote.to - remote.from;
  const delta = remote.text.length - removed;

  // Local operation is entirely before the remote mutation.
  if (edit.to <= remote.from && !(edit.from === edit.to && edit.from === remote.from && removed === 0)) {
    return edit;
  }
  // Local operation is entirely after the remote mutation. For simultaneous
  // insertions at one boundary, place the local insertion after the already
  // integrated remote text; Yjs will then converge deterministically.
  if (edit.from >= remote.to) {
    return { ...edit, from: edit.from + delta, to: edit.to + delta };
  }

  // Multiple projection-only differences can make the minimal canonical diff
  // look like one giant overlapping replacement. A unique local context is a
  // safe mapping for the user's transaction; ambiguous contexts still fall
  // through to authoritative projection reconciliation.
  return rebaseVisualEditByContext(edit, baseSource, currentSource);
}

function rebaseVisualEdits(
  edits: readonly SourceTextEdit[],
  baseSource: string,
  currentSource: string,
): SourceTextEdit[] | undefined {
  if (baseSource === currentSource) return edits.map((edit) => ({ ...edit }));
  if (edits.length !== 1) return undefined;
  const rebased = rebaseVisualEdit(edits[0]!, baseSource, currentSource);
  return rebased ? [rebased] : undefined;
}

function contentType(path: string): string {
  if (/\.png$/i.test(path)) return 'image/png';
  if (/\.jpe?g$/i.test(path)) return 'image/jpeg';
  if (/\.webp$/i.test(path)) return 'image/webp';
  if (/\.svg$/i.test(path)) return 'image/svg+xml';
  if (/\.pdf$/i.test(path)) return 'application/pdf';
  if (/\.(?:eps|ps)$/i.test(path)) return 'application/postscript';
  return 'application/octet-stream';
}

const VISUAL_GRAPHICS_EXTENSION_ORDER = ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'svg', 'eps', 'ps'] as const;
const VISUAL_ASSET_ERROR_PREFIX = 'interactive-asset-error:';

/**
 * Visual mode must fail closed for arbitrary user LaTeX. The semantic parser is
 * intentionally template-agnostic, but a future parser regression or malformed
 * construct must still leave the canonical manuscript reachable as one
 * immutable source-backed box instead of blanking the Visual editor.
 */
function parseVisualBlocksFailClosed(
  path: ReturnType<typeof projectFilePath>,
  source: string,
): readonly VisualBlock[] {
  try {
    return parseLatexFile(path, source).visualBlocks;
  } catch (reason) {
    console.error('Visual semantic projection failed; protecting canonical LaTeX instead.', reason);
    const begin = /\\begin\s*\{document\}/u.exec(source);
    const bodyFrom = begin ? begin.index + begin[0].length : 0;
    const tail = source.slice(bodyFrom);
    const end = /\\end\s*\{document\}/u.exec(tail);
    const bodyTo = end ? bodyFrom + end.index : source.length;
    if (bodyTo <= bodyFrom) return [];
    return [{
      id: `${path}:visual-fallback:${bodyFrom}:${bodyTo}`,
      kind: 'raw',
      file: path,
      range: { from: bodyFrom, to: bodyTo },
      text: source.slice(bodyFrom, bodyTo),
      reason: 'Semantic projection unavailable; canonical LaTeX preserved exactly.',
    }];
  }
}

function graphicsPageFromOptions(options?: string): number {
  if (!options) return 1;
  const match = /(?:^|,)\s*page\s*=\s*(\d+)\s*(?:,|$)/iu.exec(options);
  const parsed = match ? Number.parseInt(match[1] ?? '', 10) : 1;
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
}

function normalizeVisualProjectPath(value: string): string {
  return value
    .replaceAll('\\', '/')
    .replace(/^\.\//u, '')
    .replace(/\/{2,}/gu, '/')
    .replace(/^\//u, '');
}

function visualGraphicsPaths(source: string): string[] {
  // Keep the parser intentionally small and fail-closed: only literal
  // \graphicspath{{dir/}{other/}} entries are used for browser previews.
  // Macro-generated paths remain untouched and simply fall back to the raw
  // protected figure source when they cannot be resolved.
  let masked = '';
  let inComment = false;
  for (let index = 0; index < source.length; index += 1) {
    const char = source[index] ?? '';
    if (inComment) {
      if (char === '\n') {
        inComment = false;
        masked += char;
      } else {
        masked += ' ';
      }
      continue;
    }
    if (char === '%' && source[index - 1] !== '\\') {
      inComment = true;
      masked += ' ';
      continue;
    }
    masked += char;
  }

  const result: string[] = [];
  const command = /\\graphicspath\s*\{((?:[^{}]|\{[^{}]*\})*)\}/gu;
  while (true) {
    const match = command.exec(masked);
    if (!match) break;
    const body = match[1] ?? '';
    const path = /\{([^{}]+)\}/gu;
    while (true) {
      const item = path.exec(body);
      if (!item) break;
      const value = (item[1] ?? '').trim();
      if (!value || value.includes('#') || value.includes('\\')) continue;
      result.push(value.endsWith('/') ? value : `${value}/`);
    }
  }
  return [...new Set(result)];
}

function resolveVisualGraphicsTarget(
  from: ReturnType<typeof projectFilePath>,
  target: string,
  projectFiles: ReadonlySet<string>,
  graphicsPaths: readonly string[] = [],
): ReturnType<typeof projectFilePath> | undefined {
  const hasProjectFile = (candidate: ReturnType<typeof projectFilePath>) =>
    projectFiles.has(normalizeVisualProjectPath(String(candidate)));

  const resolveOne = (candidateTarget: string) => {
    const direct = resolveAssetTarget(from, candidateTarget);
    if (!direct) return undefined;
    if (/\.[A-Za-z0-9]+$/u.test(String(direct))) {
      if (/\.(?:eps|ps)$/iu.test(String(direct))) {
        const baseTarget = candidateTarget.replace(/\.(?:eps|ps)$/iu, '');
        for (const extension of VISUAL_GRAPHICS_EXTENSION_ORDER) {
          if (extension === 'eps' || extension === 'ps') continue;
          const sibling = resolveAssetTarget(from, `${baseTarget}.${extension}`);
          if (sibling && hasProjectFile(sibling)) return sibling;
        }
      }
      return hasProjectFile(direct) ? direct : undefined;
    }
    for (const extension of VISUAL_GRAPHICS_EXTENSION_ORDER) {
      const candidate = resolveAssetTarget(from, `${candidateTarget}.${extension}`);
      if (candidate && hasProjectFile(candidate)) return candidate;
    }
    return hasProjectFile(direct) ? direct : undefined;
  };

  // LaTeX first permits the literal reference and can then search configured
  // graphics directories. Preserve that order while still preferring a browser
  // previewable sibling over EPS when the source names EPS explicitly.
  for (const candidateTarget of [target, ...graphicsPaths.map((prefix) => `${prefix}${target}`)]) {
    const resolved = resolveOne(candidateTarget);
    if (resolved) return resolved;
  }
  return undefined;
}

async function pdfGraphicPreviewUrl(
  data: Uint8Array,
  signal: AbortSignal,
  pageNumber = 1,
): Promise<string | undefined> {
  if (signal.aborted) return undefined;
  const pdfjs = await import('pdfjs-dist');
  if (signal.aborted) return undefined;
  pdfjs.GlobalWorkerOptions.workerSrc = new URL(
    'pdfjs-dist/build/pdf.worker.min.mjs',
    import.meta.url,
  ).toString();
  const loadingTask = pdfjs.getDocument({ data });
  try {
    const documentProxy = await loadingTask.promise;
    if (signal.aborted) return undefined;
    const selectedPage = Math.max(1, Math.min(documentProxy.numPages, pageNumber));
    const page = await documentProxy.getPage(selectedPage);
    const base = page.getViewport({ scale: 1 });
    const scale = Math.min(2, Math.max(0.35, 720 / Math.max(1, base.width)));
    const viewport = page.getViewport({ scale });
    const canvas = document.createElement('canvas');
    const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.max(1, Math.floor(viewport.width * pixelRatio));
    canvas.height = Math.max(1, Math.floor(viewport.height * pixelRatio));
    const context = canvas.getContext('2d', { alpha: false });
    if (!context) return undefined;
    const renderTask = page.render({
      canvas,
      canvasContext: context,
      viewport,
      transform: pixelRatio === 1 ? undefined : [pixelRatio, 0, 0, pixelRatio, 0, 0],
    });
    await renderTask.promise;
    if (signal.aborted) return undefined;
    const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
    return blob ? URL.createObjectURL(blob) : undefined;
  } finally {
    void loadingTask.destroy();
  }
}

async function visualGraphicsPreviewUrl(
  path: string,
  data: Uint8Array,
  signal: AbortSignal,
  pageNumber = 1,
): Promise<string | undefined> {
  if (/\.pdf$/iu.test(path)) return pdfGraphicPreviewUrl(data, signal, pageNumber);
  if (/\.(?:eps|ps)$/iu.test(path)) return undefined;
  return URL.createObjectURL(new Blob([data as BlobPart], { type: contentType(path) }));
}

function captionEdit(
  source: string,
  block: Extract<VisualBlock, { kind: 'figure' | 'table' }>,
  caption: string,
): SourceTextEdit | undefined {
  if (block.captionBodyRange) {
    return {
      from: block.captionBodyRange.from,
      to: block.captionBodyRange.to,
      text: caption,
    };
  }
  if (!caption.trim()) return undefined;
  const raw = source.slice(block.range.from, block.range.to);
  const endAt = raw.lastIndexOf('\\end{');
  if (endAt < 0) return undefined;
  const insertAt = block.range.from + endAt;
  return { from: insertAt, to: insertAt, text: `  \\caption{${caption}}\n` };
}

function figureWidthPercent(
  block: Extract<VisualBlock, { kind: 'figure' }>,
): string {
  const options = block.graphicsOptions ?? '';
  const match = /(?:^|,)\s*width\s*=\s*([0-9]*\.?[0-9]+)\s*\\linewidth(?:\s*,|$)/.exec(
    options,
  );
  if (!match?.[1]) return '';
  const ratio = Number(match[1]);
  return Number.isFinite(ratio) ? String(Math.round(ratio * 1000) / 10) : '';
}

function figureOptionValue(
  block: Extract<VisualBlock, { kind: 'figure' }>,
  key: string,
): string {
  const match = new RegExp(`(?:^|,)\\s*${key}\\s*=\\s*([^,]+)`, 'i').exec(block.graphicsOptions ?? '');
  return match?.[1]?.trim() ?? '';
}

function figurePercentOption(
  block: Extract<VisualBlock, { kind: 'figure' }>,
  key: string,
  unit: '\\linewidth' | '\\textheight',
): string {
  const value = figureOptionValue(block, key);
  const escapedUnit = escapeRegExpForPattern(unit);
  const match = new RegExp(`^([0-9]*\\.?[0-9]+)\\s*${escapedUnit}$`).exec(value);
  const ratio = Number(match?.[1]);
  return Number.isFinite(ratio) ? String(Math.round(ratio * 1000) / 10) : '';
}

function escapeRegExpForPattern(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function figureGraphicsSettingsEdit(
  source: string,
  block: Extract<VisualBlock, { kind: 'figure' }>,
  settings: {
    widthPercent: string;
    heightPercent: string;
    scale: string;
    keepAspectRatio: boolean;
  },
): SourceTextEdit | undefined {
  const original = block.graphicsOptionsBodyRange
    ? source.slice(block.graphicsOptionsBodyRange.from, block.graphicsOptionsBodyRange.to)
    : '';
  const parts = original.split(',').map((part) => part.trim()).filter(Boolean);

  const replaceKey = (key: string, nextValue: string | undefined, removeWhenBlank: boolean) => {
    const index = parts.findIndex((part) => new RegExp(`^${key}\\s*=`, 'i').test(part));
    if (!nextValue) {
      if (removeWhenBlank && index >= 0) parts.splice(index, 1);
      return;
    }
    const next = `${key}=${nextValue}`;
    if (index >= 0) parts[index] = next;
    else parts.push(next);
  };

  const normalizedPercent = (value: string, unit: string): string | undefined => {
    if (!value.trim()) return undefined;
    const percent = Number(value);
    if (!Number.isFinite(percent) || percent <= 0 || percent > 1000) return undefined;
    const ratio = Math.round((percent / 100) * 1000) / 1000;
    return `${ratio}${unit}`;
  };

  const existingWidth = figureOptionValue(block, 'width');
  const existingHeight = figureOptionValue(block, 'height');
  replaceKey(
    'width',
    normalizedPercent(settings.widthPercent, '\\linewidth'),
    !existingWidth || /\\linewidth\s*$/.test(existingWidth),
  );
  replaceKey(
    'height',
    normalizedPercent(settings.heightPercent, '\\textheight'),
    !existingHeight || /\\textheight\s*$/.test(existingHeight),
  );
  replaceKey('scale', settings.scale.trim() || undefined, true);

  const flagIndex = parts.findIndex((part) => part.toLowerCase() === 'keepaspectratio');
  if (settings.keepAspectRatio && flagIndex < 0) parts.push('keepaspectratio');
  if (!settings.keepAspectRatio && flagIndex >= 0) parts.splice(flagIndex, 1);

  const next = parts.join(',');
  if (next === original.replace(/\s*,\s*/g, ',').trim()) return undefined;
  if (block.graphicsOptionsBodyRange) {
    return {
      from: block.graphicsOptionsBodyRange.from,
      to: block.graphicsOptionsBodyRange.to,
      text: next,
    };
  }
  if (!next || !block.assetBodyRange) return undefined;
  const openBraceAt = block.assetBodyRange.from - 1;
  return { from: openBraceAt, to: openBraceAt, text: `[${next}]` };
}

function figureCenteringState(source: string, block: Extract<VisualBlock, { kind: 'figure' }>): boolean {
  return /\\centering\b/.test(source.slice(block.range.from, block.range.to));
}

function figureCenteringEdit(
  source: string,
  block: Extract<VisualBlock, { kind: 'figure' }>,
  centered: boolean,
): SourceTextEdit | undefined {
  const raw = source.slice(block.range.from, block.range.to);
  const match = /\\centering\b\s*/.exec(raw);
  if (centered) {
    if (match) return undefined;
    const begin = /\\begin\s*\{figure\*?\}(?:\[[^\]]*\])?/.exec(raw);
    if (!begin) return undefined;
    const at = block.range.from + begin.index + begin[0].length;
    return { from: at, to: at, text: '\n\\centering' };
  }
  if (!match) return undefined;
  return { from: block.range.from + match.index, to: block.range.from + match.index + match[0].length, text: '' };
}

function figureDescription(source: string, block: Extract<VisualBlock, { kind: 'figure' }>): string {
  const raw = source.slice(block.range.from, block.range.to);
  return /\\Description\s*\{([^{}]*)\}/.exec(raw)?.[1] ?? '';
}

function hasFigureDescription(source: string, block: Extract<VisualBlock, { kind: 'figure' }>): boolean {
  return /\\Description\s*\{/.test(source.slice(block.range.from, block.range.to));
}

function figureDescriptionEdit(
  source: string,
  block: Extract<VisualBlock, { kind: 'figure' }>,
  description: string,
): SourceTextEdit | undefined {
  const raw = source.slice(block.range.from, block.range.to);
  const match = /\\Description\s*\{([^{}]*)\}/.exec(raw);
  if (match) {
    const bodyAt = match.index + match[0].indexOf('{') + 1;
    return {
      from: block.range.from + bodyAt,
      to: block.range.from + bodyAt + (match[1]?.length ?? 0),
      text: description,
    };
  }
  // `\\Description` is document-class specific (for example acmart), so the
  // Visual editor only edits an existing command. It never injects a
  // proprietary/class-specific accessibility macro into an arbitrary project.
  return undefined;
}



function environmentPlacement(
  source: string,
  block: Extract<VisualBlock, { kind: 'figure' | 'table' }>,
): string {
  const raw = source.slice(block.range.from, block.range.to);
  const match = block.kind === 'figure'
    ? /\\begin\s*\{figure\}\s*(?:\[([^\]]*)\])?/.exec(raw)
    : /\\begin\s*\{table\}\s*(?:\[([^\]]*)\])?/.exec(raw);
  return match?.[1]?.trim() ?? '';
}

function figurePlacementEdit(
  source: string,
  block: Extract<VisualBlock, { kind: 'figure' | 'table' }>,
  placement: string,
): SourceTextEdit | undefined {
  const raw = source.slice(block.range.from, block.range.to);
  const match = block.kind === 'figure'
    ? /\\begin\s*\{figure\}\s*(?:\[([^\]]*)\])?/.exec(raw)
    : /\\begin\s*\{table\}\s*(?:\[([^\]]*)\])?/.exec(raw);
  if (!match || match.index === undefined) return undefined;
  const full = match[0];
  const absolute = block.range.from + match.index;
  const bracketAt = full.indexOf('[');
  const normalized = placement.trim();
  if (bracketAt >= 0) {
    const closeAt = full.lastIndexOf(']');
    if (closeAt < bracketAt) return undefined;
    return {
      from: absolute + bracketAt,
      to: absolute + closeAt + 1,
      text: normalized ? `[${normalized}]` : '',
    };
  }
  if (!normalized) return undefined;
  const beginToken = block.kind === 'figure'
    ? /\\begin\s*\{figure\}/.exec(full)?.[0]
    : /\\begin\s*\{table\}/.exec(full)?.[0];
  if (!beginToken) return undefined;
  const at = absolute + beginToken.length;
  return { from: at, to: at, text: `[${normalized}]` };
}

function labelEdit(
  source: string,
  block: Extract<VisualBlock, { kind: 'figure' | 'table' }>,
  label: string,
): SourceTextEdit | undefined {
  const normalized = label.trim();
  if (block.labelBodyRange) {
    return {
      from: block.labelBodyRange.from,
      to: block.labelBodyRange.to,
      text: normalized,
    };
  }
  if (!normalized) return undefined;
  const raw = source.slice(block.range.from, block.range.to);
  const endAt = raw.lastIndexOf('\\end{');
  if (endAt < 0) return undefined;
  const insertAt = block.range.from + endAt;
  return { from: insertAt, to: insertAt, text: `  \\label{${normalized}}\n` };
}

function escapeLatexTableCell(value: string): string {
  return value
    .replaceAll('\\', '\\textbackslash{}')
    .replaceAll('{', '\\{')
    .replaceAll('}', '\\}')
    .replaceAll('&', '\\&')
    .replaceAll('%', '\\%')
    .replaceAll('#', '\\#')
    .replaceAll('_', '\\_')
    .replaceAll('$', '\\$')
    .replaceAll('^', '\\textasciicircum{}')
    .replaceAll('~', '\\textasciitilde{}');
}

function parseDelimitedTableRow(line: string, delimiter: ',' | '\t'): string[] | undefined {
  if (delimiter === '\t') return line.split('\t');
  const cells: string[] = [];
  let current = '';
  let quoted = false;
  for (let index = 0; index < line.length; index += 1) {
    const char = line[index]!;
    if (char === '"') {
      if (quoted && line[index + 1] === '"') {
        current += '"';
        index += 1;
      } else {
        quoted = !quoted;
      }
      continue;
    }
    if (char === ',' && !quoted) {
      cells.push(current);
      current = '';
      continue;
    }
    current += char;
  }
  if (quoted) return undefined;
  cells.push(current);
  return cells;
}

function parseDelimitedTableInput(input: string): string[][] | undefined {
  if (!input.trim()) return undefined;
  const lines = input.trim().split(/\r?\n/).filter((line) => line.length > 0);
  if (!lines.length) return undefined;
  const delimiter: ',' | '\t' = lines.some((line) => line.includes('\t')) ? '\t' : ',';
  const parsedRows = lines.map((line) => parseDelimitedTableRow(line, delimiter));
  if (parsedRows.some((row) => !row)) return undefined;
  const rows = parsedRows.map((row) => row!.map((cell) => cell.trim()));
  const columns = Math.max(...rows.map((row) => row.length));
  if (!columns) return undefined;
  for (const row of rows) while (row.length < columns) row.push('');
  return rows;
}

function tableImportEdits(
  source: string,
  block: Extract<VisualBlock, { kind: 'table' }>,
  input: string,
): SourceTextEdit[] | undefined {
  if (!block.editable) return undefined;
  const parsed = parseDelimitedTableInput(input);
  if (!parsed) return undefined;
  const rows = parsed.map((row) => row.map((cell) => escapeLatexTableCell(cell)));
  const columns = Math.max(...rows.map((row) => row.length));
  if (!columns) return undefined;
  for (const row of rows) while (row.length < columns) row.push('');
  const raw = source.slice(block.range.from, block.range.to);
  const begin = /\\begin\s*\{tabular\}\s*\{([^}]*)\}/.exec(raw);
  const endIndex = raw.lastIndexOf('\\end{tabular}');
  if (!begin || begin.index === undefined || endIndex < 0) return undefined;
  const spec = begin[1] ?? '';
  const plainSpec = spec.replaceAll('|', '');
  const fullGrid = /^\|[lcr](?:\|[lcr])*\|$/u.test(spec);
  if (!/^[lcr]+$/u.test(plainSpec) || (spec.includes('|') && !fullGrid)) return undefined;
  const nextColumns = Array.from({ length: columns }, (_, index) => plainSpec[index] ?? 'l');
  const nextSpec = fullGrid ? `|${nextColumns.join('|')}|` : nextColumns.join('');
  const bodyFrom = block.range.from + begin.index + begin[0].length;
  const bodyTo = block.range.from + endIndex;
  const plainRows = rows.map((values) => `  ${values.join(' & ')} \\\\`);
  const body = fullGrid
    ? `\n${['\\hline', ...plainRows.flatMap((row) => [row, '\\hline'])].join('\n')}\n`
    : `\n${plainRows.join('\n')}\n`;
  const specAt = begin[0].lastIndexOf(`{${spec}}`);
  if (specAt < 0) return undefined;
  const specFrom = block.range.from + begin.index + specAt + 1;
  return [
    { from: bodyFrom, to: bodyTo, text: body },
    { from: specFrom, to: specFrom + spec.length, text: nextSpec },
  ];
}

type VisualDialogBase = {
  revision: number;
  baseSource: string;
  visualEdits: readonly SourceTextEdit[];
};

type VisualMetadataSession = VisualDialogBase & {
  block: Extract<VisualBlock, { kind: 'figure' | 'table' }>;
};

type VisualTableMetadataSession = VisualDialogBase & {
  block: Extract<VisualBlock, { kind: 'table' }>;
};

function isVisualTableMetadataSession(
  session: VisualMetadataSession | undefined,
): session is VisualTableMetadataSession {
  return session?.block.kind === 'table';
}

type VisualMathSession = {
  revision: number;
  region: LatexMathRegion;
  actionId: string;
  baseFrom: number;
  baseTo: number;
  initialValue: string;
  anchor?: DOMRect;
};


type VisualSlashSession = Extract<LatexVisualAction, { kind: 'slash-menu' }> & { sourceRevision?: number };

const BF44_NATIVE_OWNED_SLASH_COMMANDS = new Set<LatexVisualInsertCommand>([
  'paragraph',
  'section',
  'subsection',
  'subsubsection',
  'bullet-list',
  'numbered-list',
  'inline-math',
  'display-math',
  'equation',
]);
type VisualFormatAction = Extract<LatexVisualAction, { kind: 'format-selection' }>;
type VisualMoveAction = Extract<LatexVisualAction, { kind: 'move-block' }>;
type VisualHardBreakAction = Extract<LatexVisualAction, { kind: 'hard-break' }>;

function mapBaseOffsetThroughEdits(
  offset: number,
  edits: readonly SourceTextEdit[],
): number {
  let delta = 0;
  const sorted = [...edits].sort((a, b) => a.from - b.from || a.to - b.to);
  for (const edit of sorted) {
    const removed = edit.to - edit.from;
    const change = edit.text.length - removed;
    if (edit.to < offset || (edit.to === offset && edit.from < offset)) {
      delta += change;
      continue;
    }
    if (edit.from < offset && offset < edit.to) {
      const relative = offset - edit.from;
      return edit.from + delta + Math.min(relative, edit.text.length);
    }
  }
  return offset + delta;
}

function latexInsertTemplate(command: LatexVisualInsertCommand): {
  text: string;
  focusOffset: number;
} {
  const withFocus = (text: string, marker: string) => {
    const index = text.indexOf(marker);
    return { text, focusOffset: Math.max(0, index) };
  };
  switch (command) {
    case 'paragraph':
      return withFocus(`${CANONICAL_LATEX_PARAGRAPH_SEPARATOR}Start writing here.${CANONICAL_LATEX_PARAGRAPH_SEPARATOR}`, 'Start writing here.');
    case 'section':
      return withFocus('\n\n\\section{Section title}\n\n', 'Section title');
    case 'subsection':
      return withFocus('\n\n\\subsection{Subsection title}\n\n', 'Subsection title');
    case 'subsubsection':
      return withFocus('\n\n\\subsubsection{Subsubsection title}\n\n', 'Subsubsection title');
    case 'bullet-list':
      return withFocus('\n\n\\begin{itemize}\n\\item First item\n\\item Second item\n\\end{itemize}\n\n', 'First item');
    case 'numbered-list':
      return withFocus('\n\n\\begin{enumerate}\n\\item First item\n\\item Second item\n\\end{enumerate}\n\n', 'First item');
    case 'inline-math':
      return withFocus('\\(E=mc^2\\)', 'E=mc^2');
    case 'display-math':
      return withFocus('\n\n\\[\nE=mc^2\n\\]\n\n', 'E=mc^2');
    case 'equation':
      return withFocus('\n\n\\begin{equation}\nE=mc^2\n\\end{equation}\n\n', 'E=mc^2');
    case 'table': {
      const template = createLatexTableTemplate(2, 2, {
        cells: [
          ['First', 'Second'],
          ['Third', 'Fourth'],
        ],
        caption: 'Table caption',
        includeLabel: false,
      });
      return {
        text: `\n\n${template.text}\n\n`,
        focusOffset: template.firstCellOffset + 2,
      };
    }
    case 'figure':
      return withFocus('\n\n% Choose Figure / image from the slash menu.\n\n', 'Choose Figure');
    case 'citation':
      return withFocus('\n\n\\cite{reference-key}\n\n', 'reference-key');
    case 'reference':
      return withFocus('\n\n\\ref{label}\n\n', 'label');
    case 'label':
      return withFocus('\n\n\\label{label}\n\n', 'label');
    case 'footnote':
      return withFocus('\n\n\\footnote{Footnote text}\n\n', 'Footnote text');
    case 'code-block':
      return withFocus('\n\n\\begin{verbatim}\nCode\n\\end{verbatim}\n\n', 'Code');
    case 'raw-latex':
      return withFocus('\n\n% Raw LaTeX\n\\mbox{}\n\n', '\\mbox{}');
    case 'page-break':
      return { text: '\n\n\\newpage\n\n', focusOffset: 2 };
    case 'newline':
      return {
        text: CANONICAL_LATEX_HARD_BREAK,
        focusOffset: CANONICAL_LATEX_HARD_BREAK.length,
      };
  }
}

function wrapVisualSelection(
  action: VisualFormatAction,
  selectedText: string,
): string | undefined {
  switch (action.format) {
    case 'bold':
      return `\\textbf{${selectedText}}`;
    case 'italic':
      return `\\emph{${selectedText}}`;
    case 'underline':
      return `\\underline{${selectedText}}`;
    case 'monospace':
      return `\\texttt{${selectedText}}`;
    case 'link':
      return undefined;
  }
}

export const LatexVisualEditor = React.memo(function LatexVisualEditor({
  wsPath,
  active = true,
  documentController,
  document,
  onRequestSource,
  onManuscriptSelectionChange,
  onOpenReviewThread,
  onRequestReviewComment,
  onOpenPaper,
}: {
  wsPath: string;
  active?: boolean;
  documentController: LatexDocumentController;
  document: {
    path: string;
    text: string;
    revision: number;
    lastOrigin?: SourceMutationOrigin;
  };
  onRequestSource?: (line: number, range?: { from: number; to: number }) => void;
  onManuscriptSelectionChange?: (selection: PaperMemoryManuscriptSelection | undefined) => void;
  onOpenReviewThread?: (threadId: string) => void;
  onRequestReviewComment?: (target: ReviewCommentTarget) => void;
  onOpenPaper?: (paperId: string, page?: number) => void;
}) {
  const {
    latexSource,
    projectAi,
    projectAiWriting,
    projectSemantic,
    projectStorage,
    projectAssets,
    projectPdf,
    projectTools,
    fileSystem,
    navigation,
    projectReview,
    projectCollaboration,
  } = useCoreServices();
  const reviewVersion = useAtomValue(active ? projectReview.$version : INACTIVE_VISUAL_VERSION_ATOM);
  useAtomValue(active ? projectAiWriting.$version : INACTIVE_VISUAL_VERSION_ATOM);
  const collaborationState = useAtomValue(projectCollaboration.$state);
  const collaborationStateRef = React.useRef(collaborationState);
  collaborationStateRef.current = collaborationState;
  const hostRef = React.useRef<HTMLDivElement | null>(null);
  const [reviewObjectId, setReviewObjectId] = React.useState<string>();
  const [aiSuggestBusy, setAiSuggestBusy] = React.useState(false);
  const aiSuggestMenuRef = React.useRef<HTMLDivElement>(null);
  const [aiVisualMenu, setAiVisualMenu] = React.useState<{
    anchor: { left: number; top: number; bottom: number; right: number };
    selection: {
      from: number;
      to: number;
      text: string;
      protectedVisual: boolean;
      changeId?: string;
    };
  }>();
  React.useEffect(() => {
    const closeOnOutsidePointer = (event: PointerEvent) => {
      const menu = aiSuggestMenuRef.current;
      if (!aiVisualMenu) return;
      if (event.target instanceof Node && menu?.contains(event.target)) return;
      setAiVisualMenu(undefined);
    };
    globalThis.document.addEventListener('pointerdown', closeOnOutsidePointer, true);
    return () => globalThis.document.removeEventListener('pointerdown', closeOnOutsidePointer, true);
  }, [aiVisualMenu]);
  const [aiVisualSelection, setAiVisualSelection] = React.useState<{
    from: number;
    to: number;
    text: string;
    protectedVisual: boolean;
    changeId?: string;
  }>();
  const lastVisualCursorRef = React.useRef(0);
  const onRequestSourceRef = React.useRef(onRequestSource);
  onRequestSourceRef.current = onRequestSource;
  const replaceImageInputRef = React.useRef<HTMLInputElement | null>(null);
  const insertFigureInputRef = React.useRef<HTMLInputElement | null>(null);
  const file = WsPath.assertFile(wsPath);
  const path = projectFilePath(file.path);
  const trackChangesEnabled = projectReview.getTrackChangesEnabled(file.wsName);
  const editingDocumentText = trackChangesEnabled
    ? projectReview.getProjectedSource(file.wsName, path, document.text)
    : document.text;
  const [semanticSnapshot, setSemanticSnapshot] = React.useState(() =>
    projectSemantic.getSnapshot(file.wsName),
  );
  const semanticLabelsRef = React.useRef(semanticSnapshot?.labels);
  semanticLabelsRef.current = semanticSnapshot?.labels;
  const [error, setError] = React.useState<string>();
  const [assetUrls, setAssetUrls] = React.useState<ReadonlyMap<string, string>>(
    new Map(),
  );
  const [citationSession, setCitationSession] = React.useState<
    VisualDialogBase & {
      action: Extract<LatexVisualAction, { kind: 'citation' }>;
    }
  >();
  const [citationContextSession, setCitationContextSession] = React.useState<
    VisualDialogBase & {
      action: Extract<LatexVisualAction, { kind: 'citation' }>;
      anchor: import('@learnitive/latex-source').SourceAnchor;
    }
  >();
  const [citationSufficiencyOpen, setCitationSufficiencyOpen] = React.useState(false);
  const [footnoteSession, setFootnoteSession] = React.useState<
    VisualDialogBase & {
      action: Extract<LatexVisualAction, { kind: 'footnote' }>;
    }
  >();
  const [mathSession, setMathSession] = React.useState<VisualMathSession>();
  const [slashSession, setSlashSession] = React.useState<VisualSlashSession>();
  const slashSessionRef = React.useRef<VisualSlashSession | undefined>(
    undefined,
  );
  slashSessionRef.current = slashSession;
  const [citationInsertSession, setCitationInsertSession] = React.useState<VisualSlashSession>();
  const [citationResearchSession, setCitationResearchSession] = React.useState<{ claim: string; anchor: SourceAnchor }>();
  const [tableInsertSession, setTableInsertSession] = React.useState<VisualSlashSession>();
  const [referenceInsertSession, setReferenceInsertSession] = React.useState<VisualSlashSession>();
  const [referenceEditSession, setReferenceEditSession] = React.useState<
    VisualDialogBase & { action: Extract<LatexVisualAction, { kind: 'reference' }> }
  >();
  const [linkSession, setLinkSession] = React.useState<{ action: VisualFormatAction; href?: string }>();
  const [pendingFigureSession, setPendingFigureSession] = React.useState<VisualSlashSession>();
  const syntheticMaterializationRef = React.useRef<{
    id: string;
    nextOffset: number;
  } | undefined>(undefined);
  const [metadataSession, setMetadataSession] = React.useState<VisualMetadataSession>();
  const metadataTableSession = isVisualTableMetadataSession(metadataSession)
    ? metadataSession
    : undefined;
  const [metadataCaption, setMetadataCaption] = React.useState('');
  const [metadataWidth, setMetadataWidth] = React.useState('');
  const [metadataHeight, setMetadataHeight] = React.useState('');
  const [metadataScale, setMetadataScale] = React.useState('');
  const [metadataKeepAspect, setMetadataKeepAspect] = React.useState(false);
  const [metadataCentered, setMetadataCentered] = React.useState(false);
  const [metadataDescription, setMetadataDescription] = React.useState('');
  const [metadataPlacement, setMetadataPlacement] = React.useState('');
  const [metadataLabel, setMetadataLabel] = React.useState('');
  const [metadataTableImport, setMetadataTableImport] = React.useState('');
  const [replacementAsset, setReplacementAsset] = React.useState<string>();
  const [replacementBusy, setReplacementBusy] = React.useState(false);
  const [footnoteText, setFootnoteText] = React.useState('');
  const sourceRevisionRef = React.useRef(document.revision);
  const documentTextRef = React.useRef(document.text);
  documentTextRef.current = editingDocumentText;
  const visualBaseSourceRef = React.useRef(editingDocumentText);
  const visualTextEditsRef = React.useRef<readonly SourceTextEdit[]>([]);
  const visualMathEditsRef = React.useRef<Map<string, SourceTextEdit>>(new Map());
  const visualEditorRef = React.useRef<ReturnType<typeof createLatexVisualEditor> | null>(null);
  const visualScrollTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const lastRevealRequestRef = React.useRef(0);
  const visualProjectionEdits = React.useCallback(
    () => [
      ...visualTextEditsRef.current,
      ...visualMathEditsRef.current.values(),
    ],
    [],
  );
  sourceRevisionRef.current = document.revision;

  React.useEffect(() => {
    let cancelled = false;
    void projectSemantic
      .refresh(file.wsName)
      .then((snapshot) => {
        if (!cancelled) setSemanticSnapshot(snapshot);
      })
      .catch((reason) => {
        if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason));
      });
    const unsubscribeSemantic = projectSemantic.subscribe(file.wsName, () => {
      setSemanticSnapshot(projectSemantic.getSnapshot(file.wsName));
    });
    return () => {
      cancelled = true;
      unsubscribeSemantic();
    };
  }, [file.wsName, projectSemantic]);

  const getCanonicalSource = React.useCallback(() => {
    const controllerSnapshot = documentController.getSnapshot();
    if (
      controllerSnapshot.status !== 'ready' ||
      controllerSnapshot.activePath !== wsPath ||
      controllerSnapshot.requestedPath !== wsPath
    ) {
      return undefined;
    }
    const serviceSnapshot = latexSource.getSnapshot(wsPath);
    return {
      text: controllerSnapshot.content,
      revision: controllerSnapshot.revision,
      canUndo: serviceSnapshot?.canUndo ?? false,
      canRedo: serviceSnapshot?.canRedo ?? false,
    };
  }, [documentController, latexSource, wsPath]);

  const getEditingSource = React.useCallback(() => {
    const canonical = getCanonicalSource();
    if (!canonical) return undefined;
    return trackChangesEnabled
      ? { ...canonical, text: projectReview.getProjectedSource(file.wsName, path, canonical.text) }
      : canonical;
  }, [file.wsName, getCanonicalSource, path, projectReview, reviewVersion, trackChangesEnabled]);

  const sourceSnapshot = {
    text: editingDocumentText,
    revision: document.revision,
    canUndo: trackChangesEnabled ? false : (latexSource.getSnapshot(wsPath)?.canUndo ?? false),
    canRedo: trackChangesEnabled ? false : (latexSource.getSnapshot(wsPath)?.canRedo ?? false),
  };

  const commitExactNextSource = React.useCallback((
    nextSource: string,
    origin: SourceMutationOrigin,
    expectedRevision?: number,
    history?: 'record' | 'ignore' | 'reset',
  ) => {
    const canonical = getCanonicalSource();
    const current = getEditingSource();
    if (!canonical || !current || (expectedRevision !== undefined && canonical.revision !== expectedRevision)) {
      return undefined;
    }
    const normalizedNextSource = hasMixedLatexLineEndings(current.text)
      ? nextSource
      : normalizeLatexLineEndings(
          nextSource,
          detectLatexLineEnding(current.text),
        );
    const edit = computeMinimalSourceTextEdit(current.text, normalizedNextSource);
    if (!edit) return current;
    if (trackChangesEnabled) {
      projectReview.applyProjectedEdit(file.wsName, path, canonical.text, edit);
      return { ...current, text: projectReview.getProjectedSource(file.wsName, path, canonical.text) };
    }
    const result = documentController.applyExactEdit(wsPath, {
      expectedRevision: canonical.revision,
      from: edit.from,
      to: edit.to,
      insert: edit.text,
      origin,
      ...(history ? { history } : {}),
    });
    return result.applied ? result.snapshot : undefined;
  }, [documentController, file.wsName, getCanonicalSource, getEditingSource, path, projectReview, trackChangesEnabled, wsPath]);


  const commitExactSourceEdits = React.useCallback((
    edits: readonly SourceTextEdit[],
    origin: SourceMutationOrigin,
    expectedRevision: number,
    history?: 'record' | 'ignore' | 'reset',
  ) => {
    if (edits.length === 0) return getEditingSource();
    const canonical = getCanonicalSource();
    const current = getEditingSource();
    if (!canonical || !current || canonical.revision !== expectedRevision) return undefined;
    const lineEnding = detectLatexLineEnding(current.text);
    const normalizedEdits = edits.map((edit) => ({
      ...edit,
      text: normalizeLatexLineEndings(edit.text, lineEnding),
    }));
    if (trackChangesEnabled) {
      for (const edit of [...normalizedEdits].sort((a, b) => b.from - a.from || b.to - a.to)) {
        projectReview.applyProjectedEdit(file.wsName, path, canonical.text, edit);
      }
      return getEditingSource();
    }
    const result = documentController.applyExactEdits(wsPath, {
      expectedRevision,
      edits: normalizedEdits.map((edit) => ({ from: edit.from, to: edit.to, insert: edit.text })),
      origin,
      ...(history ? { history } : {}),
    });
    return result.applied ? result.snapshot : undefined;
  }, [documentController, file.wsName, getCanonicalSource, getEditingSource, path, projectReview, trackChangesEnabled, wsPath]);


  const [visualBlocks, setVisualBlocks] = React.useState<readonly VisualBlock[]>();
  const visualBlocksRef = React.useRef<readonly VisualBlock[] | undefined>(undefined);
  visualBlocksRef.current = visualBlocks;
  const visualBlocksSourceRef = React.useRef<string | undefined>(undefined);
  // A VisualBlock projection is source-range keyed. Track activation separately
  // from the source marker so Source -> Visual can never trust a block array
  // whose ranges were produced for an older canonical source. This is
  // intentionally independent of React mount state because the Visual surface
  // stays mounted while Source mode is shown.
  const visualWasActiveRef = React.useRef(false);
  // Source mode keeps Visual mounted, but parsing a complex manuscript while
  // Source is active makes typing contend with work the author cannot see.
  // Hidden Visual therefore stays lazy and reparses only when activated.
  const assetUrlsRef = React.useRef<ReadonlyMap<string, string>>(assetUrls);
  assetUrlsRef.current = assetUrls;

  // TX7 live collaboration is Source-only; Visual never owns collaborative history.
  const collaborativeHistoryActive = false;
  const canCollaborativeUndo = false;
  const canCollaborativeRedo = false;
  const applyCollaborativeHistory = React.useCallback((_direction: 'undo' | 'redo'): boolean => false, []);

  React.useEffect(() => {
    const host = hostRef.current;
    const blocks = visualBlocks;
    if (!host || !blocks || !active) return;
    const claimVisualUserScroll = () => {
      projectPdf.claimScrollOwnership(file.wsName, 'visual-user');
    };
    const claimVisualKeyboardScroll = (event: KeyboardEvent) => {
      if (SCROLL_OWNERSHIP_KEYS.has(event.key)) claimVisualUserScroll();
    };
    const handleScroll = () => {
      if (!projectPdf.isScrollOwnedByUser(file.wsName, 'visual')) return;
      if (visualScrollTimerRef.current) clearTimeout(visualScrollTimerRef.current);
      visualScrollTimerRef.current = setTimeout(() => {
        if (!projectPdf.isScrollOwnedByUser(file.wsName, 'visual')) return;
        const rect = host.getBoundingClientRect();
        const target = globalThis.document.elementFromPoint(
          rect.left + Math.max(24, rect.width / 2),
          rect.top + Math.max(1, rect.height / 2),
        );
        const sourceElement = target instanceof Element
          ? target.closest<HTMLElement>('[data-source-id]')
          : null;
        const id = sourceElement?.dataset.sourceId;
        if (!id) return;
        const nativeRange = (visualEditorRef.current as {
          sourceRangeForId?: (sourceId: string) => { from: number; to: number } | undefined;
        } | null)?.sourceRangeForId?.(id);
        const range = nativeRange ?? sourceRangeForVisualId(blocks, id);
        if (!range) return;
        const current = getCanonicalSource();
        const editing = getEditingSource();
        if (!current || !editing) return;
        const mappedOffset = nativeRange
          ? trackChangesEnabled
            ? projectReview.projectedRangeToCanonical(
                file.wsName,
                path,
                current.text,
                range.from,
                range.from,
              ).from
            : range.from
          : mapBaseOffsetThroughEdits(range.from, visualProjectionEdits());
        const line = current.text.slice(0, mappedOffset).split('\n').length;
        projectPdf.forwardSync(file.wsName, path, line, undefined);
      }, 160);
    };
    host.addEventListener('wheel', claimVisualUserScroll, { passive: true });
    host.addEventListener('touchstart', claimVisualUserScroll, { passive: true });
    host.addEventListener('pointerdown', claimVisualUserScroll, { passive: true });
    host.addEventListener('keydown', claimVisualKeyboardScroll);
    host.addEventListener('scroll', handleScroll, { passive: true });
    return () => {
      host.removeEventListener('wheel', claimVisualUserScroll);
      host.removeEventListener('touchstart', claimVisualUserScroll);
      host.removeEventListener('pointerdown', claimVisualUserScroll);
      host.removeEventListener('keydown', claimVisualKeyboardScroll);
      host.removeEventListener('scroll', handleScroll);
      if (visualScrollTimerRef.current) clearTimeout(visualScrollTimerRef.current);
    };
  }, [
    active,
    file.wsName,
    getCanonicalSource,
    getEditingSource,
    path,
    projectPdf,
    projectReview,
    trackChangesEnabled,
    visualBlocks,
    visualProjectionEdits,
  ]);

  React.useLayoutEffect(() => {
    if (!active) return;
    const editor = visualEditorRef.current;
    const request = latexSource.getRevealRequest(wsPath);
    if (!editor || !request || request.requestId === lastRevealRequestRef.current) return;
    const source = getEditingSource()?.text ?? '';
    const lineOffset = sourceOffsetForLine(source, request.line);
    const lineEnd = source.indexOf('\n', lineOffset);
    const maxColumnOffset = (lineEnd < 0 ? source.length : lineEnd) - lineOffset;
    const sourceOffset = Math.min(
      source.length,
      lineOffset + Math.max(0, Math.min((request.column ?? 1) - 1, maxColumnOffset)),
    );
    projectPdf.claimScrollOwnership(file.wsName, 'visual-programmatic');
    if (request.select) {
      if (!editor.focusSourceOffset(sourceOffset)) return;
    } else {
      editor.revealSourceOffset(sourceOffset);
    }
    lastRevealRequestRef.current = request.requestId;
  });

  const currentVisualProjection = React.useCallback(() => {
    const current = getEditingSource();
    if (!current) return undefined;
    const edits = visualProjectionEdits();
    const expected = applySourceTextEdits(visualBaseSourceRef.current, edits);
    if (current.text !== expected) {
      // A live collaboration transaction may have advanced Y.Text immediately
      // before this structural action. The remote projection bridge will
      // reconcile the editor; do not spam a stale-source error in that case.
      if (!(collaborationState.active && collaborationState.wsName === file.wsName)) {
        toast.error(t.app.latexVisual.sourceChanged);
      }
      return undefined;
    }
    return { current, edits };
  }, [collaborationState.active, collaborationState.wsName, file.wsName, getEditingSource, visualProjectionEdits]);

  const commitStructuralSource = React.useCallback(
    (
      nextSource: string,
      focusOffset?: number,
      mathOpen?: { bodyFrom: number; bodyTo: number; value: string },
    ) => {
      const current = getEditingSource();
      if (!current) return false;
      const nextSourceSnapshot = commitExactNextSource(
        nextSource,
        'visual-structure',
        current.revision,
      );
      if (!nextSourceSnapshot) {
        toast.error(t.app.latexVisual.sourceChanged);
        return false;
      }
      const normalizedFocusOffset = focusOffset === undefined
        ? undefined
        : mapOffsetAcrossLineEndingNormalization(nextSource, nextSourceSnapshot.text, focusOffset);
      const normalizedMathOpen = mathOpen
        ? {
            ...mathOpen,
            bodyFrom: mapOffsetAcrossLineEndingNormalization(
              nextSource,
              nextSourceSnapshot.text,
              mathOpen.bodyFrom,
            ),
            bodyTo: mapOffsetAcrossLineEndingNormalization(
              nextSource,
              nextSourceSnapshot.text,
              mathOpen.bodyTo,
            ),
          }
        : undefined;
      visualBaseSourceRef.current = nextSourceSnapshot.text;
      visualTextEditsRef.current = [];
      visualMathEditsRef.current = new Map();
      slashSessionRef.current = undefined;
      setSlashSession(undefined);
      sourceRevisionRef.current = nextSourceSnapshot.revision;
      if (normalizedMathOpen) {
        const region = findLatexMathRegions(nextSourceSnapshot.text).find(
          (candidate) =>
            normalizedMathOpen.bodyFrom >= candidate.from &&
            normalizedMathOpen.bodyFrom <= candidate.to,
        );
        if (region && canEditLatexMathWithMathLive(region)) {
          setMathSession({
            revision: nextSourceSnapshot.revision,
            region,
            actionId: `inserted-math:${normalizedMathOpen.bodyFrom}`,
            baseFrom: region.from,
            baseTo: region.to,
            initialValue: normalizedMathOpen.value || latexMathRegionToMathLiveValue(region),
          });
        }
      }

      // BF3 invariant: source commit + semantic projection + exact source
      // selection are one editor operation. Install the freshly parsed
      // projection on the existing EditorView synchronously before the key
      // handler can observe another Enter/Shift+Enter.
      const nextBlocks = parseVisualBlocksFailClosed(path, nextSourceSnapshot.text);
      visualBlocksSourceRef.current = nextSourceSnapshot.text;
      visualBlocksRef.current = nextBlocks;
      const editor = visualEditorRef.current;
      const selectionMapped = editor
        ? editor.replaceProjection(
            nextBlocks,
            assetUrlsRef.current,
            normalizedFocusOffset === undefined
              ? undefined
              : {
                  anchorSource: normalizedFocusOffset,
                  headSource: normalizedFocusOffset,
                  focus: true,
                },
          )
        : normalizedFocusOffset === undefined;
      setVisualBlocks(nextBlocks);
      if (normalizedFocusOffset !== undefined && editor && !selectionMapped) {
        // Fail closed: do not manufacture a ProseMirror-only caret. The
        // source mutation is canonical and visible; reveal the exact boundary
        // so a missing projection mapping is diagnosable rather than silently
        // drifting to a neighboring structure.
        editor.revealSourceOffset(normalizedFocusOffset);
      }

      void projectSemantic
        .refresh(file.wsName)
        .then(setSemanticSnapshot)
        .catch((reason) => {
          console.warn('Unable to refresh semantic metadata after Visual structure edit', reason);
        });
      return selectionMapped;
    },
    [commitExactNextSource, file.wsName, getEditingSource, path, projectSemantic],
  );

  const focusAfterDisplayMath = React.useCallback(
    (session: VisualMathSession) => {
      if (session.region.kind !== 'display') return;
      if (
        visualEditorRef.current?.native &&
        visualEditorRef.current.focusAfterProtectedNode(session.actionId)
      ) {
        return;
      }
      const blocks = visualBlocksRef.current ?? [];
      const ownerIndex = blocks.findIndex(
        (block) =>
          (block.kind === 'rich-paragraph' || block.kind === 'text-environment') &&
          block.inlines.some(
            (inline) =>
              inline.kind === 'math' &&
              inline.range.from <= session.baseFrom &&
              inline.range.to >= session.baseTo,
          ),
      );
      if (ownerIndex < 0) return;
      const owner = blocks[ownerIndex]!;
      const adjacent = blocks[ownerIndex + 1];
      const adjacentOffset = adjacent?.kind === 'paragraph'
        ? adjacent.bodyRange.from
        : adjacent?.kind === 'rich-paragraph'
          ? adjacent.range.from
          : undefined;
      if (
        adjacentOffset !== undefined &&
        visualEditorRef.current?.focusSourceOffset(adjacentOffset)
      ) {
        return;
      }

      const current = getEditingSource();
      if (!current) return;
      const at = mapBaseOffsetThroughEdits(owner.range.to, visualProjectionEdits());
      const gap = createSourceBackedBlockDeletion(current.text, { from: at, to: at });
      if (!gap) return;
      if (gap.edit.text.length > 0) {
        const nextSource = applySourceTextEdits(current.text, [gap.edit]);
        commitStructuralSource(nextSource, gap.focusOffset);
        return;
      }

      // The source already contains enough blank-line structure but the live
      // projection can still be based on the pre-MathLive source offsets.
      // Reconcile without creating another source revision, then focus the
      // canonical current-source anchor.
      const nextBlocks = parseVisualBlocksFailClosed(path, current.text);
      visualBaseSourceRef.current = current.text;
      visualTextEditsRef.current = [];
      visualMathEditsRef.current = new Map();
      visualBlocksSourceRef.current = current.text;
      visualBlocksRef.current = nextBlocks;
      visualEditorRef.current?.replaceProjection(
        nextBlocks,
        assetUrlsRef.current,
        { anchorSource: gap.focusOffset, headSource: gap.focusOffset, focus: true },
      );
      setVisualBlocks(nextBlocks);
    },
    [commitStructuralSource, getEditingSource, path, visualProjectionEdits],
  );

  const commitVisualHardBreak = React.useCallback(
    (action: VisualHardBreakAction) => {
      const projection = currentVisualProjection();
      if (!projection) return false;

      const from = mapBaseOffsetThroughEdits(action.sourceFrom, projection.edits);
      let to = mapBaseOffsetThroughEdits(action.sourceTo, projection.edits);
      if (action.replaceSourceText !== undefined) {
        to = from + action.replaceSourceText.length;
        if (
          from < 0 ||
          to > projection.current.text.length ||
          projection.current.text.slice(from, to) !== action.replaceSourceText
        ) {
          // Never guess at the slash-query range. A stale suggestion must fail
          // closed rather than deleting neighboring manuscript text.
          toast.error(t.app.latexVisual.sourceChanged);
          return false;
        }
      }

      const inline = action.inlineContext;
      const wrapper = inline && inline.visualKind !== 'text'
        ? {
            sourceFrom: mapBaseOffsetThroughEdits(inline.sourceFrom, projection.edits),
            sourceTo: mapBaseOffsetThroughEdits(inline.sourceTo, projection.edits),
            bodyFrom: mapBaseOffsetThroughEdits(inline.bodyFrom, projection.edits),
            bodyTo: mapBaseOffsetThroughEdits(inline.bodyTo, projection.edits),
          }
        : undefined;
      const mutation = createLatexInlineSplitMutation(
        projection.current.text,
        from,
        to,
        CANONICAL_LATEX_HARD_BREAK,
        wrapper,
      );
      if (!mutation) {
        toast.error(t.app.latexVisual.sourceChanged);
        return false;
      }
      const nextSource = applySourceTextEdits(projection.current.text, [mutation.edit]);
      // The semantic projection now exposes a zero-length source-backed text
      // host after a terminal hard break, so no synthetic inline is required.
      syntheticMaterializationRef.current = undefined;
      commitStructuralSource(nextSource, mutation.focusOffset);
      return true;
    },
    [commitStructuralSource, currentVisualProjection],
  );

  const insertVisualTemplate = React.useCallback(
    (
      session: VisualSlashSession,
      template: { text: string; focusOffset: number },
      options?: { openMathValue?: string },
    ) => {
      const projection = currentVisualProjection();
      if (!projection) return;
      if (
        session.sourceRevision !== undefined &&
        projection.current.revision !== session.sourceRevision
      ) {
        slashSessionRef.current = undefined;
        setSlashSession(undefined);
        toast.error(t.app.latexVisual.sourceChanged);
        return;
      }
      const from = session.sourceCoordinates === 'current'
        ? session.sourceFrom
        : mapBaseOffsetThroughEdits(session.sourceFrom, projection.edits);
      const to = session.sourceCoordinates === 'current'
        ? session.sourceTo
        : mapBaseOffsetThroughEdits(session.sourceTo, projection.edits);
      if (session.sourceQueryText && session.placement === 'replace') {
        const candidate = projection.current.text.slice(from, to);
        if (!candidate.includes(session.sourceQueryText)) {
          toast.error(t.app.latexVisual.sourceChanged);
          return;
        }
      }
      const replaceFrom = session.placement === 'replace'
        ? from
        : session.placement === 'before'
          ? from
          : to;
      const replaceTo = session.placement === 'replace' ? to : replaceFrom;
      // BF4: a native slash query already lives inside a real Learnitive
      // paragraph whose surrounding blank-line boundaries are serialized to
      // LaTeX. Do not stack BF3's template padding on top of those boundaries.
      // Explicit source-first sessions keep the legacy template verbatim.
      let templateText = template.text;
      let templateFocusOffset = template.focusOffset;
      if (session.sourceCoordinates === 'current' && session.placement === 'replace') {
        const leading = templateText.match(/^\n+/u)?.[0].length ?? 0;
        const trailing = templateText.match(/\n+$/u)?.[0].length ?? 0;
        if (leading || trailing) {
          templateText = templateText.slice(leading, trailing ? -trailing : undefined);
          templateFocusOffset = Math.max(0, templateFocusOffset - leading);
        }
      }
      const nextSource =
        projection.current.text.slice(0, replaceFrom) +
        templateText +
        projection.current.text.slice(replaceTo);
      const focusOffset = replaceFrom + templateFocusOffset;
      commitStructuralSource(
        nextSource,
        focusOffset,
        options?.openMathValue
          ? {
              bodyFrom: focusOffset,
              bodyTo: focusOffset + options.openMathValue.length,
              value: options.openMathValue,
            }
          : undefined,
      );
    },
    [commitStructuralSource, currentVisualProjection],
  );

  const insertVisualTable = React.useCallback(
    (session: VisualSlashSession, rows: number, columns: number) => {
      // BF4.4 native table insertion replaces the slash paragraph at the live
      // PM caret. The legacy source-template path remains only as a fallback.
      if (visualEditorRef.current?.insertNativeTableAtSlash(rows, columns)) {
        setTableInsertSession(undefined);
        return;
      }
      if (visualEditorRef.current?.native) {
        // Native Visual mode never falls back to a reconstructed source offset
        // for a table slash command. If the PM bookmark is no longer valid,
        // cancel the insertion rather than placing the table in a neighbour.
        visualEditorRef.current.dismissSlashSuggestion();
        setTableInsertSession(undefined);
        return;
      }
      visualEditorRef.current?.dismissSlashSuggestion();
      const template = createLatexTableTemplate(rows, columns);
      insertVisualTemplate(session, {
        text: `\n\n${template.text}\n\n`,
        focusOffset: 2 + template.firstCellOffset,
      });
      setTableInsertSession(undefined);
    },
    [insertVisualTemplate],
  );

  const insertVisualBlock = React.useCallback(
    (session: VisualSlashSession, command: LatexVisualInsertCommand) => {
      if (command === 'newline') {
        if (session.sourceCoordinates === 'current') {
          insertVisualTemplate(session, {
            text: CANONICAL_LATEX_HARD_BREAK,
            focusOffset: CANONICAL_LATEX_HARD_BREAK.length,
          });
          return;
        }
        const sourceAt = session.inlineSourceAt ?? (
          session.placement === 'before' ? session.sourceFrom : session.sourceTo
        );
        commitVisualHardBreak({
          kind: 'hard-break',
          id: session.id,
          sourceFrom: sourceAt,
          sourceTo: sourceAt,
          atEnd: session.inlineAtEnd ?? true,
          replaceSourceText: session.sourceQueryText ?? '',
          ...(session.inlineContext ? { inlineContext: session.inlineContext } : {}),
        });
        return;
      }
      insertVisualTemplate(
        session,
        latexInsertTemplate(command),
        command === 'inline-math' || command === 'display-math' || command === 'equation'
          ? { openMathValue: 'E=mc^2' }
          : undefined,
      );
    },
    [commitVisualHardBreak, insertVisualTemplate],
  );

  const formatVisualSelection = React.useCallback(
    (action: VisualFormatAction) => {
      const projection = currentVisualProjection();
      if (!projection) return;
      const bodyStart = action.sourceCoordinates === 'current'
        ? action.bodyFrom
        : mapBaseOffsetThroughEdits(action.bodyFrom, projection.edits);
      const from = action.sourceCoordinates === 'current'
        ? action.sourceFrom
        : bodyStart + action.relativeFrom;
      const to = action.sourceCoordinates === 'current'
        ? action.sourceTo
        : bodyStart + action.relativeTo;
      if (from < 0 || to <= from || to > projection.current.text.length) return;
      const selectedText = projection.current.text.slice(from, to);
      if (action.format === 'link') {
        const data = action.actionData as { href?: string } | undefined;
        setLinkSession({ action, ...(data?.href ? { href: data.href } : {}) });
        return;
      }
      const replacement = wrapVisualSelection(action, selectedText);
      if (replacement === undefined) return;
      const prefixLength = replacement.indexOf(selectedText);
      const nextSource =
        projection.current.text.slice(0, from) +
        replacement +
        projection.current.text.slice(to);
      commitStructuralSource(
        nextSource,
        from + Math.max(0, prefixLength),
      );
    },
    [commitStructuralSource, currentVisualProjection],
  );

  const applyVisualLink = React.useCallback(
    (href?: string) => {
      const session = linkSession;
      if (!session) return;
      const projection = currentVisualProjection();
      if (!projection) return;
      const action = session.action;
      const bodyStart = action.sourceCoordinates === 'current'
        ? action.bodyFrom
        : mapBaseOffsetThroughEdits(action.bodyFrom, projection.edits);
      const bodyEnd = action.sourceCoordinates === 'current'
        ? action.bodyTo
        : mapBaseOffsetThroughEdits(action.bodyTo, projection.edits);
      const selectedFrom = action.sourceCoordinates === 'current'
        ? action.sourceFrom
        : bodyStart + action.relativeFrom;
      const selectedTo = action.sourceCoordinates === 'current'
        ? action.sourceTo
        : bodyStart + action.relativeTo;
      const existingLink = action.visualKind === 'link';
      let from = selectedFrom;
      let to = selectedTo;
      let body = projection.current.text.slice(selectedFrom, selectedTo);
      if (existingLink) {
        from = action.sourceCoordinates === 'current'
          ? action.sourceFrom
          : mapBaseOffsetThroughEdits(action.sourceFrom, projection.edits);
        to = action.sourceCoordinates === 'current'
          ? action.sourceTo
          : mapBaseOffsetThroughEdits(action.sourceTo, projection.edits);
        body = projection.current.text.slice(bodyStart, bodyEnd);
      }
      if (from < 0 || to < from || to > projection.current.text.length) return;
      const replacement = href ? `\\href{${href}}{${body}}` : body;
      const nextSource =
        projection.current.text.slice(0, from) +
        replacement +
        projection.current.text.slice(to);
      setLinkSession(undefined);
      void commitStructuralSource(nextSource, from + (href ? `\\href{${href}}{`.length : 0));
    },
    [commitStructuralSource, currentVisualProjection, linkSession],
  );

  const moveVisualBlock = React.useCallback(
    (action: VisualMoveAction) => {
      const projection = currentVisualProjection();
      if (!projection) return;
      const sourceFrom = mapBaseOffsetThroughEdits(action.sourceFrom, projection.edits);
      const sourceTo = mapBaseOffsetThroughEdits(action.sourceTo, projection.edits);
      const targetFrom = mapBaseOffsetThroughEdits(action.targetFrom, projection.edits);
      const targetTo = mapBaseOffsetThroughEdits(action.targetTo, projection.edits);
      if (
        sourceFrom < 0 ||
        sourceTo <= sourceFrom ||
        targetFrom < 0 ||
        targetTo < targetFrom ||
        sourceTo > projection.current.text.length ||
        targetTo > projection.current.text.length
      ) {
        return;
      }
      const segment = projection.current.text.slice(sourceFrom, sourceTo);
      let insertion = action.placement === 'before' ? targetFrom : targetTo;
      let without =
        projection.current.text.slice(0, sourceFrom) +
        projection.current.text.slice(sourceTo);
      if (insertion > sourceTo) insertion -= sourceTo - sourceFrom;
      const before = without.slice(0, insertion);
      const after = without.slice(insertion);
      let payload = segment;
      if (before && !before.endsWith('\n') && !payload.startsWith('\n')) {
        payload = `\n\n${payload}`;
      }
      if (after && !after.startsWith('\n') && !payload.endsWith('\n')) {
        payload = `${payload}\n\n`;
      }
      without = before + payload + after;
      void commitStructuralSource(without, insertion + Math.min(payload.length, 1));
    },
    [commitStructuralSource, currentVisualProjection],
  );

  // Phase 2 live-buffer contract: Source and Visual are two projections of the
  // exact same LatexSourceService revision. Hidden Visual mode deliberately
  // performs no parser work while Source owns the editor; activation reparses
  // the current canonical revision once. While Visual is active, its own
  // text/math writes are already represented by visualTextEditsRef/
  // visualMathEditsRef, so they deliberately do not trigger a reparse/remount.
  // Any different canonical revision (Source, AI, collaboration, Git, etc.) is
  // adopted immediately by the persistent EditorView from Phase 1.
  React.useLayoutEffect(() => {
    const justActivated = active && !visualWasActiveRef.current;
    visualWasActiveRef.current = active;

    // Source and Visual are persistent sibling surfaces. Never synchronously
    // parse hidden Visual on Source revisions. Activation performs one clean
    // parse of the latest canonical revision, then Visual owns its projection.
    if (justActivated) {
      const nextBlocks = parseVisualBlocksFailClosed(path, editingDocumentText);
      visualBlocksSourceRef.current = editingDocumentText;
      visualBlocksRef.current = nextBlocks;
      setVisualBlocks(nextBlocks);
      return;
    }

    // Hidden Visual deliberately stays stale while the author is typing in
    // Source. It catches up only if/when the author switches to Visual.
    if (!active) return;

    if (visualBlocks) {
      const projectedSource = applySourceTextEdits(
        visualBaseSourceRef.current,
        visualProjectionEdits(),
      );
      // Ordinary Visual typing is already represented by the live PM document.
      // Do not reparse it mid-keystroke; mark/range freshness is restored when
      // Visual is hidden or when it is activated again.
      if (editingDocumentText === projectedSource) return;
    }

    if (visualBlocks && visualBlocksSourceRef.current === editingDocumentText) return;
    const nextBlocks = parseVisualBlocksFailClosed(path, editingDocumentText);
    visualBlocksSourceRef.current = editingDocumentText;
    visualBlocksRef.current = nextBlocks;
    setVisualBlocks(nextBlocks);
  }, [active, editingDocumentText, path, reviewVersion, visualBlocks, visualProjectionEdits]);


  const blocks = visualBlocks;

  React.useEffect(() => {
    if (!active || !blocks) return;
    const figureBlocks = blocks.filter((block) => block.kind === 'figure');
    if (figureBlocks.length === 0) {
      setAssetUrls(new Map());
      return;
    }
    const controller = new AbortController();
    const urls: string[] = [];
    void (async () => {
      try {
        const provider = await projectStorage.getProvider(file.wsName);
        const entries = await provider.listEntries(controller.signal);
        const projectFiles = new Set(
          entries
            .filter((entry) => entry.kind === 'file')
            .map((entry) => normalizeVisualProjectPath(String(entry.path))),
        );
        const next = new Map<string, string>();
        const fromPath = projectFilePath(path);
        const graphicsPaths = visualGraphicsPaths(
          visualBlocksSourceRef.current || documentTextRef.current,
        );
        for (const block of figureBlocks) {
          if (controller.signal.aborted) continue;
          const targets = block.assets?.map((asset) => asset.target) ?? (block.asset ? [block.asset] : []);
          for (const target of targets) {
            if (controller.signal.aborted || next.has(target)) continue;
            try {
              // LaTeX permits extensionless includegraphics targets. Resolve
              // them against the actual project tree, preferring a PDF/browser
              // representation when multiple sibling formats exist. Failures are
              // explicit sentinel states so an asset can never disappear silently.
              const assetPath = resolveVisualGraphicsTarget(fromPath, target, projectFiles, graphicsPaths);
              if (!assetPath) {
                next.set(target, `${VISUAL_ASSET_ERROR_PREFIX}missing`);
                if (target === block.asset) next.set(block.id, `${VISUAL_ASSET_ERROR_PREFIX}missing`);
                continue;
              }
              const data = await provider.readFile(assetPath, controller.signal);
              if (!data) {
                next.set(target, `${VISUAL_ASSET_ERROR_PREFIX}missing`);
                if (target === block.asset) next.set(block.id, `${VISUAL_ASSET_ERROR_PREFIX}missing`);
                continue;
              }
              const asset = block.assets?.find((candidate) => candidate.target === target);
              const pageNumber = graphicsPageFromOptions(asset?.options ?? (target === block.asset ? block.graphicsOptions : undefined));
              const url = await visualGraphicsPreviewUrl(String(assetPath), data, controller.signal, pageNumber);
              if (controller.signal.aborted) {
                if (url) URL.revokeObjectURL(url);
                continue;
              }
              if (!url) {
                const state = /\.(?:eps|ps)$/iu.test(String(assetPath)) ? 'unsupported' : 'error';
                next.set(target, `${VISUAL_ASSET_ERROR_PREFIX}${state}`);
                if (target === block.asset) next.set(block.id, `${VISUAL_ASSET_ERROR_PREFIX}${state}`);
                continue;
              }
              urls.push(url);
              next.set(target, url);
              if (target === block.asset) next.set(block.id, url);
            } catch {
              if (controller.signal.aborted) continue;
              next.set(target, `${VISUAL_ASSET_ERROR_PREFIX}error`);
              if (target === block.asset) next.set(block.id, `${VISUAL_ASSET_ERROR_PREFIX}error`);
            }
          }
        }
        if (!controller.signal.aborted) setAssetUrls(next);

      } catch {
        if (controller.signal.aborted) return;
        const failed = new Map<string, string>();
        for (const block of figureBlocks) {
          const targets = block.assets?.map((asset) => asset.target) ?? (block.asset ? [block.asset] : []);
          for (const target of targets) failed.set(target, `${VISUAL_ASSET_ERROR_PREFIX}error`);
          if (block.asset) failed.set(block.id, `${VISUAL_ASSET_ERROR_PREFIX}error`);
        }
        setAssetUrls(failed);
      }
    })();
    return () => {
      controller.abort();
      for (const url of urls) URL.revokeObjectURL(url);
    };
  }, [active, blocks, file.wsName, projectStorage]);

  React.useEffect(() => {
    if (!active) return;
    visualEditorRef.current?.updateAssetUrls(assetUrls);
  }, [active, assetUrls]);

  const hasVisualBlocks = Boolean(blocks);

  // The ProseMirror view is a long-lived editing session. Source/semantic/asset
  // updates are reconciled into that same view below instead of being React
  // effect dependencies that destroy the contenteditable surface while typing.
  // biome-ignore lint/correctness/useExhaustiveDependencies: the visual editor lifecycle is intentionally keyed only by active file/session readiness; live values are read through refs/services.
  React.useEffect(() => {
    const host = hostRef.current;
    const sessionBlocks = visualBlocksRef.current;
    if (!host || !sessionBlocks || !active) return;
    host.replaceChildren();
    const baseSource = documentTextRef.current;
    visualBaseSourceRef.current = baseSource;
    visualTextEditsRef.current = [];
    visualMathEditsRef.current = new Map();

    const currentProjectionEdits = () => [
      ...visualTextEditsRef.current,
      ...visualMathEditsRef.current.values(),
    ];
    const dialogBase = (): VisualDialogBase => ({
      revision: sourceRevisionRef.current,
      baseSource: visualBaseSourceRef.current,
      visualEdits: currentProjectionEdits(),
    });
    const observeVisualAi = (
      kind: 'text' | 'cursor' | 'selection' | 'undo-redo' | 'autocomplete-open' | 'autocomplete-close',
      projectedFrom: number,
      projectedTo: number,
      protectedVisual = false,
    ) => {
      if (!active) return;
      const canonical = getCanonicalSource();
      if (!canonical) return;
      const mapped = trackChangesEnabled
        ? projectReview.projectedRangeToCanonical(file.wsName, path, canonical.text, projectedFrom, projectedTo)
        : { from: projectedFrom, to: projectedTo };
      projectAiWriting.observeEditorActivity({
        wsName: file.wsName,
        filePath: path,
        revision: canonical.revision,
        mode: 'visual',
        cursor: Math.max(0, Math.min(canonical.text.length, mapped.to)),
        selectionFrom: Math.max(0, Math.min(canonical.text.length, mapped.from)),
        selectionTo: Math.max(0, Math.min(canonical.text.length, mapped.to)),
        kind,
        protectedVisual,
      });
    };

    const editor = createLatexVisualEditor({
      host,
      blocks: sessionBlocks,
      source: baseSource,
      getSource: () => getEditingSource()?.text,
      assetUrls: assetUrlsRef.current,
      onUndo() {
        if (trackChangesEnabled) {
          toast.error('Reject the pending change from Review to undo a tracked Visual edit.');
          return;
        }
        if (applyCollaborativeHistory('undo')) return;
        void latexSource.undo(wsPath);
      },
      onRedo() {
        if (trackChangesEnabled) return;
        if (applyCollaborativeHistory('redo')) return;
        void latexSource.redo(wsPath);
      },
      getSlashOptionCount(query) {
        return getLatexVisualSlashOptionCount(query);
      },
      onEdits(edits) {
        const current = getEditingSource();
        const expectedCurrent = applySourceTextEdits(
          visualBaseSourceRef.current,
          currentProjectionEdits(),
        );
        if (!current || current.text !== expectedCurrent) {
          toast.error(t.app.latexVisual.sourceChanged);
          return;
        }
        const desired = applySourceTextEdits(visualBaseSourceRef.current, [
          ...edits,
          ...visualMathEditsRef.current.values(),
        ]);
        const next = commitExactNextSource(desired, 'visual', current.revision);
        if (!next) {
          toast.error(t.app.latexVisual.sourceChanged);
          return;
        }
        visualTextEditsRef.current = edits;
        // The live PM document is correct, but visualBlocks still owns ranges
        // from the pre-edit source. Never advertise those stale ranges as a
        // parsed projection of the new revision.
        visualBlocksSourceRef.current = undefined;
        sourceRevisionRef.current = next.revision;
        window.setTimeout(() => observeVisualAi('text', lastVisualCursorRef.current, lastVisualCursorRef.current), 0);
      },
      onNativeEdits(edits, meta) {
        const current = getEditingSource();
        if (!current) return false;

        const rebased = rebaseVisualEdits(edits, meta.baseSource, current.text);
        if (!rebased) {
          // A true overlap is the only case that cannot be safely rebased.
          // Reconcile immediately from the authoritative source instead of
          // leaving the PM document in a half-accepted state.
          const nextBlocks = parseVisualBlocksFailClosed(path, current.text);
          visualBaseSourceRef.current = current.text;
          visualTextEditsRef.current = [];
          visualMathEditsRef.current = new Map();
          visualBlocksSourceRef.current = current.text;
          sourceRevisionRef.current = current.revision;
          visualBlocksRef.current = nextBlocks;
          visualEditorRef.current?.replaceProjection(nextBlocks, assetUrlsRef.current);
          setVisualBlocks(nextBlocks);
          return false;
        }

        const next = commitExactSourceEdits(
          rebased,
          'visual',
          current.revision,
          meta.historyReplay ? 'ignore' : 'record',
        );
        if (!next) {
          const latest = getEditingSource();
          if (latest) {
            const nextBlocks = parseVisualBlocksFailClosed(path, latest.text);
            visualBaseSourceRef.current = latest.text;
            visualTextEditsRef.current = [];
            visualMathEditsRef.current = new Map();
            visualBlocksSourceRef.current = latest.text;
            sourceRevisionRef.current = latest.revision;
            visualBlocksRef.current = nextBlocks;
            visualEditorRef.current?.replaceProjection(nextBlocks, assetUrlsRef.current);
            setVisualBlocks(nextBlocks);
          }
          return false;
        }
        // Native BF4 now emits transaction-local splices, so advance the base
        // after every accepted edit instead of accumulating a whole-file patch.
        visualBaseSourceRef.current = next.text;
        visualTextEditsRef.current = [];
        visualMathEditsRef.current = new Map();
        // The PM transaction was accepted against next.text, but the semantic
        // VisualBlock ranges were not reparsed. Leaving the marker at next.text
        // was the mode-switch bug: Source -> Visual then reused stale ranges,
        // causing equations/figures/bibliography to be misclassified until a
        // hard refresh. Explicitly invalidate the block map instead.
        visualBlocksSourceRef.current = undefined;
        sourceRevisionRef.current = next.revision;
        window.setTimeout(() => observeVisualAi('text', lastVisualCursorRef.current, lastVisualCursorRef.current), 0);
        return true;
      },
      onNavigateSource(location) {
        const source = getEditingSource()?.text ?? visualBaseSourceRef.current;
        const canonicalRange = sourceRangeForVisualId(visualBlocksRef.current ?? [], location.id) ?? {
          from: location.sourceFrom,
          to: location.sourceTo,
        };
        const from = Math.max(0, Math.min(source.length, canonicalRange.from));
        const to = Math.max(from, Math.min(source.length, canonicalRange.to));
        const line = source.slice(0, from).split('\n').length;
        projectAi.setEditorContext(file.wsName, { path, line, column: 1 });
        projectPdf.forwardSync(file.wsName, path, line, undefined, { force: true });
        onRequestSourceRef.current?.(line, { from, to });
      },
      onCursorChange(cursor) {
        if (!active || !cursor) return;
        const current = getEditingSource();
        const edits = currentProjectionEdits();
        const expected = applySourceTextEdits(visualBaseSourceRef.current, edits);
        if (!current || current.text !== expected) return;
        const offset = Math.max(
          0,
          Math.min(
            current.text.length,
            cursor.sourceCoordinates === 'current'
              ? cursor.offset
              : mapBaseOffsetThroughEdits(cursor.offset, edits),
          ),
        );
        const line = current.text.slice(0, offset).split('\n').length;
        const lineStart = current.text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
        const column = offset - lineStart + 1;
        lastVisualCursorRef.current = offset;
        latexSource.setCursorLocation(wsPath, { offset, line, column });
        projectAi.setEditorContext(file.wsName, {
          path,
          line,
          column,
          offset,
        });
        observeVisualAi('cursor', offset, offset);
      },
      onSelectionChange(selection) {
        if (!active || !selection) {
          setAiVisualSelection(undefined);
          if (active) onManuscriptSelectionChange?.(undefined);
          return;
        }
        const current = getEditingSource();
        const edits = currentProjectionEdits();
        const expected = applySourceTextEdits(visualBaseSourceRef.current, edits);
        if (!current || current.text !== expected) {
          setAiVisualSelection(undefined);
          onManuscriptSelectionChange?.(undefined);
          return;
        }
        const from = selection.sourceCoordinates === 'current'
          ? selection.sourceFrom
          : mapBaseOffsetThroughEdits(selection.bodyFrom, edits) + selection.relativeFrom;
        const to = selection.sourceCoordinates === 'current'
          ? selection.sourceTo
          : mapBaseOffsetThroughEdits(selection.bodyFrom, edits) + selection.relativeTo;
        if (from < 0 || to <= from || to > current.text.length) {
          setAiVisualSelection(undefined);
          onManuscriptSelectionChange?.(undefined);
          return;
        }
        const text = current.text.slice(from, to);
        if (!text.trim()) {
          setAiVisualSelection(undefined);
          onManuscriptSelectionChange?.(undefined);
          return;
        }
        const containingBlock = (visualBlocksRef.current ?? sessionBlocks).find((block) =>
          from >= block.range.from && to <= block.range.to,
        );
        const protectedVisual = !containingBlock
          || containingBlock.kind === 'raw'
          || (containingBlock.kind === 'figure' && containingBlock.complex === true)
          || (containingBlock.kind === 'table' && !containingBlock.editable)
          || (containingBlock.kind === 'theorem' && !containingBlock.editable);
        const canonical = getCanonicalSource();
        const mapped = canonical && trackChangesEnabled
          ? projectReview.projectedRangeToCanonical(file.wsName, path, canonical.text, from, to)
          : { from, to };
        const canonicalText = canonical?.text.slice(mapped.from, mapped.to) ?? text;
        setAiVisualSelection({
          from: mapped.from,
          to: mapped.to,
          text: canonicalText,
          protectedVisual,
          ...('changeId' in mapped && mapped.changeId ? { changeId: mapped.changeId } : {}),
        });
        observeVisualAi('selection', from, to, protectedVisual);
        const line = current.text.slice(0, to).split('\n').length;
        const lineStart = current.text.lastIndexOf('\n', Math.max(0, to - 1)) + 1;
        projectAi.setEditorContext(file.wsName, {
          path,
          line,
          column: to - lineStart + 1,
          offset: to,
          selection: text,
          selectionFrom: from,
          selectionTo: to,
        });
        if (trackChangesEnabled) {
          onManuscriptSelectionChange?.(undefined);
          return;
        }
        onManuscriptSelectionChange?.({
          mode: 'visual',
          text,
          anchor: captureSourceAnchor({
            filePath: path,
            source: current.text,
            from,
            to,
            sourceRevision: current.revision,
            semanticId: selection.id,
            ...(selection.visualKind ? { semanticKind: selection.visualKind } : {}),
          }),
        });
      },
      onAction(action) {
        if (action.kind === 'list-structure') {
          const projection = currentVisualProjection();
          if (!projection) return;
          const block = (visualBlocksRef.current ?? sessionBlocks).find(
            (candidate) => candidate.id === action.id && candidate.kind === 'list',
          );
          if (!block || block.kind !== 'list' || !block.editable) return;
          const listRange = {
            from: mapBaseOffsetThroughEdits(block.range.from, projection.edits),
            to: mapBaseOffsetThroughEdits(block.range.to, projection.edits),
          };
          const items = block.items.map((item) => ({
            from: mapBaseOffsetThroughEdits(item.range.from, projection.edits),
            to: mapBaseOffsetThroughEdits(item.range.to, projection.edits),
            bodyFrom: mapBaseOffsetThroughEdits(item.bodyRange.from, projection.edits),
            bodyTo: mapBaseOffsetThroughEdits(item.bodyRange.to, projection.edits),
          }));
          const selectedItem = action.itemId
            ? block.items.findIndex((item) => item.id === action.itemId)
            : 0;
          if (action.operation === 'delete-item' && selectedItem < 0) {
            toast.error(t.app.latexVisual.sourceChanged);
            return;
          }
          const mutation = createLatexListStructureMutation(
            projection.current.text,
            listRange,
            items,
            action.operation,
            Math.max(0, selectedItem),
          );
          if (!mutation) {
            toast.error(t.app.bibliography.sourceOnlyStructure);
            return;
          }
          const nextSource = applySourceTextEdits(projection.current.text, mutation.edits);
          commitStructuralSource(nextSource, mutation.focusOffset);
          return;
        }

        if (action.kind === 'table-boundary' || action.kind === 'block-boundary') {
          const blocks = [...(visualBlocksRef.current ?? sessionBlocks)].sort(
            (left, right) => left.range.from - right.range.from || left.range.to - right.range.to,
          );
          const ownerIndex = action.kind === 'table-boundary'
            ? blocks.findIndex((candidate) => candidate.id === action.id && candidate.kind === 'table')
            : blocks.findIndex(
                (candidate) =>
                  candidate.id === action.id ||
                  (action.sourceFrom >= candidate.range.from && action.sourceTo <= candidate.range.to),
              );
          if (ownerIndex < 0) return;
          const owner = blocks[ownerIndex]!;
          const adjacent = action.direction === 'after'
            ? blocks[ownerIndex + 1]
            : blocks[ownerIndex - 1];
          const adjacentOffset = adjacent?.kind === 'paragraph'
            ? (action.direction === 'after' ? adjacent.bodyRange.from : adjacent.bodyRange.to)
            : adjacent?.kind === 'rich-paragraph'
              ? (action.direction === 'after' ? adjacent.range.from : adjacent.range.to)
              : undefined;
          if (
            adjacentOffset !== undefined &&
            visualEditorRef.current?.focusSourceOffset(adjacentOffset)
          ) {
            return;
          }

          const projection = currentVisualProjection();
          if (!projection) return;
          const baseBoundary = action.direction === 'after'
            ? owner.range.to
            : owner.range.from;
          const at = mapBaseOffsetThroughEdits(baseBoundary, projection.edits);
          if (at < 0 || at > projection.current.text.length) return;
          const gap = createSourceBackedBlockDeletion(
            projection.current.text,
            { from: at, to: at },
          );
          if (!gap) return;
          const nextSource = applySourceTextEdits(projection.current.text, [gap.edit]);
          commitStructuralSource(nextSource, gap.focusOffset);
          return;
        }

        if (action.kind === 'table-structure') {
          const projection = currentVisualProjection();
          if (!projection) return;
          const block = (visualBlocksRef.current ?? sessionBlocks).find(
            (candidate) => candidate.id === action.id && candidate.kind === 'table',
          );
          if (!block || block.kind !== 'table') return;
          const tableRange = {
            from: mapBaseOffsetThroughEdits(block.range.from, projection.edits),
            to: mapBaseOffsetThroughEdits(block.range.to, projection.edits),
          };
          const mutation = createLatexTableStructureMutation(
            projection.current.text,
            tableRange,
            action.operation,
            action.row ?? 0,
            action.column ?? 0,
          );
          if (!mutation) {
            toast.error(t.app.bibliography.sourceOnlyStructure);
            return;
          }
          const nextSource = applySourceTextEdits(projection.current.text, mutation.edits);
          setMetadataSession(undefined);
          commitStructuralSource(nextSource, mutation.focusOffset);
          return;
        }

        if (action.kind === 'hard-break') {
          commitVisualHardBreak(action);
          return;
        }

        if (action.kind === 'delete-hard-break') {
          const projection = currentVisualProjection();
          if (!projection) return;
          const from = mapBaseOffsetThroughEdits(action.sourceFrom, projection.edits);
          const to = mapBaseOffsetThroughEdits(action.sourceTo, projection.edits);
          const candidate = projection.current.text.slice(from, to);
          if (!/^(?:\\newline(?:\{\})?|\\\\)$/.test(candidate)) {
            toast.error(t.app.latexVisual.sourceChanged);
            return;
          }
          const nextSource =
            projection.current.text.slice(0, from) +
            projection.current.text.slice(to);
          syntheticMaterializationRef.current = undefined;
          commitStructuralSource(nextSource, from);
          return;
        }

        if (
          action.kind === 'heading-exit' ||
          action.kind === 'paragraph-break' ||
          action.kind === 'list-item-break' ||
          action.kind === 'list-exit' ||
          action.kind === 'materialize-text'
        ) {
          const current = getEditingSource();
          const projectionEdits = currentProjectionEdits();
          const expected = applySourceTextEdits(visualBaseSourceRef.current, projectionEdits);
          if (!current || current.text !== expected) {
            toast.error(t.app.latexVisual.sourceChanged);
            return;
          }
          const mapOffset = (offset: number) =>
            mapBaseOffsetThroughEdits(offset, projectionEdits);

          if (action.kind === 'heading-exit') {
            const headingTo = mapOffset(action.headingTo);
            if (headingTo < 0 || headingTo > current.text.length) return;
            const insertion = CANONICAL_LATEX_PARAGRAPH_SEPARATOR;
            const nextSource =
              current.text.slice(0, headingTo) + insertion + current.text.slice(headingTo);
            const focusOffset = headingTo + insertion.length;
            syntheticMaterializationRef.current = undefined;
            commitStructuralSource(nextSource, focusOffset);
            return;
          }

          if (action.kind === 'paragraph-break') {
            const from = mapOffset(action.sourceFrom);
            const to = mapOffset(action.sourceTo);
            const block = (visualBlocksRef.current ?? sessionBlocks).find((candidate) => candidate.id === action.id);
            const sourceBackedEmpty = block?.kind === 'paragraph' && block.bodyRange.from === block.bodyRange.to;
            const replacement = sourceBackedEmpty ? '\n' : CANONICAL_LATEX_PARAGRAPH_SEPARATOR;
            const inline = action.inlineContext;
            const wrapper = inline && inline.visualKind !== 'text'
              ? {
                  sourceFrom: mapOffset(inline.sourceFrom),
                  sourceTo: mapOffset(inline.sourceTo),
                  bodyFrom: mapOffset(inline.bodyFrom),
                  bodyTo: mapOffset(inline.bodyTo),
                }
              : undefined;
            const mutation = createLatexInlineSplitMutation(
              current.text,
              from,
              to,
              replacement,
              wrapper,
            );
            if (!mutation) {
              toast.error(t.app.latexVisual.sourceChanged);
              return;
            }
            const nextSource = applySourceTextEdits(current.text, [mutation.edit]);
            const focusOffset = wrapper || action.atEnd
              ? mutation.focusOffset
              : nextNonWhitespaceOffset(nextSource, mutation.focusOffset);
            syntheticMaterializationRef.current = undefined;
            commitStructuralSource(nextSource, focusOffset);
            return;
          }

          if (action.kind === 'list-item-break') {
            const from = mapOffset(action.sourceFrom);
            const to = mapOffset(action.sourceTo);
            const replacement = '\n\\item ';
            const inline = action.inlineContext;
            const wrapper = inline && inline.visualKind !== 'text'
              ? {
                  sourceFrom: mapOffset(inline.sourceFrom),
                  sourceTo: mapOffset(inline.sourceTo),
                  bodyFrom: mapOffset(inline.bodyFrom),
                  bodyTo: mapOffset(inline.bodyTo),
                }
              : undefined;
            const mutation = createLatexInlineSplitMutation(
              current.text,
              from,
              to,
              replacement,
              wrapper,
            );
            if (!mutation) {
              toast.error(t.app.latexVisual.sourceChanged);
              return;
            }
            const nextSource = applySourceTextEdits(current.text, [mutation.edit]);
            // The insertion mutation already returns the exact canonical caret
            // immediately after the new `\item ` token (or inside the balanced
            // right-hand inline wrapper). Do not derive it from the old item end:
            // trailing whitespace and reprojection made that point land on the
            // structural list-item parent rather than an editable source host.
            const focusOffset = mutation.focusOffset;
            syntheticMaterializationRef.current = undefined;
            commitStructuralSource(nextSource, focusOffset);
            return;
          }

          if (action.kind === 'list-exit') {
            const listBlock = (visualBlocksRef.current ?? sessionBlocks).find(
              (candidate) =>
                candidate.kind === 'list' &&
                candidate.items.some((item) => item.id === action.id),
            );
            if (listBlock?.kind === 'list' && listBlock.items.length === 1) {
              const mutation = createLatexListStructureMutation(
                current.text,
                {
                  from: mapOffset(listBlock.range.from),
                  to: mapOffset(listBlock.range.to),
                },
                listBlock.items.map((item) => ({
                  from: mapOffset(item.range.from),
                  to: mapOffset(item.range.to),
                  bodyFrom: mapOffset(item.bodyRange.from),
                  bodyTo: mapOffset(item.bodyRange.to),
                })),
                'delete-list',
                0,
              );
              if (!mutation) {
                toast.error(t.app.latexVisual.sourceChanged);
                return;
              }
              const nextSource = applySourceTextEdits(current.text, mutation.edits);
              syntheticMaterializationRef.current = undefined;
              commitStructuralSource(nextSource, mutation.focusOffset);
              return;
            }
            const itemFrom = mapOffset(action.itemFrom);
            const itemTo = mapOffset(action.itemTo);
            const listTo = mapOffset(action.listTo);
            if (
              itemFrom < 0 ||
              itemTo < itemFrom ||
              listTo < itemTo ||
              listTo > current.text.length
            ) {
              return;
            }
            const withoutEmptyItem = applySourceTextEdits(current.text, [
              { from: itemFrom, to: itemTo, text: '' },
            ]);
            const mappedListTo = listTo - (itemTo - itemFrom);
            const gap = createSourceBackedBlockDeletion(
              withoutEmptyItem,
              { from: mappedListTo, to: mappedListTo },
            );
            if (!gap) {
              toast.error(t.app.latexVisual.sourceChanged);
              return;
            }
            const nextSource = applySourceTextEdits(withoutEmptyItem, [gap.edit]);
            syntheticMaterializationRef.current = undefined;
            commitStructuralSource(nextSource, gap.focusOffset);
            return;
          }

          const previousMaterialization = syntheticMaterializationRef.current;
          const sourceAt =
            previousMaterialization?.id === action.id
              ? previousMaterialization.nextOffset
              : mapOffset(action.sourceAt);
          const nextSource =
            current.text.slice(0, sourceAt) +
            action.text +
            current.text.slice(sourceAt);
          const nextOffset = sourceAt + action.text.length;
          syntheticMaterializationRef.current = {
            id: action.id,
            nextOffset,
          };
          commitStructuralSource(nextSource, nextOffset);
          return;
        }

        if (action.kind === 'slash-menu') {
          const current = getEditingSource();
          const session: VisualSlashSession = {
            ...action,
            ...(current ? { sourceRevision: current.revision } : {}),
          };
          slashSessionRef.current = session;
          setSlashSession(session);
          observeVisualAi('autocomplete-open', action.sourceFrom, action.sourceTo);
          return;
        }
        if (action.kind === 'slash-close') {
          const session = slashSessionRef.current;
          slashSessionRef.current = undefined;
          setSlashSession(undefined);
          if (session) observeVisualAi('autocomplete-close', session.sourceFrom, session.sourceTo);
          return;
        }
        if (action.kind === 'slash-select') {
          const command = getLatexVisualSlashCommandAt(action.query, action.selectedIndex);
          const session = slashSessionRef.current;
          if (!command || !session) return;
          const current = getEditingSource();
          if (!current || (session.sourceRevision !== undefined && current.revision !== session.sourceRevision)) {
            slashSessionRef.current = undefined;
            setSlashSession(undefined);
            visualEditorRef.current?.dismissSlashSuggestion();
            toast.error(t.app.latexVisual.sourceChanged);
            return;
          }
          // BF4.4: execute safe Visual slash commands at the live PM caret
          // before falling back to source-coordinate insertion.
          if (visualEditorRef.current?.executeNativeSlashCommand(command)) {
            slashSessionRef.current = undefined;
            setSlashSession(undefined);
            return;
          }
          if (command === 'table' && visualEditorRef.current?.native) {
            // Keep the native PM slash bookmark alive while the size picker is
            // open; insertion then replaces this exact paragraph.
            slashSessionRef.current = undefined;
            setSlashSession(undefined);
            setTableInsertSession(session);
            return;
          }
          if (
            visualEditorRef.current?.native &&
            BF44_NATIVE_OWNED_SLASH_COMMANDS.has(command)
          ) {
            // Do not reintroduce BF3 source-offset insertion for commands that
            // BF4.4 owns in PM. An invalid/stale PM location cancels safely.
            visualEditorRef.current.dismissSlashSuggestion();
            slashSessionRef.current = undefined;
            setSlashSession(undefined);
            return;
          }
          visualEditorRef.current?.dismissSlashSuggestion();
          slashSessionRef.current = undefined;
          setSlashSession(undefined);
          if (command === 'figure') {
            setPendingFigureSession(session);
            window.setTimeout(() => insertFigureInputRef.current?.click(), 0);
            return;
          }
          if (command === 'citation') {
            setCitationInsertSession(session);
            return;
          }
          if (command === 'reference') {
            setReferenceInsertSession(session);
            return;
          }
          if (command === 'table') {
            setTableInsertSession(session);
            return;
          }
          insertVisualBlock(session, command);
          return;
        }
        if (action.kind === 'ai-suggest-selection') {
          const projection = currentVisualProjection();
          if (!projection) return;
          const selected = action.selection;
          const bodyStart = selected.sourceCoordinates === 'current'
            ? selected.bodyFrom
            : mapBaseOffsetThroughEdits(selected.bodyFrom, projection.edits);
          const from = selected.sourceCoordinates === 'current'
            ? selected.sourceFrom
            : bodyStart + selected.relativeFrom;
          const to = selected.sourceCoordinates === 'current'
            ? selected.sourceTo
            : bodyStart + selected.relativeTo;
          if (from < 0 || to <= from || to > projection.current.text.length) return;
          const containingBlock = (visualBlocksRef.current ?? sessionBlocks).find((block) =>
            from >= block.range.from && to <= block.range.to,
          );
          const protectedVisual = !containingBlock
            || containingBlock.kind === 'raw'
            || (containingBlock.kind === 'figure' && containingBlock.complex === true)
            || (containingBlock.kind === 'table' && !containingBlock.editable)
            || (containingBlock.kind === 'theorem' && !containingBlock.editable);
          const canonical = getCanonicalSource();
          const mapped = canonical && trackChangesEnabled
            ? projectReview.projectedRangeToCanonical(file.wsName, path, canonical.text, from, to)
            : { from, to };
          const selection = {
            from: mapped.from,
            to: mapped.to,
            text: canonical?.text.slice(mapped.from, mapped.to) ?? projection.current.text.slice(from, to),
            protectedVisual,
            ...('changeId' in mapped && mapped.changeId ? { changeId: mapped.changeId } : {}),
          };
          setAiVisualSelection(selection);
          setAiVisualMenu({
            anchor: action.anchor ?? { left: 16, top: 48, bottom: 48, right: 16 },
            selection,
          });
          return;
        }
        if (action.kind === 'format-selection') {
          formatVisualSelection(action);
          return;
        }
        if (action.kind === 'move-block') {
          moveVisualBlock(action);
          return;
        }
        if (action.kind === 'math') {
          if (!action.editable) {
            toast.error(t.app.latexSourceEditor.sourceOnlyEquationDescription);
            return;
          }
          const current = getEditingSource();
          if (!current) return;
          const sourceCoordinates = action.sourceCoordinates ?? 'base';
          let mappedFrom = action.from;
          let mappedTo = action.to;
          if (sourceCoordinates === 'base') {
            const expectedSource = applySourceTextEdits(
              visualBaseSourceRef.current,
              currentProjectionEdits(),
            );
            if (current.text !== expectedSource) {
              toast.error(t.app.latexVisual.sourceChanged);
              return;
            }
            const projectedEdits = currentProjectionEdits();
            mappedFrom = mapBaseOffsetThroughEdits(action.from, projectedEdits);
            mappedTo = mapBaseOffsetThroughEdits(action.to, projectedEdits);
          }
          const region = findLatexMathRegions(current.text).find(
            (candidate) =>
              (mappedFrom >= candidate.from && mappedFrom <= candidate.to) ||
              (candidate.from >= mappedFrom && candidate.to <= mappedTo),
          );
          if (!region || !canEditLatexMathWithMathLive(region)) {
            toast.error(t.app.latexSourceEditor.sourceOnlyEquationDescription);
            return;
          }
          const element = [...(hostRef.current?.querySelectorAll<HTMLElement>('[data-source-id]') ?? [])]
            .find((candidate) => candidate.dataset.sourceId === action.id);
          setMathSession({
            revision: current.revision,
            region,
            actionId: action.id,
            baseFrom: action.from,
            baseTo: action.to,
            // The protected PM node is the Visual authority. Its stored LaTeX
            // must hydrate MathLive exactly; never reopen an existing
            // derivative/fraction from the insertion default or rendered DOM.
            initialValue: action.value || latexMathRegionToMathLiveValue(region),
            ...(element ? { anchor: element.getBoundingClientRect() } : {}),
          });
          return;
        }
        if (action.kind === 'citation') {
          const current = getEditingSource();
          if (!current) return;
          const base = action.sourceCoordinates === 'current'
            ? { revision: current.revision, baseSource: current.text, visualEdits: [] as const }
            : dialogBase();
          const mappedFrom = action.sourceCoordinates === 'current'
            ? action.from
            : mapBaseOffsetThroughEdits(action.from, base.visualEdits);
          const mappedTo = action.sourceCoordinates === 'current'
            ? action.to
            : mapBaseOffsetThroughEdits(action.to, base.visualEdits);
          setCitationContextSession({
            action,
            ...base,
            anchor: captureSourceAnchor({
              filePath: path,
              source: current.text,
              from: mappedFrom,
              to: mappedTo,
              sourceRevision: current.revision,
              semanticId: action.id,
              semanticKind: 'citation',
            }),
          });
          return;
        }
        if (action.kind === 'reference') {
          const current = getEditingSource();
          if (!current) return;
          setReferenceEditSession({
            action,
            ...(action.sourceCoordinates === 'current'
              ? { revision: current.revision, baseSource: current.text, visualEdits: [] as const }
              : dialogBase()),
          });
          return;
        }
        if (action.kind === 'footnote') {
          if (!action.editable) {
            toast.error(t.app.bibliography.sourceOnlyFootnote);
            return;
          }
          const current = getEditingSource();
          if (!current) return;
          setFootnoteSession({
            action,
            ...(action.sourceCoordinates === 'current'
              ? { revision: current.revision, baseSource: current.text, visualEdits: [] as const }
              : dialogBase()),
          });
          setFootnoteText(action.text);
          return;
        }
        let block = (visualBlocksRef.current ?? sessionBlocks).find(
          (candidate) => candidate.id === action.id,
        );
        let base = dialogBase();
        if (
          (action.kind === 'figure' || action.kind === 'table') &&
          action.sourceCoordinates === 'current' &&
          action.sourceFrom !== undefined &&
          action.sourceTo !== undefined
        ) {
          const current = getEditingSource();
          if (current) {
            const currentBlocks = parseVisualBlocksFailClosed(path, current.text);
            const currentBlock = currentBlocks.find(
              (candidate) =>
                candidate.kind === action.kind &&
                candidate.range.from <= action.sourceFrom! &&
                candidate.range.to >= action.sourceTo!,
            );
            if (currentBlock) {
              block = currentBlock;
              base = { revision: current.revision, baseSource: current.text, visualEdits: [] };
            }
          }
        }
        if (block?.kind === 'figure' || block?.kind === 'table') {
          setMetadataSession({ block, ...base });
          setMetadataCaption(block.caption ?? '');
          setMetadataWidth(block.kind === 'figure' ? figureWidthPercent(block) : '');
          setMetadataHeight(block.kind === 'figure' ? figurePercentOption(block, 'height', '\\textheight') : '');
          setMetadataScale(block.kind === 'figure' ? figureOptionValue(block, 'scale') : '');
          setMetadataKeepAspect(block.kind === 'figure' ? (block.graphicsOptions ?? '').split(',').some((part) => part.trim().toLowerCase() === 'keepaspectratio') : false);
          setMetadataCentered(block.kind === 'figure' ? figureCenteringState(base.baseSource, block) : false);
          setMetadataDescription(block.kind === 'figure' ? figureDescription(base.baseSource, block) : '');
          setMetadataPlacement(environmentPlacement(base.baseSource, block));
          setMetadataLabel(block.label ?? '');
          setMetadataTableImport('');
          setReplacementAsset(undefined);
        }
      },
    });
    visualEditorRef.current = editor;
    const cursor = latexSource.getCursorLocation(wsPath);
    if (cursor) {
      // Source -> Visual mode switch: carry the canonical CodeMirror cursor
      // into the source-mapped Visual projection when that region is
      // editable. Unsupported/raw regions still scroll into view without
      // manufacturing an unsafe Visual selection.
      if (!editor.focusSourceOffset(cursor.offset)) {
        editor.revealSourceOffset(cursor.offset);
      }
    }
    return () => {
      if (visualEditorRef.current === editor) visualEditorRef.current = null;
      editor.destroy();
    };
  }, [active, hasVisualBlocks, trackChangesEnabled, wsPath]);

  // biome-ignore lint/correctness/useExhaustiveDependencies: only a semantic block projection change should swap the ProseMirror document; callback identity changes during typing must not reset it.
  React.useEffect(() => {
    const editor = visualEditorRef.current;
    if (!active || !blocks || !editor) return;
    const current = getEditingSource();
    if (!current) return;

    // Learnitive document swapping: install a re-parsed semantic projection
    // on the existing EditorView, preserving the caret/focus/scroll rather than
    // remounting ProseMirror. A block change represents a structural/external
    // source refresh; ordinary Visual typing deliberately does not update
    // visualBlocks while the session is active.
    visualBaseSourceRef.current = current.text;
    visualTextEditsRef.current = [];
    visualMathEditsRef.current = new Map();
    visualBlocksSourceRef.current = current.text;
    sourceRevisionRef.current = current.revision;
    const nativeCurrentSource = (editor as { currentSource?: () => string }).currentSource?.();
    if (nativeCurrentSource !== current.text) {
      editor.replaceProjection(blocks, assetUrlsRef.current);
    }
  }, [active, blocks]);

  const closeMathSession = () => {
    const session = mathSession;
    setMathSession(undefined);
    if (session) focusAfterDisplayMath(session);
  };

  const applyMathSession = (value: string) => {
    const session = mathSession;
    const current = getEditingSource();
    if (!session || !current) return;
    if (current.revision !== session.revision) {
      toast.error(t.app.latexSourceEditor.mathSourceChanged);
      setMathSession(undefined);
      return;
    }
    try {
      if (visualEditorRef.current?.native) {
        // BF4.4 fidelity: merely opening an existing protected equation and
        // pressing Apply must not force MathLive-normalized LaTeX back through
        // the PM serializer. Preserve the original protected node/source when
        // the authoritative field value is unchanged.
        if (value !== session.initialValue) {
          visualEditorRef.current.updateMathValue(session.actionId, value);
        }
        setMathSession(undefined);
        focusAfterDisplayMath(session);
        return;
      }
      const edit = createLatexMathReplacementEdit(
        session.region,
        value,
        detectLatexLineEnding(current.text),
      );
      const nextSource = applySourceTextEdits(current.text, [edit]);
      const result = commitExactNextSource(nextSource, 'mathlive', session.revision);
      if (!result) {
        toast.error(t.app.latexSourceEditor.mathSourceChanged);
        setMathSession(undefined);
        return;
      }
      sourceRevisionRef.current = result.revision;
      visualMathEditsRef.current.set(session.actionId, {
        from: session.baseFrom,
        to: session.baseTo,
        text: edit.text,
      });
      visualEditorRef.current?.updateMathValue(session.actionId, value);
      setMathSession(undefined);
      focusAfterDisplayMath(session);
    } catch (reason) {
      console.error('Unable to apply Visual MathLive edit', reason);
      toast.error(t.app.latexVisual.sourceChanged);
      setMathSession(undefined);
    }
  };

  const applyDialogEdits = (
    session: VisualDialogBase,
    edits: readonly SourceTextEdit[],
  ): boolean => {
    const current = getEditingSource();
    if (!current || current.revision !== session.revision) {
      toast.error(t.app.bibliography.sourceChanged);
      return false;
    }
    try {
      const desired = applySourceTextEdits(session.baseSource, [
        ...session.visualEdits,
        ...edits,
      ]);
      const next = commitExactNextSource(desired, 'visual', session.revision);
      if (!next) {
        toast.error(t.app.bibliography.sourceChanged);
        return false;
      }
      sourceRevisionRef.current = next.revision;
      return true;
    } catch {
      toast.error(t.app.bibliography.sourceChanged);
      return false;
    }
  };

  const importVisualFigure = async (selected: File, session: VisualSlashSession) => {
    try {
      const extension = selected.name.split('.').at(-1)?.toLowerCase() ?? '';
      if (!BUSYTEX_DIRECT_IMAGE_FORMATS.has(extension)) {
        toast.error('Use PNG, JPEG, or PDF for browser-local LaTeX compilation.');
        return;
      }
      const initialProjection = currentVisualProjection();
      if (!initialProjection) return;
      if (
        session.sourceRevision !== undefined &&
        initialProjection.current.revision !== session.sourceRevision
      ) {
        toast.error(t.app.latexVisual.sourceChanged);
        return;
      }
      if (session.sourceQueryText && session.placement === 'replace') {
        const initialFrom = session.sourceCoordinates === 'current'
          ? session.sourceFrom
          : mapBaseOffsetThroughEdits(session.sourceFrom, initialProjection.edits);
        const initialTo = session.sourceCoordinates === 'current'
          ? session.sourceTo
          : mapBaseOffsetThroughEdits(session.sourceTo, initialProjection.edits);
        if (
          !initialProjection.current.text
            .slice(initialFrom, initialTo)
            .includes(session.sourceQueryText)
        ) {
          toast.error(t.app.latexVisual.sourceChanged);
          return;
        }
      }
      const asset = await projectAssets.importImage(file.wsName, {
        fileName: selected.name,
        data: new Uint8Array(await selected.arrayBuffer()),
      });
      fileSystem.refreshFileTree();
      const imageTarget = relativeProjectTarget(String(path), String(asset.path));
      const text = [
        '',
        '',
        '\\begin{figure}[htbp]',
        '\\centering',
        `\\includegraphics[width=0.8\\linewidth]{${imageTarget}}`,
        '\\caption{Figure caption}',
        '\\end{figure}',
        '',
        '',
      ].join('\n');
      const projection = currentVisualProjection();
      if (!projection) return;
      if (
        session.sourceRevision !== undefined &&
        projection.current.revision !== session.sourceRevision
      ) {
        toast.error(t.app.latexVisual.sourceChanged);
        return;
      }
      const currentFrom = session.sourceCoordinates === 'current'
        ? session.sourceFrom
        : mapBaseOffsetThroughEdits(session.sourceFrom, projection.edits);
      const currentTo = session.sourceCoordinates === 'current'
        ? session.sourceTo
        : mapBaseOffsetThroughEdits(session.sourceTo, projection.edits);
      const replaceFrom = session.placement === 'replace'
        ? currentFrom
        : session.placement === 'before'
          ? currentFrom
          : currentTo;
      const replaceTo = session.placement === 'replace' ? currentTo : replaceFrom;
      if (session.sourceQueryText && session.placement === 'replace') {
        const candidate = projection.current.text.slice(currentFrom, currentTo);
        if (!candidate.includes(session.sourceQueryText)) {
          toast.error(t.app.latexVisual.sourceChanged);
          return;
        }
      }
      let templateText = text;
      let templateFocusOffset = Math.max(0, text.indexOf('Figure caption'));
      if (session.sourceCoordinates === 'current' && session.placement === 'replace') {
        const leading = templateText.match(/^\n+/u)?.[0].length ?? 0;
        const trailing = templateText.match(/\n+$/u)?.[0].length ?? 0;
        templateText = templateText.slice(leading, trailing ? -trailing : undefined);
        templateFocusOffset = Math.max(0, templateFocusOffset - leading);
      }
      const inserted =
        projection.current.text.slice(0, replaceFrom) +
        templateText +
        projection.current.text.slice(replaceTo);
      const alreadyHasGraphicx = hasLatexPackage(inserted, 'graphicx');
      const withGraphicx = ensureLatexPackage(inserted, 'graphicx');
      if (!alreadyHasGraphicx && withGraphicx.insertedLength === 0) {
        toast.error('Unable to provision graphicx because this file has no \\begin{document}.');
        return;
      }
      const packageShift =
        withGraphicx.insertionOffset >= 0 && withGraphicx.insertionOffset <= replaceFrom
          ? withGraphicx.insertedLength
          : 0;
      commitStructuralSource(
        withGraphicx.source,
        replaceFrom + packageShift + templateFocusOffset,
      );
      toast.success(`Inserted ${asset.fileName}`);
    } catch (reason) {
      console.error('Unable to insert Visual figure', reason);
      toast.error(t.app.bibliography.imageReplaceFailed);
    } finally {
      setPendingFigureSession(undefined);
    }
  };

  const importReplacementImage = async (selected: File) => {
    setReplacementBusy(true);
    try {
      const extension = selected.name.split('.').at(-1)?.toLowerCase() ?? '';
      if (!BUSYTEX_DIRECT_IMAGE_FORMATS.has(extension)) {
        toast.error('Use PNG, JPEG, or PDF for browser-local LaTeX compilation.');
        return;
      }
      const asset = await projectAssets.importImage(file.wsName, {
        fileName: selected.name,
        data: new Uint8Array(await selected.arrayBuffer()),
      });
      setReplacementAsset(asset.path);
      fileSystem.refreshFileTree();
      toast.success(
        t.app.bibliography.replacementStored({ fileName: asset.fileName }),
      );
    } catch (reason) {
      console.error('Unable to replace visual figure asset', reason);
      toast.error(t.app.bibliography.imageReplaceFailed);
    } finally {
      setReplacementBusy(false);
    }
  };

  React.useEffect(() => {
    void reviewVersion;
    const host = hostRef.current;
    if (!host || !blocks) return;
    const decorations = projectReview.getEditorDecorations(file.wsName, path, document.text);
    const nativeReview = visualEditorRef.current as {
      updateReviewDecorations?: (items: readonly {
        id: string;
        from: number;
        to: number;
        status: 'open' | 'resolved' | 'outdated';
        kind: 'comment' | 'change';
        changeType?: 'insert' | 'delete' | 'replace';
        originalText?: string;
        proposedText?: string;
      }[]) => void;
    } | null;
    if (nativeReview?.updateReviewDecorations) {
      // BF4.1 Visual comments are live manuscript annotations, not history
      // markers. Resolved/outdated comments remain available in Review/Source
      // history but must disappear from the editable manuscript immediately.
      nativeReview.updateReviewDecorations(
        decorations.filter((item) => item.kind === 'change' || item.status === 'open'),
      );
      return;
    }

    // Legacy fallback only. BF4/BF4.1 native Visual mode owns review
    // highlighting through ProseMirror decorations so ranges map with PM
    // transactions instead of attaching a stale class to an entire block.
    for (const element of host.querySelectorAll<HTMLElement>('[data-review-thread-id]')) {
      delete element.dataset.reviewThreadId;
      element.classList.remove('interactive-visual-comment-anchor', 'interactive-visual-comment-resolved', 'interactive-visual-comment-outdated', 'interactive-visual-change-anchor', 'interactive-visual-change-insert', 'interactive-visual-change-delete', 'interactive-visual-change-replace');
    }
    for (const decoration of decorations) {
      const block = blocks.find((candidate) => decoration.from <= candidate.range.to && decoration.to >= candidate.range.from);
      const id = block?.id;
      if (!id) continue;
      const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/gu, '\\$&');
      const element = host.querySelector<HTMLElement>(`[data-source-id="${escaped}"]`);
      if (!element) continue;
      element.dataset.reviewThreadId = decoration.id;
      element.classList.add('interactive-visual-comment-anchor');
      if (decoration.kind === 'change') {
        element.classList.add('interactive-visual-change-anchor', `interactive-visual-change-${decoration.changeType ?? 'replace'}`);
      }
      if (decoration.status === 'resolved') element.classList.add('interactive-visual-comment-resolved');
      if (decoration.status === 'outdated') element.classList.add('interactive-visual-comment-outdated');
    }
  }, [blocks, file.wsName, path, projectReview, reviewVersion]);

  // TX7: Visual collaboration decorations/authorship are intentionally disabled.


  const requestVisualObjectComment = () => {
    if (!reviewObjectId || !blocks) return;
    const current = latexSource.getSnapshot(wsPath);
    const editing = getEditingSource();
    if (!current || !editing) return;
    const nativeRange = (visualEditorRef.current as {
      sourceRangeForId?: (id: string) => { from: number; to: number } | undefined;
    } | null)?.sourceRangeForId?.(reviewObjectId);
    const range = nativeRange ?? sourceRangeForVisualId(blocks, reviewObjectId);
    if (!range || range.from < 0 || range.to > editing.text.length || range.to < range.from) return;
    const mapped = trackChangesEnabled
      ? projectReview.projectedRangeToCanonical(file.wsName, path, current.text, range.from, range.to)
      : range;
    if ('changeId' in mapped && typeof mapped.changeId === 'string' && mapped.changeId) {
      onOpenReviewThread?.(mapped.changeId);
      return;
    }
    const liveBlocks = parseVisualBlocksFailClosed(path, editing.text);
    const containingBlock = liveBlocks.find(
      (candidate) => range.from >= candidate.range.from && range.to <= candidate.range.to,
    );
    onRequestReviewComment?.({
      scope: 'anchor',
      anchor: captureSourceAnchor({
        filePath: path,
        source: current.text,
        from: mapped.from,
        to: mapped.to,
        sourceRevision: current.revision,
        semanticId: reviewObjectId,
        semanticKind: containingBlock?.kind ?? 'visual-object',
      }),
      selectedText: current.text.slice(mapped.from, mapped.to),
    });
  };

  const runVisualAiSuggest = async (
    action: AiSuggestionAction,
    selectionOverride?: NonNullable<typeof aiVisualSelection>,
  ) => {
    const selection = selectionOverride ?? aiVisualSelection;
    if (!selection) {
      toast.info('Select editable Visual text before using AI Suggest.');
      return;
    }
    if (selection.changeId) {
      onOpenReviewThread?.(selection.changeId);
      toast.info('This selection is already covered by a pending review change.');
      return;
    }
    if (selection.protectedVisual) {
      const line = document.text.slice(0, Math.max(0, Math.min(selection.from, document.text.length))).split('\n').length;
      toast.info('Protected LaTeX is reviewed at source level. Opening the exact source range.');
      onRequestSourceRef.current?.(line, { from: selection.from, to: selection.to });
      return;
    }
    if (action === 'suggest-citation') {
      const canonical = getCanonicalSource();
      if (!canonical) return;
      const claim = canonical.text.slice(selection.from, selection.to).trim();
      if (!claim) {
        toast.info('Select the claim that needs a citation.');
        return;
      }
      setCitationResearchSession({
        claim,
        anchor: captureSourceAnchor({
          filePath: path,
          source: canonical.text,
          from: selection.from,
          to: selection.to,
          sourceRevision: canonical.revision,
          semanticKind: 'grounded-citation-suggestion',
        }),
      });
      void projectSemantic.refresh(file.wsName).then(setSemanticSnapshot).catch((reason) => {
        console.error('Unable to refresh bibliography before grounded citation search', reason);
      });
      return;
    }
    setAiSuggestBusy(true);
    try {
      const suggestion = await projectAiWriting.suggestSelection(file.wsName, {
        filePath: path,
        from: selection.from,
        to: selection.to,
        action,
      });
      if (!suggestion) {
        toast.info('Learnitive AI did not find a safe change to suggest.');
        return;
      }
      toast.success('AI suggestion added to Review. It is the same pending change in Source and Visual.');
      if (suggestion.reviewThreadId) onOpenReviewThread?.(suggestion.reviewThreadId);
    } catch (reason) {
      toast.error(reason instanceof Error ? reason.message : 'AI Suggest failed.');
    } finally {
      setAiSuggestBusy(false);
    }
  };

  const insertGroundedVisualCitation = async (keys: readonly string[]) => {
    if (keys.length === 0 || !citationResearchSession) return;
    const canonical = getCanonicalSource();
    if (!canonical) return;
    const resolved = resolveSourceAnchor(citationResearchSession.anchor, {
      source: canonical.text,
      sourceRevision: canonical.revision,
    });
    if (resolved.status !== 'resolved') {
      toast.error('The selected claim changed while researching citations. Select it again and retry.');
      setCitationResearchSession(undefined);
      return;
    }
    const selected = canonical.text.slice(resolved.value.from, resolved.value.to);
    const terminal = selected.match(/[.!?…]+(?:["'’”)}\]]*)?[ \t]*$/u);
    const insertionOffset = terminal ? resolved.value.to - terminal[0].length : resolved.value.to;
    const needsSpace = insertionOffset > resolved.value.from && !/\s/u.test(canonical.text[insertionOffset - 1] ?? '');
    const command = `${needsSpace ? ' ' : ''}\\cite{${keys.join(',')}}`;
    try {
      if (trackChangesEnabled) {
        const insertionAnchor = captureSourceAnchor({
          filePath: path,
          source: canonical.text,
          from: insertionOffset,
          to: insertionOffset,
          sourceRevision: canonical.revision,
          semanticKind: 'citation-insertion',
        });
        await projectReview.proposeSourceChange(file.wsName, {
          filePath: path,
          from: insertionOffset,
          to: insertionOffset,
          originalText: '',
          proposedText: command,
          sourceRevision: canonical.revision,
          sourceAnchor: insertionAnchor,
          semanticKind: 'citation-insertion',
          author: projectReview.getLocalAuthor(file.wsName),
          metadata: { source: 'user', reason: 'Grounded citation selected from scholarly search.' },
        });
      } else {
        const next = commitExactSourceEdits(
          [{ from: insertionOffset, to: insertionOffset, text: command }],
          'citation',
          canonical.revision,
        );
        if (!next) throw new Error(t.app.bibliography.sourceChanged);
        sourceRevisionRef.current = next.revision;
      }
      setCitationResearchSession(undefined);
      toast.success('Grounded citation inserted from the selected scholarly reference.');
    } catch (reason) {
      toast.error(reason instanceof Error ? reason.message : t.app.bibliography.sourceChanged);
    }
  };

  if (error) {
    return (
      <div
        className="flex min-h-48 flex-col items-center justify-center gap-3 p-6"
        role="alert"
      >
        <AlertTriangle className="size-5 text-destructive" />
        <p className="max-w-lg text-center text-sm">
          {t.app.latexVisual.loadFailed}
        </p>
        <Button variant="outline" onClick={() => window.location.reload()}>
          {t.app.latexSourceEditor.reloadPage}
        </Button>
      </div>
    );
  }

  if (!blocks) {
    return (
      <div className="flex min-h-48 items-center justify-center text-muted-foreground">
        {t.app.latexVisual.loading}
      </div>
    );
  }

  const rawCount = blocks.filter((block) => block.kind === 'raw').length;
  const visualReviewCards = projectReview
    .getChanges(file.wsName, { status: 'pending', file: path })
    .filter((change) => change.presentation === 'source-only' || change.type === 'delete');
  const sourceLineForChange = (from: number) => document.text.slice(0, Math.max(0, Math.min(from, document.text.length))).split('\n').length;
  return (
    <section
      className="flex min-h-0 flex-1 flex-col overflow-hidden"
      data-editor-engine="latex-visual"
      data-ws-path={wsPath}
      data-active={active ? 'true' : 'false'}
    >
      <div className="flex shrink-0 items-center gap-2 border-b bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
        <span>{t.app.latexVisual.losslessNotice}</span>
        <div className="ml-auto flex items-center gap-1">
          {rawCount > 0 && (
            <span className="mr-1 rounded bg-muted px-2 py-0.5">
              {t.app.latexVisual.rawBlocks({ count: rawCount })}
            </span>
          )}
          <Button
            type="button"
            variant="ghost"
            size="sm"
            disabled={!reviewObjectId}
            onClick={requestVisualObjectComment}
            title="Comment on the selected visual object"
          >
            <MessageSquarePlus className="mr-1 size-3.5" />Comment
          </Button>
          <Button
            type="button"
            variant="ghost"
            size="icon"
            className="h-7 w-7"
            aria-label="Undo Visual edit"
            title="Undo (Ctrl/Cmd+Z)"
            disabled={collaborativeHistoryActive ? !canCollaborativeUndo : !sourceSnapshot?.canUndo}
            onClick={() => {
              if (!applyCollaborativeHistory('undo')) void latexSource.undo(wsPath);
            }}
          >
            <Undo2 className="h-3.5 w-3.5" />
          </Button>
          <Button
            type="button"
            variant="ghost"
            size="icon"
            className="h-7 w-7"
            aria-label="Redo Visual edit"
            title="Redo (Ctrl/Cmd+Shift+Z)"
            disabled={collaborativeHistoryActive ? !canCollaborativeRedo : !sourceSnapshot?.canRedo}
            onClick={() => {
              if (!applyCollaborativeHistory('redo')) void latexSource.redo(wsPath);
            }}
          >
            <Redo2 className="h-3.5 w-3.5" />
          </Button>
        </div>
      </div>
      {aiVisualMenu && (
        <div
          ref={aiSuggestMenuRef}
          className="fixed z-[130] grid min-w-52 gap-0.5 rounded-md border bg-popover p-1 text-xs text-popover-foreground shadow-xl"
          data-testid="latex-visual-ai-suggest-menu"
          style={{
            left: Math.max(8, aiVisualMenu.anchor.left),
            top: Math.max(8, aiVisualMenu.anchor.bottom + 6),
          }}
          onPointerDown={(event) => event.preventDefault()}
        >
          {aiVisualMenu.selection.protectedVisual ? (
            <button
              type="button"
              className="rounded px-2 py-1.5 text-left hover:bg-accent"
              onClick={() => {
                const selection = aiVisualMenu.selection;
                setAiVisualMenu(undefined);
                void runVisualAiSuggest('improve-latex', selection);
              }}
            >
              Open protected LaTeX in Source
            </button>
          ) : (
            ([
              ['improve-writing', 'Improve writing'],
              ['academic-tone', 'Academic tone'],
              ['make-concise', 'Make concise'],
              ['clarify', 'Clarify'],
              ['explain', 'Explain'],
              ['fix-grammar', 'Fix grammar'],
              ['improve-latex', 'Improve LaTeX'],
              ['strengthen-argument', 'Strengthen argument'],
              ['add-evidence', 'Add evidence'],
              ['suggest-citation', 'Suggest citation'],
              ['review-selection', 'Review selection'],
            ] as const).map(([action, label]) => (
              <button
                key={action}
                type="button"
                disabled={aiSuggestBusy}
                className="rounded px-2 py-1.5 text-left hover:bg-accent disabled:opacity-50"
                onClick={() => {
                  const selection = aiVisualMenu.selection;
                  setAiVisualMenu(undefined);
                  void runVisualAiSuggest(action, selection);
                }}
              >
                {label}
              </button>
            ))
          )}
        </div>
      )}
      {visualReviewCards.length > 0 && (
        <div className="shrink-0 space-y-1 border-b bg-muted/10 px-3 py-2" data-testid="visual-source-only-changes">
          {visualReviewCards.map((change) => (
            <div key={change.id} className="flex items-start gap-2 rounded border bg-background p-2 text-xs">
              <GitCompareArrows className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
              <div className="min-w-0 flex-1">
                <div className="font-medium">{change.presentation === 'source-only' ? 'Source-only change' : 'Tracked deletion'} · {change.filePath}</div>
                <div className="mt-0.5 truncate text-muted-foreground">{change.type === 'delete' ? `− ${change.originalText}` : `${change.originalText ? `− ${change.originalText} ` : ''}${change.proposedText ? `+ ${change.proposedText}` : ''}`}</div>
              </div>
              <Button size="sm" variant="ghost" onClick={() => onOpenReviewThread?.(change.id)}>Review</Button>
              <Button size="sm" variant="ghost" onClick={() => { onRequestSourceRef.current?.(sourceLineForChange(change.anchor.from)); }}>Open Source</Button>
              <Button size="sm" variant="ghost" onClick={() => void projectReview.acceptReviewChange(file.wsName, change.id).then(() => { documentController.refreshActiveSource(wsPath); }).catch((reason) => toast.error(reason instanceof Error ? reason.message : 'Unable to accept change.'))}>Accept</Button>
              <Button size="sm" variant="ghost" onClick={() => void projectReview.rejectReviewChange(file.wsName, change.id).catch((reason) => toast.error(reason instanceof Error ? reason.message : 'Unable to reject change.'))}>Reject</Button>
            </div>
          ))}
        </div>
      )}
      <div
        ref={hostRef}
        className="relative min-h-0 flex-1 overflow-auto px-6 py-5"
        onClickCapture={(event) => {
          const target = event.target instanceof Element ? event.target.closest<HTMLElement>('[data-review-thread-id]') : null;
          const id = target?.dataset.reviewThreadId;
          if (id) {
            event.preventDefault();
            event.stopPropagation();
            onOpenReviewThread?.(id);
            return;
          }
          const sourceElement = event.target instanceof Element ? event.target.closest<HTMLElement>('[data-source-id]') : null;
          setReviewObjectId(sourceElement?.dataset.sourceId);
        }}
      />
      <input
        ref={insertFigureInputRef}
        className="sr-only"
        type="file"
        accept={VISUAL_IMAGE_ACCEPT}
        aria-label="Insert Visual figure"
        onChange={(event) => {
          const selected = event.currentTarget.files?.[0];
          event.currentTarget.value = '';
          const session = pendingFigureSession;
          if (selected && session) void importVisualFigure(selected, session);
          else setPendingFigureSession(undefined);
        }}
      />

      {slashSession && (
        <LatexVisualSlashMenu
          anchor={slashSession.anchor}
          query={slashSession.query ?? ''}
          selectedIndex={slashSession.selectedIndex ?? 0}
          onClose={() => {
            visualEditorRef.current?.dismissSlashSuggestion();
            setSlashSession(undefined);
          }}
          onSelect={(command) => {
            const session = slashSession;
            const current = getEditingSource();
            if (
              !current ||
              (session.sourceRevision !== undefined && current.revision !== session.sourceRevision)
            ) {
              visualEditorRef.current?.dismissSlashSuggestion();
              slashSessionRef.current = undefined;
              setSlashSession(undefined);
              toast.error(t.app.latexVisual.sourceChanged);
              return;
            }
            // BF4.4: execute safe Visual slash commands at the live PM caret
            // before falling back to source-coordinate insertion.
            if (visualEditorRef.current?.executeNativeSlashCommand(command)) {
              slashSessionRef.current = undefined;
              setSlashSession(undefined);
              return;
            }
            if (command === 'table' && visualEditorRef.current?.native) {
              slashSessionRef.current = undefined;
              setSlashSession(undefined);
              setTableInsertSession(session);
              return;
            }
            if (
              visualEditorRef.current?.native &&
              BF44_NATIVE_OWNED_SLASH_COMMANDS.has(command)
            ) {
              visualEditorRef.current.dismissSlashSuggestion();
              slashSessionRef.current = undefined;
              setSlashSession(undefined);
              return;
            }
            visualEditorRef.current?.dismissSlashSuggestion();
            slashSessionRef.current = undefined;
            setSlashSession(undefined);
            if (command === 'figure') {
              setPendingFigureSession(session);
              window.setTimeout(() => insertFigureInputRef.current?.click(), 0);
              return;
            }
            if (command === 'citation') {
              setCitationInsertSession(session);
              return;
            }
            if (command === 'reference') {
              setReferenceInsertSession(session);
              return;
            }
            if (command === 'table') {
              setTableInsertSession(session);
              return;
            }
            insertVisualBlock(session, command);
          }}
        />
      )}

      <Dialog open={Boolean(tableInsertSession)} onOpenChange={(open) => {
        if (!open) {
          visualEditorRef.current?.dismissSlashSuggestion();
          setTableInsertSession(undefined);
        }
      }}>
        <DialogContent className="w-auto max-w-[calc(100vw-2rem)] sm:max-w-sm" data-testid="latex-visual-table-picker-dialog">
          <DialogHeader>
            <DialogTitle>Insert table</DialogTitle>
            <DialogDescription>Choose the number of rows and columns.</DialogDescription>
          </DialogHeader>
          <LatexTableGridPicker
            onSelect={({ rows, columns }) => {
              const session = tableInsertSession;
              if (session) insertVisualTable(session, rows, columns);
            }}
            onCancel={() => {
              visualEditorRef.current?.dismissSlashSuggestion();
              setTableInsertSession(undefined);
            }}
          />
        </DialogContent>
      </Dialog>

      {mathSession && (
        <MathLiveLocalBoundary
          key={`${mathSession.revision}:${mathSession.region.replaceFrom}:${mathSession.region.replaceTo}`}
          sourceFallback={mathSession.region.content}
          onClose={closeMathSession}
        >
          <React.Suspense
            fallback={
              <MathLiveLoadingOverlay>
                {t.app.latexSourceEditor.loadingEquation}
              </MathLiveLoadingOverlay>
            }
          >
            <MathLiveEquationEditor
              anchor={mathSession.anchor}
              initialValue={mathSession.initialValue}
              kind={mathSession.region.kind}
              title={t.app.latexSourceEditor.editEquation}
              onSave={(value) => applyMathSession(value)}
              onCancel={closeMathSession}
            />
          </React.Suspense>
        </MathLiveLocalBoundary>
      )}

      <CitationContextDialog
        open={Boolean(citationContextSession)}
        onOpenChange={(open) => { if (!open) setCitationContextSession(undefined); }}
        wsName={WsPath.assertFile(wsPath).wsName}
        citationKey={citationContextSession?.action.targets[0]}
        citationAnchor={citationContextSession?.anchor}
        onOpenPaper={onOpenPaper}
        onOpenSufficiency={() => setCitationSufficiencyOpen(true)}
        onEdit={() => {
          if (citationContextSession) setCitationSession({
            revision: citationContextSession.revision,
            baseSource: citationContextSession.baseSource,
            visualEdits: citationContextSession.visualEdits,
            action: citationContextSession.action,
          });
        }}
      />
      <CitationSufficiencyDialog
        open={citationSufficiencyOpen}
        onOpenChange={setCitationSufficiencyOpen}
        wsName={WsPath.assertFile(wsPath).wsName}
        onOpenPaper={onOpenPaper}
      />

      <CitationPickerDialog
        open={Boolean(citationSession || citationInsertSession || citationResearchSession)}
        onOpenChange={(open) => {
          if (!open) { setCitationSession(undefined); setCitationInsertSession(undefined); setCitationResearchSession(undefined); }
        }}
        entries={semanticSnapshot?.bibliographyEntries ?? []}
        wsName={WsPath.assertFile(wsPath).wsName}
        initialKeys={citationSession?.action.targets ?? []}
        initialTab={citationResearchSession ? 'suggested' : 'local'}
        initialResearchQuery={citationResearchSession?.claim ?? ''}
        autoRunResearchSearch={Boolean(citationResearchSession)}
        onApply={(keys) => {
          if (citationResearchSession) {
            void insertGroundedVisualCitation(keys);
            return;
          }
          if (citationInsertSession) {
            const session = citationInsertSession;
            setCitationInsertSession(undefined);
            insertVisualTemplate(session, {
              text: `\n\n\\cite{${keys.join(',')}}\n\n`,
              focusOffset: 2,
            });
            return;
          }
          const session = citationSession;
          if (!session) return;
          applyDialogEdits(session, [
            {
              from: session.action.from,
              to: session.action.to,
              text: `\\${session.action.command}{${keys.join(',')}}`,
            },
          ]);
          setCitationSession(undefined);
        }}
      />

      <LatexVisualReferencePicker
        open={Boolean(referenceInsertSession || referenceEditSession)}
        title={referenceEditSession ? 'Edit reference' : 'Insert reference'}
        description={
          referenceEditSession
            ? 'Choose a different project label or jump to the current target.'
            : 'Choose a label already defined in this project.'
        }
        currentTarget={referenceEditSession?.action.target}
        targets={(semanticSnapshot?.labels ?? []).map((label) => ({
          target: label.target ?? '',
          file: label.file,
          line: label.line,
          ...(label.name ? { name: label.name } : {}),
        }))}
        onOpenChange={(open) => {
          if (!open) {
            setReferenceInsertSession(undefined);
            setReferenceEditSession(undefined);
          }
        }}
        onNavigateCurrent={() => {
          const target = referenceEditSession?.action.target;
          if (!target) return;
          const label = semanticLabelsRef.current?.find((candidate) => candidate.target === target);
          if (!label) {
            toast.error(t.app.bibliography.referenceMissing);
            return;
          }
          const targetWsPath = WsPath.fromParts(file.wsName, label.file).wsPath;
          setReferenceEditSession(undefined);
          latexSource.requestSelect(targetWsPath, label.line);
          navigation.goWsPath(targetWsPath);
        }}
        onSelect={(target) => {
          if (referenceInsertSession) {
            const session = referenceInsertSession;
            setReferenceInsertSession(undefined);
            insertVisualTemplate(session, {
              text: `\n\n\\ref{${target}}\n\n`,
              focusOffset: 2,
            });
            return;
          }
          const session = referenceEditSession;
          if (!session) return;
          if (applyDialogEdits(session, [
            {
              from: session.action.from,
              to: session.action.to,
              text: `\\${session.action.command}{${target}}`,
            },
          ])) {
            setReferenceEditSession(undefined);
          }
        }}
      />

      {linkSession && (
        <LatexVisualLinkEditor
          anchor={linkSession.action.anchor}
          initialHref={linkSession.href}
          canRemove={linkSession.action.visualKind === 'link'}
          onApply={(href) => applyVisualLink(href)}
          onRemove={() => applyVisualLink(undefined)}
          onClose={() => setLinkSession(undefined)}
        />
      )}

      <Dialog
        open={Boolean(footnoteSession)}
        onOpenChange={(open) => {
          if (!open) setFootnoteSession(undefined);
        }}
      >
        <DialogContent className="sm:max-w-lg">
          <DialogHeader>
            <DialogTitle>{t.app.bibliography.editFootnote}</DialogTitle>
            <DialogDescription>
              {t.app.bibliography.footnoteDescription}
            </DialogDescription>
          </DialogHeader>
          <Label htmlFor="interactive-footnote-text">
            {t.app.bibliography.footnote}
          </Label>
          <Input
            id="interactive-footnote-text"
            value={footnoteText}
            onChange={(event) => setFootnoteText(event.currentTarget.value)}
          />
          <div className="flex justify-end gap-2">
            <Button
              variant="outline"
              onClick={() => setFootnoteSession(undefined)}
            >
              {t.app.bibliography.cancel}
            </Button>
            <Button
              onClick={() => {
                const session = footnoteSession;
                if (!session) return;
                applyDialogEdits(session, [
                  {
                    from: session.action.bodyFrom,
                    to: session.action.bodyTo,
                    text: footnoteText,
                  },
                ]);
                setFootnoteSession(undefined);
              }}
            >
              {t.app.bibliography.apply}
            </Button>
          </div>
        </DialogContent>
      </Dialog>

      <Dialog
        open={Boolean(metadataSession)}
        onOpenChange={(open) => {
          if (!open) {
            setMetadataSession(undefined);
            setReplacementAsset(undefined);
          }
        }}
      >
        <DialogContent className="z-[160] sm:max-w-lg">
          <DialogHeader>
            <DialogTitle>
              {metadataSession?.block.kind === 'figure'
                ? t.app.bibliography.figure
                : t.app.bibliography.table}
            </DialogTitle>
            <DialogDescription>
              {t.app.bibliography.figureTableDescription}
            </DialogDescription>
          </DialogHeader>
          <Label htmlFor="interactive-visual-caption">
            {t.app.bibliography.caption}
          </Label>
          <Input
            id="interactive-visual-caption"
            value={metadataCaption}
            onChange={(event) => setMetadataCaption(event.currentTarget.value)}
          />

          {metadataSession?.block.kind === 'figure' && (
            <>
              <Label htmlFor="interactive-visual-figure-width">
                {t.app.bibliography.widthPercent}
              </Label>
              <Input
                id="interactive-visual-figure-width"
                inputMode="decimal"
                placeholder="80"
                value={metadataWidth}
                onChange={(event) => setMetadataWidth(event.currentTarget.value)}
              />
              <Label htmlFor="interactive-visual-figure-height">
                {t.app.bibliography.heightPercent}
              </Label>
              <Input
                id="interactive-visual-figure-height"
                inputMode="decimal"
                placeholder="60"
                value={metadataHeight}
                onChange={(event) => setMetadataHeight(event.currentTarget.value)}
              />
              <Label htmlFor="interactive-visual-figure-scale">{t.app.bibliography.scale}</Label>
              <Input
                id="interactive-visual-figure-scale"
                inputMode="decimal"
                placeholder="1"
                value={metadataScale}
                onChange={(event) => setMetadataScale(event.currentTarget.value)}
              />
              <label className="flex cursor-pointer items-center gap-2 text-sm">
                <input
                  checked={metadataKeepAspect}
                  className="size-4"
                  type="checkbox"
                  onChange={(event) => setMetadataKeepAspect(event.currentTarget.checked)}
                />
                {t.app.bibliography.keepAspectRatio}
              </label>
              <label className="flex cursor-pointer items-center gap-2 text-sm">
                <input
                  checked={metadataCentered}
                  className="size-4"
                  type="checkbox"
                  onChange={(event) => setMetadataCentered(event.currentTarget.checked)}
                />
                {t.app.bibliography.centered}
              </label>
              <Label htmlFor="interactive-visual-figure-description">
                {t.app.bibliography.accessibilityDescription}
              </Label>
              <textarea
                id="interactive-visual-figure-description"
                className="min-h-20 w-full rounded-md border bg-background px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-60"
                disabled={!hasFigureDescription(metadataSession.baseSource, metadataSession.block)}
                value={metadataDescription}
                onChange={(event) => setMetadataDescription(event.currentTarget.value)}
              />
              {!hasFigureDescription(metadataSession.baseSource, metadataSession.block) && (
                <p className="text-xs text-muted-foreground">
                  {t.app.bibliography.accessibilityExistingOnly}
                </p>
              )}
              <input
                ref={replaceImageInputRef}
                className="sr-only"
                type="file"
                accept={VISUAL_IMAGE_ACCEPT}
                aria-label={t.app.bibliography.replaceImage}
                onChange={(event) => {
                  const selected = event.currentTarget.files?.[0];
                  event.currentTarget.value = '';
                  if (selected) void importReplacementImage(selected);
                }}
              />
              <div className="flex items-center gap-2">
                <Button
                  type="button"
                  variant="outline"
                  disabled={replacementBusy}
                  onClick={() => replaceImageInputRef.current?.click()}
                >
                  {t.app.bibliography.replaceImage}
                </Button>
                {replacementAsset && (
                  <span className="min-w-0 truncate font-mono text-muted-foreground text-xs">
                    {replacementAsset}
                  </span>
                )}
              </div>
            </>
          )}

          <Label htmlFor="interactive-visual-environment-placement">{t.app.bibliography.placement}</Label>
          <Input
            id="interactive-visual-environment-placement"
            data-testid={metadataSession?.block.kind === 'figure' ? 'interactive-visual-figure-placement' : 'interactive-visual-table-placement'}
            placeholder="htbp"
            value={metadataPlacement}
            onChange={(event) => setMetadataPlacement(event.currentTarget.value)}
          />

          <Label htmlFor="interactive-visual-label">{t.app.bibliography.label}</Label>
          <Input
            id="interactive-visual-label"
            placeholder="fig:results or tab:data"
            value={metadataLabel}
            onChange={(event) => setMetadataLabel(event.currentTarget.value)}
          />
          {metadataSession?.block.label && (
            <p className="text-xs text-muted-foreground">{t.app.projectTools.symbolRefactorNotice}</p>
          )}

          {metadataTableSession?.block.editable && (
            <details className="rounded-md border p-3" data-testid="interactive-visual-table-advanced">
              <summary className="cursor-pointer text-sm font-medium">Advanced settings</summary>
              <div className="mt-3 space-y-2">
                <Label htmlFor="interactive-visual-table-import">{t.app.bibliography.tableImport}</Label>
                <textarea
                  id="interactive-visual-table-import"
                  className="min-h-24 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
                  placeholder={'Name,Value\nAlpha,10\nBeta,20'}
                  value={metadataTableImport}
                  onChange={(event) => setMetadataTableImport(event.currentTarget.value)}
                />
                <Button
                  type="button"
                  size="sm"
                  variant="outline"
                  disabled={!metadataTableImport.trim()}
                  onClick={() => {
                    const session = metadataSession;
                    if (!session || session.block.kind !== 'table') return;
                    const parsedRows = parseDelimitedTableInput(metadataTableImport);
                    if (!parsedRows) {
                      toast.error(t.app.bibliography.sourceOnlyStructure);
                      return;
                    }
                    if (visualEditorRef.current?.native) {
                      const replaced = visualEditorRef.current.replaceTableData(session.block.id, parsedRows);
                      if (!replaced) {
                        toast.error(t.app.bibliography.sourceOnlyStructure);
                        return;
                      }
                      setMetadataSession(undefined);
                      setMetadataTableImport('');
                      return;
                    }
                    const edits = tableImportEdits(session.baseSource, session.block, metadataTableImport);
                    if (!edits) {
                      toast.error(t.app.bibliography.sourceOnlyStructure);
                      return;
                    }
                    const current = getCanonicalSource();
                    if (!current || current.revision !== session.revision) {
                      toast.error(t.app.bibliography.sourceChanged);
                      return;
                    }
                    const desired = applySourceTextEdits(session.baseSource, [...session.visualEdits, ...edits]);
                    setMetadataSession(undefined);
                    setMetadataTableImport('');
                    commitStructuralSource(desired);
                  }}
                >
                  {t.app.bibliography.replaceTableData}
                </Button>
              </div>
            </details>
          )}
          <div className="flex justify-end gap-2">
            <Button
              variant="outline"
              onClick={() => {
                setMetadataSession(undefined);
                setReplacementAsset(undefined);
              }}
            >
              {t.app.bibliography.cancel}
            </Button>
            <Button
              onClick={() => {
                const session = metadataSession;
                if (!session) return;
                void (async () => {
                  const edits: SourceTextEdit[] = [];
                const caption = captionEdit(
                  session.baseSource,
                  session.block,
                  metadataCaption,
                );
                if (caption) edits.push(caption);
                if (session.block.kind === 'figure') {
                  const graphics = figureGraphicsSettingsEdit(
                    session.baseSource,
                    session.block,
                    {
                      widthPercent: metadataWidth,
                      heightPercent: metadataHeight,
                      scale: metadataScale,
                      keepAspectRatio: metadataKeepAspect,
                    },
                  );
                  if (graphics) edits.push(graphics);
                  const centering = figureCenteringEdit(
                    session.baseSource,
                    session.block,
                    metadataCentered,
                  );
                  if (centering) edits.push(centering);
                  const description = figureDescriptionEdit(
                    session.baseSource,
                    session.block,
                    metadataDescription,
                  );
                  if (description) edits.push(description);
                  if (replacementAsset && session.block.assetBodyRange) {
                    edits.push({
                      from: session.block.assetBodyRange.from,
                      to: session.block.assetBodyRange.to,
                      text: relativeProjectTarget(String(path), replacementAsset),
                    });
                  }
                }
                const placement = figurePlacementEdit(
                  session.baseSource,
                  session.block,
                  metadataPlacement,
                );
                if (placement) edits.push(placement);
                if (!session.block.label && metadataLabel.trim()) {
                  const insertedLabel = labelEdit(
                    session.baseSource,
                    session.block,
                    metadataLabel,
                  );
                  if (insertedLabel) edits.push(insertedLabel);
                }
                const applied = edits.length === 0 || applyDialogEdits(session, edits);
                if (!applied) return;
                if (session.block.label && metadataLabel.trim() !== session.block.label) {
                  try {
                    await projectTools.renameLabel(file.wsName, session.block.label, metadataLabel.trim());
                  } catch (reason) {
                    console.error('Unable to rename visual label', reason);
                    toast.error(reason instanceof Error ? reason.message : t.app.projectTools.refactorFailed);
                    return;
                  }
                }
                setMetadataSession(undefined);
                setReplacementAsset(undefined);
                      })();
              }}
            >
              {t.app.bibliography.apply}
            </Button>
          </div>
        </DialogContent>
      </Dialog>
    </section>
  );
}, (previous, next) => {
  const previousActive = previous.active ?? true;
  const nextActive = next.active ?? true;
  // PageEditor keeps Source and Visual mounted to preserve mode state. While
  // both renders are hidden, Source revisions can update the full document prop
  // without doing any Visual React/render work. Switching to Visual changes
  // `active`, forcing one render with the latest document revision.
  return !previousActive && !nextActive && previous.wsPath === next.wsPath;
});
