← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/msa/export-actions.tsx

12.9 KB · Sep 30, 2026 · 23:01 UTC

↓ Download file

import { useState } from "react";
import { FormattedMessage } from "react-intl";

import { useWorkbenchFeedback } from "../ui/workbench-feedback";
import type {
  PreparedSequenceWorkspaceArtifact,
  SequenceWorkspaceArtifactPublisher,
} from "../views/workbench-persistence";
import type { SequenceWorkspaceChunkProducer } from "../views/workspace-artifact-stream";
import {
  createAlignedFastaWorkspaceProducer,
  createReferenceFastaWorkspaceProducer,
  createSearchHitsTsvWorkspaceProducer,
  createVisibleRangeSvgWorkspaceProducer,
} from "../views/workspace-export-producers";
import { WorkspacePublishButton } from "../workspace-publish-button";
import { buildAlignedFasta, buildVisibleRangeText } from "./exports";
import type { MsaMotifSearchHit } from "./search";
import type { MsaColumnRange, MsaSequenceRow } from "./types";

const publishActionClass =
  "cursor-interaction rounded-md border border-token-border px-2 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-50";
const MAX_MANUAL_COPY_BYTES = 128 * 1_024;

type CopyFeedback =
  | { status: "copied" }
  | { status: "manual"; value: string }
  | { status: "unavailable" };

