tmp/webapp-docs/frontend/components/PromptResult.js

/**
 * One generated prompt and its image batches. Each "Generate images" press adds a batch beneath
 * the prompt. Per image: open in the OS default app, reveal in Explorer, or remove (with an option
 * to delete from disk). A batch and the whole prompt can be cleared too. Clicking an image opens it
 * in the single view.
 *
 * The prompt text and its source DPL / original are all **click-to-copy** (no separate copy button).
 * Hovering shows the full text; hovering the **DPL** also shows a live example that re-rolls every
 * second (so you can see the range of what that DPL produces).
 * @module gui/components/PromptResult
 */
import { memo, useEffect, useRef, useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { isOutputFile, openImageFile, revealImageFile, openImageInNewTab } from "../lib/output.js";
import { expandPrompt } from "../lib/promptEngine.js";
import { TrashIcon } from "./icons.jsx";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const msgs = defineMessages({
  resultAlt: {
    id: "promptResult.resultAlt",
    defaultMessage: "result"
  },
  dplTitle: {
    id: "promptResult.dplTitle",
    defaultMessage: "The DPL this was rolled from — click to copy"
  },
  example: {
    id: "promptResult.example",
    defaultMessage: "Example:"
  },
  originalTitle: {
    id: "promptResult.originalTitle",
    defaultMessage: "Original prompt (before auto-fix) — click to copy\n\n{original}"
  },
  copyTitle: {
    id: "promptResult.copyTitle",
    defaultMessage: "Click to copy\n\n{text}"
  },
  genTitle: {
    id: "promptResult.genTitle",
    defaultMessage: "Generate a batch of images for this prompt"
  },
  moreImages: {
    id: "promptResult.moreImages",
    defaultMessage: "More images"
  },
  genImages: {
    id: "promptResult.genImages",
    defaultMessage: "Generate images"
  },
  clearTitle: {
    id: "promptResult.clearTitle",
    defaultMessage: "Clear this prompt's images (asks about deleting from disk)"
  },
  clear: {
    id: "promptResult.clear",
    defaultMessage: "clear"
  },
  batch: {
    id: "promptResult.batch",
    defaultMessage: "Batch {n}"
  },
  rendering: {
    id: "promptResult.rendering",
    defaultMessage: "rendering…"
  },
  removeBatch: {
    id: "promptResult.removeBatch",
    defaultMessage: "remove batch"
  },
  renderingAria: {
    id: "promptResult.renderingAria",
    defaultMessage: "rendering"
  },
  openSingle: {
    id: "promptResult.openSingle",
    defaultMessage: "Open in the single view"
  },
  openNewTab: {
    id: "promptResult.openNewTab",
    defaultMessage: "Open in a new tab"
  },
  openDefault: {
    id: "promptResult.openDefault",
    defaultMessage: "Open in default app"
  },
  reveal: {
    id: "promptResult.reveal",
    defaultMessage: "Reveal in file explorer"
  },
  removeImage: {
    id: "promptResult.removeImage",
    defaultMessage: "Remove image"
  }
});

/**
 * A result image that fades in once it has actually loaded.
 * @param {object} props `{ src }`.
 * @returns {JSX.Element}
 */
function ResultImage({
  src
}) {
  const intl = useIntl();
  const [loaded, setLoaded] = useState(false);
  return /*#__PURE__*/_jsxDEV("img", {
    src: src,
    alt: intl.formatMessage(msgs.resultAlt),
    className: `result-img${loaded ? " loaded" : ""}`,
    loading: "lazy",
    onLoad: () => setLoaded(true)
  }, void 0, false);
}

/**
 * The source-DPL line: click-to-copy, with a hover tooltip showing the full DPL and a concrete
 * example that re-rolls every second (the same live preview the building-block chips use).
 * @param {object} props `{ dpl, settings }`.
 * @returns {JSX.Element}
 */
function DplHoverCode({
  dpl,
  settings
}) {
  const intl = useIntl();
  const [hover, setHover] = useState(false);
  const [ex, setEx] = useState("");
  const settingsRef = useRef(settings);
  settingsRef.current = settings;
  useEffect(() => {
    if (!hover) {
      setEx("");
      return undefined;
    }
    const roll = () => {
      try {
        setEx(expandPrompt(dpl, {
          ...settingsRef.current,
          autoAddFx: false,
          autoAddArtists: false
        }));
      } catch {
        setEx("");
      }
    };
    roll();
    const id = setInterval(roll, 1000);
    return () => clearInterval(id);
  }, [hover, dpl]);
  return /*#__PURE__*/_jsxDEV("span", {
    className: "dpl-hover",
    onMouseEnter: () => setHover(true),
    onMouseLeave: () => setHover(false),
    children: [/*#__PURE__*/_jsxDEV("code", {
      className: "prompt-dpl",
      title: intl.formatMessage(msgs.dplTitle),
      onClick: () => navigator.clipboard?.writeText(dpl).catch(() => {}),
      children: dpl
    }, void 0, false), hover && /*#__PURE__*/_jsxDEV("div", {
      className: "dpl-hover-tip",
      role: "tooltip",
      children: [/*#__PURE__*/_jsxDEV("div", {
        className: "dpl-hover-full",
        children: dpl
      }, void 0, false), ex && /*#__PURE__*/_jsxDEV("div", {
        className: "dpl-hover-ex",
        children: [/*#__PURE__*/_jsxDEV("span", {
          className: "dpl-hover-ex-label",
          children: intl.formatMessage(msgs.example)
        }, void 0, false), " ", ex]
      }, void 0, true)]
    }, void 0, true)]
  }, void 0, true);
}
const ImageIcon = () => /*#__PURE__*/_jsxDEV("svg", {
  width: "15",
  height: "15",
  viewBox: "0 0 24 24",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: "2",
  strokeLinecap: "round",
  strokeLinejoin: "round",
  "aria-hidden": "true",
  children: [/*#__PURE__*/_jsxDEV("rect", {
    x: "3",
    y: "3",
    width: "18",
    height: "18",
    rx: "2",
    ry: "2"
  }, void 0, false), /*#__PURE__*/_jsxDEV("circle", {
    cx: "8.5",
    cy: "8.5",
    r: "1.5"
  }, void 0, false), /*#__PURE__*/_jsxDEV("polyline", {
    points: "21 15 16 10 5 21"
  }, void 0, false)]
}, void 0, true);

/**
 * @param {object} props
 * @param {object} props.prompt `{ id, text, batches: [{ id, busy, images }] }`.
 * @param {number} props.index Zero-based index in the list.
 * @param {object} props.settings The generation settings (for the DPL example tooltip).
 * @param {boolean} props.canGenerate Whether the active provider can render images.
 * @param {Function} props.onGenerate `(promptId)`.
 * @param {Function} props.onCopy `(text)`.
 * @param {Function} props.onRemoveImage `(promptId, batchId, img)`.
 * @param {Function} props.onRemoveBatch `(promptId, batchId)`.
 * @param {Function} props.onClearImages `(promptId)`.
 * @param {Function} [props.onImageClick] `(img)` — open the image in the single view.
 * @returns {JSX.Element}
 */
function PromptResult({
  prompt,
  index,
  number,
  settings,
  canGenerate,
  onGenerate,
  onCopy,
  onRemoveImage,
  onRemoveBatch,
  onClearImages,
  onImageClick
}) {
  const intl = useIntl();
  const hasImages = prompt.batches.some(b => b.images.length);
  return /*#__PURE__*/_jsxDEV("li", {
    className: "prompt-result",
    children: [/*#__PURE__*/_jsxDEV("div", {
      className: "prompt-line",
      children: [/*#__PURE__*/_jsxDEV("span", {
        className: "idx",
        children: String(number ?? index + 1).padStart(2, "0")
      }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
        className: "prompt-main",
        children: [prompt.dpl && /*#__PURE__*/_jsxDEV(DplHoverCode, {
          dpl: prompt.dpl,
          settings: settings
        }, void 0, false), prompt.original && /*#__PURE__*/_jsxDEV("code", {
          className: "prompt-dpl prompt-original",
          title: intl.formatMessage(msgs.originalTitle, {
            original: prompt.original
          }),
          onClick: () => navigator.clipboard?.writeText(prompt.original).catch(() => {}),
          children: prompt.original
        }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
          className: "prompt-text",
          title: intl.formatMessage(msgs.copyTitle, {
            text: prompt.text
          }),
          onClick: () => onCopy(prompt.text),
          children: prompt.text
        }, void 0, false)]
      }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
        className: "prompt-actions",
        children: [canGenerate && /*#__PURE__*/_jsxDEV("button", {
          className: "gen-btn",
          onClick: () => onGenerate(prompt.id),
          title: intl.formatMessage(msgs.genTitle),
          children: [/*#__PURE__*/_jsxDEV(ImageIcon, {}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
            className: "gen-btn-label",
            children: intl.formatMessage(prompt.batches.length ? msgs.moreImages : msgs.genImages)
          }, void 0, false)]
        }, void 0, true), hasImages && /*#__PURE__*/_jsxDEV("button", {
          className: "copy-mini clear-icon-btn",
          title: intl.formatMessage(msgs.clearTitle),
          "aria-label": intl.formatMessage(msgs.clearTitle),
          onClick: () => onClearImages(prompt.id),
          children: /*#__PURE__*/_jsxDEV(TrashIcon, {}, void 0, false)
        }, void 0, false)]
      }, void 0, true)]
    }, void 0, true), prompt.batches.map((b, bi) => /*#__PURE__*/_jsxDEV("div", {
      className: "batch",
      children: [/*#__PURE__*/_jsxDEV("div", {
        className: "batch-head",
        children: [/*#__PURE__*/_jsxDEV("span", {
          className: "batch-label",
          children: intl.formatMessage(msgs.batch, {
            n: prompt.batches.length - bi
          })
        }, void 0, false), b.busy ? /*#__PURE__*/_jsxDEV("span", {
          className: "batch-status",
          children: intl.formatMessage(msgs.rendering)
        }, void 0, false) : b.images.length > 0 && /*#__PURE__*/_jsxDEV("button", {
          className: "link-btn",
          onClick: () => onRemoveBatch(prompt.id, b.id),
          children: intl.formatMessage(msgs.removeBatch)
        }, void 0, false)]
      }, void 0, true), b.busy ? /*#__PURE__*/_jsxDEV("div", {
        className: "gallery",
        children: Array.from({
          length: b.count || 1
        }).map((_, i) => /*#__PURE__*/_jsxDEV("figure", {
          className: "skeleton-fig",
          children: /*#__PURE__*/_jsxDEV("div", {
            className: "img-skeleton",
            "aria-label": intl.formatMessage(msgs.renderingAria)
          }, void 0, false)
        }, i, false))
      }, void 0, false) : /*#__PURE__*/_jsxDEV("div", {
        className: "gallery",
        children: b.images.map(img => /*#__PURE__*/_jsxDEV("figure", {
          children: [/*#__PURE__*/_jsxDEV("a", {
            href: img,
            target: "_blank",
            rel: "noreferrer",
            title: intl.formatMessage(onImageClick ? msgs.openSingle : msgs.openNewTab),
            onClick: e => {
              e.preventDefault();
              if (onImageClick) onImageClick(img);else openImageInNewTab(img);
            },
            children: /*#__PURE__*/_jsxDEV(ResultImage, {
              src: img
            }, void 0, false)
          }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
            className: "img-actions",
            children: [isOutputFile(img) && /*#__PURE__*/_jsxDEV(_Fragment, {
              children: [/*#__PURE__*/_jsxDEV("button", {
                title: intl.formatMessage(msgs.openDefault),
                onClick: () => openImageFile(img),
                children: "↗"
              }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
                title: intl.formatMessage(msgs.reveal),
                onClick: () => revealImageFile(img),
                children: "⌖"
              }, void 0, false)]
            }, void 0, true), /*#__PURE__*/_jsxDEV("button", {
              title: intl.formatMessage(msgs.removeImage),
              onClick: () => onRemoveImage(prompt.id, b.id, img),
              children: "✕"
            }, void 0, false)]
          }, void 0, true)]
        }, img, true))
      }, void 0, false)]
    }, b.id, true))]
  }, void 0, true);
}

/**
 * Re-render a row only when its own data changes. Crucially, an image batch resolving elsewhere
 * updates just that prompt's object (the `setPrompts` maps keep the SAME reference for every other
 * row), so at 1000 rows a single image landing re-renders one `<li>`, not the whole list. The
 * handler props are intentionally excluded from the compare: they act via `promptId` + functional
 * `setPrompts` + a live prompts ref, so a retained closure stays correct even if its identity is
 * stale.
 * @param {object} prev Previous props.
 * @param {object} next Next props.
 * @returns {boolean} True when the row can skip re-rendering.
 */
function sameRow(prev, next) {
  return prev.prompt === next.prompt && prev.index === next.index && prev.number === next.number && prev.canGenerate === next.canGenerate && prev.settings === next.settings && prev.onImageClick === next.onImageClick;
}
export default /*#__PURE__*/memo(PromptResult, sameRow);