tscircuit/ti

ti tscircuit library

Version
1.0.193
License
unset
Stars
0

system-block-ui/src/components/OutputPanel.tsx

import {
  type KeyboardEvent as ReactKeyboardEvent,
  useEffect,
  useId,
  useRef,
  useState,
} from "react";
import { GENERATED_SYSTEM_MAIN_FILE_NAME } from "../rendering/generated-source-files";
import {
  ArchiveIcon,
  ChevronDownIcon,
  CircuitIcon,
  CodeIcon,
  DownloadIcon,
  PdfIcon,
} from "./Icons";
import { TsxCode } from "./TsxCode";

interface OutputPanelProps {
  tsx: string;
  hasSchematic: boolean;
  schematicSheetCount: number;
  onDownloadSchematicPdf: () => void | Promise<void>;
  onDownloadCircuitJson: () => void | Promise<void>;
  onDownloadTscircuitTsxZip: () => void | Promise<void>;
  onDownloadKicadProject: () => void | Promise<void>;
  onDownloadAltiumProject: () => void | Promise<void>;
}

type DownloadKind =
  | "pdf"
  | "circuit-json"
  | "tscircuit-tsx"
  | "kicad"
  | "altium";

interface DownloadAvailability {
  isBusy: boolean;
}

const DOWNLOAD_STATUS_LABELS: Record<DownloadKind, string> = {
  pdf: "PDF",
  "circuit-json": "Circuit JSON",
  "tscircuit-tsx": "tscircuit TSX",
  kicad: "KiCad",
  altium: "Altium",
};

export function getNextDownloadMenuIndex(
  key: string,
  currentIndex: number,
  itemCount: number,
): number | undefined {
  if (itemCount <= 0) return undefined;

  switch (key) {
    case "ArrowDown":
      return currentIndex < 0 ? 0 : (currentIndex + 1) % itemCount;
    case "ArrowUp":
      return currentIndex < 0
        ? itemCount - 1
        : (currentIndex - 1 + itemCount) % itemCount;
    case "Home":
      return 0;
    case "End":
      return itemCount - 1;
    default:
      return undefined;
  }
}

export function isDownloadOptionAvailable(
  _kind: DownloadKind,
  { isBusy }: DownloadAvailability,
): boolean {
  return !isBusy;
}