export function MsaExportActions({
  hits,
  publishWorkspaceArtifact,
  referenceLabel,
  referenceSequence,
  rows,
  serverSourceAlignedFasta = false,
  selectedColumnRange,
  sourceRevision,
  visibleColumnEnd,
  visibleColumnStart,
}: {
  hits: Array<MsaMotifSearchHit>;
  publishWorkspaceArtifact?: SequenceWorkspaceArtifactPublisher;
  referenceLabel: string;
  referenceSequence: string | null;
  rows: Array<MsaSequenceRow>;
  serverSourceAlignedFasta?: boolean;
  selectedColumnRange: MsaColumnRange | null;
  sourceRevision: number;
  visibleColumnEnd: number;
  visibleColumnStart: number;
}): React.ReactElement {
  const [copyFeedback, setCopyFeedback] = useState<CopyFeedback>();
  const dismissCopyFeedback = useWorkbenchFeedback({
    id: "alignment.copy-feedback",
    kind: "copy",
    label: "Alignment copy feedback",
    message:
      copyFeedback?.status === "manual"
        ? "Clipboard access is unavailable; selectable alignment text is ready for manual copying."
        : copyFeedback?.status === "unavailable"
          ? "This selection is too large for manual copying. Save it to the workspace instead."
          : copyFeedback?.status === "copied"
            ? "Copied to clipboard."
            : undefined,
    onDismiss: () => setCopyFeedback(undefined),
    visible: copyFeedback != null,
  });

  async function copyText(value: string): Promise<void> {
    setCopyFeedback(undefined);
    if (value.length === 0) {
      return;
    }
    try {
      if (navigator.clipboard?.writeText == null) {
        throw new Error("The Clipboard API is unavailable.");
      }
      await navigator.clipboard.writeText(value);
      setCopyFeedback({ status: "copied" });
    } catch {
      if (
        value.length > MAX_MANUAL_COPY_BYTES ||
        new TextEncoder().encode(value).byteLength > MAX_MANUAL_COPY_BYTES
      ) {
        setCopyFeedback({ status: "unavailable" });
      } else if (copyWithSelection(value)) {
        setCopyFeedback({ status: "copied" });
      } else {
        setCopyFeedback({ status: "manual", value });
      }
    }
  }

  return (
    <section className="flex flex-wrap items-center gap-2 border-b border-token-border bg-token-main-surface-primary px-3 py-2 text-xs text-token-text-secondary">
      <span className="font-medium text-token-text-primary">
        <FormattedMessage
          id="codex.filePreview.msa.exports"
          defaultMessage="Copy / export"
          description="Heading for copy and export actions in the MSA viewer."
        />
      </span>
      <ExportButton
        onClick={() =>
          void copyText(
            buildVisibleRangeText({
              endColumn: visibleColumnEnd,
              rows,
              startColumn: visibleColumnStart,
            }),
          )
        }
      >
        <FormattedMessage
          id="codex.filePreview.msa.copyVisibleRange"
          defaultMessage="Copy visible range"
          description="Button label for copying the currently visible MSA rectangle."
        />
      </ExportButton>
      {selectedColumnRange == null ? null : (
        <>
          <ExportButton
            onClick={() =>
              void copyText(
                buildVisibleRangeText({
                  endColumn: selectedColumnRange.end,
                  rows,
                  startColumn: selectedColumnRange.start,
                }),
              )
            }
          >
            <FormattedMessage
              id="codex.filePreview.msa.copySelectedRange"
              defaultMessage="Copy selected range"
              description="Button label for copying the currently selected MSA rectangle."
            />
          </ExportButton>
          {publishWorkspaceArtifact == null ? null : (
            <WorkspacePublishButton
              artifact={workspaceArtifact({
                createChunks: createVisibleRangeSvgWorkspaceProducer({
                  endColumn: selectedColumnRange.end,
                  rows,
                  startColumn: selectedColumnRange.start,
                }),
                format: "svg",
                kind: "selected-range-svg",
                mediaType: "image/svg+xml",
                name: "msa-selected-range.svg",
                sourceRevision,
              })}
              className={publishActionClass}
              label="Publish selected SVG"
              publisher={publishWorkspaceArtifact}
            />
          )}
        </>
      )}
      <ExportButton onClick={() => void copyText(buildAlignedFasta(rows))}>
        <FormattedMessage
          id="codex.filePreview.msa.copyVisibleRowsFasta"
          defaultMessage="Copy visible rows FASTA"
          description="Button label for copying visible MSA rows as aligned FASTA."
        />
      </ExportButton>
      {publishWorkspaceArtifact == null ? null : (
        <WorkspacePublishButton
          artifact={workspaceArtifact({
            createChunks: createAlignedFastaWorkspaceProducer(rows),
            format: "aligned-fasta",
            kind: "visible-rows-fasta",
            mediaType: "text/x-fasta",
            name: "msa-visible-rows.afa",
            sourceRevision,
          })}
          className={publishActionClass}
          label="Publish visible FASTA"
          publisher={publishWorkspaceArtifact}
        />
      )}
      {publishWorkspaceArtifact == null || !serverSourceAlignedFasta ? null : (
        <WorkspacePublishButton
          artifact={{
            format: "aligned-fasta",
            mediaType: "text/x-fasta",
            name: "alignment.afa",
            provenance: {
              engine: "sequence-viewer-alignment-server-export-v1",
              parameters: { kind: "canonical-source-alignment" },
              sourceRevision,
            },
            serverGeneration: {
              compression: "auto",
              kind: "opened-source",
            },
          }}
          className={publishActionClass}
          label="Publish source alignment"
          publisher={publishWorkspaceArtifact}
        />
      )}
      {publishWorkspaceArtifact?.supportsNativeRichGeneration !== true ||
      sourceRevision !== 0
        ? null
        : (
            [
              ["a3m", "text/x-a3m", "alignment.a3m", "A3M"],
              ["clustal", "text/x-clustal", "alignment.aln", "CLUSTAL"],
              ["stockholm", "text/x-stockholm", "alignment.sto", "Stockholm"],
              ["pdf", "application/pdf", "alignment.pdf", "PDF"],
            ] as const
          ).map(([format, mediaType, name, label]) => (
            <WorkspacePublishButton
              artifact={{
                format,
                mediaType,
                name,
                provenance: {
                  engine: "sequence-viewer-alignment-native-export-v1",
                  parameters: { kind: `source-${format}`, scope: "all" },
                  sourceRevision,
                },
                serverGeneration: {
                  compression: "none",
                  kind: "native-rich",
                },
              }}
              className={publishActionClass}
              key={format}
              label={`Publish source ${label}`}
              publisher={publishWorkspaceArtifact}
            />
          ))}
      {publishWorkspaceArtifact == null || referenceSequence == null ? null : (
        <WorkspacePublishButton
          artifact={workspaceArtifact({
            createChunks: createReferenceFastaWorkspaceProducer({
              label: referenceLabel,
              sequence: referenceSequence,
            }),
            format: "aligned-fasta",
            kind: "reference-row-fasta",
            mediaType: "text/x-fasta",
            name: "msa-reference.afa",
            sourceRevision,
          })}
          className={publishActionClass}
          label="Publish reference row"
          publisher={publishWorkspaceArtifact}
        />
      )}
      {publishWorkspaceArtifact == null || hits.length === 0 ? null : (
        <WorkspacePublishButton
          artifact={workspaceArtifact({
            createChunks: createSearchHitsTsvWorkspaceProducer(hits),
            format: "tsv",
            kind: "search-hits-tsv",
            mediaType: "text/tab-separated-values",
            name: "msa-search-hits.tsv",
            sourceRevision,
          })}
          className={publishActionClass}
          label="Publish hit TSV"
          publisher={publishWorkspaceArtifact}
        />
      )}
      {publishWorkspaceArtifact == null ? null : (
        <WorkspacePublishButton
          artifact={workspaceArtifact({
            createChunks: createVisibleRangeSvgWorkspaceProducer({
              endColumn: visibleColumnEnd,
              rows,
              startColumn: visibleColumnStart,
            }),
            format: "svg",
            kind: "visible-range-svg",
            mediaType: "image/svg+xml",
            name: "msa-visible-range.svg",
            sourceRevision,
          })}
          className={publishActionClass}
          label="Publish range SVG"
          publisher={publishWorkspaceArtifact}
        />
      )}
      {publishWorkspaceArtifact == null ? (
        <span className="basis-full" role="status">
          Workspace export is unavailable for this viewer.
        </span>
      ) : null}
      {copyFeedback?.status === "copied" ? (
        <span className="basis-full" role="status">
          Copied to clipboard.
        </span>
      ) : null}
      {copyFeedback?.status === "manual" ? (
        <label className="grid basis-full gap-1">
          <span role="status">
            Clipboard access is unavailable. Select the text below and press
            Command+C or Ctrl+C to copy.
          </span>
          <textarea
            aria-label="Alignment text to copy manually"
            autoFocus
            className="max-h-32 w-full rounded border border-token-border bg-token-main-surface-primary p-2 font-mono text-xs text-token-text-primary"
            onFocus={(event) => event.currentTarget.select()}
            readOnly
            rows={3}
            value={copyFeedback.value}
          />
        </label>
      ) : null}
      {copyFeedback?.status === "unavailable" ? (
        <span className="basis-full" role="alert">
          This selection is too large to display for manual copying. Save it to
          your workspace instead.
        </span>
      ) : null}
      {copyFeedback == null ? null : (
        <ExportButton onClick={dismissCopyFeedback}>
          Dismiss copy feedback
        </ExportButton>
      )}
    </section>
  );
}

