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

/**
 * The active image provider's *common* image knobs, surfaced inline at the bottom-left of the
 * prompt box (next to the Prompts counter) instead of being buried in the provider gear: the
 * batch count ("Images") and the output size (a Size / Ratio select, or a Width × Height pair —
 * whichever the provider exposes). Capability-driven: it reads the provider's own settings schema
 * and renders only the controls that exist, so providers without image knobs (plain text, the
 * copy-prompt syntax providers like Midjourney) render nothing. Values are written to the same
 * `providerParams[id]` namespace the provider gear (`ProviderBox`) uses, so the two stay in sync.
 *
 * Compact width (<=768px): **Images stays inline, but Size tucks behind a small cog popover** — on a
 * narrow phone the Prompts + Images + Size(W×H) cluster would otherwise overflow the box's right edge.
 * Prompts-only and Prompts+Images stay fully inline; only the wider Size control moves behind the cog.
 * Desktop renders everything inline (unchanged) — the split is driven by the hydration-safe {@link useCompact}.
 * @module gui/components/InlineImageControls
 */
import { useEffect, useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { getProvider } from "../lib/providers/index.js";
import { useProviderSettings } from "../lib/useProvider.js";
import { useCompact } from "../lib/useCompact.js";
import { GearIcon } from "./icons.jsx";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const msgs = defineMessages({
  imagesTitle: {
    id: "inlineImg.imagesTitle",
    defaultMessage: "Images generated per prompt"
  },
  images: {
    id: "inlineImg.images",
    defaultMessage: "Images"
  },
  ratio: {
    id: "inlineImg.ratio",
    defaultMessage: "Ratio"
  },
  size: {
    id: "inlineImg.size",
    defaultMessage: "Size"
  },
  sizeTitle: {
    id: "inlineImg.sizeTitle",
    defaultMessage: "Output size — width × height"
  },
  sizeMenu: {
    id: "inlineImg.sizeMenu",
    defaultMessage: "Size settings"
  },
  width: {
    id: "inlineImg.width",
    defaultMessage: "Width"
  },
  height: {
    id: "inlineImg.height",
    defaultMessage: "Height"
  }
});

// Keys a provider may use to express output size / aspect. The first matching field wins (rendered
// as its native control — select or text); only when there's none do we fall back to the
// imageWidth × imageHeight number pair. `ar` is Midjourney's aspect-ratio flag.
const SIZE_KEYS = ["size", "imageSize", "aspectRatio", "ar"];

/**
 * Inline provider image controls (Images + Size).
 * @param {object} props
 * @param {object} props.settings The current settings.
 * @param {Function} props.setSettings Update the settings.
 * @returns {(JSX.Element|null)}
 */
export default function InlineImageControls({
  settings,
  setSettings
}) {
  const intl = useIntl();
  const provider = getProvider(settings.provider);
  const pid = provider?.id;
  const {
    schema,
    options
  } = useProviderSettings(pid);
  const compact = useCompact();
  const [sizeOpen, setSizeOpen] = useState(false);

  // Close the compact Size popover on Escape.
  useEffect(() => {
    if (!sizeOpen) return undefined;
    const onKey = e => e.key === "Escape" && setSizeOpen(false);
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [sizeOpen]);

  // Surface for any provider that exposes the relevant fields — the in-app generators (api tier)
  // plus copy-prompt providers like Midjourney (its aspect ratio). Providers without these fields
  // (plain text) render nothing.
  if (!provider || !schema) return null;
  const fields = schema.fields || [];
  const defaults = schema.defaults || {};
  const params = settings.providerParams?.[pid] || {};
  const setParam = (key, value) => setSettings(s => ({
    ...s,
    providerParams: {
      ...s.providerParams,
      [pid]: {
        ...s.providerParams?.[pid],
        [key]: value
      }
    }
  }));
  const batch = fields.find(f => f.key === "batchSize");
  const sizeField = fields.find(f => SIZE_KEYS.includes(f.key));
  const width = fields.find(f => f.key === "imageWidth");
  const height = fields.find(f => f.key === "imageHeight");
  if (!batch && !sizeField && !(width && height)) return null;
  const sizeLabel = intl.formatMessage(sizeField && (sizeField.key === "aspectRatio" || sizeField.key === "ar") ? msgs.ratio : msgs.size);
  const sizeOptions = sizeField ? sizeField.options || options[sizeField.optionsFrom] || [] : [];

  // The Images batch-count control (always inline when present).
  const imagesEl = batch && /*#__PURE__*/_jsxDEV("label", {
    className: "field-count",
    title: intl.formatMessage(msgs.imagesTitle),
    children: [/*#__PURE__*/_jsxDEV("span", {
      className: "field-count-label",
      children: intl.formatMessage(msgs.images)
    }, void 0, false), /*#__PURE__*/_jsxDEV("input", {
      type: "number",
      min: batch.min ?? 1,
      max: batch.max,
      value: params.batchSize ?? defaults.batchSize ?? 1,
      onChange: e => setParam("batchSize", Math.max(1, Number(e.target.value) || 1)),
      "aria-label": intl.formatMessage(msgs.imagesTitle)
    }, void 0, false)]
  }, void 0, true);

  // The Size control — a Size/Ratio select, a text field, or the Width × Height pair.
  const sizeEl = sizeField ? /*#__PURE__*/_jsxDEV("label", {
    className: "field-count field-size",
    title: sizeField.label,
    children: [/*#__PURE__*/_jsxDEV("span", {
      className: "field-count-label",
      children: sizeLabel
    }, void 0, false), sizeField.type === "select" ? /*#__PURE__*/_jsxDEV("select", {
      className: "field-size-select",
      value: params[sizeField.key] ?? defaults[sizeField.key] ?? "",
      onChange: e => setParam(sizeField.key, e.target.value),
      "aria-label": sizeField.label,
      children: sizeOptions.map(o => {
        const opt = typeof o === "string" ? {
          value: o,
          label: o
        } : o;
        return /*#__PURE__*/_jsxDEV("option", {
          value: opt.value,
          children: opt.label
        }, opt.value, false);
      })
    }, void 0, false) : /*#__PURE__*/_jsxDEV("input", {
      type: sizeField.type === "number" ? "number" : "text",
      className: "field-size-text",
      value: params[sizeField.key] ?? defaults[sizeField.key] ?? "",
      onChange: e => setParam(sizeField.key, e.target.value),
      "aria-label": sizeField.label,
      placeholder: "1:1"
    }, void 0, false)]
  }, void 0, true) : width && height ? /*#__PURE__*/_jsxDEV("label", {
    className: "field-count field-size",
    title: intl.formatMessage(msgs.sizeTitle),
    children: [/*#__PURE__*/_jsxDEV("span", {
      className: "field-count-label",
      children: intl.formatMessage(msgs.size)
    }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
      className: "field-size-wh",
      children: [/*#__PURE__*/_jsxDEV("input", {
        type: "number",
        min: width.min,
        max: width.max,
        step: width.step,
        value: params.imageWidth ?? defaults.imageWidth ?? 512,
        onChange: e => setParam("imageWidth", Number(e.target.value) || 0),
        "aria-label": intl.formatMessage(msgs.width)
      }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
        className: "field-size-x",
        "aria-hidden": "true",
        children: "×"
      }, void 0, false), /*#__PURE__*/_jsxDEV("input", {
        type: "number",
        min: height.min,
        max: height.max,
        step: height.step,
        value: params.imageHeight ?? defaults.imageHeight ?? 512,
        onChange: e => setParam("imageHeight", Number(e.target.value) || 0),
        "aria-label": intl.formatMessage(msgs.height)
      }, void 0, false)]
    }, void 0, true)]
  }, void 0, true) : null;
  return /*#__PURE__*/_jsxDEV(_Fragment, {
    children: [imagesEl, sizeEl && !compact && sizeEl, sizeEl && compact && /*#__PURE__*/_jsxDEV("div", {
      className: "size-cog",
      children: [/*#__PURE__*/_jsxDEV("button", {
        type: "button",
        className: `size-cog-btn${sizeOpen ? " on" : ""}`,
        "aria-haspopup": "dialog",
        "aria-expanded": sizeOpen,
        "aria-label": intl.formatMessage(msgs.sizeMenu),
        title: intl.formatMessage(msgs.sizeMenu),
        onClick: () => setSizeOpen(o => !o),
        children: /*#__PURE__*/_jsxDEV(GearIcon, {}, void 0, false)
      }, void 0, false), sizeOpen && /*#__PURE__*/_jsxDEV(_Fragment, {
        children: [/*#__PURE__*/_jsxDEV("div", {
          className: "size-cog-scrim",
          onClick: () => setSizeOpen(false),
          "aria-hidden": "true"
        }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
          className: "size-cog-pop",
          role: "dialog",
          "aria-label": intl.formatMessage(msgs.sizeMenu),
          children: sizeEl
        }, void 0, false)]
      }, void 0, true)]
    }, void 0, true)]
  }, void 0, true);
}