export function OutputPanel({
  tsx,
  hasSchematic,
  schematicSheetCount,
  onDownloadSchematicPdf,
  onDownloadCircuitJson,
  onDownloadTscircuitTsxZip,
  onDownloadKicadProject,
  onDownloadAltiumProject,
}: OutputPanelProps) {
  const [downloadMenuOpen, setDownloadMenuOpen] = useState(false);
  const [focusedDownloadIndex, setFocusedDownloadIndex] = useState(0);
  const [activeDownload, setActiveDownload] = useState<DownloadKind>();
  const downloadMenuId = useId();
  const downloadMenuRef = useRef<HTMLDivElement>(null);
  const downloadTriggerRef = useRef<HTMLButtonElement>(null);
  const downloadItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const downloadsDisabled = activeDownload !== undefined;
  const downloadAvailability: DownloadAvailability = {
    isBusy: downloadsDisabled,
  };

  useEffect(() => {
    if (!downloadMenuOpen) return;
    downloadItemRefs.current[focusedDownloadIndex]?.focus();
  }, [downloadMenuOpen, focusedDownloadIndex]);

  useEffect(() => {
    if (!downloadMenuOpen) return;

    const handlePointerDown = (event: PointerEvent) => {
      if (
        event.target instanceof Node &&
        !downloadMenuRef.current?.contains(event.target)
      ) {
        setDownloadMenuOpen(false);
      }
    };
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      event.preventDefault();
      setDownloadMenuOpen(false);
      downloadTriggerRef.current?.focus();
    };
    const handleFocusIn = (event: FocusEvent) => {
      if (
        event.target instanceof Node &&
        !downloadMenuRef.current?.contains(event.target)
      ) {
        setDownloadMenuOpen(false);
      }
    };

    document.addEventListener("pointerdown", handlePointerDown);
    document.addEventListener("keydown", handleKeyDown);
    document.addEventListener("focusin", handleFocusIn);
    return () => {
      document.removeEventListener("pointerdown", handlePointerDown);
      document.removeEventListener("keydown", handleKeyDown);
      document.removeEventListener("focusin", handleFocusIn);
    };
  }, [downloadMenuOpen]);

  useEffect(() => {
    if (downloadsDisabled) setDownloadMenuOpen(false);
  }, [downloadsDisabled]);

  const handleDownloadMenuKeyDown = (
    event: ReactKeyboardEvent<HTMLDivElement>,
  ) => {
    const nextIndex = getNextDownloadMenuIndex(
      event.key,
      focusedDownloadIndex,
      downloadOptions.length,
    );
    if (nextIndex === undefined) return;
    event.preventDefault();
    setFocusedDownloadIndex(nextIndex);
    downloadItemRefs.current[nextIndex]?.focus();
  };

  const openDownloadMenu = (focusedIndex: number) => {
    if (downloadsDisabled) return;
    setFocusedDownloadIndex(focusedIndex);
    setDownloadMenuOpen(true);
  };

  const runDownload = async (
    kind: DownloadKind,
    action: () => void | Promise<void>,
  ) => {
    setDownloadMenuOpen(false);
    downloadTriggerRef.current?.focus();
    setActiveDownload(kind);
    try {
      await action();
    } catch (error) {
      console.error(`Could not download ${kind} export.`, error);
    } finally {
      setActiveDownload(undefined);
      downloadTriggerRef.current?.focus();
    }
  };

  const downloadOptions = [
    {
      kind: "pdf" as const,
      label: "Schematic PDF",
      description: `Vector schematic · ${schematicSheetCount} sheet${schematicSheetCount === 1 ? "" : "s"}`,
      extension: "PDF",
      icon: <PdfIcon />,
      action: onDownloadSchematicPdf,
    },
    {
      kind: "circuit-json" as const,
      label: "Circuit JSON",
      description: "Evaluated design data · schematic-only",
      extension: "JSON",
      icon: <CircuitIcon />,
      action: onDownloadCircuitJson,
    },
    {
      kind: "tscircuit-tsx" as const,
      label: "tscircuit TSX (ZIP)",
      description: "Editable source · TSX + diagram module",
      extension: "ZIP",
      icon: <CodeIcon />,
      action: onDownloadTscircuitTsxZip,
    },
    {
      kind: "kicad" as const,
      label: "KiCad Project (ZIP)",
      description: "Editable schematic-first project",
      extension: "ZIP",
      icon: <ArchiveIcon />,
      action: onDownloadKicadProject,
    },
    {
      kind: "altium" as const,
      label: "Altium Project (ZIP)",
      description: "Editable schematic-first project",
      extension: "ZIP",
      icon: <ArchiveIcon />,
      action: onDownloadAltiumProject,
    },
  ];

  return (
    <aside
      aria-labelledby={`${downloadMenuId}-heading`}
      className="output-panel"
    >
      <header className="output-toolbar">
        <h2 className="output-toolbar-title" id={`${downloadMenuId}-heading`}>
          <CodeIcon />
          Generated TSX
        </h2>
        <div className="download-menu" ref={downloadMenuRef}>
          <button
            aria-busy={activeDownload ? true : undefined}
            aria-controls={downloadMenuId}
            aria-expanded={downloadMenuOpen}
            aria-haspopup="menu"
            aria-label={
              activeDownload
                ? `Preparing ${DOWNLOAD_STATUS_LABELS[activeDownload]} download`
                : hasSchematic
                  ? `Download schematic, source, or CAD exports for ${schematicSheetCount} sheet${schematicSheetCount === 1 ? "" : "s"}`
                  : "Download tscircuit source or view schematic export options"
            }
            aria-disabled={activeDownload ? true : undefined}
            className="primary-button download-menu-trigger"
            id={`${downloadMenuId}-trigger`}
            onClick={() => {
              if (downloadsDisabled) return;
              if (downloadMenuOpen) {
                setDownloadMenuOpen(false);
              } else {
                openDownloadMenu(0);
              }
            }}
            onKeyDown={(event) => {
              if (downloadsDisabled) return;
              const focusedIndex = getNextDownloadMenuIndex(
                event.key,
                -1,
                downloadOptions.length,
              );
              if (focusedIndex === undefined) return;
              event.preventDefault();
              openDownloadMenu(focusedIndex);
            }}
            ref={downloadTriggerRef}
            type="button"
          >
            <span className="button-content">
              {activeDownload ? (
                <>
                  <span aria-hidden="true" className="download-spinner" />
                  Preparing {DOWNLOAD_STATUS_LABELS[activeDownload]}…
                </>
              ) : (
                <>
                  <DownloadIcon />
                  Download
                  <ChevronDownIcon className="download-menu-chevron" />
                </>
              )}
            </span>
          </button>

          {downloadMenuOpen && (
            <div
              aria-labelledby={`${downloadMenuId}-trigger`}
              className="download-menu-popover"
              id={downloadMenuId}
              onKeyDown={handleDownloadMenuKeyDown}
              role="menu"
            >
              {downloadOptions.map((option, index) => {
                const optionAvailable = isDownloadOptionAvailable(
                  option.kind,
                  downloadAvailability,
                );
                return (
                  <button
                    aria-disabled={optionAvailable ? undefined : true}
                    className="download-menu-item"
                    key={option.kind}
                    onClick={() => {
                      if (!optionAvailable) return;
                      void runDownload(option.kind, option.action);
                    }}
                    onFocus={() => setFocusedDownloadIndex(index)}
                    ref={(element) => {
                      downloadItemRefs.current[index] = element;
                    }}
                    role="menuitem"
                    tabIndex={index === focusedDownloadIndex ? 0 : -1}
                    type="button"
                  >
                    <span className="download-menu-item-icon">
                      {option.icon}
                    </span>
                    <span className="download-menu-item-copy">
                      <strong>{option.label}</strong>
                      <small>{option.description}</small>
                    </span>
                    <span className="download-menu-extension">
                      {option.extension}
                    </span>
                  </button>
                );
              })}
            </div>
          )}
        </div>
      </header>

      <div className="generated-code-pane">
        <div className="generated-code-file">
          <span>{GENERATED_SYSTEM_MAIN_FILE_NAME}</span>
          <span>Read-only</span>
        </div>
        <TsxCode source={tsx} />
      </div>
    </aside>
  );
}