function workspaceArtifact({
  createChunks,
  format,
  kind,
  mediaType,
  name,
  sourceRevision,
}: Omit<
  PreparedSequenceWorkspaceArtifact,
  "content" | "provenance" | "serverGeneration"
> & {
  createChunks: SequenceWorkspaceChunkProducer;
  kind: string;
  sourceRevision: number;
}): PreparedSequenceWorkspaceArtifact {
  return {
    createChunks,
    format,
    mediaType,
    name,
    provenance: {
      engine: "sequence-viewer-alignment-browser-export-v1",
      parameters: { kind },
      sourceRevision,
    },
  };
}

function ExportButton({
  children,
  disabled = false,
  onClick,
}: {
  children: React.ReactNode;
  disabled?: boolean;
  onClick: () => void;
}): React.ReactElement {
  return (
    <button
      className="cursor-interaction rounded-md border border-token-border px-2 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-50"
      disabled={disabled}
      onClick={onClick}
      type="button"
    >
      {children}
    </button>
  );
}

function copyWithSelection(value: string): boolean {
  if (typeof document.execCommand !== "function") {
    return false;
  }

  const field = document.createElement("textarea");
  field.value = value;
  field.readOnly = true;
  field.style.position = "fixed";
  field.style.left = "-9999px";
  document.body.append(field);
  field.select();

  try {
    return document.execCommand("copy");
  } catch {
    return false;
  } finally {
    field.remove();
  }
}

SHA-256: 74b225a3fec7cf5ac74ebae1e677ab7582dae11e1505525ac8af8bc3da11f052