import { useCoreServices } from '@learnitive/context';
import {
  createLatexCodeMirror,
  createLearnitiveMathLiveExtension,
  createLatexReviewExtension,
  setLatexReviewDecorations,
  getLatexCodeMirrorCursor,
  getLatexCodeMirrorCursorRect,
  getLatexCodeMirrorLocation,
  getLatexCodeMirrorViewportCenterLocation,
  getLatexCodeMirrorSelection,
  revealLatexCodeMirrorLocation,
  selectLatexCodeMirrorLocation,
  selectLatexCodeMirrorOffset,
  type LatexCodeMirrorPersistedState,
  type LatexToolbarEntry,
  type LatexNavigationTarget,
  type LatexProjectCompletionData,
} from '@learnitive/latex-codemirror';
import {
  canEditLatexMathWithMathLive,
  createLatexImageInsertion,
  createLatexMathReplacementEdit,
  createLatexMathInsertion,
  findLatexMathAtPosition,
  latexMathRegionToMathLiveValue,
  type LatexMathInsertionStyle,
  type LatexMathKind,
  type LatexMathRegion,
  type SourceMutationOrigin,
  type SourceTextEdit,
  captureSourceAnchor,
  resolveSourceAnchor,
  type SourceAnchor,
  applySourceTextEdits,
  createLatexTableTemplate,
  canonicalOffsetToEditorOffset,
  detectLatexLineEnding,
  editorInsertedTextToCanonical,
  editorOffsetToCanonicalOffset,
  latexSourceToEditorText,
  normalizeLatexLineEndings,
} from '@learnitive/latex-source';
import {
  projectFilePath,
  safeProjectFilePath,
} from '@learnitive/project-storage';
import type { ProjectBibliographyEntry } from '@learnitive/project-semantic-index';
import type { AiSuggestionAction } from '@learnitive/service-core';
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, toast } from '@learnitive/ui-components';
import { WsPath } from '@learnitive/ws-path';
import { useAtomValue } from 'jotai';
import { AlertTriangle, ImagePlus, LocateFixed, MessageSquarePlus, Sparkles } from 'lucide-react';
import React from 'react';
import {
  persistSourceEditorFontSize,
  persistSourceEditorLineNumbers,
  persistSourceEditorSpellcheck,
  persistSourceEditorWordWrap,
  readSourceEditorPreferences,
  readSourceEditorSpellcheck,
} from '../editor-preferences';
import { CitationPickerDialog } from './citation-picker-dialog';
import { CitationContextDialog } from './citation-context-dialog';
import { CitationSufficiencyDialog } from './citation-sufficiency-dialog';
import { LearnitivePluginToolbar } from './interactive-plugin-toolbar';
import { LatexTableGridPicker } from './latex-table-grid-picker';
import { MathLiveLocalBoundary } from './mathlive-local-boundary';
import { SourceEditorToolbar } from './source-editor-toolbar';
import type { LatexDocumentController } from '../latex-document-controller';
import type { PaperMemoryManuscriptSelection } from '../paper-memory-manuscript';
import type { ReviewCommentTarget } from './review-comment-dialog';

const MathLiveEquationEditor = React.lazy(async () => {
  const module = await import('./mathlive-equation-editor');
  return { default: module.MathLiveEquationEditor };
});

const ACCEPTED_IMAGE_TYPES =
  '.png,.svg,.eps,.pdf,image/png,image/svg+xml,application/pdf';
const TOOLBAR_STORAGE_KEY = 'learnitive:latex-source:toolbar';
const SCROLL_OWNERSHIP_KEYS = new Set([
  'ArrowUp',
  'ArrowDown',
  'PageUp',
  'PageDown',
  'Home',
  'End',
  ' ',

]);

const LATEX_TOOLBAR_TEST_IDS = {
  cursor: 'latex-cursor-location',
  wordWrap: 'latex-soft-wrap-toggle',
  settings: 'latex-source-editor-settings',
  fontSize: 'latex-font-size',
  lineNumbers: 'latex-line-numbers',
  spellcheck: 'latex-spellcheck',
  fullscreen: 'latex-fullscreen-toggle',
} as const;

function readBooleanPreference(key: string, fallback: boolean): boolean {
  try {
    const value = window.localStorage.getItem(key);
    if (value === null) return fallback;
    return value !== 'off';
  } catch {
    return fallback;
  }
}

function persistPreference(key: string, value: string): void {
  try {
    window.localStorage.setItem(key, value);
  } catch {
    // Storage restrictions are non-fatal; the live setting still applies.
  }
}

