<script lang="ts">
  import { onMount, tick } from 'svelte';
  import { compileTypstProjectToSvg, loadTypstProjectFromPublic, type TypstProject } from './lib/typst';
  import FileTreeNode, { type FileNode } from './lib/FileTreeNode.svelte';

  type WindowWithTypstLocation = Window & typeof globalThis & {
    handleTypstLocation?: (link: Element, page: number, x: number, y: number) => boolean;
  };

  let project: TypstProject | null = null;
  let activeFilePath: string = ''; // projectPath like /proj/hlm.typ
  let source = ''; // active file content
  let diskFileContents = new Map<string, string>();
  let svg = '';
  let errorMsg = '';
  let compiling = false;
  let saving = false;
  let loading = true;
  let explorerOpen = false;
  let previewOpen = true;
  let viewMenuOpen = false;
  let optionsMenuOpen = false;
  let editorWordWrap = true;
  let activeDiskContent: string | undefined = undefined;
  let activeFileDirty = false;
  let activeFileDisplayPath = toDisplayPath('/proj/hlm.typ');
  
  interface ImagePreviewBox {
    coords: number[];
    coordKey: string;
    projectPath: string;
    charText: string;
  }
  interface ImagePreviewItem {
    imageName: string;
    url: string;
    boxes: ImagePreviewBox[];
    width: number;
    height: number;
  }
  interface CoordMatch {
    imageName: string;
    coords: number[];
    coordKey: string;
  }
  interface HorizontalCoordMarker {
    charText: string;
    coordKey: string;
    visibleIndex: number;
  }
  interface BoxBounds {
    minX: number;
    minY: number;
    maxX: number;
    maxY: number;
  }
  interface ActiveImageBox {
    imageName: string;
    coordKey: string;
  }
  interface ImageEditSession {
    previewIndex: number;
    boxIndex: number;
    imageName: string;
    originalCoordKey: string;
    mode: 'move' | 'top' | 'right' | 'bottom' | 'left';
    startX: number;
    startY: number;
    startBounds: BoxBounds;
    dirty: boolean;
  }
  let imagePreviews: ImagePreviewItem[] = [];
  let imageOpen = false;
  let activeImageBox: ActiveImageBox | null = null;
  let imageEditSession: ImageEditSession | null = null;
  let editorTextarea: HTMLTextAreaElement | null = null;
  let editorMeasure: HTMLDivElement | null = null;
  let editorHighlightBox: HTMLDivElement | null = null;
  let editorHighlight: { projectPath: string; start: number; end: number } | null = null;
  let lastPreviewHydrationKey = '';

  function closeImagePreview() {
    imageOpen = false;
    activeImageBox = null;
    imageEditSession = null;
  }

  function clearImagePreview() {
    imagePreviews = imagePreviews.map((item) => ({
      ...item,
      boxes: []
    }));
    activeImageBox = null;
    imageEditSession = null;
  }

  function clearEditorHighlight() {
    editorHighlight = null;
    if (editorHighlightBox) {
      editorHighlightBox.style.display = 'none';
    }
  }

  function getCoordElements(): Element[] {
    return Array.from(document.querySelectorAll('.previewContent .svg [data-coord]'));
  }

  function getCoordHitRect(el: Element): DOMRect {
    const pseudoLink = el.querySelector('.pseudo-link');
    return (pseudoLink instanceof Element ? pseudoLink : el).getBoundingClientRect();
  }

  function rectContainsPoint(rect: DOMRect, x: number, y: number) {
    return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
  }

  function getRectOverlapArea(a: DOMRect, b: DOMRect) {
    const left = Math.max(a.left, b.left);
    const right = Math.min(a.right, b.right);
    const top = Math.max(a.top, b.top);
    const bottom = Math.min(a.bottom, b.bottom);
    const width = Math.max(0, right - left);
    const height = Math.max(0, bottom - top);
    return width * height;
  }

  function parseCoordAttr(coordAttr: string | null): CoordMatch | null {
    if (!coordAttr || !coordAttr.includes('coord:')) return null;
    const coordMatch = coordAttr.match(/coord:([^"]+)/);
    if (!coordMatch) return null;
    const coordKey = coordMatch[1];
    const parts = coordKey.split(',');
    if (parts.length < 8) return null;
    return {
      imageName: parts[0],
      coords: parts.slice(1).map(Number),
      coordKey
    };
  }

  function findCoordElementAtPoint(clientX: number, clientY: number) {
    for (const el of getCoordElements()) {
      const rect = getCoordHitRect(el);
      if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
        return el;
      }
    }
    return null;
  }

  function getBoxBounds(coords: number[]): BoxBounds {
    return {
      minX: Math.min(coords[0], coords[2], coords[4], coords[6]),
      minY: Math.min(coords[1], coords[3], coords[5], coords[7]),
      maxX: Math.max(coords[0], coords[2], coords[4], coords[6]),
      maxY: Math.max(coords[1], coords[3], coords[5], coords[7])
    };
  }

  function boundsToCoords(bounds: BoxBounds): number[] {
    const minX = Math.round(bounds.minX);
    const minY = Math.round(bounds.minY);
    const maxX = Math.round(bounds.maxX);
    const maxY = Math.round(bounds.maxY);
    return [minX, minY, maxX, minY, maxX, maxY, minX, maxY];
  }

  function clamp(value: number, min: number, max: number) {
    return Math.min(max, Math.max(min, value));
  }

  function updateImagePreviews(coordsData: Array<{ imageName: string, coords: number[], coordKey: string, projectPath: string, charText: string }>) {
    const map = new Map<string, ImagePreviewBox[]>();
    for (const item of coordsData) {
      if (!map.has(item.imageName)) {
        map.set(item.imageName, []);
      }
      map.get(item.imageName)!.push({
        coords: item.coords,
        coordKey: item.coordKey,
        projectPath: item.projectPath,
        charText: item.charText
      });
    }
    
    imagePreviews = Array.from(map.entries()).map(([imageName, boxes]) => {
      const existing = imagePreviews.find(p => p.imageName === imageName);
      return {
        imageName,
        url: `/images/${imageName}.webp`,
        boxes,
        width: existing ? existing.width : 0,
        height: existing ? existing.height : 0
      };
    });
    activeImageBox = null;
    imageEditSession = null;
    
    if (imagePreviews.length > 0) {
      imageOpen = true;
    }
  }

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

  function findCoordInProject(coordKey: string) {
    if (!project) return null;
    const escapedCoordKey = escapeRegExp(coordKey);
    const re = new RegExp(`#c\\("([^"]+)",\\s*"${escapedCoordKey}"\\)`, 'g');
    for (const [projectPath, content] of project.files.entries()) {
      let match: RegExpExecArray | null;
      while ((match = re.exec(content))) {
        const charText = match[1];
        return {
          projectPath,
          charText,
          snippet: match[0]
        };
      }
    }
    return null;
  }

  function findCoordInEditorText(text: string, coordKey: string, charText: string) {
    const escapedCoordKey = escapeRegExp(coordKey);
    const escapedCharText = escapeRegExp(charText);
    const re = new RegExp(`#c\\("${escapedCharText}",\\s*"${escapedCoordKey}"\\)`);
    const match = re.exec(text);
    if (!match) return null;
    const start = match.index + '#c("'.length;
    return {
      start,
      end: start + charText.length
    };
  }

  function replaceCoordInText(text: string, charText: string, oldCoordKey: string, nextCoordKey: string) {
    const escapedCharText = escapeRegExp(charText);
    const escapedCoordKey = escapeRegExp(oldCoordKey);
    const re = new RegExp(`(#c\\("${escapedCharText}",\\s*")${escapedCoordKey}("\\))`);
    if (!re.test(text)) return null;
    return text.replace(re, `$1${nextCoordKey}$2`);
  }

  function isActiveImageBox(imageName: string, coordKey: string) {
    return activeImageBox?.imageName === imageName && activeImageBox.coordKey === coordKey;
  }

  function updateImageBoxCoords(previewIndex: number, boxIndex: number, nextCoords: number[]) {
    imagePreviews = imagePreviews.map((preview, currentPreviewIndex) => {
      if (currentPreviewIndex !== previewIndex) return preview;
      return {
        ...preview,
        boxes: preview.boxes.map((box, currentBoxIndex) =>
          currentBoxIndex === boxIndex ? { ...box, coords: nextCoords } : box
        )
      };
    });
  }

  function updateImageBoxAfterCommit(previewIndex: number, boxIndex: number, nextCoords: number[], nextCoordKey: string) {
    imagePreviews = imagePreviews.map((preview, currentPreviewIndex) => {
      if (currentPreviewIndex !== previewIndex) return preview;
      return {
        ...preview,
        boxes: preview.boxes.map((box, currentBoxIndex) =>
          currentBoxIndex === boxIndex
            ? { ...box, coords: nextCoords, coordKey: nextCoordKey }
            : box
        )
      };
    });
  }

  function getImageSvgPoint(event: MouseEvent, svgEl: SVGSVGElement) {
    const rect = svgEl.getBoundingClientRect();
    const viewBox = svgEl.viewBox.baseVal;
    const width = viewBox?.width || rect.width || 1;
    const height = viewBox?.height || rect.height || 1;
    return {
      x: ((event.clientX - rect.left) / Math.max(rect.width, 1)) * width,
      y: ((event.clientY - rect.top) / Math.max(rect.height, 1)) * height
    };
  }

  function buildDragBounds(
    mode: ImageEditSession['mode'],
    startBounds: BoxBounds,
    deltaX: number,
    deltaY: number,
    maxWidth: number,
    maxHeight: number
  ): BoxBounds {
    const minSize = 6;
    if (mode === 'move') {
      const width = startBounds.maxX - startBounds.minX;
      const height = startBounds.maxY - startBounds.minY;
      let minX = startBounds.minX + deltaX;
      let minY = startBounds.minY + deltaY;
      minX = clamp(minX, 0, Math.max(0, maxWidth - width));
      minY = clamp(minY, 0, Math.max(0, maxHeight - height));
      return {
        minX,
        minY,
        maxX: minX + width,
        maxY: minY + height
      };
    }

    const next = { ...startBounds };
    if (mode === 'left') {
      next.minX = clamp(startBounds.minX + deltaX, 0, startBounds.maxX - minSize);
    }
    if (mode === 'right') {
      next.maxX = clamp(startBounds.maxX + deltaX, startBounds.minX + minSize, maxWidth);
    }
    if (mode === 'top') {
      next.minY = clamp(startBounds.minY + deltaY, 0, startBounds.maxY - minSize);
    }
    if (mode === 'bottom') {
      next.maxY = clamp(startBounds.maxY + deltaY, startBounds.minY + minSize, maxHeight);
    }
    return next;
  }

  function commitImageBoxEdit(session: ImageEditSession) {
    const preview = imagePreviews[session.previewIndex];
    const box = preview?.boxes[session.boxIndex];
    if (!preview || !box) return;

    const nextCoordKey = `${preview.imageName},${box.coords.map((value) => Math.round(value)).join(',')}`;
    if (nextCoordKey === session.originalCoordKey) return;
    if (!project) return;

    const targetContent = box.projectPath === activeFilePath
      ? source
      : (project.files.get(box.projectPath) ?? '');
    const nextSource = replaceCoordInText(targetContent, box.charText, session.originalCoordKey, nextCoordKey);
    if (!nextSource) return;

    project.files.set(box.projectPath, nextSource);
    if (box.projectPath === activeFilePath) {
      source = nextSource;
      syncEditorHighlightOverlay();
    } else {
      openFile(box.projectPath);
    }

    updateImageBoxAfterCommit(session.previewIndex, session.boxIndex, box.coords, nextCoordKey);
    activeImageBox = {
      imageName: preview.imageName,
      coordKey: nextCoordKey
    };
    scheduleCompile();
  }

  function startImageBoxEdit(
    event: MouseEvent,
    previewIndex: number,
    boxIndex: number,
    mode: ImageEditSession['mode']
  ) {
    const preview = imagePreviews[previewIndex];
    const box = preview?.boxes[boxIndex];
    const svgEl = event.currentTarget instanceof SVGElement
      ? event.currentTarget.ownerSVGElement
      : null;
    if (!preview || !box || !(svgEl instanceof SVGSVGElement)) return;

    const point = getImageSvgPoint(event, svgEl);
    activeImageBox = {
      imageName: preview.imageName,
      coordKey: box.coordKey
    };
    imageEditSession = {
      previewIndex,
      boxIndex,
      imageName: preview.imageName,
      originalCoordKey: box.coordKey,
      mode,
      startX: point.x,
      startY: point.y,
      startBounds: getBoxBounds(box.coords),
      dirty: false
    };
    void openFileAndReveal(box.projectPath, box.coordKey, box.charText);
  }

  function handleImageEditMouseMove(event: MouseEvent) {
    if (!imageEditSession) return;
    const preview = imagePreviews[imageEditSession.previewIndex];
    const box = preview?.boxes[imageEditSession.boxIndex];
    if (!preview || !box) return;

    const overlaySvg = document.querySelectorAll('.overlaySvg')[imageEditSession.previewIndex];
    if (!(overlaySvg instanceof SVGSVGElement)) return;

    const point = getImageSvgPoint(event, overlaySvg);
    const deltaX = point.x - imageEditSession.startX;
    const deltaY = point.y - imageEditSession.startY;
    const nextBounds = buildDragBounds(
      imageEditSession.mode,
      imageEditSession.startBounds,
      deltaX,
      deltaY,
      preview.width,
      preview.height
    );
    updateImageBoxCoords(imageEditSession.previewIndex, imageEditSession.boxIndex, boundsToCoords(nextBounds));
    imageEditSession = {
      ...imageEditSession,
      dirty: true
    };
  }

  function handleImageEditMouseUp() {
    if (!imageEditSession) return;
    const session = imageEditSession;
    imageEditSession = null;
    if (session.dirty) {
      commitImageBoxEdit(session);
    }
  }

  function segmentGraphemes(text: string) {
    if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
      const segmenter = new Intl.Segmenter('zh-Hans', { granularity: 'grapheme' });
      return Array.from(segmenter.segment(text), (item) => item.segment);
    }
    return Array.from(text);
  }

  function extractCalligraphyBody(content: string) {
    const start = content.indexOf('#calligraphy-work');
    if (start < 0) return null;
    const openBracket = content.indexOf('[', start);
    if (openBracket < 0) return null;

    let depth = 0;
    let inString = false;
    let stringQuote = '';
    let escaped = false;
    for (let i = openBracket; i < content.length; i += 1) {
      const ch = content[i];
      if (inString) {
        if (escaped) {
          escaped = false;
        } else if (ch === '\\') {
          escaped = true;
        } else if (ch === stringQuote) {
          inString = false;
          stringQuote = '';
        }
        continue;
      }
      if (ch === '"' || ch === "'") {
        inString = true;
        stringQuote = ch;
        continue;
      }
      if (ch === '[') {
        depth += 1;
      } else if (ch === ']') {
        depth -= 1;
        if (depth === 0) {
          return content.slice(openBracket + 1, i);
        }
      }
    }
    return null;
  }

  function readQuotedString(text: string, start: number) {
    const quote = text[start];
    if (quote !== '"' && quote !== "'") return null;
    let result = '';
    let escaped = false;
    for (let i = start + 1; i < text.length; i += 1) {
      const ch = text[i];
      if (escaped) {
        result += ch;
        escaped = false;
      } else if (ch === '\\') {
        escaped = true;
      } else if (ch === quote) {
        return { value: result, nextIndex: i + 1 };
      } else {
        result += ch;
      }
    }
    return null;
  }

  function parseHorizontalCoordMarkers(content: string) {
    const body = extractCalligraphyBody(content);
    if (!body) return [];

    const markers: HorizontalCoordMarker[] = [];
    let visibleIndex = 0;
    let atLineStart = true;

    for (let i = 0; i < body.length;) {
      const ch = body[i];

      if (ch === '\r') {
        i += 1;
        continue;
      }
      if (ch === '\n') {
        atLineStart = true;
        i += 1;
        continue;
      }
      if (atLineStart && (ch === ' ' || ch === '\t')) {
        i += 1;
        continue;
      }
      if (ch === ' ' || ch === '　' || ch === '\t') {
        atLineStart = false;
        i += 1;
        continue;
      }

      if (body.startsWith('#c(', i)) {
        let cursor = i + 3;
        while (/\s/.test(body[cursor] ?? '')) cursor += 1;
        const charArg = readQuotedString(body, cursor);
        if (!charArg) {
          i += 1;
          atLineStart = false;
          continue;
        }
        cursor = charArg.nextIndex;
        while (/\s/.test(body[cursor] ?? '')) cursor += 1;
        if (body[cursor] !== ',') {
          i += 1;
          atLineStart = false;
          continue;
        }
        cursor += 1;
        while (/\s/.test(body[cursor] ?? '')) cursor += 1;
        const coordArg = readQuotedString(body, cursor);
        if (!coordArg) {
          i += 1;
          atLineStart = false;
          continue;
        }
        cursor = coordArg.nextIndex;
        while (/\s/.test(body[cursor] ?? '')) cursor += 1;
        if (body[cursor] !== ')') {
          i += 1;
          atLineStart = false;
          continue;
        }

        const chars = segmentGraphemes(charArg.value);
        for (const charText of chars) {
          markers.push({
            charText,
            coordKey: coordArg.value,
            visibleIndex
          });
          visibleIndex += 1;
        }
        i = cursor + 1;
        atLineStart = false;
        continue;
      }

      const clusters = segmentGraphemes(body.slice(i, i + 2));
      const nextChar = clusters[0] ?? ch;
      visibleIndex += 1;
      i += nextChar.length;
      atLineStart = false;
    }

    return markers;
  }

  function getHorizontalGlyphElements(svgRoot: Element) {
    return Array.from(svgRoot.querySelectorAll('foreignObject'))
      .filter((el): el is SVGForeignObjectElement => el instanceof SVGForeignObjectElement)
      .filter((el) => {
        const rect = el.getBoundingClientRect();
        return rect.width > 0 && rect.height > 0;
      })
      .sort((a, b) => {
        const rectA = a.getBoundingClientRect();
        const rectB = b.getBoundingClientRect();
        const rowDelta = rectA.top - rectB.top;
        if (Math.abs(rowDelta) > Math.max(4, Math.min(rectA.height, rectB.height) * 0.5)) {
          return rowDelta;
        }
        return rectA.left - rectB.left;
      });
  }

  function applyHorizontalCoordFallback(svgRoot: Element) {
    if (svgRoot.querySelector('[data-coord]')) return;
    const currentContent = activeFilePath === project?.entryPath
      ? source
      : (activeFilePath ? source : project?.files.get(project?.entryPath ?? '') ?? '');
    if (!currentContent || !currentContent.includes('type: "AllH"')) return;

    const markers = parseHorizontalCoordMarkers(currentContent);
    if (markers.length === 0) return;

    const glyphElements = getHorizontalGlyphElements(svgRoot);
    if (glyphElements.length === 0) return;

    for (const el of glyphElements) {
      el.removeAttribute('data-coord');
      el.removeAttribute('data-coord-char');
    }

    const applied: Array<{ index: number; coordKey: string; charText: string }> = [];
    for (const marker of markers) {
      const target = glyphElements[marker.visibleIndex];
      if (!target) continue;
      target.setAttribute('data-coord', `coord:${marker.coordKey}`);
      target.setAttribute('data-coord-char', marker.charText);
      applied.push({
        index: marker.visibleIndex,
        coordKey: marker.coordKey,
        charText: marker.charText
      });
    }

  }

  function measureEditorRange(start: number, end: number) {
    if (!editorTextarea || !editorMeasure) return null;

    const before = editorTextarea.value.slice(0, start);
    const selected = editorTextarea.value.slice(start, end) || ' ';
    const after = editorTextarea.value.slice(end);

    editorMeasure.style.width = `${editorTextarea.clientWidth}px`;
    editorMeasure.textContent = '';

    const beforeNode = document.createTextNode(before);
    const mark = document.createElement('span');
    mark.textContent = selected;
    mark.style.background = 'transparent';
    const afterNode = document.createTextNode(after);
    editorMeasure.append(beforeNode, mark, afterNode);

    return {
      markTop: mark.offsetTop,
      markLeft: mark.offsetLeft,
      markWidth: Math.max(mark.offsetWidth, 10),
      markHeight: Math.max(mark.offsetHeight, 20)
    };
  }

  function syncEditorHighlightOverlay() {
    if (!editorTextarea || !editorHighlightBox || !editorHighlight) {
      if (editorHighlightBox) editorHighlightBox.style.display = 'none';
      return;
    }
    if (activeFilePath !== editorHighlight.projectPath) {
      editorHighlightBox.style.display = 'none';
      return;
    }

    const metrics = measureEditorRange(editorHighlight.start, editorHighlight.end);
    if (!metrics) {
      editorHighlightBox.style.display = 'none';
      return;
    }

    const left = metrics.markLeft - editorTextarea.scrollLeft;
    const top = metrics.markTop - editorTextarea.scrollTop;
    const width = metrics.markWidth;
    const height = metrics.markHeight;

    editorHighlightBox.style.display = 'block';
    editorHighlightBox.style.left = `${left}px`;
    editorHighlightBox.style.top = `${top}px`;
    editorHighlightBox.style.width = `${width}px`;
    editorHighlightBox.style.height = `${height}px`;
  }

  function hasPreviewSelection() {
    const selection = window.getSelection();
    if (!selection || selection.isCollapsed) return false;
    const previewContent = document.querySelector('.previewContent');
    if (!previewContent) return false;
    return previewContent.contains(selection.anchorNode) || previewContent.contains(selection.focusNode);
  }

  function scrollPreviewToAnchor(targetId: string) {
    const previewContent = document.querySelector('.previewContent');
    if (!previewContent) return false;
    const selector = `#${CSS.escape(targetId)}`;
    const anchorTarget = previewContent.querySelector(selector);
    if (!(anchorTarget instanceof Element)) return false;
    anchorTarget.scrollIntoView({ block: 'center', inline: 'nearest' });
    return true;
  }

  function getNavTargetId(link: Element | null) {
    if (!(link instanceof Element)) return null;
    const dataTarget = link.getAttribute('data-nav-target');
    if (dataTarget) return dataTarget;
    const href = link.getAttribute('href') ?? link.getAttribute('xlink:href');
    if (href?.startsWith('#')) return href.slice(1);
    return null;
  }

  function scrollPreviewToTypstLocation(page: number, x: number, y: number) {
    const previewContent = document.querySelector('.previewContent');
    const svgRoot = previewContent?.querySelector('.svg svg');
    if (!(previewContent instanceof HTMLElement) || !(svgRoot instanceof SVGSVGElement)) return false;

    const directPageGroups = Array.from(svgRoot.children).filter(
      (el): el is SVGGElement => el instanceof SVGGElement && el.hasAttribute('transform')
    );
    const pageGroup = directPageGroups[Math.max(0, Math.round(page) - 1)] ?? null;
    const previewRect = previewContent.getBoundingClientRect();
    const svgRect = svgRoot.getBoundingClientRect();
    const viewBox = svgRoot.viewBox.baseVal;
    const scaleY = viewBox?.height ? svgRect.height / viewBox.height : 1;
    const targetRect = pageGroup?.getBoundingClientRect() ?? svgRect;
    const targetScrollTop =
      previewContent.scrollTop +
      (targetRect.top - previewRect.top) +
      y * scaleY -
      previewContent.clientHeight * 0.35;

    previewContent.scrollTo({
      top: Math.max(0, targetScrollTop),
      behavior: 'smooth'
    });
    return true;
  }

  function handleDocumentSelectionChange() {
    if (!hasPreviewSelection()) {
      clearEditorHighlight();
    }
  }

  function handleEditorWordWrapChange() {
    if (!editorHighlight) return;
    const currentHighlight = editorHighlight;
    void tick().then(() => {
      if (!editorTextarea) return;
      const metrics = measureEditorRange(currentHighlight.start, currentHighlight.end);
      if (metrics) {
        if (editorWordWrap) {
          editorTextarea.scrollLeft = 0;
        } else {
          const targetLeft = Math.max(
            0,
            metrics.markLeft - (editorTextarea.clientWidth - metrics.markWidth) / 2
          );
          editorTextarea.scrollLeft = targetLeft;
        }
      }
      syncEditorHighlightOverlay();
    });
  }

  async function openFileAndReveal(projectPath: string, coordKey: string, charText: string) {
    openFile(projectPath);
    await tick();
    if (!editorTextarea) return;
    await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
    const match = findCoordInEditorText(editorTextarea.value, coordKey, charText);
    if (!match) return;
    const selectionStart = match.start;
    const selectionEnd = match.end;
    editorHighlight = { projectPath, start: selectionStart, end: selectionEnd };

    const metrics = measureEditorRange(selectionStart, selectionEnd);
    if (metrics) {
      const targetTop = Math.max(
        0,
        metrics.markTop - (editorTextarea.clientHeight - metrics.markHeight) / 2
      );
      editorTextarea.scrollTop = targetTop;
      if (editorWordWrap) {
        editorTextarea.scrollLeft = 0;
      } else {
        const targetLeft = Math.max(
          0,
          metrics.markLeft - (editorTextarea.clientWidth - metrics.markWidth) / 2
        );
        editorTextarea.scrollLeft = targetLeft;
      }
    }

    await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
    syncEditorHighlightOverlay();
  }



  function handleMouseUp(e: MouseEvent) {
    // 延迟以确保选区在 mouseup 后完全更新
    setTimeout(() => {
      const selection = window.getSelection();
      if (!selection || selection.isCollapsed) {
        clearImagePreview();
        clearEditorHighlight();
        return;
      }

      const previewContent = document.querySelector('.previewContent');
      if (!previewContent) {
        clearImagePreview();
        clearEditorHighlight();
        return;
      }

      // 检查选区是否在预览区内
      if (!previewContent.contains(selection.anchorNode)) {
        clearImagePreview();
        clearEditorHighlight();
        return;
      }

      // 注意：SVG 文本的 selection.toString() 在很多浏览器里是空字符串！
      // 所以我们绝不能用 selection.toString().length === 0 来判断是否没选中。

      const selectedCoords: CoordMatch[] = [];
      const range = selection.getRangeAt(0);
      const selectionRects = Array.from(range.getClientRects());

      for (const coordElement of getCoordElements()) {
        const coordRect = getCoordHitRect(coordElement);
        const centerX = coordRect.left + coordRect.width / 2;
        const centerY = coordRect.top + coordRect.height / 2;
        const coordArea = Math.max(1, coordRect.width * coordRect.height);
        const intersects = selectionRects.some((rect) => {
          if (rect.width <= 0 || rect.height <= 0) return false;
          const overlapArea = getRectOverlapArea(rect, coordRect);
          const overlapRatio = overlapArea / coordArea;
          return rectContainsPoint(rect, centerX, centerY) || overlapRatio >= 0.55;
        });
        if (!intersects) continue;
        const parsed = parseCoordAttr(coordElement.getAttribute('data-coord'));
        if (parsed) {
          const exists = selectedCoords.some((c) =>
            c.imageName === parsed.imageName &&
            c.coords.every((val, i) => val === parsed.coords[i])
          );
          if (!exists) {
            selectedCoords.push(parsed);
          }
        }
      }

      if (selectedCoords.length > 0) {
        const coordsWithSource = selectedCoords
          .map((coord) => {
            const match = findCoordInProject(coord.coordKey);
            if (!match) return null;
            return {
              imageName: coord.imageName,
              coords: coord.coords,
              coordKey: coord.coordKey,
              projectPath: match.projectPath,
              charText: match.charText
            };
          })
          .filter((item): item is { imageName: string, coords: number[], coordKey: string, projectPath: string, charText: string } => item !== null);
        updateImagePreviews(coordsWithSource);
        const match = findCoordInProject(selectedCoords[0].coordKey);
        if (match) {
          void openFileAndReveal(match.projectPath, selectedCoords[0].coordKey, match.charText);
        }
      } else {
        clearImagePreview();
        clearEditorHighlight();
      }
    }, 50);
  }

  function handlePreviewClick(e: MouseEvent) {
    const target = e.target as Element;
    const selection = window.getSelection();
    // 如果存在选区（不是点击），就拦截掉，防止弹出单张原图
    if (selection && !selection.isCollapsed) {
      return;
    }

    const navLink = target.closest('a');
    const navTarget = getNavTargetId(navLink);
    if (navLink instanceof Element) {
      if (navTarget && scrollPreviewToAnchor(navTarget)) {
        e.preventDefault();
        e.stopPropagation();
      }
      return;
    }
    
    // 改成交互仅通过“划选”触发定位，单击不再打开原图。
    const coordElement = target.closest('[data-coord]') || findCoordElementAtPoint(e.clientX, e.clientY);
    if (coordElement) {
      e.preventDefault();
      e.stopPropagation();
    }
  }

  let timer: number | undefined;

  function handlePreviewMouseDown(_e: MouseEvent) {}

  function toggleViewMenu(e: MouseEvent) {
    e.stopPropagation();
    viewMenuOpen = !viewMenuOpen;
    optionsMenuOpen = false;
  }

  function toggleOptionsMenu(e: MouseEvent) {
    e.stopPropagation();
    optionsMenuOpen = !optionsMenuOpen;
    viewMenuOpen = false;
  }

  function closeMenus() {
    viewMenuOpen = false;
    optionsMenuOpen = false;
  }

  function toDisplayPath(projectPath: string) {
    return projectPath.replace(/^\/proj\//, '');
  }

  function setDiskFileContent(projectPath: string, content: string) {
    const next = new Map(diskFileContents);
    next.set(projectPath, content);
    diskFileContents = next;
  }

  function isFileDirty(projectPath: string, content: string) {
    return diskFileContents.get(projectPath) !== content;
  }

  async function saveActiveFileToDisk() {
    if (!project || !activeFilePath || saving) return;

    saving = true;
    try {
      const response = await fetch('/__save_typst_file', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          projectPath: activeFilePath,
          content: source
        })
      });
      if (!response.ok) {
        const message = await response.text();
        throw new Error(message || `保存失败（HTTP ${response.status}）`);
      }

      project.files.set(activeFilePath, source);
      setDiskFileContent(activeFilePath, source);
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      console.error('保存 .typ 文件失败：', error);
      window.alert(`保存失败：${message}`);
    } finally {
      saving = false;
    }
  }

  function handleWindowKeyDown(event: KeyboardEvent) {
    if (!activeFilePath) return;
    if (!(event.ctrlKey || event.metaKey)) return;
    if (event.key.toLowerCase() !== 's') return;

    event.preventDefault();
    void saveActiveFileToDisk();
  }

  function buildTreeFromProject(p: TypstProject): FileNode {
    const root: FileNode = { name: '', path: '/proj', kind: 'folder', children: [] };
    const ensureFolder = (parent: FileNode, name: string, path: string) => {
      const found = parent.children?.find((c: FileNode) => c.kind === 'folder' && c.name === name);
      if (found) return found;
      const folder: FileNode = { name, path, kind: 'folder', children: [] };
      parent.children!.push(folder);
      return folder;
    };

    for (const projectPath of p.files.keys()) {
      const rel = toDisplayPath(projectPath);
      const parts = rel.split('/').filter(Boolean);
      let cur = root;
      for (let i = 0; i < parts.length; i++) {
        const name = parts[i];
        const isLast = i === parts.length - 1;
        if (isLast) {
          cur.children!.push({ name, path: projectPath, kind: 'file' });
        } else {
          const folderPath = `${cur.path}/${name}`;
          cur = ensureFolder(cur, name, folderPath);
        }
      }
    }

    const sortNode = (node: FileNode) => {
      if (!node.children) return;
      node.children.sort((a: FileNode, b: FileNode) => {
        if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1;
        return a.name.localeCompare(b.name);
      });
      node.children.forEach(sortNode);
    };
    sortNode(root);
    return root;
  }

  $: tree = project ? buildTreeFromProject(project) : null;
  $: activeDiskContent = activeFilePath ? diskFileContents.get(activeFilePath) : undefined;
  $: activeFileDirty = activeFilePath ? activeDiskContent !== source : false;
  $: activeFileDisplayPath = activeFilePath
    ? `${toDisplayPath(activeFilePath)}${activeFileDirty ? ' *' : ''}`
    : toDisplayPath('/proj/hlm.typ');

  function openFile(projectPath: string) {
    // 保存当前文件修改
    if (project && activeFilePath) {
      project.files.set(activeFilePath, source);
    }
    if (!project) return;
    const content = project.files.get(projectPath);
    if (content === undefined) {
      errorMsg = `找不到文件：${toDisplayPath(projectPath)}`;
      return;
    }
    activeFilePath = projectPath;
    source = content;
    clearEditorHighlight();
    scheduleCompile();
  }

  function closeFile() {
    if (project && activeFilePath) {
      project.files.set(activeFilePath, source);
    }
    activeFilePath = '';
    source = '';
    clearEditorHighlight();
    // scheduleCompile(); // 保持预览内容不变，或者如果需要可以清空
  }

  async function doCompile() {
    compiling = true;
    errorMsg = '';
    try {
      if (!project) throw new Error('项目尚未加载完成');
      const overrides = activeFilePath ? { [activeFilePath]: source } : undefined;
      let rawSvg = await compileTypstProjectToSvg(project, overrides);
      
      // 我们不再粗暴替换 <a> 标签，因为这会破坏 typst 的排版样式和结构
      // 但为了确保鼠标不再显示手形，并且可以强制进行文本划选，我们将 href 移除并放在 data-coord 中
      // 由于浏览器的底层实现只要看到 <a href="..."> 就会强制变成手形并允许拖拽链接
      rawSvg = rawSvg.replace(/<a([^>]*?)(?:href|xlink:href)="([^"]*?coord:[^"]+)"([^>]*?)>/g, '<a$1data-coord="$2"$3>');
      rawSvg = rawSvg.replace(/<a([^>]*?)xlink:href="#([^"]+)"([^>]*?)>/g, '<a$1href="#$2" data-nav-target="$2"$3>');
      rawSvg = rawSvg.replace(/<a([^>]*?)href="#([^"]+)"((?:(?!data-nav-target)[^>])*)>/g, '<a$1href="#$2" data-nav-target="$2"$3>');
      // 强制删除可能由 typst 生成的 cursor 属性，防止它在内联样式里覆盖我们的 CSS
      rawSvg = rawSvg.replace(/cursor:\s*pointer/g, 'cursor: text');
      
      // 添加全局的 SVG 样式以确保 SVG 本身可以响应选择事件，去除导致选中失效的 bounding-box 逻辑
      if (!rawSvg.includes('style="user-select: text; -webkit-user-select: text;"')) {
        rawSvg = rawSvg.replace(/<svg /, '<svg style="user-select: text; -webkit-user-select: text;" ');
      }
      
      // 去除 SVG 内部所有可能阻挡选择的 pointer-events
      rawSvg = rawSvg.replace(/pointer-events:\s*none/g, '');
      
      svg = rawSvg;
      await tick();
      const svgRoot = document.querySelector('.previewContent .svg');
      if (svgRoot) {
        applyHorizontalCoordFallback(svgRoot);
      }
    } catch (e) {
      // typst.ts 抛出的错误对象结构不稳定，这里尽量把信息转成字符串展示
      errorMsg = e instanceof Error ? e.message : String(e);
      svg = '';
    } finally {
      compiling = false;
    }
  }

  function scheduleCompile() {
    if (timer) window.clearTimeout(timer);
    timer = window.setTimeout(() => {
      void doCompile();
    }, 300);
  }

  onMount(() => {
    // We handle selection via mouseup on the preview container
    const windowWithTypstLocation = window as WindowWithTypstLocation;
    document.addEventListener('selectionchange', handleDocumentSelectionChange);
    windowWithTypstLocation.handleTypstLocation = (_link: Element, page: number, x: number, y: number) =>
      scrollPreviewToTypstLocation(page, x, y);

    void (async () => {
      try {
        // 默认加载 public/hlm.typ 作为入口（会递归抓取 include/import 的 .typ 依赖）
        // project = await loadTypstProjectFromPublic('/hlm.typ');
        project = await loadTypstProjectFromPublic('/hp.typ');
        diskFileContents = new Map(project.files);
        activeFilePath = project.entryPath;
        source = project.files.get(activeFilePath) ?? '';
        await doCompile();
      } catch (e) {
        errorMsg = e instanceof Error ? e.message : String(e);
      } finally {
        loading = false;
      };
    })();

    return () => {
      document.removeEventListener('selectionchange', handleDocumentSelectionChange);
      delete windowWithTypstLocation.handleTypstLocation;
    };
  });

  $: {
    if (!previewOpen || !svg) {
      lastPreviewHydrationKey = '';
    } else {
      const hydrationKey = `${activeFilePath}|${svg.length}|${previewOpen}`;
      if (hydrationKey !== lastPreviewHydrationKey) {
        lastPreviewHydrationKey = hydrationKey;
        void tick().then(() => {
          const svgRoot = document.querySelector('.previewContent .svg');
          if (svgRoot) {
            applyHorizontalCoordFallback(svgRoot);
          }
        });
      }
    }
  }

