feat(promptbox): full editing controls in fullscreen focus mode

Add a footer to the fullscreen prompt modal (desktop lib + webapp) so
focus mode is a complete editing surface, not just the textarea:

- Model selector, always-on reference-image row, and a Done button
  (closes the modal) instead of Generate.
- Video: also surfaces the input-mode (keyframe/reference) picker and
  the @Characters button (seedance_2p0). Shared image-row / character /
  input-mode elements are reused inline and in the modal (DRY).
- Webapp: model selector + leftToolbar + image row in the footer.

Layout: editor area is flex-1/min-h-0/overflow-hidden with the image row
and footer shrink-0, so the editor takes only the leftover space and
scrolls internally - resizing the window shorter no longer overflows.
MentionTextarea gains a `style` prop so the modal can cap resize/height.
This commit is contained in:
bflatastic
2026-06-26 23:00:54 +07:00
parent 25cf131473
commit 4a20522eec
8 changed files with 230 additions and 111 deletions
@@ -323,24 +323,28 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
});
}, [prompt, hasMentionItems, mentionRegex, mentionLabelMap, mentionItems]);
// Shared reference-image row, used both inline (gated by isImageRowVisible)
// and always-on in the fullscreen focus modal.
const imagePromptRowEl = supportsImagePrompts ? (
<ImagePromptRow
maxImagePromptCount={maxImagePromptCount}
referenceImages={referenceImages}
setReferenceImages={onReferenceImagesChange}
onPickFromLibrary={onPickFromLibrary}
onClearAll={onClearAllRefs}
isVideo={isVideo}
isReferenceMode={isReferenceMode}
endFrameImage={endFrameImage}
setEndFrameImage={onEndFrameImageChange}
showEndFrameSection={showEndFrameSection}
onPickEndFrameFromLibrary={onPickEndFrameFromLibrary}
/>
) : null;
return (
<div ref={ref} className="prompt-box-root">
<div className="relative flex flex-col">
{isImageRowVisible && (
<ImagePromptRow
maxImagePromptCount={maxImagePromptCount}
referenceImages={referenceImages}
setReferenceImages={onReferenceImagesChange}
onPickFromLibrary={onPickFromLibrary}
onClearAll={onClearAllRefs}
isVideo={isVideo}
isReferenceMode={isReferenceMode}
endFrameImage={endFrameImage}
setEndFrameImage={onEndFrameImageChange}
showEndFrameSection={showEndFrameSection}
onPickEndFrameFromLibrary={onPickEndFrameFromLibrary}
/>
)}
{isImageRowVisible && imagePromptRowEl}
{mediaReferenceRow}
@@ -555,7 +559,17 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
</div>
</div>
</div>
<PromptFullscreenModal isOpen={isFullscreen} onClose={closeFullscreen}>
<PromptFullscreenModal
isOpen={isFullscreen}
onClose={closeFullscreen}
footerControls={
<>
{modelSelector}
{leftToolbar}
</>
}
imagePromptRow={imagePromptRowEl}
>
{hasMentionItems && mentionItems ? (
<MentionTextarea
value={prompt}
@@ -563,7 +577,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
mentionItems={mentionItems}
placeholder={placeholder}
className="promptbox-scrollbar h-full min-h-0 w-full overflow-y-auto text-base-fg placeholder-base-fg/60"
style={{ maxHeight: "calc(70vh - 7rem)", resize: "none" }}
style={{ resize: "none" }}
colorMap={mentionColorMap}
onKeyDown={(e) => {
if (
@@ -581,7 +595,6 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
<textarea
placeholder={placeholder}
className="promptbox-scrollbar text-md h-full min-h-0 w-full resize-none overflow-y-auto bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
style={{ maxHeight: "calc(70vh - 7rem)" }}
value={prompt}
onChange={handleChange}
onKeyDown={handleKeyDown}
@@ -6,6 +6,7 @@ import {
useState,
} from "react";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUpRightAndDownLeftFromCenter } from "@fortawesome/pro-solid-svg-icons";
@@ -56,12 +57,19 @@ interface PromptFullscreenModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
/** Controls rendered in the footer, left of the Done button (e.g. the model
* selector + character button). */
footerControls?: ReactNode;
/** Reference-image row, always shown in focus mode. */
imagePromptRow?: ReactNode;
}
export const PromptFullscreenModal = ({
isOpen,
onClose,
children,
footerControls,
imagePromptRow,
}: PromptFullscreenModalProps) => {
const contentRef = useRef<HTMLDivElement>(null);
@@ -79,18 +87,30 @@ export const PromptFullscreenModal = ({
accessibleTitle="Prompt focus mode"
closeOnOutsideClick
closeOnEsc
className="h-[70vh] w-full max-w-4xl"
className="w-full max-w-4xl"
backdropClassName="backdrop-blur-md"
>
{/* Title lives inside the flex column (not the Modal title slot) so the
editor fits the fixed panel height cleanly. No overflow-hidden here —
the mention autocomplete dropdown renders outside the editor bounds
and must not be clipped. */}
<div ref={contentRef} className="flex h-full flex-col gap-2">
<h2 className="text-lg font-bold text-base-fg">Prompt</h2>
{/* flex column so a flex-1 editor (the MentionTextarea root wrapper)
or an h-full textarea fills the remaining space. */}
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
{/* Explicit inline height (not a Tailwind arbitrary class) so the column
is reliably bounded across the modal's nested wrappers — that's what
lets the editor scroll instead of stretching the panel. min-h-0 lets
the flex children shrink below content size. */}
<div className="flex min-h-0 flex-col gap-2" style={{ height: "70vh" }}>
<h2 className="shrink-0 text-lg font-bold text-base-fg">Prompt</h2>
{/* flex-1 so the editor takes only the space left after the (shrink-0)
image row + footer — keeps it from overflowing when the window is
shorter. min-h-0 + overflow-hidden bound the editor area so its own
textarea scrolls instead of pushing the layout. */}
<div
ref={contentRef}
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
{children}
</div>
{imagePromptRow && <div className="shrink-0">{imagePromptRow}</div>}
<div className="flex shrink-0 items-center justify-between gap-2">
<div className="flex items-center gap-2">{footerControls}</div>
<Button onClick={onClose}>Done</Button>
</div>
</div>
</Modal>
);
@@ -558,11 +558,26 @@ export const PromptBox2D = ({
onClose={closeFullscreen}
promptLength={prompt.length}
maxLength={maxLen}
footerControls={modelSelector}
imagePromptRow={
selectedImageModel?.canUseImagePrompt ? (
<ImagePromptRow
visible={true}
maxImagePromptCount={Math.max(
1,
selectedImageModel?.maxImagePromptCount ?? 1,
)}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage as any}
/>
) : undefined
}
>
<textarea
placeholder="Describe your image..."
className="promptbox-scrollbar text-md h-full min-h-0 w-full resize-none overflow-y-auto rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
style={{ maxHeight: "calc(70vh - 7rem)" }}
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
@@ -898,11 +898,26 @@ export const PromptBox3D = ({
onClose={closeFullscreen}
promptLength={prompt.length}
maxLength={maxLen}
footerControls={modelSelector}
imagePromptRow={
selectedImageModel?.canUseImagePrompt ? (
<ImagePromptRow
visible={true}
maxImagePromptCount={Math.max(
1,
selectedImageModel?.maxImagePromptCount ?? 1,
)}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage as any}
/>
) : undefined
}
>
<textarea
placeholder="Describe your image..."
className="promptbox-scrollbar text-md h-full min-h-0 w-full resize-none overflow-y-auto rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
style={{ maxHeight: "calc(70vh - 7rem)" }}
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
@@ -568,11 +568,25 @@ export const PromptBoxEdit = ({
onClose={closeFullscreen}
promptLength={prompt.length}
maxLength={maxLen}
imagePromptRow={
selectedImageModel?.canUseImagePrompt ? (
<ImagePromptRow
visible={true}
maxImagePromptCount={Math.max(
1,
selectedImageModel?.maxImagePromptCount ?? 1,
)}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage}
/>
) : undefined
}
>
<textarea
placeholder="Write what you want to change in your image and click generate..."
className="promptbox-scrollbar text-md h-full min-h-0 w-full resize-none overflow-y-auto rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
style={{ maxHeight: "calc(70vh - 7rem)" }}
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
@@ -574,11 +574,26 @@ export const PromptBoxImage = ({
onClose={closeFullscreen}
promptLength={prompt.length}
maxLength={maxLen}
footerControls={modelSelector}
imagePromptRow={
selectedModel?.canUseImagePrompt ? (
<ImagePromptRow
visible={true}
maxImagePromptCount={Math.max(
1,
selectedModel?.maxImagePromptCount ?? 1,
)}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage as any}
/>
) : undefined
}
>
<textarea
placeholder="Describe what you want in the image..."
className="promptbox-scrollbar text-md h-full min-h-0 w-full resize-none overflow-y-auto rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
style={{ maxHeight: "calc(70vh - 7rem)" }}
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
@@ -967,6 +967,70 @@ export const PromptBoxVideo = ({
}
}, [selectedModel, endFrameImage, setEndFrameImage]);
// Shared reference-image row, used both inline (gated by isImageRowVisible)
// and always-on in the fullscreen focus modal.
const imagePromptRowEl = (
<ImagePromptRow
visible={true}
isVideo={true}
isReferenceMode={isReferenceMode}
maxImagePromptCount={maxImageCount}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
onImageClick={(image) => {
setContent(
<img
src={image.url}
alt="Reference preview"
className="h-full w-full object-contain"
/>,
);
setIsModalOpen(true);
}}
uploadImage={uploadImage}
endFrameImage={isReferenceMode ? undefined : endFrameImage}
setEndFrameImage={isReferenceMode ? undefined : setEndFrameImage}
allowUploadEnd={!isReferenceMode && !!selectedModel?.endFrame}
showEndFrameSection={!isReferenceMode && !!selectedModel?.endFrame}
referenceVideos={isReferenceMode ? referenceVideos : undefined}
setReferenceVideos={isReferenceMode ? setReferenceVideos : undefined}
maxVideoCount={selectedModel?.maxReferenceVideos ?? 3}
maxVideoRefDuration={selectedModel?.maxVideoRefDuration ?? 15}
showVideoReferenceSection={isReferenceMode}
uploadVideo={uploadVideo}
referenceAudios={isReferenceMode ? referenceAudios : undefined}
setReferenceAudios={isReferenceMode ? setReferenceAudios : undefined}
maxAudioCount={selectedModel?.maxReferenceAudios ?? 2}
maxAudioRefDuration={selectedModel?.maxAudioRefDuration ?? 15}
uploadAudio={uploadAudio}
/>
);
// Character button (seedance_2p0 only), reused in the fullscreen footer.
const characterButtonEl =
selectedModel?.id === "seedance_2p0" ? (
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
>
@Characters
</button>
) : null;
// Input-mode (keyframe/reference) picker, reused in the fullscreen footer.
const inputModeEl = inputModeOptions ? (
<Tooltip content="Input Mode" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={inputModeOptions}
onSelect={handleInputModeSelect}
mode="toggle"
panelTitle="Input Mode"
/>
</Tooltip>
) : null;
return (
<>
<Modal
@@ -979,47 +1043,7 @@ export const PromptBoxVideo = ({
{content}
</Modal>
<div className="relative z-20 flex flex-col gap-3">
{isImageRowVisible && (
<ImagePromptRow
visible={true}
isVideo={true}
isReferenceMode={isReferenceMode}
maxImagePromptCount={maxImageCount}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
onImageClick={(image) => {
setContent(
<img
src={image.url}
alt="Reference preview"
className="h-full w-full object-contain"
/>,
);
setIsModalOpen(true);
}}
uploadImage={uploadImage}
endFrameImage={isReferenceMode ? undefined : endFrameImage}
setEndFrameImage={isReferenceMode ? undefined : setEndFrameImage}
allowUploadEnd={!isReferenceMode && !!selectedModel?.endFrame}
showEndFrameSection={!isReferenceMode && !!selectedModel?.endFrame}
referenceVideos={isReferenceMode ? referenceVideos : undefined}
setReferenceVideos={
isReferenceMode ? setReferenceVideos : undefined
}
maxVideoCount={selectedModel?.maxReferenceVideos ?? 3}
maxVideoRefDuration={selectedModel?.maxVideoRefDuration ?? 15}
showVideoReferenceSection={isReferenceMode}
uploadVideo={uploadVideo}
referenceAudios={isReferenceMode ? referenceAudios : undefined}
setReferenceAudios={
isReferenceMode ? setReferenceAudios : undefined
}
maxAudioCount={selectedModel?.maxReferenceAudios ?? 2}
maxAudioRefDuration={selectedModel?.maxAudioRefDuration ?? 15}
uploadAudio={uploadAudio}
/>
)}
{isImageRowVisible && imagePromptRowEl}
<div
className={twMerge(
"glass relative w-full rounded-2xl p-4",
@@ -1184,31 +1208,9 @@ export const PromptBoxVideo = ({
</Tooltip>
)}
{inputModeOptions && (
<Tooltip
content="Input Mode"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={inputModeOptions}
onSelect={handleInputModeSelect}
mode="toggle"
panelTitle="Input Mode"
/>
</Tooltip>
)}
{inputModeEl}
{selectedModel?.id === "seedance_2p0" && (
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
>
@Characters
</button>
)}
{characterButtonEl}
</div>
<div className="flex items-center gap-2">
{modelNeedsAnImageButNoneAreSelected && (
@@ -1336,6 +1338,14 @@ export const PromptBoxVideo = ({
onClose={closeFullscreen}
promptLength={prompt.length}
maxLength={maxLen}
footerControls={
<>
{modelSelector}
{inputModeEl}
{characterButtonEl}
</>
}
imagePromptRow={imagePromptRowEl}
>
{hasAnyMentionables ? (
<MentionTextarea
@@ -1349,13 +1359,12 @@ export const PromptBoxVideo = ({
: "Describe what you want to happen in the video..."
}
className="promptbox-scrollbar text-md h-full min-h-0 w-full resize-none overflow-y-auto rounded bg-transparent text-base-fg"
style={{ maxHeight: "calc(70vh - 7rem)", resize: "none" }}
style={{ resize: "none" }}
/>
) : (
<textarea
placeholder="Describe what you want to happen in the video..."
className="promptbox-scrollbar text-md h-full min-h-0 w-full resize-none overflow-y-auto rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
style={{ maxHeight: "calc(70vh - 7rem)" }}
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
@@ -1,5 +1,6 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { focusEditorAtEnd } from "./focusEditorAtEnd";
// Shared "focus mode" overlay for prompt editors. Reuses @storyteller/ui-modal
@@ -20,6 +21,17 @@ interface PromptFullscreenModalProps {
* MentionTextarea bound to the same value + onChange as the inline one.
*/
children: ReactNode;
/**
* Optional controls rendered in the footer, left of the Done button — e.g.
* the model selector + character button. The caller composes these from its
* own toolbar pieces.
*/
footerControls?: ReactNode;
/**
* Optional reference-image row rendered above the footer, always visible in
* focus mode regardless of the inline row's collapse state.
*/
imagePromptRow?: ReactNode;
}
export const PromptFullscreenModal = ({
@@ -28,6 +40,8 @@ export const PromptFullscreenModal = ({
promptLength,
maxLength,
children,
footerControls,
imagePromptRow,
}: PromptFullscreenModalProps) => {
const overLimit = isFinite(maxLength) && promptLength > maxLength;
const contentRef = useRef<HTMLDivElement>(null);
@@ -52,21 +66,20 @@ export const PromptFullscreenModal = ({
{/* Explicit inline height (not a Tailwind arbitrary class) so the column
is reliably bounded across the modal's nested wrappers — that's what
lets the editor's textarea scroll instead of stretching the panel.
min-h-0 on the flex children lets them shrink below content size. No
overflow-hidden on the editor holder — the mention autocomplete
dropdown renders outside it and must not be clipped; the editor scrolls
via its own overflow-y-auto. */}
<div
className="flex min-h-0 flex-col gap-2"
style={{ height: "70vh" }}
>
min-h-0 on the flex children lets them shrink below content size. */}
<div className="flex min-h-0 flex-col gap-2" style={{ height: "70vh" }}>
<h2 className="shrink-0 text-lg font-bold text-base-fg">Prompt</h2>
{/* flex-col so a flex-1 editor (MentionTextarea root) fills it; min-h-0
so the editor area can shrink and its content scrolls. */}
<div ref={contentRef} className="flex min-h-0 flex-1 flex-col">
{/* flex-1 so the editor takes only the space left after the (shrink-0)
image row + footer — that's what keeps it from overflowing when the
window is shorter. min-h-0 + overflow-hidden bound the editor area so
its own textarea scrolls instead of pushing the layout. */}
<div
ref={contentRef}
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
{children}
</div>
<div className="flex items-center justify-end">
<div className="flex shrink-0 items-center justify-end">
<span
className={`text-[11px] tabular-nums ${
overLimit ? "text-red-500" : "text-base-fg/40"
@@ -75,6 +88,11 @@ export const PromptFullscreenModal = ({
{promptLength} / {isFinite(maxLength) ? maxLength : "∞"}
</span>
</div>
{imagePromptRow && <div className="shrink-0">{imagePromptRow}</div>}
<div className="flex shrink-0 items-center justify-between gap-2">
<div className="flex items-center gap-2">{footerControls}</div>
<Button onClick={onClose}>Done</Button>
</div>
</div>
</Modal>
);