function normalizeProjectPath(baseFile: string, target: string): string {
  const parts = baseFile.split('/');
  parts.pop();
  for (const part of target.replace(/^\.\//, '').split('/')) {
    if (!part || part === '.') continue;
    if (part === '..') parts.pop();
    else parts.push(part);
  }
  return parts.join('/');
}

export function LatexSourceEditor({
  wsPath,
  className = '',
  active = true,
  restoredState,
  onPersistState,
  latexDocumentController,
  onManuscriptSelectionChange,
  onRequestReviewComment,
  onOpenReviewThread,
  onOpenPaper,
}: {
  wsPath: string;
  name: string;
  className?: string;
  active?: boolean;
  restoredState?: LatexCodeMirrorPersistedState;
  onPersistState?: (state: LatexCodeMirrorPersistedState, revision: number) => void;
  latexDocumentController?: LatexDocumentController;
  onManuscriptSelectionChange?: (selection: PaperMemoryManuscriptSelection | undefined) => void;
  onRequestReviewComment?: (target: ReviewCommentTarget) => void;
  onOpenReviewThread?: (threadId: string) => void;
  onOpenPaper?: (paperId: string, page?: number) => void;
}) {
  const {
    fileSystem,
    latexSource,
    projectAi,
    projectAiWriting,
    projectAssets,
    projectCollaboration,
    navigation,
    projectPdf,
    projectSemantic,
    projectStorage,
    projectReview,
  } = useCoreServices();
  useAtomValue(projectPdf.$version);
  const collaborationState = useAtomValue(projectCollaboration.$state);
  const reviewVersion = useAtomValue(projectReview.$version);
  const sourceFile = WsPath.assertFile(wsPath);
  const sourceProjectPath = projectFilePath(sourceFile.path);
  const trackChangesEnabled = projectReview.getTrackChangesEnabled(sourceFile.wsName);
  const editorSectionRef = React.useRef<HTMLElement>(null);
  const editorHostRef = React.useRef<HTMLDivElement>(null);
  const editorViewRef = React.useRef<
    ReturnType<typeof createLatexCodeMirror> | null
  >(null);
  const collaborationBoundRef = React.useRef(false);
  const imageInputRef = React.useRef<HTMLInputElement>(null);
  const pendingSelectionRef = React.useRef<number | undefined>(undefined);
  const lastRevealRequestRef = React.useRef<number>(0);
  const cursorSyncTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const editorContextTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const lastEditorActivityKindRef = React.useRef<'text' | 'cursor' | 'selection' | 'undo-redo' | 'autocomplete-open' | 'autocomplete-close'>('cursor');
  const scrollSyncTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  // A local CodeMirror transaction already contains the exact resulting text.
  // Remember the canonical revision it produced so the source subscription
  // does not immediately diff the entire large document back into the same
  // CodeMirror view on the next React render.
  const sourceOwnedRevisionRef = React.useRef<number | undefined>(undefined);
  // CodeMirror normalizes line endings to LF. Cache whether this editor
  // projection contains CRLF so ordinary LF projects can map offsets without
  // scanning the entire manuscript for a carriage return on every keystroke.
  const editorSourceHasCarriageReturnRef = React.useRef(false);
  const activeRef = React.useRef(active);
  activeRef.current = active;
  const [, rerender] = React.useReducer((value) => value + 1, 0);
  const restoredStateRef = React.useRef(restoredState);
  const onPersistStateRef = React.useRef(onPersistState);
  onPersistStateRef.current = onPersistState;
  const [historyState, setHistoryState] = React.useState({ canUndo: false, canRedo: false });
  const [nativeHistory, setNativeHistory] = React.useState(true);
  const [imageBusy, setImageBusy] = React.useState(false);
  const [aiSuggestBusy, setAiSuggestBusy] = React.useState(false);
  const aiSuggestToolbarRef = React.useRef<HTMLDivElement>(null);
  const [sourceAiSelection, setSourceAiSelection] = React.useState<{
    from: number;
    to: number;
    text: string;
    changeId?: string;
    anchor: { left: number; top: number; bottom: number; right: number };
  }>();
  const [sourceAiMenuOpen, setSourceAiMenuOpen] = React.useState(false);
  React.useEffect(() => {
    const closeOnOutsidePointer = (event: PointerEvent) => {
      if (!sourceAiMenuOpen) return;
      const toolbar = aiSuggestToolbarRef.current;
      if (event.target instanceof Node && toolbar?.contains(event.target)) return;
      setSourceAiMenuOpen(false);
    };
    document.addEventListener('pointerdown', closeOnOutsidePointer, true);
    return () => document.removeEventListener('pointerdown', closeOnOutsidePointer, true);
  }, [sourceAiMenuOpen]);
  const initialEditorPreferencesRef = React.useRef<ReturnType<typeof readSourceEditorPreferences> | undefined>(undefined);
  if (!initialEditorPreferencesRef.current) initialEditorPreferencesRef.current = readSourceEditorPreferences();
  const initialEditorPreferences = initialEditorPreferencesRef.current;
  const [softWrap, setSoftWrap] = React.useState(initialEditorPreferences.wordWrap);
  const [showLineNumbers, setShowLineNumbers] = React.useState(initialEditorPreferences.lineNumbers);
  const [fontSize, setFontSize] = React.useState(initialEditorPreferences.fontSize);
  const [spellcheck, setSpellcheck] = React.useState(() => readSourceEditorSpellcheck('latex'));
  const [toolbarVisible, setToolbarVisible] = React.useState(() => readBooleanPreference(TOOLBAR_STORAGE_KEY, true));
  const [fullscreen, setFullscreen] = React.useState(false);
  const softWrapRef = React.useRef(softWrap);
  softWrapRef.current = softWrap;
  const showLineNumbersRef = React.useRef(showLineNumbers);
  showLineNumbersRef.current = showLineNumbers;
  const fontSizeRef = React.useRef(fontSize);
  fontSizeRef.current = fontSize;
  const spellcheckRef = React.useRef(spellcheck);
  spellcheckRef.current = spellcheck;
  const [toolbarItems, setToolbarItems] = React.useState<readonly LatexToolbarEntry[]>([]);
  const [citationPickerOpen, setCitationPickerOpen] = React.useState(false);
  const [citationResearchSession, setCitationResearchSession] = React.useState<{ claim: string; anchor: SourceAnchor }>();
  const [citationContext, setCitationContext] = React.useState<{ key: string; anchor?: import('@learnitive/latex-source').SourceAnchor }>();
  const [citationSufficiencyOpen, setCitationSufficiencyOpen] = React.useState(false);
  const [tablePickerOpen, setTablePickerOpen] = React.useState(false);
  const [citationEntries, setCitationEntries] = React.useState<ProjectBibliographyEntry[]>([]);
  const [cursorLocation, setCursorLocation] = React.useState({
    offset: 0,
    line: 1,
    column: 1,
  });
  const [activeMath, setActiveMath] = React.useState<LatexMathRegion>();
  const [mathEditor, setMathEditor] = React.useState<{
    kind: LatexMathKind;
    insertionStyle: LatexMathInsertionStyle;
    revision: number;
    cursor: number;
    replaceTo: number;
    initialValue: string;
    region?: LatexMathRegion;
    anchor?: DOMRect;
  }>();

  React.useEffect(
    () => latexSource.subscribe(wsPath, rerender),
    [latexSource, wsPath],
  );

  const snapshot = latexSource.getSnapshot(wsPath);
  const text = snapshot?.text ?? '';
  const editorReady = Boolean(snapshot);
  const getCanonicalSourceText = React.useCallback(() => {
    const filePath = WsPath.assertFile(wsPath);
    return projectCollaboration.getCanonicalSourceText(filePath.wsName, filePath.path)
      ?? latexSource.getSnapshot(wsPath)?.text
      ?? '';
  }, [latexSource, projectCollaboration, wsPath]);

  const getDisplaySourceText = React.useCallback(() => {
    const filePath = WsPath.assertFile(wsPath);
    const canonical = getCanonicalSourceText();
    return trackChangesEnabled && !collaborationBoundRef.current
      ? projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), canonical)
      : canonical;
  }, [getCanonicalSourceText, projectReview, trackChangesEnabled, wsPath]);

  const canonicalOffsetFromEditor = React.useCallback((offset: number, source = getDisplaySourceText()) => {
    if (!collaborationBoundRef.current && !editorSourceHasCarriageReturnRef.current) {
      return Math.max(0, Math.min(offset, source.length));
    }
    // Keep the collaboration bridge explicit: collaborative CodeMirror positions
    // are already canonical, while non-collaborative CRLF displays must map back
    // to canonical LF offsets. The explicit ternary is also part of the BF49
    // deployment source-guard contract.
    return collaborationBoundRef.current ? offset : editorOffsetToCanonicalOffset(source, offset);
  }, [getDisplaySourceText]);
  const editorOffsetFromCanonical = React.useCallback((offset: number, source = getDisplaySourceText()) => {
    if (collaborationBoundRef.current) return offset;
    if (!editorSourceHasCarriageReturnRef.current) return Math.max(0, Math.min(offset, source.length));
    return canonicalOffsetToEditorOffset(source, offset);
  }, [getDisplaySourceText]);
  const canonicalSelectionFromEditor = React.useCallback((selection: { from: number; to: number; text: string } | undefined) => {
    if (!selection) return undefined;
    if (collaborationBoundRef.current) return selection;
    const source = getDisplaySourceText();
    if (!editorSourceHasCarriageReturnRef.current) {
      const from = Math.max(0, Math.min(selection.from, source.length));
      const to = Math.max(0, Math.min(selection.to, source.length));
      return { from, to, text: source.slice(from, to) };
    }
    const from = editorOffsetToCanonicalOffset(source, selection.from);
    const to = editorOffsetToCanonicalOffset(source, selection.to);
    return { from, to, text: source.slice(from, to) };
  }, [getDisplaySourceText]);

  const canonicalAiRange = React.useCallback((from: number, to: number) => {
    const filePath = WsPath.assertFile(wsPath);
    const canonical = getCanonicalSourceText();
    return trackChangesEnabled
      ? projectReview.projectedRangeToCanonical(filePath.wsName, projectFilePath(filePath.path), canonical, from, to)
      : { from, to };
  }, [getCanonicalSourceText, projectReview, trackChangesEnabled, wsPath]);

  const refreshSourceAiSelectionToolbar = React.useCallback(() => {
    const editor = editorViewRef.current;
    if (!activeRef.current || !editor) {
      setSourceAiSelection(undefined);
      setSourceAiMenuOpen(false);
      return;
    }
    const editorSelection = getLatexCodeMirrorSelection(editor.view);
    if (editorSelection.from === editorSelection.to || !editorSelection.text.trim()) {
      setSourceAiSelection(undefined);
      setSourceAiMenuOpen(false);
      return;
    }
    const selection = canonicalSelectionFromEditor(editorSelection);
    if (!selection || selection.from === selection.to) {
      setSourceAiSelection(undefined);
      setSourceAiMenuOpen(false);
      return;
    }
    const mapped = canonicalAiRange(selection.from, selection.to);
    const start = editor.view.coordsAtPos(editorSelection.from);
    const end = editor.view.coordsAtPos(editorSelection.to);
    if (!start || !end) {
      setSourceAiSelection(undefined);
      setSourceAiMenuOpen(false);
      return;
    }
    const canonical = getCanonicalSourceText();
    setSourceAiSelection({
      from: mapped.from,
      to: mapped.to,
      text: canonical.slice(mapped.from, mapped.to),
      ...('changeId' in mapped && mapped.changeId ? { changeId: mapped.changeId } : {}),
      anchor: {
        left: Math.min(start.left, end.left),
        right: Math.max(start.right, end.right),
        top: Math.min(start.top, end.top),
        bottom: Math.max(start.bottom, end.bottom),
      },
    });
  }, [canonicalAiRange, canonicalSelectionFromEditor, getCanonicalSourceText]);

  const observeAiActivity = React.useCallback((kind: 'text' | 'cursor' | 'selection' | 'undo-redo' | 'autocomplete-open' | 'autocomplete-close', editorFrom: number, editorTo: number) => {
    if (!activeRef.current) return;
    lastEditorActivityKindRef.current = kind;
    queueMicrotask(refreshSourceAiSelectionToolbar);
    // Keep the CodeMirror transaction hot path lightweight. The AI writing
    // service now resolves canonical source and Review/semantic context only
    // after its idle debounce; this callback sends revision + offsets only.
    queueMicrotask(() => {
      if (!activeRef.current) return;
      const filePath = WsPath.assertFile(wsPath);
      const snapshot = latexSource.getSnapshot(wsPath);
      if (!snapshot) return;
      const canonical = collaborationBoundRef.current
        ? projectCollaboration.getCanonicalSourceText(filePath.wsName, filePath.path) ?? snapshot.text
        : snapshot.text;
      let mapped: { from: number; to: number };
      if (collaborationBoundRef.current) {
        mapped = { from: editorFrom, to: editorTo };
      } else if (trackChangesEnabled) {
        const display = projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), canonical);
        const projectedFrom = editorOffsetToCanonicalOffset(display, editorFrom);
        const projectedTo = editorOffsetToCanonicalOffset(display, editorTo);
        mapped = projectReview.projectedRangeToCanonical(filePath.wsName, projectFilePath(filePath.path), canonical, projectedFrom, projectedTo);
      } else {
        mapped = editorSourceHasCarriageReturnRef.current
          ? {
              from: editorOffsetToCanonicalOffset(canonical, editorFrom),
              to: editorOffsetToCanonicalOffset(canonical, editorTo),
            }
          : { from: editorFrom, to: editorTo };
      }
      projectAiWriting.observeEditorActivity({
        wsName: filePath.wsName,
        filePath: projectFilePath(filePath.path),
        revision: snapshot.revision,
        mode: 'source',
        cursor: mapped.to,
        selectionFrom: mapped.from,
        selectionTo: mapped.to,
        kind,
      });
    });
  }, [latexSource, projectAiWriting, projectCollaboration, projectReview, refreshSourceAiSelectionToolbar, trackChangesEnabled, wsPath]);

  React.useEffect(() => {
    const refresh = () => refreshSourceAiSelectionToolbar();
    globalThis.addEventListener('resize', refresh);
    return () => globalThis.removeEventListener('resize', refresh);
  }, [refreshSourceAiSelectionToolbar]);

  const manualSave = React.useCallback(async () => {
    try {
      await latexSource.flush(wsPath);
    } catch (error) {
      toast.error(
        error instanceof Error && error.message
          ? error.message
          : t.app.latexSourceEditor.saveFailed,
      );
      return;
    }

    const saved = latexSource.getSnapshot(wsPath);
    if (!saved) {
      toast.error(t.app.latexSourceEditor.saveFailed);
      return;
    }
    if (saved.externalConflict) {
      toast.error(t.app.latexSourceEditor.externalConflict);
      return;
    }
    if (saved.saveError) {
      toast.error(
        saved.saveError instanceof Error && saved.saveError.message
          ? saved.saveError.message
          : t.app.latexSourceEditor.saveFailed,
      );
      return;
    }
    if (saved.dirty || saved.saving) {
      toast.error(t.app.latexSourceEditor.saveFailed);
      return;
    }
    toast.success('Saved');
  }, [latexSource, wsPath]);

  React.useEffect(() => {
    const filePath = WsPath.assertFile(wsPath);
    projectPdf.setActiveSourceFile(filePath.wsName, projectFilePath(filePath.path));
  }, [projectPdf, wsPath]);

  const getProjectCompletionData = React.useCallback(async (): Promise<LatexProjectCompletionData> => {
    const filePath = WsPath.assertFile(wsPath);
    const semantic = projectSemantic.getSnapshot(filePath.wsName) ?? await projectSemantic.refresh(filePath.wsName);
    const provider = await projectStorage.getProvider(filePath.wsName);
    const entries = await provider.listEntries();
    return {
      citations: semantic.bibliographyEntries.map((entry) => ({
        key: entry.key,
        title:
          entry.fields.find((field) => field.name.toLowerCase() === 'title')?.value ||
          entry.fields.find((field) => field.name.toLowerCase() === 'author')?.value ||
          entry.key,
      })),
      labels: semantic.labels
        .filter((label) => Boolean(label.target))
        .map((label) => ({ label: label.target!, detail: `${label.file}:${label.line}` })),
      files: entries
        .filter((entry) => entry.kind === 'file')
        .map((entry) => ({ path: entry.path })),
    };
  }, [projectSemantic, projectStorage, wsPath]);

  const navigateProjectTarget = React.useCallback(async (target: LatexNavigationTarget) => {
    const filePath = WsPath.assertFile(wsPath);
    const semantic = projectSemantic.getSnapshot(filePath.wsName) ?? await projectSemantic.refresh(filePath.wsName);
    if (target.kind === 'reference') {
      const label = semantic.labels.find((candidate) => candidate.target === target.target);
      if (!label) return;
      const targetWsPath = WsPath.fromParts(filePath.wsName, label.file).wsPath;
      latexSource.requestSelect(targetWsPath, label.line);
      navigation.goWsPath(targetWsPath);
      return;
    }
    if (target.kind === 'citation') {
      const currentSnapshot = latexSource.getSnapshot(wsPath);
      const view = editorViewRef.current;
      // Never make the CodeMirror construction effect depend on React cursor
      // state. Cursor/selection changes are extremely frequent; using
      // cursorLocation here made navigateProjectTarget change identity, which
      // in turn destroyed/recreated the editor on every caret move. Read the
      // live view (or the shared canonical cursor) at invocation time instead.
      const cursor = view
        ? canonicalOffsetFromEditor(getLatexCodeMirrorCursor(view.view))
        : latexSource.getCursorLocation(wsPath)?.offset ?? 0;
      const node = semantic.citations.find((candidate) =>
        candidate.file === sourceProjectPath &&
        candidate.target === target.target &&
        cursor >= candidate.range.from &&
        cursor <= candidate.range.to,
      ) ?? semantic.citations.find((candidate) => candidate.file === sourceProjectPath && candidate.target === target.target);
      const anchor = currentSnapshot && node
        ? captureSourceAnchor({
            filePath: sourceProjectPath,
            source: getCanonicalSourceText(),
            from: node.range.from,
            to: node.range.to,
            sourceRevision: currentSnapshot.revision,
            semanticId: node.id,
            semanticKind: node.kind,
          })
        : undefined;
      setCitationContext({ key: target.target, ...(anchor ? { anchor } : {}) });
      return;
    }

    const provider = await projectStorage.getProvider(filePath.wsName);
    const entries = await provider.listEntries();
    const existing = new Set(entries.filter((entry) => entry.kind === 'file').map((entry) => entry.path));
    const baseTarget = normalizeProjectPath(filePath.path, target.target);
    const candidates = [baseTarget];
    if (/^(?:input|include|subfile)$/.test(target.command) && !/\.[A-Za-z0-9]+$/.test(baseTarget)) candidates.push(`${baseTarget}.tex`);
    if (/^(?:bibliography|addbibresource)$/.test(target.command) && !/\.bib$/i.test(baseTarget)) candidates.push(`${baseTarget}.bib`);
    const resolved = candidates
      .map((candidate) => safeProjectFilePath(candidate))
      .find((candidate) => candidate.ok && existing.has(candidate.path));
    if (resolved?.ok) {
      navigation.goWsPath(
        WsPath.fromParts(filePath.wsName, resolved.path).wsPath,
      );
    }
  }, [canonicalOffsetFromEditor, getCanonicalSourceText, latexSource, navigation, projectSemantic, projectStorage, sourceProjectPath, wsPath]);

  const applySourceEditorMutation = React.useCallback((
    edit: SourceTextEdit,
    origin: SourceMutationOrigin,
    semanticKind?: string,
  ): boolean => {
    if (projectCollaboration.isSourcePathCollaborative(sourceFile.wsName, sourceFile.path)) {
      const reviewSource = origin === 'ai'
        ? 'ai'
        : origin === 'citation'
          ? 'citation'
          : origin === 'terminal'
            ? 'terminal'
            : origin === 'system' || origin === 'collaboration'
              ? 'system'
              : 'toolbar';
      return projectCollaboration.applyTextEdits(sourceFile.wsName, sourceFile.path, [edit], { reviewSource, ...(semanticKind ? { semanticKind } : {}) });
    }
    const current = latexSource.getSnapshot(wsPath);
    if (!current) return false;
    const editingText = trackChangesEnabled
      ? projectReview.getProjectedSource(sourceFile.wsName, sourceProjectPath, current.text)
      : current.text;
    const normalizedEdit = {
      ...edit,
      text: normalizeLatexLineEndings(edit.text, detectLatexLineEnding(editingText)),
    };
    if (trackChangesEnabled) {
      projectReview.applyProjectedEdit(
        sourceFile.wsName,
        sourceProjectPath,
        current.text,
        normalizedEdit,
        { ...(semanticKind ? { semanticKind } : {}), source: origin === 'ai' ? 'ai' : origin === 'citation' ? 'citation' : origin === 'terminal' ? 'terminal' : 'toolbar' },
      );
      return true;
    }
    if (latexDocumentController) {
      return latexDocumentController.applyExactEdit(wsPath, {
        expectedRevision: current.revision,
        from: normalizedEdit.from,
        to: normalizedEdit.to,
        insert: normalizedEdit.text,
        origin,
      }).applied;
    }
    latexSource.applyEdit(wsPath, normalizedEdit, origin);
    return true;
  }, [latexDocumentController, latexSource, projectCollaboration, projectReview, sourceFile.path, sourceFile.wsName, sourceProjectPath, trackChangesEnabled, wsPath]);


  const importImage = React.useCallback(async (file: File) => {
    const filePath = WsPath.assertFile(wsPath);
    const view = editorViewRef.current;
    const canonicalText = getCanonicalSourceText();
    const currentText = trackChangesEnabled
      ? projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), canonicalText)
      : canonicalText;
    const cursor = view ? canonicalOffsetFromEditor(getLatexCodeMirrorCursor(view.view), currentText) : currentText.length;
    setImageBusy(true);
    try {
      const asset = await projectAssets.importImage(filePath.wsName, {
        fileName: file.name,
        data: new Uint8Array(await file.arrayBuffer()),
      });
      const insertion = createLatexImageInsertion({
        source: currentText,
        cursor,
        projectPath: asset.path,
        lineEnding: detectLatexLineEnding(currentText),
      });
      if (!applySourceEditorMutation(insertion.edit, 'asset', 'figure')) throw new Error('Unable to apply image insertion');
      pendingSelectionRef.current = insertion.edit.from + insertion.edit.text.length;
      fileSystem.refreshFileTree();
      toast.success(
        t.app.latexSourceEditor.imageInserted({ fileName: asset.fileName }),
      );
    } catch (error) {
      console.error('Unable to insert project image', error);
      toast.error(t.app.latexSourceEditor.imageInsertFailed);
    } finally {
      setImageBusy(false);
    }
  }, [applySourceEditorMutation, canonicalOffsetFromEditor, fileSystem, getCanonicalSourceText, projectAssets, projectReview, trackChangesEnabled, wsPath]);

  React.useEffect(() => {
    // Collaboration activation/room changes intentionally rebuild the binding.
    void collaborationState.active;
    void collaborationState.roomId;
    if (!editorReady || !editorHostRef.current) return;
    const filePath = WsPath.assertFile(wsPath);
    const collaborationExtension = projectCollaboration.getCodeMirrorExtension(
      filePath.wsName,
      filePath.path,
    );
    // TX7 installs exactly one direct y-codemirror binding for this file.
    collaborationBoundRef.current = Boolean(collaborationExtension);
    const useNativeHistory = !collaborationExtension;
    setNativeHistory(useNativeHistory);
    const view = createLatexCodeMirror({
      doc: (() => {
        const canonical = collaborationExtension
          ? projectCollaboration.getCollaborativeText(filePath.wsName, filePath.path) ?? latexSource.getSnapshot(wsPath)?.text ?? ''
          : latexSource.getSnapshot(wsPath)?.text ?? '';
        const display = trackChangesEnabled && !collaborationExtension
          ? projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), canonical)
          : canonical;
        // Keep the Yjs binding byte/offset model untouched. Local CodeMirror
        // uses its native LF Text model while canonical TeX source preserves
        // the project's LF/CRLF convention through explicit offset mapping.
        editorSourceHasCarriageReturnRef.current = !collaborationExtension && display.includes('\r');
        return collaborationExtension ? display : latexSourceToEditorText(display);
      })(),
      parent: editorHostRef.current,
      ariaLabel: t.app.latexSourceEditor.editorLabel,
      language: /\.bib$/iu.test(filePath.path) ? 'bibtex' : 'latex',
      ...(collaborationExtension
        ? {}
        : {
            onChanges: (edits) => {
              const current = latexSource.getSnapshot(wsPath);
              if (!current) return;
              const displaySource = trackChangesEnabled
                ? projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), current.text)
                : current.text;
              const canonicalEdits = edits.map((edit) => ({
                from: editorSourceHasCarriageReturnRef.current
                  ? editorOffsetToCanonicalOffset(displaySource, edit.from)
                  : edit.from,
                to: editorSourceHasCarriageReturnRef.current
                  ? editorOffsetToCanonicalOffset(displaySource, edit.to)
                  : edit.to,
                text: editorInsertedTextToCanonical(displaySource, edit.text),
              }));
              if (trackChangesEnabled) {
                for (const edit of [...canonicalEdits].sort((a, b) => b.from - a.from || b.to - a.to)) {
                  projectReview.applyProjectedEdit(
                    filePath.wsName,
                    projectFilePath(filePath.path),
                    current.text,
                    edit,
                  );
                }
                return;
              }
              if (latexDocumentController) {
                const result = latexDocumentController.applyExactEdits(wsPath, {
                  expectedRevision: current.revision,
                  edits: canonicalEdits.map((edit) => ({
                    from: edit.from,
                    to: edit.to,
                    insert: edit.text,
                  })),
                  origin: 'source',
                  history: 'ignore',
                });
                if (result.applied) sourceOwnedRevisionRef.current = result.snapshot.revision;
              } else {
                latexSource.applyEdits(wsPath, canonicalEdits, 'source', { history: 'ignore' });
                sourceOwnedRevisionRef.current = latexSource.getSnapshot(wsPath)?.revision;
              }
            },
          }),
      softWrap: softWrapRef.current,
      showLineNumbers: showLineNumbersRef.current,
      fontSize: fontSizeRef.current,
      spellcheck: spellcheckRef.current,
      getProjectCompletionData,
      onNavigate: navigateProjectTarget,
      onPasteFile: (file) => void importImage(file),
      nativeHistory: useNativeHistory,
      restoredState: restoredStateRef.current,
      onHistoryChange: setHistoryState,
      onActivity: ({ kind, selectionFrom, selectionTo }) => {
        observeAiActivity(kind, selectionFrom, selectionTo);
      },
      onSave: () => void manualSave(),
      onUndo: () => {
        if (collaborationExtension) {
          projectCollaboration.undoCollaborativeText(filePath.wsName, filePath.path);
          return;
        }
        if (trackChangesEnabled) {
          toast.info('Reject a pending review change to undo it while Track Changes is on.');
          return;
        }
        void latexSource.undo(wsPath);
      },
      onRedo: () => {
        if (collaborationExtension) {
          projectCollaboration.redoCollaborativeText(filePath.wsName, filePath.path);
          return;
        }
        if (trackChangesEnabled) return;
        void latexSource.redo(wsPath);
      },
      extensions: [
        ...(collaborationExtension ? [collaborationExtension] : []),
        createLatexReviewExtension((threadId) => {
          // A decoration click can race with comment deletion. Never reopen a
          // thread that no longer exists; force the decoration field back to
          // the service's current authoritative thread set instead.
          if (!projectReview.getThread(filePath.wsName, threadId)) {
            const activeView = editorViewRef.current;
            if (activeView) {
              const canonical = getCanonicalSourceText();
              const display = trackChangesEnabled
                ? projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), canonical)
                : canonical;
              const decorations = projectReview.getEditorDecorations(
                filePath.wsName,
                projectFilePath(filePath.path),
                canonical,
              );
              activeView.view.dispatch({
                effects: setLatexReviewDecorations.of(
                  collaborationExtension
                    ? decorations
                    : decorations.map((item) => ({
                        ...item,
                        from: canonicalOffsetToEditorOffset(display, item.from),
                        to: canonicalOffsetToEditorOffset(display, item.to),
                      })),
                ),
              });
            }
            return;
          }
          onOpenReviewThread?.(threadId);
        }),
        createLearnitiveMathLiveExtension({
          previewMode: 'hover-cursor',
          onOpen: ({ region, anchor }) => {
            const currentSnapshot = latexSource.getSnapshot(wsPath);
            if (!currentSnapshot) return;
            const displaySource = trackChangesEnabled
              ? projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), currentSnapshot.text)
              : currentSnapshot.text;
            const canonicalRegion = collaborationExtension
              ? region
              : findLatexMathAtPosition(
                  displaySource,
                  editorOffsetToCanonicalOffset(displaySource, region.from),
                );
            if (!canonicalRegion || !canEditLatexMathWithMathLive(canonicalRegion)) return;
            setMathEditor({
              kind: canonicalRegion.kind,
              insertionStyle: canonicalRegion.kind === 'inline' ? 'inline' : 'display',
              revision: currentSnapshot.revision,
              cursor: canonicalRegion.from,
              replaceTo: canonicalRegion.to,
              initialValue: latexMathRegionToMathLiveValue(canonicalRegion),
              region: canonicalRegion,
              anchor,
            });
          },
        }),
      ],
      onCursorChange: ({ offset, line, column }) => {
        const snapshotNow = latexSource.getSnapshot(wsPath);
        const canonicalSource = collaborationExtension
          ? projectCollaboration.getCanonicalSourceText(filePath.wsName, filePath.path) ?? snapshotNow?.text ?? ''
          : snapshotNow?.text ?? '';
        const currentSource = trackChangesEnabled && !collaborationExtension
          ? projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), canonicalSource)
          : canonicalSource;
        const canonicalOffset = collaborationExtension || !editorSourceHasCarriageReturnRef.current
          ? offset
          : editorOffsetToCanonicalOffset(currentSource, offset);
        setCursorLocation({ offset: canonicalOffset, line, column });
        // Both editor surfaces stay mounted. Only the active surface may own
        // the shared canonical cursor/AI/PDF context; otherwise a hidden
        // CodeMirror canonical-text update can overwrite the Visual caret.
        if (!activeRef.current) return;
        latexSource.setCursorLocation(wsPath, { offset: canonicalOffset, line, column });

        // Math detection currently scans LaTeX structure and selection/context
        // projection can also be O(document size). Defer both until the cursor
        // is stable instead of running them synchronously for every typed key.
        if (editorContextTimerRef.current) clearTimeout(editorContextTimerRef.current);
        const editorContextDelayMs = lastEditorActivityKindRef.current === 'text' ? 600 : 120;
        editorContextTimerRef.current = setTimeout(() => {
          editorContextTimerRef.current = undefined;
          if (!activeRef.current) return;
          const activeView = editorViewRef.current;
          const latestSnapshot = latexSource.getSnapshot(wsPath);
          if (!activeView || !latestSnapshot) return;
          const latestCanonical = collaborationExtension
            ? projectCollaboration.getCanonicalSourceText(filePath.wsName, filePath.path) ?? latestSnapshot.text
            : latestSnapshot.text;
          const latestSource = trackChangesEnabled && !collaborationExtension
            ? projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), latestCanonical)
            : latestCanonical;
          const latestLocation = getLatexCodeMirrorLocation(activeView.view);
          const latestOffset = collaborationExtension || !editorSourceHasCarriageReturnRef.current
            ? latestLocation.offset
            : editorOffsetToCanonicalOffset(latestSource, latestLocation.offset);
          setActiveMath(findLatexMathAtPosition(latestSource, latestOffset));

          const editorSelection = getLatexCodeMirrorSelection(activeView.view);
          let selection: { from: number; to: number; text: string } | undefined;
          if (editorSelection.from !== editorSelection.to) {
            if (collaborationExtension || !editorSourceHasCarriageReturnRef.current) {
              selection = editorSelection;
            } else {
              const from = editorOffsetToCanonicalOffset(latestSource, editorSelection.from);
              const to = editorOffsetToCanonicalOffset(latestSource, editorSelection.to);
              selection = { from, to, text: latestSource.slice(from, to) };
            }
          }
          projectAi.setEditorContext(filePath.wsName, {
            path: projectFilePath(filePath.path),
            line: latestLocation.line,
            column: latestLocation.column,
            offset: latestOffset,
            ...(selection?.text ? {
              selection: selection.text,
              selectionFrom: selection.from,
              selectionTo: selection.to,
            } : {}),
          });
          if (selection?.text && !trackChangesEnabled) {
            onManuscriptSelectionChange?.({
              mode: 'source',
              text: selection.text,
              anchor: captureSourceAnchor({
                filePath: projectFilePath(filePath.path),
                source: latestSnapshot.text,
                from: selection.from,
                to: selection.to,
                sourceRevision: latestSnapshot.revision,
              }),
            });
          } else {
            onManuscriptSelectionChange?.(undefined);
          }
        }, editorContextDelayMs);

        if (cursorSyncTimerRef.current) clearTimeout(cursorSyncTimerRef.current);
        const pdfSyncDelayMs = lastEditorActivityKindRef.current === 'text' ? 700 : 180;
        cursorSyncTimerRef.current = setTimeout(() => {
          const currentFilePath = WsPath.assertFile(wsPath);
          projectPdf.forwardSync(
            currentFilePath.wsName,
            projectFilePath(currentFilePath.path),
            line,
            column,
          );
        }, pdfSyncDelayMs);
      },
    });
    editorViewRef.current = view;
    const editorInitialLocation = getLatexCodeMirrorLocation(view.view);
    const initialLocation = {
      ...editorInitialLocation,
      offset: collaborationExtension
        ? editorInitialLocation.offset
        : editorOffsetToCanonicalOffset(getDisplaySourceText(), editorInitialLocation.offset),
    };
    setCursorLocation(initialLocation);
    if (activeRef.current) {
      latexSource.setCursorLocation(wsPath, initialLocation);
      projectAi.setEditorContext(filePath.wsName, {
        path: projectFilePath(filePath.path),
        line: initialLocation.line,
        column: initialLocation.column,
        offset: initialLocation.offset,
      });
    }
    setToolbarItems(view.toolbar.getItems());
    const unsubscribeToolbar = view.toolbar.subscribe(() => {
      setToolbarItems(view.toolbar.getItems());
    });
    const claimSourceUserScroll = () => {
      if (activeRef.current) projectPdf.claimScrollOwnership(filePath.wsName, 'source-user');
    };
    const claimSourceKeyboardScroll = (event: KeyboardEvent) => {
      if (SCROLL_OWNERSHIP_KEYS.has(event.key)) claimSourceUserScroll();
    };
    const handleAutomaticAiSuggestionKey = (event: KeyboardEvent) => {
      if (event.key !== 'Tab' && event.key !== 'Escape') return;
      const selection = getLatexCodeMirrorSelection(view.view);
      if (!selection) return;
      const display = getDisplaySourceText();
      const projectedFrom = collaborationExtension ? selection.from : editorOffsetToCanonicalOffset(display, selection.from);
      const projectedTo = collaborationExtension ? selection.to : editorOffsetToCanonicalOffset(display, selection.to);
      const canonical = getCanonicalSourceText();
      const mapped = projectReview.projectedRangeToCanonical(
        filePath.wsName,
        projectFilePath(filePath.path),
        canonical,
        projectedFrom,
        projectedTo,
      );
      if (!mapped.changeId) return;
      const thread = projectReview.getThread(filePath.wsName, mapped.changeId);
      if (!thread?.change || thread.change.status !== 'pending' || !thread.change.metadata?.automatic) return;
      event.preventDefault();
      event.stopPropagation();
      if (event.key === 'Tab') {
        void projectReview.acceptReviewChange(filePath.wsName, mapped.changeId).catch((reason) =>
          toast.error(reason instanceof Error ? reason.message : 'Unable to accept AI suggestion.'),
        );
      } else {
        void projectReview.rejectReviewChange(filePath.wsName, mapped.changeId).catch((reason) =>
          toast.error(reason instanceof Error ? reason.message : 'Unable to dismiss AI suggestion.'),
        );
      }
    };
    const handleViewportScroll = () => {
      refreshSourceAiSelectionToolbar();
      if (!activeRef.current || !projectPdf.isScrollOwnedByUser(filePath.wsName, 'source')) return;
      if (scrollSyncTimerRef.current) clearTimeout(scrollSyncTimerRef.current);
      scrollSyncTimerRef.current = setTimeout(() => {
        if (!activeRef.current || !projectPdf.isScrollOwnedByUser(filePath.wsName, 'source')) return;
        const location = getLatexCodeMirrorViewportCenterLocation(view.view);
        projectPdf.forwardSync(
          filePath.wsName,
          projectFilePath(filePath.path),
          location.line,
          location.column,
        );
      }, 140);
    };
    view.view.scrollDOM.addEventListener('wheel', claimSourceUserScroll, { passive: true });
    view.view.scrollDOM.addEventListener('touchstart', claimSourceUserScroll, { passive: true });
    view.view.scrollDOM.addEventListener('pointerdown', claimSourceUserScroll, { passive: true });
    view.view.scrollDOM.addEventListener('keydown', claimSourceKeyboardScroll);
    view.view.dom.addEventListener('keydown', handleAutomaticAiSuggestionKey, true);
    view.view.scrollDOM.addEventListener('scroll', handleViewportScroll, { passive: true });
    return () => {
      unsubscribeToolbar();
      view.view.scrollDOM.removeEventListener('wheel', claimSourceUserScroll);
      view.view.scrollDOM.removeEventListener('touchstart', claimSourceUserScroll);
      view.view.scrollDOM.removeEventListener('pointerdown', claimSourceUserScroll);
      view.view.scrollDOM.removeEventListener('keydown', claimSourceKeyboardScroll);
      view.view.dom.removeEventListener('keydown', handleAutomaticAiSuggestionKey, true);
      view.view.scrollDOM.removeEventListener('scroll', handleViewportScroll);
      if (scrollSyncTimerRef.current) clearTimeout(scrollSyncTimerRef.current);
      if (editorContextTimerRef.current) clearTimeout(editorContextTimerRef.current);
      if (cursorSyncTimerRef.current) clearTimeout(cursorSyncTimerRef.current);
      const persisted = view.captureState();
      restoredStateRef.current = persisted;
      const revision = latexSource.getSnapshot(wsPath)?.revision ?? 0;
      onPersistStateRef.current?.(persisted, revision);
      if (activeRef.current) onManuscriptSelectionChange?.(undefined);
      editorViewRef.current = null;
      collaborationBoundRef.current = false;
      if (collaborationExtension) projectCollaboration.clearCollaborativeCursor(filePath.wsName);
      view.destroy();
    };
  }, [
    collaborationState.active,
    collaborationState.roomId,
    editorReady,
    latexSource,
    latexDocumentController,
    projectCollaboration,
    projectPdf,
    projectAi,
    projectAiWriting,
    projectReview,
    trackChangesEnabled,
    getCanonicalSourceText,
    getDisplaySourceText,
    getProjectCompletionData,
    importImage,
    manualSave,
    navigateProjectTarget,
    observeAiActivity,
    refreshSourceAiSelectionToolbar,
    wsPath,
  ]);

  React.useEffect(() => {
    void reviewVersion;
    const view = editorViewRef.current;
    if (!view) return;
    const filePath = WsPath.assertFile(wsPath);
    const canonical = getCanonicalSourceText();
    const display = trackChangesEnabled && !collaborationBoundRef.current
      ? projectReview.getProjectedSource(filePath.wsName, projectFilePath(filePath.path), canonical)
      : canonical;
    const decorations = projectReview.getEditorDecorations(
      filePath.wsName,
      projectFilePath(filePath.path),
      canonical,
    );
    view.view.dispatch({
      effects: setLatexReviewDecorations.of(
        collaborationBoundRef.current
          ? decorations
          : decorations.map((item) => ({
              ...item,
              from: canonicalOffsetToEditorOffset(display, item.from),
              to: canonicalOffsetToEditorOffset(display, item.to),
            })),
      ),
    });
  }, [getCanonicalSourceText, projectReview, reviewVersion, trackChangesEnabled, wsPath]);

  React.useLayoutEffect(() => {
    if (!active) return;
    const view = editorViewRef.current;
    if (!view) return;
    const sharedCursor = latexSource.getCursorLocation(wsPath);
    if (sharedCursor) {
      // Visual -> Source switch: use the source-mapped Visual caret rather
      // than the stale cursor retained by the hidden CodeMirror surface.
      selectLatexCodeMirrorOffset(view.view, editorOffsetFromCanonical(sharedCursor.offset));
    } else {
      const location = getLatexCodeMirrorLocation(view.view);
      latexSource.setCursorLocation(wsPath, location);
    }
    // CodeMirror keeps its document/cursor/undo state while Source is hidden.
    // Re-measure only when the persistent surface becomes visible again.
    view.view.requestMeasure();
  }, [active, editorOffsetFromCanonical, latexSource, wsPath]);

  React.useEffect(() => {
    editorViewRef.current?.setSoftWrap(softWrap);
    persistSourceEditorWordWrap(softWrap);
  }, [softWrap]);

  React.useEffect(() => {
    editorViewRef.current?.setLineNumbers(showLineNumbers);
    persistSourceEditorLineNumbers(showLineNumbers);
  }, [showLineNumbers]);

  React.useEffect(() => {
    editorViewRef.current?.setFontSize(fontSize);
    persistSourceEditorFontSize(fontSize);
  }, [fontSize]);

  React.useEffect(() => {
    editorViewRef.current?.setSpellcheck(spellcheck);
    persistSourceEditorSpellcheck('latex', spellcheck);
  }, [spellcheck]);

  React.useEffect(() => {
    persistPreference(TOOLBAR_STORAGE_KEY, toolbarVisible ? 'on' : 'off');
  }, [toolbarVisible]);

  React.useEffect(() => {
    const onFullscreenChange = () => setFullscreen(document.fullscreenElement === editorSectionRef.current);
    document.addEventListener('fullscreenchange', onFullscreenChange);
    return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
  }, []);

  const toggleFullscreen = async () => {
    const section = editorSectionRef.current;
    if (!section) return;
    if (document.fullscreenElement === section) await document.exitFullscreen();
    else await section.requestFullscreen();
  };

  React.useLayoutEffect(() => {
    const view = editorViewRef.current;
    if (!view) return;
    // When Yjs owns the CodeMirror binding, canonical service changes are
    // mirrored into Y.Text by ProjectCollaborationService and arrive through
    // y-codemirror. Applying them here as well could duplicate the same edit.
    if (!collaborationBoundRef.current) {
      const snapshotNow = latexSource.getSnapshot(wsPath);
      const alreadyAppliedBySource = !trackChangesEnabled
        && snapshotNow?.lastOrigin === 'source'
        && sourceOwnedRevisionRef.current === snapshotNow.revision;
      if (alreadyAppliedBySource) {
        // CodeMirror produced this exact revision. Avoid converting/diffing the
        // complete document back into the same editor after each keystroke.
        sourceOwnedRevisionRef.current = undefined;
      } else {
        const displayText = trackChangesEnabled
          ? projectReview.getProjectedSource(sourceFile.wsName, sourceProjectPath, text)
          : text;
        editorSourceHasCarriageReturnRef.current = displayText.includes('\r');
        view.applyCanonicalText(latexSourceToEditorText(displayText));
      }
    }
    const selection = pendingSelectionRef.current;
    if (selection !== undefined) {
      pendingSelectionRef.current = undefined;
      selectLatexCodeMirrorOffset(view.view, editorOffsetFromCanonical(selection));
    }
  }, [editorOffsetFromCanonical, latexSource, text, projectReview, reviewVersion, sourceFile.wsName, sourceProjectPath, trackChangesEnabled, wsPath]);

  React.useLayoutEffect(() => {
    const view = editorViewRef.current;
    const request = latexSource.getRevealRequest(wsPath);
    if (!view || !request || request.requestId === lastRevealRequestRef.current) return;
    lastRevealRequestRef.current = request.requestId;
    // Reverse PDF scroll/click reveals are explicitly owned as programmatic
    // Source movement. The Source scroll listener therefore cannot bounce the
    // resulting reveal back to PDF; the next direct Source gesture takes over.
    projectPdf.claimScrollOwnership(WsPath.assertFile(wsPath).wsName, 'source-programmatic');
    if (request.select && request.from !== undefined && request.to !== undefined) {
      const from = Math.max(0, Math.min(view.view.state.doc.length, editorOffsetFromCanonical(request.from)));
      const to = Math.max(from, Math.min(view.view.state.doc.length, editorOffsetFromCanonical(request.to)));
      view.view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true });
      if (activeRef.current) view.view.focus();
    } else if (request.select) {
      selectLatexCodeMirrorLocation(view.view, request.line, request.column ?? 1, {
        focus: activeRef.current,
      });
    } else {
      revealLatexCodeMirrorLocation(view.view, request.line, request.column ?? 1);
    }
  });

  React.useEffect(
    () => () => {
      if (cursorSyncTimerRef.current) clearTimeout(cursorSyncTimerRef.current);
      if (scrollSyncTimerRef.current) clearTimeout(scrollSyncTimerRef.current);
      void latexSource.flush(wsPath);
    },
    [latexSource, wsPath],
  );



  const requestReviewComment = () => {
    const current = latexSource.getSnapshot(wsPath);
    if (!current) return;
    const view = editorViewRef.current;
    const selection = canonicalSelectionFromEditor(view ? getLatexCodeMirrorSelection(view.view) : undefined);
    const projectedFrom = selection?.from ?? cursorLocation.offset;
    const projectedTo = selection?.to ?? cursorLocation.offset;
    const filePath = WsPath.assertFile(wsPath);
    const canonicalText = getCanonicalSourceText();
    const mapped = trackChangesEnabled
      ? projectReview.projectedRangeToCanonical(filePath.wsName, projectFilePath(filePath.path), canonicalText, projectedFrom, projectedTo)
      : { from: projectedFrom, to: projectedTo };
    if (mapped.changeId) {
      onOpenReviewThread?.(mapped.changeId);
      return;
    }
    onRequestReviewComment?.({
      scope: 'anchor',
      anchor: captureSourceAnchor({
        filePath: projectFilePath(filePath.path),
        source: canonicalText,
        from: mapped.from,
        to: mapped.to,
        sourceRevision: current.revision,
      }),
      ...(mapped.to > mapped.from ? { selectedText: canonicalText.slice(mapped.from, mapped.to) } : {}),
    });
  };

  const runAiSuggest = async (
    action: AiSuggestionAction,
    selectionOverride?: Omit<NonNullable<typeof sourceAiSelection>, 'anchor'>,
  ) => {
    const current = latexSource.getSnapshot(wsPath);
    const view = editorViewRef.current;
    if (!current || !view) return;
    const liveSelection = selectionOverride
      ?? (() => {
        const selection = canonicalSelectionFromEditor(getLatexCodeMirrorSelection(view.view));
        if (!selection || selection.from === selection.to) return undefined;
        const mapped = canonicalAiRange(selection.from, selection.to);
        return {
          from: mapped.from,
          to: mapped.to,
          text: getCanonicalSourceText().slice(mapped.from, mapped.to),
          ...('changeId' in mapped && mapped.changeId ? { changeId: mapped.changeId } : {}),
        };
      })();
    if (!liveSelection || liveSelection.from === liveSelection.to) {
      toast.info('Select manuscript text before using AI Suggest.');
      return;
    }
    const filePath = WsPath.assertFile(wsPath);
    if (liveSelection.changeId) {
      onOpenReviewThread?.(liveSelection.changeId);
      toast.info('This selection is already covered by a pending review change.');
      return;
    }
    if (action === 'suggest-citation') {
      const canonical = getCanonicalSourceText();
      const claim = canonical.slice(liveSelection.from, liveSelection.to).trim();
      if (!claim) {
        toast.info('Select the claim that needs a citation.');
        return;
      }
      const anchor = captureSourceAnchor({
        filePath: projectFilePath(filePath.path),
        source: canonical,
        from: liveSelection.from,
        to: liveSelection.to,
        sourceRevision: current.revision,
        semanticKind: 'grounded-citation-suggestion',
      });
      setCitationResearchSession({ claim, anchor });
      // Open the grounded search immediately. Bibliography refresh is useful
      // for matching retrieved papers to existing keys, but it must not block
      // scholarly discovery when the project has no .bib file yet (the picker
      // can create references.bib after the author chooses a real record).
      setCitationPickerOpen(true);
      void projectSemantic.refresh(filePath.wsName).then((semantic) => {
        setCitationEntries(semantic.bibliographyEntries);
      }).catch((reason) => {
        console.error('Unable to refresh bibliography before grounded citation search', reason);
      });
      return;
    }
    setAiSuggestBusy(true);
    try {
      const suggestion = await projectAiWriting.suggestSelection(filePath.wsName, {
        filePath: projectFilePath(filePath.path),
        from: liveSelection.from,
        to: liveSelection.to,
        action,
      });
      if (!suggestion) {
        toast.info('Learnitive AI did not find a safe change to suggest.');
        return;
      }
      toast.success('AI suggestion added to Review. Accept or reject it when ready.');
      if (suggestion.reviewThreadId) onOpenReviewThread?.(suggestion.reviewThreadId);
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'AI Suggest failed.');
    } finally {
      setAiSuggestBusy(false);
    }
  };

  const collaborativeHistoryActive =
    collaborationState.active && collaborationState.wsName === sourceFile.wsName;
  const canCollaborativeUndo = collaborativeHistoryActive
    ? projectCollaboration.canUndoCollaborativeText(sourceFile.wsName, sourceFile.path)
    : false;
  const canCollaborativeRedo = collaborativeHistoryActive
    ? projectCollaboration.canRedoCollaborativeText(sourceFile.wsName, sourceFile.path)
    : false;

  const sourceFileSupportsMath = /\.(?:tex|ltx)$/i.test(
    WsPath.assertFile(wsPath).fileName,
  );
  const sourceFileSupportsLatexToolbar = /\.(?:tex|ltx|sty|cls)$/i.test(
    WsPath.assertFile(wsPath).fileName,
  );

  const openCitationPicker = async () => {
    const filePath = WsPath.assertFile(wsPath);
    setCitationResearchSession(undefined);
    try {
      const semantic = await projectSemantic.refresh(filePath.wsName);
      setCitationEntries(semantic.bibliographyEntries);
      setCitationPickerOpen(true);
    } catch (reason) {
      console.error('Unable to load project bibliography', reason);
      toast.error(t.app.bibliography.loadFailed);
    }
  };

  const insertTable = (rows: number, columns: number) => {
    const view = editorViewRef.current;
    const selection = canonicalSelectionFromEditor(view
      ? getLatexCodeMirrorSelection(view.view)
      : undefined) ?? { from: cursorLocation.offset, to: cursorLocation.offset, text: '' };
    const displaySource = getDisplaySourceText();
    const lineEnding = detectLatexLineEnding(displaySource);
    const template = createLatexTableTemplate(rows, columns, { lineEnding });
    const payload = `${lineEnding}${lineEnding}${template.text}${lineEnding}${lineEnding}`;
    const applied = applySourceEditorMutation(
      { from: selection.from, to: selection.to, text: payload },
      'source',
      'table',
    );
    if (!applied) return;
    pendingSelectionRef.current = selection.from + lineEnding.length * 2 + template.firstCellOffset;
    setTablePickerOpen(false);
  };

  const insertCitation = (keys: readonly string[]) => {
    if (keys.length === 0) return;
    const view = editorViewRef.current;
    const selection = canonicalSelectionFromEditor(view
      ? getLatexCodeMirrorSelection(view.view)
      : undefined) ?? { from: cursorLocation.offset, to: cursorLocation.offset, text: '' };
    const command = `\\cite{${keys.join(',')}}`;
    applySourceEditorMutation(
      { from: selection.from, to: selection.to, text: command },
      'citation',
      'citation',
    );
    pendingSelectionRef.current = selection.from + command.length;
  };

  const insertGroundedCitation = async (keys: readonly string[]) => {
    if (keys.length === 0 || !citationResearchSession) return;
    const current = latexSource.getSnapshot(wsPath);
    if (!current) return;
    const canonical = getCanonicalSourceText();
    const resolved = resolveSourceAnchor(citationResearchSession.anchor, {
      source: canonical,
      sourceRevision: current.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.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[insertionOffset - 1] ?? '');
    const command = `${needsSpace ? ' ' : ''}\\cite{${keys.join(',')}}`;
    try {
      if (trackChangesEnabled) {
        const insertionAnchor = captureSourceAnchor({
          filePath: sourceProjectPath,
          source: canonical,
          from: insertionOffset,
          to: insertionOffset,
          sourceRevision: current.revision,
          semanticKind: 'citation-insertion',
        });
        await projectReview.proposeSourceChange(sourceFile.wsName, {
          filePath: sourceProjectPath,
          from: insertionOffset,
          to: insertionOffset,
          originalText: '',
          proposedText: command,
          sourceRevision: current.revision,
          sourceAnchor: insertionAnchor,
          semanticKind: 'citation-insertion',
          author: projectReview.getLocalAuthor(sourceFile.wsName),
          metadata: { source: 'user', reason: 'Grounded citation selected from scholarly search.' },
        });
      } else {
        const applied = applySourceEditorMutation(
          { from: insertionOffset, to: insertionOffset, text: command },
          'citation',
          'citation',
        );
        if (!applied) throw new Error(t.app.bibliography.sourceChanged);
        pendingSelectionRef.current = insertionOffset + command.length;
      }
      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);
    }
  };

  const openMathEditor = (
    kind: LatexMathKind,
    region: LatexMathRegion | undefined = activeMath,
    insertionStyle: LatexMathInsertionStyle = kind === 'inline' ? 'inline' : 'equation',
  ) => {
    const currentSnapshot = latexSource.getSnapshot(wsPath);
    if (!currentSnapshot || (region && !canEditLatexMathWithMathLive(region))) return;
    const view = editorViewRef.current;
    const selection = canonicalSelectionFromEditor(view
      ? getLatexCodeMirrorSelection(view.view)
      : undefined) ?? { from: cursorLocation.offset, to: cursorLocation.offset, text: '' };
    const cursor = region?.from ?? selection.from;
    setMathEditor({
      kind: region?.kind ?? kind,
      insertionStyle: region ? (region.kind === 'inline' ? 'inline' : 'display') : insertionStyle,
      revision: currentSnapshot.revision,
      cursor,
      replaceTo: region?.to ?? selection.to,
      initialValue: region
        ? latexMathRegionToMathLiveValue(region)
        : selection.text,
      ...(region ? { region } : {}),
      ...(view ? { anchor: getLatexCodeMirrorCursorRect(view.view) } : {}),
    });
  };

  const applyMathEdit = (
    value: string,
    insertionStyle: LatexMathInsertionStyle,
  ) => {
    const session = mathEditor;
    const currentSnapshot = latexSource.getSnapshot(wsPath);
    if (!session || !currentSnapshot) return;
    if (currentSnapshot.revision !== session.revision) {
      toast.error(t.app.latexSourceEditor.mathSourceChanged);
      setMathEditor(undefined);
      return;
    }

    const canonicalText = getCanonicalSourceText();
    const editingMathSource = trackChangesEnabled
      ? projectReview.getProjectedSource(sourceFile.wsName, sourceProjectPath, canonicalText)
      : canonicalText;
    const edit = session.region
      ? createLatexMathReplacementEdit(
          session.region,
          value,
          detectLatexLineEnding(editingMathSource),
        )
      : createLatexMathInsertion({
          cursor: session.cursor,
          sourceLength: editingMathSource.length,
          replaceTo: session.replaceTo,
          content: value,
          kind: insertionStyle === 'inline' ? 'inline' : 'display',
          style: insertionStyle,
          lineEnding: detectLatexLineEnding(editingMathSource),
        }).edit;
    const applied = applySourceEditorMutation(edit, 'mathlive', 'equation');
    if (!applied) {
      toast.error(t.app.latexSourceEditor.mathSourceChanged);
      setMathEditor(undefined);
      return;
    }
    pendingSelectionRef.current = edit.from + edit.text.length;
    setMathEditor(undefined);
  };

  if (!snapshot) {
    return (
      <div className={`p-6 ${className}`} role="alert">
        <p className="font-medium">{t.app.latexSourceEditor.loadFailed}</p>
      </div>
    );
  }

  const saveLabel = snapshot.externalConflict
    ? t.app.latexSourceEditor.externalConflict
    : snapshot.saveError
      ? t.app.latexSourceEditor.saveFailed
      : snapshot.saving || snapshot.dirty
        ? t.app.latexSourceEditor.saving
        : t.app.latexSourceEditor.saved;

  return (
    <section
      ref={editorSectionRef}
      className={`flex min-h-0 flex-1 flex-col overflow-hidden bg-background ${className}`}
      data-editor-engine="latex-source"
      data-ws-path={wsPath}
    >
      <SourceEditorToolbar
        path={WsPath.assertFile(wsPath).path}
        cursor={cursorLocation}
        status={saveLabel}
        wordWrap={softWrap}
        fontSize={fontSize}
        lineNumbers={showLineNumbers}
        spellcheck={spellcheck}
        fullscreen={fullscreen}
        canUndo={!(trackChangesEnabled && !collaborativeHistoryActive) && (collaborativeHistoryActive ? canCollaborativeUndo : (nativeHistory ? historyState.canUndo || snapshot.canUndo : snapshot.canUndo))}
        canRedo={!(trackChangesEnabled && !collaborativeHistoryActive) && (collaborativeHistoryActive ? canCollaborativeRedo : (nativeHistory ? historyState.canRedo || snapshot.canRedo : snapshot.canRedo))}
        onWordWrapChange={setSoftWrap}
        onFontSizeChange={setFontSize}
        onLineNumbersChange={setShowLineNumbers}
        onSpellcheckChange={setSpellcheck}
        onFullscreen={toggleFullscreen}
        onUndo={() => {
          if (collaborativeHistoryActive) projectCollaboration.undoCollaborativeText(sourceFile.wsName, sourceFile.path);
          else if (trackChangesEnabled) return;
          else if (nativeHistory && historyState.canUndo) editorViewRef.current?.undo();
          else void latexSource.undo(wsPath);
        }}
        onRedo={() => {
          if (collaborativeHistoryActive) projectCollaboration.redoCollaborativeText(sourceFile.wsName, sourceFile.path);
          else if (trackChangesEnabled) return;
          else if (nativeHistory && snapshot.canRedo) void latexSource.redo(wsPath);
          else if (nativeHistory) editorViewRef.current?.redo();
          else void latexSource.redo(wsPath);
        }}
        onSave={() => void manualSave()}
        settingsExtra={(
          <label className="flex items-center justify-between gap-3">
            <span>Plugin toolbar</span>
            <input
              data-testid="latex-toolbar-visible"
              type="checkbox"
              checked={toolbarVisible}
              onChange={(event) => setToolbarVisible(event.currentTarget.checked)}
            />
          </label>
        )}
        testIds={LATEX_TOOLBAR_TEST_IDS}
        undoLabel={t.app.latexSourceEditor.undo}
        redoLabel={t.app.latexSourceEditor.redo}
        saveLabel={t.app.latexSourceEditor.save}
      />
      <div className="flex shrink-0 flex-wrap items-center gap-2 border-b bg-background px-3 py-1.5">
        <Button
          aria-label={t.app.latexSourceEditor.syncPdf}
          data-synctex-state={
            projectPdf.getWorkspaceState(WsPath.assertFile(wsPath).wsName)?.synctexAvailable
              ? 'ready'
              : 'unavailable'
          }
          data-testid="latex-source-synctex-locate"
          title={(() => {
            const pdfState = projectPdf.getWorkspaceState(WsPath.assertFile(wsPath).wsName);
            return pdfState?.synctexAvailable
              ? t.app.latexSourceEditor.syncPdf
              : pdfState?.synctexUnavailableReason ??
                  pdfState?.synctexError ??
                  t.app.pdfPreview.synctexUnavailable;
          })()}
          disabled={
            !projectPdf.getWorkspaceState(WsPath.assertFile(wsPath).wsName)
              ?.synctexAvailable
          }
          size="icon"
          variant="ghost"
          onClick={() => {
            const view = editorViewRef.current;
            if (!view) return;
            const filePath = WsPath.assertFile(wsPath);
            const location = getLatexCodeMirrorLocation(view.view);
            projectPdf.forwardSync(
              filePath.wsName,
              projectFilePath(filePath.path),
              location.line,
              location.column,
              { force: true },
            );
          }}
        >
          <LocateFixed className="size-4" />
        </Button>
        {sourceFileSupportsMath &&
          activeMath &&
          canEditLatexMathWithMathLive(activeMath) && (
            <Button
              size="sm"
              variant="outline"
              onClick={() => openMathEditor(activeMath.kind, activeMath)}
              title={t.app.latexSourceEditor.editEquation}
            >
              {t.app.latexSourceEditor.editEquation}
            </Button>
          )}
        {sourceFileSupportsMath &&
          activeMath &&
          !canEditLatexMathWithMathLive(activeMath) && (
            <span
              className="rounded-md border bg-muted/30 px-2 py-1 text-muted-foreground text-xs"
              title={t.app.latexSourceEditor.sourceOnlyEquationDescription}
            >
              {t.app.latexSourceEditor.sourceOnlyEquation}
            </span>
          )}
        <Button
          size="sm"
          variant="outline"
          onClick={requestReviewComment}
          title="Add review comment at the current selection or cursor"
        >
          <MessageSquarePlus className="mr-1 size-4" />
          Comment
        </Button>
        <input
          ref={imageInputRef}
          className="sr-only"
          type="file"
          accept={ACCEPTED_IMAGE_TYPES}
          aria-label={t.app.latexSourceEditor.insertImage}
          onChange={(event) => {
            const file = event.currentTarget.files?.[0];
            event.currentTarget.value = '';
            if (file) {
              void importImage(file);
            }
          }}
        />
        <Button
          disabled={imageBusy}
          size="sm"
          variant="outline"
          onClick={() => imageInputRef.current?.click()}
        >
          <ImagePlus className="mr-1 size-4" />
          {t.app.latexSourceEditor.insertImage}
        </Button>
      </div>

      {sourceAiSelection && (
        <div
          ref={aiSuggestToolbarRef}
          className="interactive-visual-selection-toolbar"
          data-testid="latex-source-ai-selection-toolbar"
          style={{
            left: (sourceAiSelection.anchor.left + sourceAiSelection.anchor.right) / 2,
            top: Math.max(8, sourceAiSelection.anchor.top - 8),
          }}
          onPointerDown={(event) => event.preventDefault()}
        >
          <button
            type="button"
            className="interactive-visual-selection-button interactive-ai-selection-button gap-1"
            aria-label="AI Suggest"
            title="AI Suggest for the selected source text"
            disabled={aiSuggestBusy}
            onClick={() => setSourceAiMenuOpen((open) => !open)}
          >
            <Sparkles className="size-3.5" />
            <span>AI Suggest</span>
          </button>
          {sourceAiMenuOpen && (
            <div
              className="absolute left-1/2 top-full z-[135] mt-1 grid min-w-52 -translate-x-1/2 gap-0.5 rounded-md border bg-popover p-1 text-xs text-popover-foreground shadow-xl"
              data-testid="latex-ai-suggest-menu"
            >
              {([
                ['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"
                  className="rounded px-2 py-1.5 text-left hover:bg-accent disabled:opacity-50"
                  disabled={aiSuggestBusy}
                  onClick={() => {
                    const selection = sourceAiSelection;
                    setSourceAiMenuOpen(false);
                    void runAiSuggest(action, selection);
                  }}
                >
                  {label}
                </button>
              ))}
            </div>
          )}
        </div>
      )}

      {sourceFileSupportsLatexToolbar && toolbarVisible && (
        <LearnitivePluginToolbar
          items={toolbarItems}
          onRun={(key) => {
            if (key === 'citation' && sourceFileSupportsMath) {
              void openCitationPicker();
              return;
            }
            if (key === 'table') {
              setTablePickerOpen(true);
              return;
            }
            if (sourceFileSupportsMath && key === 'inline-math') {
              openMathEditor('inline');
              return;
            }
            editorViewRef.current?.toolbar.run(key);
          }}
        />
      )}

      {snapshot.externalConflict && (
        <div className="flex flex-wrap items-center gap-2 border-b bg-amber-50 px-3 py-2 text-amber-950 dark:bg-amber-950/30 dark:text-amber-100">
          <AlertTriangle className="size-4 shrink-0" />
          <span className="min-w-52 flex-1 text-sm">
            {t.app.latexSourceEditor.externalConflictDescription}
          </span>
          <Button
            size="sm"
            variant="outline"
            onClick={() =>
              void latexSource.reloadFromStorage(wsPath, {
                discardLocalChanges: true,
              })
            }
          >
            {t.app.latexSourceEditor.useDiskVersion}
          </Button>
          <Button
            size="sm"
            onClick={() => void latexSource.keepLocalAfterExternalChange(wsPath)}
          >
            {t.app.latexSourceEditor.keepMyVersion}
          </Button>
        </div>
      )}

      <div
        ref={editorHostRef}
        className="min-h-0 flex-1 overflow-hidden bg-background"
        data-latex-codemirror
        data-cursor-offset={cursorLocation.offset}
        data-cursor-line={cursorLocation.line}
      />

      <Dialog open={tablePickerOpen} onOpenChange={setTablePickerOpen}>
        <DialogContent className="w-auto max-w-[calc(100vw-2rem)] sm:max-w-sm" data-testid="latex-source-table-picker-dialog">
          <DialogHeader>
            <DialogTitle>Insert table</DialogTitle>
            <DialogDescription>Choose the number of rows and columns.</DialogDescription>
          </DialogHeader>
          <LatexTableGridPicker
            onSelect={({ rows, columns }) => insertTable(rows, columns)}
            onCancel={() => setTablePickerOpen(false)}
          />
        </DialogContent>
      </Dialog>

      <CitationPickerDialog
        open={citationPickerOpen}
        onOpenChange={(open) => {
          setCitationPickerOpen(open);
          if (!open) setCitationResearchSession(undefined);
        }}
        entries={citationEntries}
        wsName={WsPath.assertFile(wsPath).wsName}
        initialTab={citationResearchSession ? 'suggested' : 'local'}
        initialResearchQuery={citationResearchSession?.claim ?? ''}
        autoRunResearchSearch={Boolean(citationResearchSession)}
        onApply={citationResearchSession ? (keys) => { void insertGroundedCitation(keys); } : insertCitation}
      />
      <CitationContextDialog
        open={Boolean(citationContext)}
        onOpenChange={(open) => { if (!open) setCitationContext(undefined); }}
        wsName={WsPath.assertFile(wsPath).wsName}
        citationKey={citationContext?.key}
        citationAnchor={citationContext?.anchor}
        onOpenPaper={onOpenPaper}
        onOpenSufficiency={() => setCitationSufficiencyOpen(true)}
      />
      <CitationSufficiencyDialog
        open={citationSufficiencyOpen}
        onOpenChange={setCitationSufficiencyOpen}
        wsName={WsPath.assertFile(wsPath).wsName}
        onOpenPaper={onOpenPaper}
      />
      {mathEditor && (
        <MathLiveLocalBoundary
          key={`${mathEditor.revision}:${mathEditor.cursor}:${mathEditor.replaceTo}`}
          sourceFallback={mathEditor.region?.content ?? mathEditor.initialValue}
          onClose={() => setMathEditor(undefined)}
        >
          <React.Suspense
            fallback={
              <div className="fixed right-4 bottom-4 z-[90] rounded-md border bg-background px-3 py-2 text-sm shadow-lg">
                {t.app.latexSourceEditor.loadingEquation}
              </div>
            }
          >
            <MathLiveEquationEditor
              anchor={mathEditor.anchor}
              initialValue={mathEditor.initialValue}
              kind={mathEditor.kind}
              insertionStyle={mathEditor.insertionStyle}
              allowInsertionStyleChange={!mathEditor.region}
              title={
                mathEditor.region
                  ? t.app.latexSourceEditor.editEquation
                  : t.app.latexSourceEditor.insertEquation
              }
              onSave={applyMathEdit}
              onCancel={() => setMathEditor(undefined)}
            />
          </React.Suspense>
        </MathLiveLocalBoundary>
      )}
    </section>
  );
}