</script>

<svelte:window on:click={closeMenus} on:keydown={handleWindowKeyDown} on:mousemove={handleImageEditMouseMove} on:mouseup={handleImageEditMouseUp} />

<div class="app">
  <header class="toolbar">
    <div class="toolbarLeft">
      <div class="title">Typst Editor (Svelte)</div>
      <div class="menubar">
        <div class="menuItem" class:open={viewMenuOpen}>
          <button class="menuBtn" type="button" on:click={toggleViewMenu}>View</button>
          {#if viewMenuOpen}
            <!-- svelte-ignore a11y_click_events_have_key_events -->
            <!-- svelte-ignore a11y_no_static_element_interactions -->
            <div class="menuDropdown" on:click|stopPropagation>
              <label class="menuOption">
                <input type="checkbox" bind:checked={previewOpen} />
                Show Preview
              </label>
              <label class="menuOption">
                <input type="checkbox" bind:checked={imageOpen} />
                Show Original Image
              </label>
            </div>
          {/if}
        </div>
        <div class="menuItem" class:open={optionsMenuOpen}>
          <button class="menuBtn" type="button" on:click={toggleOptionsMenu}>Options</button>
          {#if optionsMenuOpen}
            <!-- svelte-ignore a11y_click_events_have_key_events -->
            <!-- svelte-ignore a11y_no_static_element_interactions -->
            <div class="menuDropdown" on:click|stopPropagation>
              <label class="menuOption">
                <input type="checkbox" bind:checked={editorWordWrap} on:change={handleEditorWordWrapChange} />
                Word Wrap
              </label>
            </div>
          {/if}
        </div>
      </div>
    </div>
    <div class="status">
      {#if loading}
        加载中…
      {:else if saving}
        保存中…
      {:else if compiling}
        编译中…
      {:else}
        {activeFileDisplayPath} · 实时预览
      {/if}
    </div>
  </header>

  <main class="layout" class:explorerOpen class:editorClosed={!activeFilePath} class:previewClosed={!previewOpen} class:imageOpen={imageOpen}>
    <!-- 左侧窄侧边栏（参考 typst.app 的纵向工具栏） -->
    <aside class="rail">
      <button
        class="railBtn"
        type="button"
        aria-label="Explore files"
        title=""
        on:click={() => (explorerOpen = !explorerOpen)}
      >
        <!-- 文件管理器图标（与 typst.app 的“文件/工程”图标风格保持一致：线框、圆角端点） -->
        <!-- 该 SVG 来自 typst.app 的 Explore files 按钮 -->
        <svg class="railIcon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
          <path
            d="M15 2.3584H1.00005C0.814397 2.3584 0.63635 2.43215 0.505074 2.56342C0.373799 2.6947 0.300049 2.87275 0.300049 3.0584C0.300049 3.24405 0.373799 3.4221 0.505074 3.55337C0.63635 3.68465 0.814397 3.7584 1.00005 3.7584H1.53525V12.941C1.53525 13.1266 1.609 13.3047 1.74027 13.436C1.87155 13.5672 2.0496 13.641 2.23525 13.641H13.7646C13.9503 13.641 14.1283 13.5672 14.2596 13.436C14.3909 13.3047 14.4646 13.1266 14.4646 12.941V3.7588H15C15.1857 3.7588 15.3637 3.68505 15.495 3.55377C15.6263 3.4225 15.7 3.24445 15.7 3.0588C15.7 2.87315 15.6263 2.6951 15.495 2.56382C15.3637 2.43255 15.1857 2.3588 15 2.3588V2.3584ZM13.0645 12.2412H2.93555V3.7588H13.0645V12.2412ZM5.97005 6.3525C5.97005 6.26057 5.98815 6.16955 6.02333 6.08462C6.05851 5.99969 6.11007 5.92252 6.17507 5.85752C6.24008 5.79252 6.31724 5.74096 6.40217 5.70578C6.4871 5.6706 6.57812 5.6525 6.67005 5.6525H9.33005C9.5157 5.6525 9.69375 5.72625 9.82502 5.85752C9.9563 5.9888 10.03 6.16685 10.03 6.3525C10.03 6.53815 9.9563 6.7162 9.82502 6.84747C9.69375 6.97875 9.5157 7.0525 9.33005 7.0525H6.67005C6.4844 7.0525 6.30635 6.97875 6.17507 6.84747C6.0438 6.7162 5.97005 6.53815 5.97005 6.3525Z"
            fill="currentColor"
          />
        </svg>
        <!-- 悬浮提示：默认隐藏，hover 才显示 -->
        <span class="tooltip">Explore files</span>
      </button>
    </aside>

    <!-- 文件管理器抽屉：点击后向右展开 -->
    <aside class="explorer">
      <div class="explorerHeader">
        <div class="explorerTitle">Files</div>
        <button class="explorerClose" type="button" on:click={() => (explorerOpen = false)}>×</button>
      </div>

      {#if tree}
        <div class="tree">
          {#each tree.children ?? [] as node (node.path)}
            <FileTreeNode {node} {activeFilePath} {toDisplayPath} on:open={(e) => openFile(e.detail)} />
          {/each}
        </div>
      {:else}
        <div class="treeEmpty">暂无文件</div>
      {/if}
    </aside>

    <section class="editor" style:border-right={activeFilePath ? undefined : "none"}>
      {#if activeFilePath}
        <div class="editorHeader">
          <div class="fileName" class:dirty={activeFileDirty}>{activeFileDisplayPath}</div>
          <button class="editorClose" type="button" title="关闭文件" on:click={closeFile}>×</button>
        </div>
        <div class="editorBody">
          <div
            class="editorMeasure"
            class:wrapEnabled={editorWordWrap}
            class:wrapDisabled={!editorWordWrap}
            bind:this={editorMeasure}
            aria-hidden="true"
          ></div>
          <div class="editorHighlightBox" bind:this={editorHighlightBox} aria-hidden="true"></div>
          <textarea
            bind:this={editorTextarea}
            bind:value={source}
            class:wrapEnabled={editorWordWrap}
            class:wrapDisabled={!editorWordWrap}
            on:input={() => {
              scheduleCompile();
              syncEditorHighlightOverlay();
            }}
            on:scroll={syncEditorHighlightOverlay}
            spellcheck="false"
            wrap={editorWordWrap ? 'soft' : 'off'}
          ></textarea>
        </div>
      {/if}
    </section>

    {#if previewOpen}
      <section class="preview" style:border-left={activeFilePath ? "1px solid rgba(255, 255, 255, 0.08)" : "none"}>
        <div class="previewHeader">
          <div class="previewTitle">Preview</div>
          <button class="previewClose" type="button" title="关闭预览" on:click={() => previewOpen = false}>×</button>
        </div>
        <!-- svelte-ignore a11y_click_events_have_key_events -->
        <!-- svelte-ignore a11y_no_static_element_interactions -->
        <div class="previewContent" on:mousedown={handlePreviewMouseDown} on:click={handlePreviewClick} on:mouseup={handleMouseUp} role="presentation">
          {#if errorMsg}
            <div class="error">
              <div class="errorTitle">编译失败</div>
              <pre>{errorMsg}</pre>
            </div>
          {:else if svg}
            <div class="paper">
              <div class="svg">{@html svg}</div>
            </div>
          {:else}
            <div class="empty">暂无预览</div>
          {/if}
        </div>
      </section>
    {/if}

    {#if imageOpen}
      <section class="imagePreview" style:border-left="1px solid rgba(255, 255, 255, 0.08)">
        <div class="previewHeader">
          <div class="previewTitle">原图定位</div>
          <button class="previewClose" type="button" title="关闭" on:click={closeImagePreview}>×</button>
        </div>
        <div class="imageContent">
          {#if imagePreviews.length > 0}
            <div class="imagesScroll">
              {#each imagePreviews as preview, i}
                <div class="imageWrapper">
                  <div class="imageLabel">{preview.imageName}</div>
                  <div class="imageContainer">
                    <img 
                      src={preview.url} 
                      alt={preview.imageName} 
                      class="baseImage" 
                      bind:naturalWidth={imagePreviews[i].width} 
                      bind:naturalHeight={imagePreviews[i].height} 
                    />
                    {#if preview.width && preview.height}
                      <svg class="overlaySvg" viewBox="0 0 {preview.width} {preview.height}" preserveAspectRatio="none">
                        {#each preview.boxes as box, boxIndex (box.coordKey)}
                          {@const bounds = getBoxBounds(box.coords)}
                          {@const active = isActiveImageBox(preview.imageName, box.coordKey)}
                          {@const edgeThickness = Math.max(3, Math.min(5, Math.min(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) * 0.08))}
                          {@const horizontalHandleWidth = Math.max(10, Math.min((bounds.maxX - bounds.minX) * 0.7, Math.max(12, (bounds.maxX - bounds.minX) * 0.32)))}
                          {@const verticalHandleHeight = Math.max(10, Math.min((bounds.maxY - bounds.minY) * 0.7, Math.max(12, (bounds.maxY - bounds.minY) * 0.32)))}
                          <g>
                            <!-- svelte-ignore a11y_no_static_element_interactions -->
                            <rect
                              class="coordBox"
                              class:active={active}
                              x={bounds.minX}
                              y={bounds.minY}
                              width={bounds.maxX - bounds.minX}
                              height={bounds.maxY - bounds.minY}
                              fill={active ? "rgba(255, 0, 0, 0.22)" : "rgba(255, 0, 0, 0.15)"}
                              stroke="red"
                              stroke-width={active ? "3" : "2"}
                              on:mousedown|stopPropagation|preventDefault={(e) => startImageBoxEdit(e, i, boxIndex, 'move')}
                            />
                            <!-- svelte-ignore a11y_no_static_element_interactions -->
                            <rect
                              class="boxHandle boxHandleTop"
                              class:active={active}
                              x={bounds.minX + (bounds.maxX - bounds.minX - horizontalHandleWidth) / 2}
                              y={bounds.minY - edgeThickness / 2}
                              width={horizontalHandleWidth}
                              height={edgeThickness}
                              on:mousedown|stopPropagation|preventDefault={(e) => startImageBoxEdit(e, i, boxIndex, 'top')}
                            />
                            <!-- svelte-ignore a11y_no_static_element_interactions -->
                            <rect
                              class="boxHandle boxHandleRight"
                              class:active={active}
                              x={bounds.maxX - edgeThickness / 2}
                              y={bounds.minY + (bounds.maxY - bounds.minY - verticalHandleHeight) / 2}
                              width={edgeThickness}
                              height={verticalHandleHeight}
                              on:mousedown|stopPropagation|preventDefault={(e) => startImageBoxEdit(e, i, boxIndex, 'right')}
                            />
                            <!-- svelte-ignore a11y_no_static_element_interactions -->
                            <rect
                              class="boxHandle boxHandleBottom"
                              class:active={active}
                              x={bounds.minX + (bounds.maxX - bounds.minX - horizontalHandleWidth) / 2}
                              y={bounds.maxY - edgeThickness / 2}
                              width={horizontalHandleWidth}
                              height={edgeThickness}
                              on:mousedown|stopPropagation|preventDefault={(e) => startImageBoxEdit(e, i, boxIndex, 'bottom')}
                            />
                            <!-- svelte-ignore a11y_no_static_element_interactions -->
                            <rect
                              class="boxHandle boxHandleLeft"
                              class:active={active}
                              x={bounds.minX - edgeThickness / 2}
                              y={bounds.minY + (bounds.maxY - bounds.minY - verticalHandleHeight) / 2}
                              width={edgeThickness}
                              height={verticalHandleHeight}
                              on:mousedown|stopPropagation|preventDefault={(e) => startImageBoxEdit(e, i, boxIndex, 'left')}
                            />
                          </g>
                        {/each}
                      </svg>
                    {/if}
                  </div>
                </div>
              {/each}
            </div>
          {:else}
            <div class="empty">点击或划选左侧预览区文本查看原图</div>
          {/if}
        </div>
      </section>
    {/if}
  </main>
</div>

<style>
  .app {
    height: 100vh;
    display: flex;
    flex-direction: column;
    background: #0b0f19;
    color: #e6eaf2;
    font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial,
      'Apple Color Emoji', 'Segoe UI Emoji';
  }

  .toolbar {
    height: 48px;
    flex: 0 0 auto;
    position: relative;
    z-index: 20;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 14px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
    background: rgba(255, 255, 255, 0.02);
    backdrop-filter: blur(6px);
    overflow: visible;
  }

  .toolbarLeft {
    display: flex;
    align-items: center;
    gap: 16px;
  }

  .title {
    font-weight: 600;
    letter-spacing: 0.2px;
  }

  .menubar {
    display: flex;
    align-items: center;
  }

  .menuItem {
    position: relative;
  }

  .menuBtn {
    appearance: none;
    background: transparent;
    border: none;
    color: inherit;
    font-family: inherit;
    font-size: 13px;
    padding: 4px 8px;
    border-radius: 4px;
    cursor: pointer;
    opacity: 0.8;
  }

  .menuBtn:hover, .menuItem.open .menuBtn {
    background: rgba(255, 255, 255, 0.08);
    opacity: 1;
  }

  .menuDropdown {
    position: absolute;
    top: 100%;
    left: 0;
    margin-top: 4px;
    background: #1a1e28;
    border: 1px solid rgba(255, 255, 255, 0.1);
    border-radius: 6px;
    padding: 6px 0;
    min-width: 150px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
    z-index: 100;
  }

  .menuOption {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 16px;
    font-size: 13px;
    cursor: pointer;
  }

  .menuOption:hover {
    background: rgba(255, 255, 255, 0.06);
  }

  .status {
    opacity: 0.8;
    font-size: 13px;
  }

  .layout {
    flex: 1 1 auto;
    min-height: 0;
    display: grid;
    /* rail + (explorer) + editor + preview + image */
    --explorer-w: 0px;
    --editor-w: 1fr;
    --preview-w: 1fr;
    --image-w: 0px;
    grid-template-columns: 48px var(--explorer-w) var(--editor-w) var(--preview-w) var(--image-w);
  }

  .layout.explorerOpen {
    --explorer-w: 280px;
  }

  .layout.editorClosed {
    --editor-w: 0fr;
  }

  .layout.previewClosed {
    --preview-w: 0fr;
  }

  .layout.imageOpen {
    --image-w: 1fr;
  }

  .editor,
  .preview,
  .imagePreview {
    min-height: 0;
  }

  .rail {
    grid-column: 1;
    grid-row: 1;
    border-right: 1px solid rgba(255, 255, 255, 0.08);
    background: #0b0f19;
    display: flex;
    flex-direction: column;
    padding: 10px 8px;
    gap: 8px;
  }

  .railBtn {
    appearance: none;
    border: 1px solid rgba(255, 255, 255, 0.12);
    background: rgba(255, 255, 255, 0.03);
    color: inherit;
    border-radius: 10px;
    width: 36px;
    height: 36px;
    padding: 0;
    cursor: pointer;
    display: grid;
    place-items: center;
    position: relative;
  }

  .railBtn:hover {
    background: rgba(255, 255, 255, 0.06);
  }

  .railIcon {
    width: 16px;
    height: 16px;
    display: block;
    color: rgba(230, 234, 242, 0.92);
  }

  .tooltip {
    position: absolute;
    left: 44px;
    top: 50%;
    transform: translateY(-50%) translateX(-4px);
    background: rgba(10, 13, 20, 0.95);
    border: 1px solid rgba(255, 255, 255, 0.12);
    border-radius: 10px;
    padding: 6px 10px;
    font-size: 12px;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity 120ms ease, transform 120ms ease;
    box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
  }

  .railBtn:hover .tooltip,
  .railBtn:focus-visible .tooltip {
    opacity: 1;
    transform: translateY(-50%) translateX(0);
  }

  .explorer {
    grid-column: 2;
    grid-row: 1;
    width: 100%;
    overflow: hidden;
    border-right: 1px solid rgba(255, 255, 255, 0.08);
    background: rgba(10, 13, 20, 0.98);
  }

  .explorerHeader {
    height: 48px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 12px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  }

  .explorerTitle {
    font-weight: 600;
    font-size: 13px;
    opacity: 0.92;
  }

  .explorerClose {
    appearance: none;
    border: 0;
    background: transparent;
    color: inherit;
    font-size: 18px;
    cursor: pointer;
    opacity: 0.7;
  }

  .explorerClose:hover {
    opacity: 1;
  }

  .tree {
    padding: 10px 8px 18px;
    font-size: 12px;
  }

  .treeEmpty {
    padding: 10px 12px;
    opacity: 0.7;
    font-size: 12px;
  }

  .editorHeader {
    height: 36px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 12px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
    background: rgba(255, 255, 255, 0.02);
  }

  .editorClose {
    appearance: none;
    border: 0;
    background: transparent;
    color: inherit;
    font-size: 18px;
    cursor: pointer;
    opacity: 0.7;
    line-height: 1;
    padding: 0;
  }

  .editorClose:hover {
    opacity: 1;
  }


  .fileName {
    font-size: 12px;
    opacity: 0.9;
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
      monospace;
    transition: color 120ms ease, text-shadow 120ms ease;
  }

  .fileName.dirty {
    color: #ffd166;
    text-shadow: 0 0 12px rgba(255, 209, 102, 0.2);
  }

  .editor {
    border-right: 1px solid rgba(255, 255, 255, 0.08);
    background: #0b0f19;
    display: flex;
    flex-direction: column;
    overflow: hidden; /* 防止内容撑开宽度 */
  }

  /* 去掉 .editor.hidden 的 display: none，只让它的内容不占据宽度 */

  .editorBody {
    position: relative;
    flex: 1 1 auto;
    min-height: 0;
    overflow: hidden;
  }

  .editorMeasure {
    position: absolute;
    inset: 0;
    box-sizing: border-box;
    padding: 14px;
    overflow: hidden;
    visibility: hidden;
    pointer-events: none;
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
      monospace;
    font-size: 13px;
    line-height: 1.55;
    tab-size: 4;
    font-variant-ligatures: none;
  }

  .editorHighlightBox {
    position: absolute;
    display: none;
    pointer-events: none;
    z-index: 2;
    border-radius: 3px;
    box-sizing: border-box;
    border: 2px solid rgba(255, 208, 0, 0.98);
    background: transparent;
    box-shadow: 0 0 0 1px rgba(255, 243, 170, 0.95), 0 0 12px rgba(255, 208, 0, 0.28);
  }

  textarea {
    width: 100%;
    height: 100%;
    box-sizing: border-box;
    resize: none;
    border: 0;
    outline: none;
    padding: 14px;
    background: transparent;
    color: inherit;
    position: relative;
    z-index: 1;
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
      monospace;
    font-size: 13px;
    line-height: 1.55;
    tab-size: 4;
    font-variant-ligatures: none;
  }

  .wrapEnabled {
    white-space: pre-wrap;
    word-wrap: break-word;
    overflow-wrap: break-word;
    word-break: normal;
  }

  .wrapDisabled {
    white-space: pre;
    word-wrap: normal;
    overflow-wrap: normal;
    word-break: normal;
  }

  textarea::selection {
    background: rgba(255, 208, 0, 0.88);
    color: #111;
  }

  .preview {
    grid-column: 4;
    grid-row: 1;
    background: #0a0d14;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  .previewHeader {
    height: 36px;
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 12px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
    background: rgba(255, 255, 255, 0.02);
  }

  .previewTitle {
    font-size: 12px;
    opacity: 0.9;
    font-weight: 600;
  }

  .previewClose {
    appearance: none;
    border: 0;
    background: transparent;
    color: inherit;
    font-size: 18px;
    cursor: pointer;
    opacity: 0.7;
    line-height: 1;
    padding: 0;
  }

  .previewClose:hover {
    opacity: 1;
  }

  .previewContent {
    flex: 1 1 auto;
    overflow: auto;
    padding: 14px;
  }

  .paper {
    background: #ffffff;
    border-radius: 12px;
    padding: 24px;
    box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
    margin: 0 auto;
    max-width: 980px;
  }

  /* SVG selection overrides */
  .svg :global(svg) {
    width: 100%;
    height: auto;
    display: block;
  }

  .svg :global(svg *) {
    user-select: text !important;
    -webkit-user-select: text !important;
  }

  .svg :global(.typst-doc) {
    --typst-cursor: text !important;
    cursor: text !important;
  }

  .svg :global(a[data-coord]) {
    cursor: text !important;
    -webkit-user-drag: none !important;
    text-decoration: none;
    color: inherit;
    pointer-events: none !important;
  }

  .svg :global(a:not([data-coord])),
  .svg :global(a:not([data-coord]) *) {
    cursor: pointer !important;
    pointer-events: auto !important;
  }

  .svg :global(foreignObject),
  .svg :global(foreignObject *),
  .svg :global(g) {
    cursor: text !important;
    pointer-events: auto !important;
    user-select: text !important;
    -webkit-user-select: text !important;
  }

  .svg :global(::selection) {
    background-color: rgba(0, 120, 215, 0.4) !important;
    color: inherit;
  }
  
  /* 添加针对 SVG 元素的特殊选区颜色支持 */
  .svg :global(text::selection),
  .svg :global(tspan::selection) {
    background: rgba(0, 120, 215, 0.4) !important;
    fill: #000 !important; /* 很多浏览器在 SVG 选区中会改变字体颜色 */
  }

  /* 坐标字的透明热区继续穿透，普通目录链接则保留原生点击跳转 */
  .svg :global([data-coord] .typst-link) {
    pointer-events: none !important;
  }

  .svg :global([data-coord] .pseudo-link) {
    pointer-events: none !important;
  }

  .error {
    border: 1px solid rgba(255, 88, 88, 0.35);
    background: rgba(255, 88, 88, 0.08);
    border-radius: 10px;
    padding: 12px;
  }

  .errorTitle {
    font-weight: 600;
    margin-bottom: 8px;
  }

  pre {
    margin: 0;
    white-space: pre-wrap;
    word-break: break-word;
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
      monospace;
    font-size: 12px;
    line-height: 1.5;
    opacity: 0.95;
  }

  .empty {
    opacity: 0.7;
  }

  .imagePreview {
    grid-column: 5;
    grid-row: 1;
    background: #0a0d14;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  .imageContent {
    flex: 1 1 auto;
    overflow: auto;
    padding: 14px;
    display: flex;
    justify-content: center;
    align-items: flex-start;
  }

  .imagesScroll {
    display: flex;
    flex-direction: column;
    gap: 24px;
    width: 100%;
  }

  .imageWrapper {
    display: flex;
    flex-direction: column;
  }

  .imageLabel {
    font-size: 12px;
    opacity: 0.8;
    margin-bottom: 8px;
    font-family: ui-monospace, monospace;
  }

  .imageContainer {
    position: relative;
    display: inline-block;
    max-width: 100%;
  }

  .baseImage {
    display: block;
    max-width: 100%;
    height: auto;
  }

  .overlaySvg {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: auto;
  }

  .coordBox {
    cursor: move;
  }

  .coordBox.active {
    filter: drop-shadow(0 0 4px rgba(255, 0, 0, 0.45));
  }

  .boxHandle {
    fill: transparent;
    stroke: #ff8a8a;
    stroke-width: 2;
    opacity: 0.001;
    pointer-events: all;
    transition: opacity 120ms ease, stroke 120ms ease;
  }

  .boxHandle:hover,
  .boxHandle.active {
    fill: transparent;
    stroke-width: 2;
    stroke: #ffb3b3;
    opacity: 0.9;
  }

  .boxHandleTop,
  .boxHandleBottom {
    cursor: ns-resize;
  }

  .boxHandleLeft,
  .boxHandleRight {
    cursor: ew-resize;
  }

  @media (max-width: 960px) {
    .layout {
      grid-template-columns: 48px var(--explorer-w) 1fr;
      grid-template-rows: var(--editor-h, 45vh) var(--preview-h, 55vh) var(--image-h, 0fr);
    }

    .layout.editorClosed {
      --editor-h: 0fr;
      --preview-h: 100vh;
    }

    .layout.previewClosed {
      --editor-h: 100vh;
      --preview-h: 0fr;
    }

    .layout.imageOpen {
      --editor-h: 30vh;
      --preview-h: 40vh;
      --image-h: 30vh;
    }

    .editor {
      border-right: 0;
      border-bottom: 1px solid rgba(255, 255, 255, 0.08);
    }

    .preview {
      grid-column: 1 / -1;
      grid-row: 2;
    }

    .imagePreview {
      grid-column: 1 / -1;
      grid-row: 3;
    }
  }
</style>
