/**
* The "wrapper" control: a floating bottom-right button that frames every generated prompt
* with a START and an END snippet (the v3 root layer = open + middle + close). Clicking it
* opens a small popover listing saved wrapper presets (apply one, or "None"); a "Manage
* presets" button there opens a modal with the two side-by-side Start/End editors plus
* preset naming/management. The selected wrapper lives in `settings.wrapper` (so it is
* shared by the share-link); the preset library lives in localStorage.
* @module web-app/components/WrapperFab
*/
import { useEffect, useRef, useState } from "react";
import { getWrappers, saveWrapper, removeWrapper, renameWrapper, getDefaultWrapper, saveDefaultWrapper, DEFAULT_WRAPPER_SEED } from "../lib/wrapperStore.js";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const ico = {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round"
};
// Brackets — a "wraps around your prompt" glyph.
const WrapIcon = () => /*#__PURE__*/_jsxDEV("svg", {
...ico,
"aria-hidden": "true",
children: [/*#__PURE__*/_jsxDEV("path", {
d: "M8 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h2"
}, void 0, false), /*#__PURE__*/_jsxDEV("path", {
d: "M16 4h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-2"
}, void 0, false)]
}, void 0, true);
// Counter-clockwise arrow — "revert to default".
const RevertIcon = () => /*#__PURE__*/_jsxDEV("svg", {
...ico,
width: 15,
height: 15,
"aria-hidden": "true",
children: [/*#__PURE__*/_jsxDEV("path", {
d: "M3 4v6h6"
}, void 0, false), /*#__PURE__*/_jsxDEV("path", {
d: "M3.5 10a8 8 0 1 1-.5 6"
}, void 0, false)]
}, void 0, true);
/**
* @param {object} props
* @param {object} props.settings Current settings (reads `wrapper` / `wrapperName`).
* @param {Function} props.setSettings Update settings.
* @returns {JSX.Element}
*/
export default function WrapperFab({
settings,
setSettings
}) {
const [view, setView] = useState(""); // "" | "list" | "manage"
const [wrappers, setWrappers] = useState(() => getWrappers());
const refresh = () => setWrappers(getWrappers());
// The popover is position:fixed (so it escapes the scrolling pane and can't clip); anchor its
// bottom-right to just above the trigger button.
const btnRef = useRef(null);
const [pos, setPos] = useState(null);
function openList() {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({
right: Math.round(window.innerWidth - r.right),
bottom: Math.round(window.innerHeight - r.top + 8),
// Never let the popover grow past the top of the screen — cap it to the gap above the button.
maxHeight: Math.max(160, Math.round(r.top - 16))
});
setView("list");
}
// Manage-modal editor state.
const [sel, setSel] = useState(""); // the preset name being edited ("" = unsaved/new)
const [name, setName] = useState("");
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
// No explicit choice yet → the built-in Default is in effect. "None" means "no wrapper".
const activeName = settings.wrapperName ?? "Default";
const isActive = activeName !== "None";
const names = Object.keys(wrappers).sort((a, b) => a.localeCompare(b));
const def = getDefaultWrapper(); // live Default — used for placeholders on a brand-new wrapper
const isNew = sel === ""; // editing a brand-new (unsaved) wrapper
// Close on Escape.
useEffect(() => {
if (!view) return undefined;
const onKey = e => e.key === "Escape" && setView(view === "manage" ? "list" : "");
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [view]);
function applyWrapper(n) {
if (n === "None") {
setSettings({
...settings,
wrapper: {
start: "",
end: ""
},
wrapperName: "None"
});
} else if (n === "Default") {
setSettings({
...settings,
wrapper: getDefaultWrapper(),
wrapperName: "Default"
});
} else {
const w = wrappers[n] || {
start: "",
end: ""
};
setSettings({
...settings,
wrapper: {
start: w.start,
end: w.end
},
wrapperName: n
});
}
setView("");
}
// Load a preset (by name, "Default", or "" = a brand-new one) into the editor.
function loadInto(n) {
if (n === "Default") {
const w = getDefaultWrapper();
setSel("Default");
setName("Default");
setStart(w.start || "");
setEnd(w.end || "");
return;
}
const w = n && getWrappers()[n] || {
start: "",
end: ""
};
setSel(n && getWrappers()[n] ? n : "");
setName(n && getWrappers()[n] ? n : "");
setStart(w.start || "");
setEnd(w.end || "");
}
function openManage(n = activeName) {
refresh();
loadInto(n === "None" ? "Default" : n);
setView("manage");
}
function editPreset(n) {
loadInto(n);
}
// A brand-new wrapper: clear the fields (the Default's text shows as placeholder instead).
function newPreset() {
setSel("");
setName("");
setStart("");
setEnd("");
}
function save() {
const n = name.trim();
if (!n) return;
// The Default is special: it's edited in place (you can't rename it into a normal preset).
if (sel === "Default" && n === "Default") {
saveDefaultWrapper({
start,
end
});
if (!activeName || activeName === "Default") {
setSettings({
...settings,
wrapper: {
start,
end
},
wrapperName: "Default"
});
}
return;
}
if (sel && sel !== "Default" && sel !== n) renameWrapper(sel, n); // renamed an existing preset
saveWrapper(n, {
start,
end
});
refresh();
setSel(n);
// If we were editing the active wrapper (or it now matches), keep it applied/in sync.
if (!activeName || activeName === sel || activeName === n) {
setSettings({
...settings,
wrapper: {
start,
end
},
wrapperName: n
});
}
}
function del(n) {
if (n === "Default") return; // the Default can't be deleted (reset it instead)
removeWrapper(n);
refresh();
if (sel === n) loadInto("Default");
// If the deleted preset was applied, fall back to the built-in Default.
if (activeName === n) setSettings({
...settings,
wrapper: getDefaultWrapper(),
wrapperName: "Default"
});
}
// Revert ONE pane to the default: pull from the live Default file, or — when the Default itself is
// being edited — from the hard-coded seed (the "hard copy"). Only sets the field; Save persists it.
function revertPane(which) {
const src = sel === "Default" ? DEFAULT_WRAPPER_SEED : getDefaultWrapper();
if (which === "start") setStart(src.start || "");else setEnd(src.end || "");
}
return /*#__PURE__*/_jsxDEV(_Fragment, {
children: [/*#__PURE__*/_jsxDEV("button", {
ref: btnRef,
className: `field-act wrap-trigger${isActive ? " on" : ""}`,
onClick: () => view === "list" ? setView("") : openList(),
title: `Wrapper: ${activeName} — frames every prompt with a start and end`,
"aria-label": "Wrapper presets",
"aria-pressed": view === "list",
children: /*#__PURE__*/_jsxDEV(WrapIcon, {}, void 0, false)
}, void 0, false), view === "list" && /*#__PURE__*/_jsxDEV(_Fragment, {
children: [/*#__PURE__*/_jsxDEV("div", {
className: "wrap-pop-scrim",
onClick: () => setView("")
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "wrap-pop",
role: "menu",
"aria-label": "Wrapper presets",
style: pos ? {
position: "fixed",
right: pos.right,
bottom: pos.bottom,
maxHeight: pos.maxHeight
} : undefined,
children: [/*#__PURE__*/_jsxDEV("div", {
className: "wrap-pop-head",
children: [/*#__PURE__*/_jsxDEV("span", {
children: "Wrapper"
}, void 0, false), /*#__PURE__*/_jsxDEV("button", {
className: "wrap-manage-btn",
onClick: () => openManage(),
children: "Manage Wrappers"
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("label", {
className: "wrap-auto-toggle",
title: "When on, blocks may add their own Auto Begin / Auto End framing to the prompt. When off, only this wrapper frames it.",
children: [/*#__PURE__*/_jsxDEV("input", {
type: "checkbox",
checked: settings.useAutoSections !== false,
onChange: e => setSettings({
...settings,
useAutoSections: e.target.checked
})
}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
children: "Allow used blocks to extend current wrapper"
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("div", {
className: "wrap-pop-list",
children: [/*#__PURE__*/_jsxDEV("button", {
className: `wrap-pop-item${activeName === "Default" ? " on" : ""}`,
onClick: () => applyWrapper("Default"),
children: [/*#__PURE__*/_jsxDEV("span", {
className: "wrap-pop-name",
children: "Default"
}, void 0, false), activeName === "Default" && /*#__PURE__*/_jsxDEV("span", {
className: "wrap-check",
children: "✓"
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("button", {
className: `wrap-pop-item${activeName === "None" ? " on" : ""}`,
onClick: () => applyWrapper("None"),
children: [/*#__PURE__*/_jsxDEV("span", {
className: "wrap-pop-name",
children: "None"
}, void 0, false), activeName === "None" && /*#__PURE__*/_jsxDEV("span", {
className: "wrap-check",
children: "✓"
}, void 0, false)]
}, void 0, true), names.length === 0 && /*#__PURE__*/_jsxDEV("p", {
className: "wrap-empty",
children: "No saved wrappers — Manage presets to add one."
}, void 0, false), names.map(n => /*#__PURE__*/_jsxDEV("button", {
className: `wrap-pop-item${activeName === n ? " on" : ""}`,
onClick: () => applyWrapper(n),
children: [/*#__PURE__*/_jsxDEV("span", {
className: "wrap-pop-name",
children: n
}, void 0, false), activeName === n && /*#__PURE__*/_jsxDEV("span", {
className: "wrap-check",
children: "✓"
}, void 0, false)]
}, n, true))]
}, void 0, true)]
}, void 0, true)]
}, void 0, true), view === "manage" && /*#__PURE__*/_jsxDEV(_Fragment, {
children: [/*#__PURE__*/_jsxDEV("div", {
className: "wrap-modal-overlay",
onClick: () => setView("list")
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "wrap-modal",
role: "dialog",
"aria-label": "Manage wrappers",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "wrap-modal-head",
children: [/*#__PURE__*/_jsxDEV("h2", {
children: "Manage Wrappers"
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "grow"
}, void 0, false), /*#__PURE__*/_jsxDEV("button", {
className: "drawer-close",
onClick: () => setView("list"),
"aria-label": "Close",
children: "×"
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("div", {
className: "wrap-modal-body",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "wrap-presets",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "wrap-preset-pills",
children: [/*#__PURE__*/_jsxDEV("span", {
className: `wrap-preset-pill${sel === "Default" ? " on" : ""}`,
children: /*#__PURE__*/_jsxDEV("button", {
className: "wrap-preset-pick",
onClick: () => editPreset("Default"),
title: "Edit the Default wrapper",
children: "Default"
}, void 0, false)
}, void 0, false), names.map(n => /*#__PURE__*/_jsxDEV("span", {
className: `wrap-preset-pill${sel === n ? " on" : ""}`,
children: [/*#__PURE__*/_jsxDEV("button", {
className: "wrap-preset-pick",
onClick: () => editPreset(n),
title: `Edit “${n}”`,
children: n
}, void 0, false), /*#__PURE__*/_jsxDEV("button", {
className: "wrap-preset-del",
onClick: () => del(n),
title: `Delete “${n}”`,
"aria-label": `Delete ${n}`,
children: "×"
}, void 0, false)]
}, n, true)), /*#__PURE__*/_jsxDEV("button", {
className: "wrap-preset-new",
onClick: newPreset,
children: "+ New"
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("input", {
className: "panel-input wrap-name",
placeholder: "Wrapper name…",
value: name,
onChange: e => setName(e.target.value),
readOnly: sel === "Default",
"aria-label": "Wrapper name"
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("div", {
className: "wrap-grid",
children: [/*#__PURE__*/_jsxDEV("label", {
className: "wrap-box",
children: [/*#__PURE__*/_jsxDEV("span", {
className: "wrap-box-head",
children: [/*#__PURE__*/_jsxDEV("span", {
className: "wrap-box-label",
children: "Start"
}, void 0, false), /*#__PURE__*/_jsxDEV("button", {
type: "button",
className: "wrap-revert",
onClick: () => revertPane("start"),
title: sel === "Default" ? "Reset Start to the built-in default" : "Reset Start to the default wrapper",
"aria-label": "Revert Start to default",
children: /*#__PURE__*/_jsxDEV(RevertIcon, {}, void 0, false)
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("textarea", {
value: start,
onChange: e => setStart(e.target.value),
placeholder: isNew ? def.start || "Rendered before the prompt…" : "Rendered before the prompt — e.g. masterpiece, best quality",
spellCheck: false
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("label", {
className: "wrap-box",
children: [/*#__PURE__*/_jsxDEV("span", {
className: "wrap-box-head",
children: [/*#__PURE__*/_jsxDEV("span", {
className: "wrap-box-label",
children: "End"
}, void 0, false), /*#__PURE__*/_jsxDEV("button", {
type: "button",
className: "wrap-revert",
onClick: () => revertPane("end"),
title: sel === "Default" ? "Reset End to the built-in default" : "Reset End to the default wrapper",
"aria-label": "Revert End to default",
children: /*#__PURE__*/_jsxDEV(RevertIcon, {}, void 0, false)
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("textarea", {
value: end,
onChange: e => setEnd(e.target.value),
placeholder: isNew ? def.end || "Rendered after the prompt…" : "Rendered after the prompt — e.g. {#fx}, {#artists}",
spellCheck: false
}, void 0, false)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true), /*#__PURE__*/_jsxDEV("div", {
className: "wrap-modal-foot",
children: [/*#__PURE__*/_jsxDEV("span", {
className: "wrap-foot-hint",
children: "Wraps every generated prompt: start, your prompt, end."
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "grow"
}, void 0, false), /*#__PURE__*/_jsxDEV("button", {
className: "ghost",
onClick: () => setView("list"),
children: "Cancel"
}, void 0, false), /*#__PURE__*/_jsxDEV("button", {
className: "primary",
onClick: save,
disabled: !name.trim(),
children: "Save"
}, void 0, false)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true);
}