mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-23 01:59:31 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c4541dd0 | ||
|
|
1ca39c1adb | ||
|
|
976642f493 |
@@ -23,6 +23,11 @@ import { MentionTextarea } from "./MentionTextarea";
|
||||
import { getMentionColor, buildMentionColorMap } from "./mention-colors";
|
||||
import type { RefImage, MentionItem } from "./types";
|
||||
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
|
||||
import {
|
||||
PromptFullscreenButton,
|
||||
PromptFullscreenModal,
|
||||
useFullscreenPrompt,
|
||||
} from "./PromptFullscreen";
|
||||
|
||||
// ── Props ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -107,6 +112,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showImagePrompts, setShowImagePrompts] = useState(false);
|
||||
const { isFullscreen, openFullscreen, closeFullscreen } =
|
||||
useFullscreenPrompt();
|
||||
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
|
||||
@@ -386,7 +393,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
mentionItems={mentionItems}
|
||||
placeholder={placeholder}
|
||||
className={twMerge(
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full text-base-fg placeholder-base-fg/60",
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full pr-8 text-base-fg placeholder-base-fg/60",
|
||||
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
|
||||
)}
|
||||
colorMap={mentionColorMap}
|
||||
@@ -411,7 +418,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
ref={highlightRef}
|
||||
aria-hidden
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words text-sm text-base-fg",
|
||||
"pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words pr-8 text-sm text-base-fg",
|
||||
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
|
||||
)}
|
||||
>
|
||||
@@ -425,7 +432,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
className={twMerge(
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full flex-1 resize-y overflow-y-auto bg-transparent text-md text-base-fg placeholder-base-fg/60 focus:outline-none",
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full flex-1 resize-y overflow-y-auto bg-transparent pr-8 text-md text-base-fg placeholder-base-fg/60 focus:outline-none",
|
||||
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
|
||||
hasMentionItems && "text-transparent caret-white",
|
||||
)}
|
||||
@@ -504,6 +511,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PromptFullscreenButton onClick={openFullscreen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -547,6 +555,37 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PromptFullscreenModal isOpen={isFullscreen} onClose={closeFullscreen}>
|
||||
{hasMentionItems && mentionItems ? (
|
||||
<MentionTextarea
|
||||
value={prompt}
|
||||
onChange={onPromptChange}
|
||||
mentionItems={mentionItems}
|
||||
placeholder={placeholder}
|
||||
className="promptbox-scrollbar h-full w-full text-base-fg placeholder-base-fg/60"
|
||||
colorMap={mentionColorMap}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
enterToGenerate &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
placeholder={placeholder}
|
||||
className="promptbox-scrollbar text-md h-full w-full resize-none bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
)}
|
||||
</PromptFullscreenModal>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUpRightAndDownLeftFromCenter } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
// Webapp "focus mode" for the prompt editor. Mirrors the desktop lib's
|
||||
// (@storyteller/ui-promptbox) fullscreen feature but lives here because the
|
||||
// webapp has its own PromptBox. Reuses the shared @storyteller/ui-modal for a
|
||||
// Trello-card-style centered panel + blurred backdrop. The overlay renders a
|
||||
// fresh editor bound to the SAME value + onChange, so text/mentions stay
|
||||
// continuous across open/close.
|
||||
|
||||
// ── Expand button ────────────────────────────────────────────────────────
|
||||
|
||||
interface PromptFullscreenButtonProps {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PromptFullscreenButton = ({
|
||||
onClick,
|
||||
className,
|
||||
}: PromptFullscreenButtonProps) => {
|
||||
// Absolute positioning lives on the outer wrapper, not the button: Tooltip
|
||||
// wraps its child in its own relative div, so positioning the button directly
|
||||
// would anchor it to that wrapper and land it in the wrong spot.
|
||||
return (
|
||||
<div className={`absolute right-0 top-0 z-10 ${className ?? ""}`}>
|
||||
<Tooltip content="Focus mode" position="top" delay={200}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand prompt to focus mode"
|
||||
onClick={onClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-base-fg/5 text-base-fg/50 transition-colors hover:bg-base-fg/10 hover:text-base-fg/90 focus:outline-none"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faUpRightAndDownLeftFromCenter}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Fullscreen modal ─────────────────────────────────────────────────────
|
||||
|
||||
interface PromptFullscreenModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PromptFullscreenModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
}: PromptFullscreenModalProps) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus the editor and drop the caret at the end once the overlay opens.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const id = window.setTimeout(() => focusEditorAtEnd(contentRef.current), 60);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
accessibleTitle="Prompt focus mode"
|
||||
closeOnOutsideClick
|
||||
closeOnEsc
|
||||
className="h-[70vh] 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>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Focuses the first prompt editor inside `container` and places the caret at
|
||||
// the end of its content. Handles both a plain <textarea> and a contentEditable
|
||||
// div (the MentionTextarea), since the overlay can hold either.
|
||||
function focusEditorAtEnd(container: HTMLElement | null): void {
|
||||
if (!container) return;
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
const end = textarea.value.length;
|
||||
textarea.setSelectionRange(end, end);
|
||||
return;
|
||||
}
|
||||
|
||||
const editable = container.querySelector<HTMLElement>(
|
||||
'[contenteditable="true"]',
|
||||
);
|
||||
if (editable) {
|
||||
editable.focus();
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editable);
|
||||
range.collapse(false); // collapse to the end
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
// ── State hook ───────────────────────────────────────────────────────────
|
||||
|
||||
export function useFullscreenPrompt() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const openFullscreen = useCallback(() => setIsFullscreen(true), []);
|
||||
const closeFullscreen = useCallback(() => setIsFullscreen(false), []);
|
||||
return { isFullscreen, openFullscreen, closeFullscreen };
|
||||
}
|
||||
@@ -22,7 +22,7 @@ if (import.meta.env.DEV) {
|
||||
// (disabled — it overrides the origin above and points at localhost:12345,
|
||||
// which breaks dev unless a local backend is running. Re-enable only when
|
||||
// testing against a local storyteller-web.)
|
||||
StorytellerApiHostStore.getInstance().setDevelopment();
|
||||
//StorytellerApiHostStore.getInstance().setDevelopment();
|
||||
} catch (e) {
|
||||
console.warn("Failed to set dev API host override", e);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Stage3D, usePageSceneStore } from "@storyteller/ui-pagescene";
|
||||
import {
|
||||
GalleryModal,
|
||||
GalleryDragComponent,
|
||||
galleryDragHidesImmediately,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { useStage3DCostEstimate } from "../../lib/cost-estimate-api";
|
||||
@@ -122,6 +123,17 @@ function PageSceneEditor() {
|
||||
didAutoCollapseRef.current = true;
|
||||
}, [setOpen]);
|
||||
|
||||
// On the 3D editor the primary drop target is the scene behind the gallery
|
||||
// modal, so dragging an asset should hide the gallery immediately (rather than
|
||||
// only once the cursor leaves the modal). This page mounts/unmounts with the
|
||||
// /edit-3d route, so flip the flag back off on leave.
|
||||
useEffect(() => {
|
||||
galleryDragHidesImmediately.value = true;
|
||||
return () => {
|
||||
galleryDragHidesImmediately.value = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
usePageSceneStore.getState().setCurrentUserToken(user?.user_token);
|
||||
}, [user?.user_token]);
|
||||
|
||||
@@ -50,6 +50,9 @@ async function captureModelThumbnail(modelUrl: string): Promise<string | null> {
|
||||
scene.add(frontLight);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
// Without anonymous CORS the GLTF + textures taint the WebGL canvas, so
|
||||
// toDataURL() below throws a SecurityError and the thumbnail is lost.
|
||||
loader.setCrossOrigin("anonymous");
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
@@ -99,7 +102,17 @@ async function captureModelThumbnail(modelUrl: string): Promise<string | null> {
|
||||
|
||||
// Render and capture
|
||||
renderer.render(scene, camera);
|
||||
const dataUrl = renderer.domElement.toDataURL("image/png");
|
||||
let dataUrl: string | null = null;
|
||||
try {
|
||||
dataUrl = renderer.domElement.toDataURL("image/png");
|
||||
} catch (err) {
|
||||
// A tainted canvas (CORS) throws SecurityError here. Log so the
|
||||
// failure is diagnosable instead of silently producing no thumbnail.
|
||||
console.warn(
|
||||
"[captureModelThumbnail] toDataURL failed (likely CORS taint):",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
renderer.dispose();
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
// The website host mounts the same Stage3D with a web-built adapter
|
||||
// and its own router-flavored equivalents of these effects.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useSignalEffect } from "@preact/signals-react/runtime";
|
||||
import { Stage3D, usePageSceneStore } from "@storyteller/ui-pagescene";
|
||||
import { galleryDragHidesImmediately } from "@storyteller/ui-gallery-modal";
|
||||
import { useTabStore } from "~/pages/Stores/TabState";
|
||||
import { authentication, scene } from "~/signals";
|
||||
import { getCurrentLocationWithoutParams } from "~/utilities";
|
||||
@@ -36,6 +38,17 @@ export const PageScene = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
.setCurrentUserToken(authentication.userInfo.value?.user_token);
|
||||
});
|
||||
|
||||
// On the 3D editor the primary drop target is the scene behind the gallery
|
||||
// modal, so dragging an asset should hide the gallery immediately (rather than
|
||||
// only once the cursor leaves the modal). PageScene mounts/unmounts with the
|
||||
// 3D tab, so flip the flag back off on leave.
|
||||
useEffect(() => {
|
||||
galleryDragHidesImmediately.value = true;
|
||||
return () => {
|
||||
galleryDragHidesImmediately.value = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// URL ↔ loaded scene sync. Lives in the host wrapper so the lib
|
||||
// stays router-agnostic.
|
||||
const params = useParams();
|
||||
@@ -70,6 +83,7 @@ export const PageScene = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
sceneToken={sceneToken}
|
||||
cacheJsonString={cacheJsonString}
|
||||
onSceneSerialized={onSceneSerialized}
|
||||
modelSelectorPlacement="prompt-box"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,12 @@ export interface MediaLinks {
|
||||
* placeholders.
|
||||
*/
|
||||
export interface MediaFileCoverImageDetails {
|
||||
/** Modern CDN links for the cover image; prefer `maybe_links.cdn_url`. */
|
||||
maybe_links?: {
|
||||
cdn_url: string;
|
||||
thumbnail_template?: string | null;
|
||||
} | null;
|
||||
/** @deprecated Points at the bucket; use `maybe_links` instead. */
|
||||
maybe_cover_image_public_bucket_url?: string | null;
|
||||
maybe_cover_image_public_bucket_path?: string | null;
|
||||
default_cover?: {
|
||||
|
||||
@@ -748,6 +748,12 @@ export const GalleryModal = React.memo(
|
||||
const modalIsOpen =
|
||||
isOpen || (mode === "view" && galleryModalVisibleViewMode.value);
|
||||
if (!modalIsOpen) return;
|
||||
// Clear any stale drag-under state from a previous session. A canvas-drop
|
||||
// close (reopen-off) intentionally leaves this true so the panel stays
|
||||
// faded through the close animation; if we didn't reset it on reopen, the
|
||||
// freshly-opened modal would render hidden (contentHidden) and the user
|
||||
// couldn't see it — making the library button appear broken.
|
||||
galleryModalDraggingUnder.value = false;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setUsernameError(false);
|
||||
@@ -793,7 +799,10 @@ export const GalleryModal = React.memo(
|
||||
item.media_class === "video"
|
||||
? item.media_links.maybe_video_previews?.animated
|
||||
: item.media_class === "dimensional"
|
||||
? item.cover_image?.maybe_cover_image_public_bucket_url
|
||||
? // Prefer the modern CDN link; fall back to the deprecated
|
||||
// bucket URL for older responses.
|
||||
(item.cover_image?.maybe_links?.cdn_url ??
|
||||
item.cover_image?.maybe_cover_image_public_bucket_url)
|
||||
: getThumbnailUrl(item.media_links.maybe_thumbnail_template, {
|
||||
width: THUMBNAIL_SIZES.MEDIUM,
|
||||
}),
|
||||
@@ -825,7 +834,9 @@ export const GalleryModal = React.memo(
|
||||
label: getLabel(item),
|
||||
thumbnail:
|
||||
item.media_class === "dimensional"
|
||||
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
|
||||
? (item.cover_image?.maybe_links?.cdn_url ??
|
||||
item.cover_image?.maybe_cover_image_public_bucket_url ??
|
||||
null)
|
||||
: getMediaThumbnail(item.media_links, item.media_class, {
|
||||
size: THUMBNAIL_SIZES.MEDIUM,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GalleryItem } from "./gallery-modal";
|
||||
import {
|
||||
galleryDragHidesImmediately,
|
||||
galleryModalDraggingUnder,
|
||||
galleryModalVisibleViewMode,
|
||||
galleryReopenAfterDragSignal,
|
||||
@@ -13,6 +14,11 @@ interface DragState {
|
||||
startY: number;
|
||||
currX: number;
|
||||
currY: number;
|
||||
// True once the cursor has left the modal bounds during a drag. We only then
|
||||
// make the modal pointer-transparent so the drop can pass through to the
|
||||
// canvas. While the cursor is still over the modal (e.g. aiming at a sidebar
|
||||
// folder), the modal stays fully visible and interactive.
|
||||
modalHidden: boolean;
|
||||
}
|
||||
|
||||
const dragState: DragState = {
|
||||
@@ -23,6 +29,7 @@ const dragState: DragState = {
|
||||
startY: 0,
|
||||
currX: 0,
|
||||
currY: 0,
|
||||
modalHidden: false,
|
||||
};
|
||||
|
||||
const dragThreshold = 5;
|
||||
@@ -121,11 +128,30 @@ function onPointerDown(
|
||||
dragState.currX = event.pageX;
|
||||
dragState.currY = event.pageY;
|
||||
dragState.isDragging = false;
|
||||
dragState.modalHidden = false;
|
||||
document.body.style.cursor = "grabbing";
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a screen point is outside the gallery modal's content bounds. Used to
|
||||
* decide when to make the modal pointer-transparent so a drag can pass under it
|
||||
* onto the canvas. Returns false (treat as inside) when the modal element can't
|
||||
* be found, so we never hide it spuriously.
|
||||
*/
|
||||
function isOutsideModal(clientX: number, clientY: number): boolean {
|
||||
const modalEl = document.querySelector("[data-gallery-modal]");
|
||||
if (!modalEl) return false;
|
||||
const rect = modalEl.getBoundingClientRect();
|
||||
return (
|
||||
clientX < rect.left ||
|
||||
clientX > rect.right ||
|
||||
clientY < rect.top ||
|
||||
clientY > rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder chip under a screen point, found geometrically. We can't use
|
||||
* `elementFromPoint` here: once a drag begins the modal goes pointer-transparent
|
||||
@@ -160,11 +186,13 @@ function onPointerMove(event: PointerEvent) {
|
||||
) {
|
||||
dragState.isDragging = true;
|
||||
createDragPreview(dragState.items.length);
|
||||
// Let pointer events pass through the gallery the moment the drag begins, so
|
||||
// the item can be dropped anywhere — including under the modal. The panel
|
||||
// eases to translucent (reopen on) or all the way out (reopen off) via the
|
||||
// Modal's contentDimmed/contentHidden, so the transition is never abrupt.
|
||||
galleryModalDraggingUnder.value = true;
|
||||
// Hosts with a full-screen canvas behind the modal (e.g. the 3D editor)
|
||||
// hide the gallery the instant the drag starts so the asset can drop
|
||||
// straight into the scene — folders are still hit-tested geometrically.
|
||||
if (galleryDragHidesImmediately.value) {
|
||||
dragState.modalHidden = true;
|
||||
galleryModalDraggingUnder.value = true;
|
||||
}
|
||||
}
|
||||
dragState.currX = event.pageX;
|
||||
dragState.currY = event.pageY;
|
||||
@@ -172,6 +200,17 @@ function onPointerMove(event: PointerEvent) {
|
||||
if (dragState.isDragging) {
|
||||
updateDragPreviewPosition(event.clientX, event.clientY);
|
||||
|
||||
// Otherwise only make the modal pointer-transparent once the cursor leaves
|
||||
// its bounds — that's when the user intends a drop onto the canvas behind
|
||||
// it. While the cursor is still over the modal (e.g. aiming at a sidebar
|
||||
// folder) the modal stays fully visible and interactive so folders can be
|
||||
// targeted. Once hidden it stays hidden for the rest of the drag, so
|
||||
// re-entering the (now transparent) panel area doesn't flicker it back.
|
||||
if (!dragState.modalHidden && isOutsideModal(event.clientX, event.clientY)) {
|
||||
dragState.modalHidden = true;
|
||||
galleryModalDraggingUnder.value = true;
|
||||
}
|
||||
|
||||
// Highlight the folder under the cursor (if any) — folders still accept
|
||||
// drops even while the gallery is dimmed.
|
||||
const overFolder = folderIdAt(event.clientX, event.clientY);
|
||||
@@ -243,10 +282,13 @@ function onPointerUp(event: PointerEvent) {
|
||||
emitFolderDrop(dragState.items, folderId);
|
||||
spawnDropRipple(event.clientX, event.clientY);
|
||||
} else if (
|
||||
dragState.item.mediaClass === "image" ||
|
||||
dragState.item.mediaClass === "dimensional"
|
||||
dragState.modalHidden &&
|
||||
(dragState.item.mediaClass === "image" ||
|
||||
dragState.item.mediaClass === "dimensional")
|
||||
) {
|
||||
// Dropped onto the canvas (anywhere not on a folder) — add to the scene.
|
||||
// The cursor left the modal and dropped onto the canvas (not a folder) —
|
||||
// add to the scene. Gated on `modalHidden` so a missed folder drop *inside*
|
||||
// the modal is a harmless no-op rather than a scene-add that closes it.
|
||||
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
|
||||
spawnDropRipple(event.clientX, event.clientY);
|
||||
// Close the gallery after adding unless the user asked it to stay open.
|
||||
@@ -267,6 +309,7 @@ function onPointerUp(event: PointerEvent) {
|
||||
dragState.item = null;
|
||||
dragState.items = [];
|
||||
dragState.isDragging = false;
|
||||
dragState.modalHidden = false;
|
||||
|
||||
// Refocus the gallery (un-dim, restore pointer events) — unless it's closing,
|
||||
// in which case it stays faded until reopened (reset on open).
|
||||
|
||||
@@ -10,6 +10,13 @@ export const galleryModalVisibleViewMode = signal(false);
|
||||
// on drop.
|
||||
export const galleryModalDraggingUnder = signal(false);
|
||||
|
||||
// When true, the gallery hides the moment a drag starts (instead of only once
|
||||
// the cursor leaves the modal bounds). Set by hosts where the primary drop
|
||||
// target is a full-screen canvas directly behind the modal — e.g. the 3D editor
|
||||
// page — so the user can immediately drop an asset into the scene. Elsewhere
|
||||
// (Library, Draw, Moodboard) it stays false so sidebar folders remain targetable.
|
||||
export const galleryDragHidesImmediately = signal(false);
|
||||
|
||||
// Lightbox Modal Signals
|
||||
export const galleryModalLightboxMediaId = signal<string | null>(null);
|
||||
export const galleryModalLightboxVisible = signal(false);
|
||||
|
||||
@@ -125,7 +125,11 @@ export const MenuIconSelector: React.FC<MenuIconSelectorProps> = ({
|
||||
: item.description;
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.id}
|
||||
// Include activeMenu in the key so the tooltip subtree remounts on
|
||||
// tab change and starts closed. Without this, switching tabs while
|
||||
// hovering leaves the trigger's pointerleave unfired and the tooltip
|
||||
// gets stuck open until the user manually hovers + leaves.
|
||||
key={`${item.id}-${activeMenu}`}
|
||||
content={content}
|
||||
position={position}
|
||||
delay={100}
|
||||
|
||||
@@ -1042,7 +1042,8 @@ export const Modal = ({
|
||||
),
|
||||
transform: to(
|
||||
[styles.transform, dimSpring.factor],
|
||||
(tr, f) => `${tr} scale(${0.93 + 0.07 * (f as number)})`,
|
||||
(tr, f) =>
|
||||
`${tr} scale(${0.93 + 0.07 * (f as number)})`,
|
||||
),
|
||||
transformOrigin: "center center",
|
||||
willChange: "transform, opacity", // Optimize for animations
|
||||
|
||||
@@ -239,6 +239,10 @@ export class MouseControls {
|
||||
|
||||
if (currentObject.userData.isCharacter) {
|
||||
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(true));
|
||||
} else {
|
||||
// Selecting a non-character object must hide the pose controls; otherwise
|
||||
// the floating pose button stays stuck from a prior character selection.
|
||||
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(false));
|
||||
}
|
||||
|
||||
// Normal selection behavior
|
||||
@@ -306,6 +310,10 @@ export class MouseControls {
|
||||
if (this.deps.getPoseMode() === "pose") {
|
||||
this.deps.bus.emit(new PoseModeChangedEvent("select"));
|
||||
}
|
||||
// NB: Do NOT hide the pose controls here. Leaving pose mode ("Done") while
|
||||
// the character is still selected should keep the button visible and just
|
||||
// flip it back to "Enter Pose Mode". It only dismisses on actual
|
||||
// deselection (empty-space / Escape / Delete / selecting a non-character).
|
||||
console.log("FK mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,5 +3,8 @@ export * from "./lib/PromptBox3D";
|
||||
export * from "./lib/PromptBoxEdit";
|
||||
export * from "./lib/PromptBoxVideo";
|
||||
export * from "./lib/PromptBoxImage";
|
||||
export * from "./lib/useAutoGrowEditorHeight";
|
||||
export * from "./lib/PromptFullscreenModal";
|
||||
export * from "./lib/PromptFullscreenButton";
|
||||
export * from "./lib/promptStore";
|
||||
export * from "./lib/PromptBoxErrorBoundary";
|
||||
|
||||
@@ -28,6 +28,11 @@ import { ImagePromptRow, UploadImageFn } from "./ImagePromptRow";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { GenerationCountPicker } from "./common/GenerationCountPicker";
|
||||
import {
|
||||
PromptFullscreenModal,
|
||||
useFullscreenPrompt,
|
||||
} from "./PromptFullscreenModal";
|
||||
import { PromptFullscreenButton } from "./PromptFullscreenButton";
|
||||
import { StoreApi, UseBoundStore } from "zustand";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
|
||||
@@ -78,6 +83,8 @@ export const PromptBox2D = ({
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { isFullscreen, openFullscreen, closeFullscreen } =
|
||||
useFullscreenPrompt();
|
||||
|
||||
// CSS viewport units handle resize reactivity automatically
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
@@ -429,7 +436,7 @@ export const PromptBox2D = ({
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Describe your image..."
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none ${isExpanded ? "max-h-[500px]" : "max-h-[5.5em]"}`}
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-8 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none ${isExpanded ? "max-h-[500px]" : "max-h-[5.5em]"}`}
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
@@ -437,6 +444,7 @@ export const PromptBox2D = ({
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
<PromptFullscreenButton onClick={openFullscreen} />
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${isFinite(maxLen) && prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
|
||||
>
|
||||
@@ -545,6 +553,21 @@ export const PromptBox2D = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PromptFullscreenModal
|
||||
isOpen={isFullscreen}
|
||||
onClose={closeFullscreen}
|
||||
promptLength={prompt.length}
|
||||
maxLength={maxLen}
|
||||
>
|
||||
<textarea
|
||||
placeholder="Describe your image..."
|
||||
className="promptbox-scrollbar text-md h-full w-full resize-none rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</PromptFullscreenModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,6 +49,11 @@ import {
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
import { AspectRatioPicker } from "./common/AspectRatioPicker";
|
||||
import {
|
||||
PromptFullscreenModal,
|
||||
useFullscreenPrompt,
|
||||
} from "./PromptFullscreenModal";
|
||||
import { PromptFullscreenButton } from "./PromptFullscreenButton";
|
||||
|
||||
interface PromptBox3DProps {
|
||||
cameras: Camera[];
|
||||
@@ -136,6 +141,8 @@ export const PromptBox3D = ({
|
||||
const setResolution = usePrompt3DStore((s) => s.setResolution);
|
||||
const [isEnqueueing, setIsEnqueueing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { isFullscreen, openFullscreen, closeFullscreen } =
|
||||
useFullscreenPrompt();
|
||||
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded((prev) => {
|
||||
@@ -684,7 +691,7 @@ export const PromptBox3D = ({
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Describe your image..."
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none ${isExpanded ? "max-h-[300px]" : "max-h-[5.5em]"}`}
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-8 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none ${isExpanded ? "max-h-[300px]" : "max-h-[5.5em]"}`}
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
@@ -698,6 +705,7 @@ export const PromptBox3D = ({
|
||||
setIsPromptBoxFocused(false);
|
||||
}}
|
||||
/>
|
||||
<PromptFullscreenButton onClick={openFullscreen} />
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${isFinite(maxLen) && prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
|
||||
>
|
||||
@@ -885,6 +893,29 @@ export const PromptBox3D = ({
|
||||
setFocalLengthDragging={setFocalLengthDragging}
|
||||
/>
|
||||
</div>
|
||||
<PromptFullscreenModal
|
||||
isOpen={isFullscreen}
|
||||
onClose={closeFullscreen}
|
||||
promptLength={prompt.length}
|
||||
maxLength={maxLen}
|
||||
>
|
||||
<textarea
|
||||
placeholder="Describe your image..."
|
||||
className="promptbox-scrollbar text-md h-full w-full resize-none rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
disableHotkeyInput(DomLevels.INPUT);
|
||||
setIsPromptBoxFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
enableHotkeyInput(DomLevels.INPUT);
|
||||
setIsPromptBoxFocused(false);
|
||||
}}
|
||||
/>
|
||||
</PromptFullscreenModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,11 @@ import { toast } from "@storyteller/ui-toaster";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { AspectRatioPicker } from "./common/AspectRatioPicker";
|
||||
import { GenerationCountPicker } from "./common/GenerationCountPicker";
|
||||
import {
|
||||
PromptFullscreenModal,
|
||||
useFullscreenPrompt,
|
||||
} from "./PromptFullscreenModal";
|
||||
import { PromptFullscreenButton } from "./PromptFullscreenButton";
|
||||
|
||||
export interface PromptBoxEditProps {
|
||||
onModeChange?: (mode: string) => void;
|
||||
@@ -83,6 +88,8 @@ export const PromptBoxEdit = ({
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { isFullscreen, openFullscreen, closeFullscreen } =
|
||||
useFullscreenPrompt();
|
||||
|
||||
// CSS viewport units handle resize reactivity automatically
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
@@ -430,7 +437,7 @@ export const PromptBoxEdit = ({
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Write what you want to change in your image and click generate..."
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none ${isExpanded ? "max-h-[500px]" : "max-h-[5.5em]"}`}
|
||||
className={`promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-8 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none ${isExpanded ? "max-h-[500px]" : "max-h-[5.5em]"}`}
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
@@ -438,6 +445,7 @@ export const PromptBoxEdit = ({
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<PromptFullscreenButton onClick={openFullscreen} />
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${isFinite(maxLen) && prompt.length > maxLen ? "text-red-500" : "text-white/40"}`}
|
||||
>
|
||||
@@ -555,6 +563,21 @@ export const PromptBoxEdit = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PromptFullscreenModal
|
||||
isOpen={isFullscreen}
|
||||
onClose={closeFullscreen}
|
||||
promptLength={prompt.length}
|
||||
maxLength={maxLen}
|
||||
>
|
||||
<textarea
|
||||
placeholder="Write what you want to change in your image and click generate..."
|
||||
className="promptbox-scrollbar text-md h-full w-full resize-none rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</PromptFullscreenModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
RefImage,
|
||||
useEnterToGenerateStore,
|
||||
} from "./promptStore";
|
||||
import { useAutoGrowEditorHeight } from "./useAutoGrowEditorHeight";
|
||||
import { PromptFullscreenModal, useFullscreenPrompt } from "./PromptFullscreenModal";
|
||||
import { PromptFullscreenButton } from "./PromptFullscreenButton";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
@@ -101,20 +104,16 @@ export const PromptBoxImage = ({
|
||||
const setGenerationCount = usePromptImageStore((s) => s.setGenerationCount);
|
||||
const [isEnqueueing, setIsEnqueueing] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// CSS viewport units handle resize reactivity automatically
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = next ? EXPANDED_HEIGHT : "auto";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Collapsed auto-grow + manual expand, position-aware and recomputed only on
|
||||
// toggle / content change / viewport resize (not every render).
|
||||
const { isExpanded, toggleExpand } = useAutoGrowEditorHeight(
|
||||
textareaRef,
|
||||
prompt,
|
||||
);
|
||||
const { isFullscreen, openFullscreen, closeFullscreen } =
|
||||
useFullscreenPrompt();
|
||||
|
||||
const referenceImages = usePromptImageStore((s) => s.referenceImages);
|
||||
const setReferenceImages = usePromptImageStore((s) => s.setReferenceImages);
|
||||
@@ -176,22 +175,6 @@ export const PromptBoxImage = ({
|
||||
},
|
||||
]);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
// Hard pixel limit so the resize handle can never exceed viewport
|
||||
const maxH = isExpanded ? 500 : Math.min(window.innerHeight - 700, 500);
|
||||
textareaRef.current.style.maxHeight = `${Math.max(maxH, 88)}px`;
|
||||
textareaRef.current.style.minHeight = "0";
|
||||
if (!isExpanded) {
|
||||
// Cap auto-grow at ~5.5em so it doesn't fight with manual resize
|
||||
const capped = Math.min(textareaRef.current.scrollHeight, 88);
|
||||
textareaRef.current.style.minHeight = `${capped}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (imageMediaId && url) {
|
||||
const referenceImage: RefImage = {
|
||||
@@ -467,7 +450,7 @@ export const PromptBoxImage = ({
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Describe what you want in the image..."
|
||||
className="promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
className="promptbox-scrollbar text-md mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-8 pt-1 text-base-fg placeholder-base-fg/60 transition-[height] duration-200 ease-out focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
@@ -475,6 +458,7 @@ export const PromptBoxImage = ({
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<PromptFullscreenButton onClick={openFullscreen} />
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${isFinite(maxLen) && prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
|
||||
>
|
||||
@@ -585,6 +569,21 @@ export const PromptBoxImage = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PromptFullscreenModal
|
||||
isOpen={isFullscreen}
|
||||
onClose={closeFullscreen}
|
||||
promptLength={prompt.length}
|
||||
maxLength={maxLen}
|
||||
>
|
||||
<textarea
|
||||
placeholder="Describe what you want in the image..."
|
||||
className="promptbox-scrollbar text-md h-full w-full resize-none rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</PromptFullscreenModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,6 +43,11 @@ import { CharactersModal } from "./CharactersModal";
|
||||
import { CharactersApi } from "@storyteller/api";
|
||||
import { MentionTextarea } from "./MentionTextarea";
|
||||
import type { MentionItem } from "./MentionTextarea";
|
||||
import {
|
||||
PromptFullscreenModal,
|
||||
useFullscreenPrompt,
|
||||
} from "./PromptFullscreenModal";
|
||||
import { PromptFullscreenButton } from "./PromptFullscreenButton";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -165,6 +170,8 @@ export const PromptBoxVideo = ({
|
||||
const [isEnqueueing, setIsEnqueueing] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { isFullscreen, openFullscreen, closeFullscreen } =
|
||||
useFullscreenPrompt();
|
||||
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
|
||||
|
||||
// Characters store for @-mentions
|
||||
@@ -1048,7 +1055,7 @@ export const PromptBoxVideo = ({
|
||||
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
|
||||
: "Describe what you want to happen in the video..."
|
||||
}
|
||||
className="promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg"
|
||||
className="promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-8 pt-1 text-base-fg"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
const isSubmitCombo = enterToGenerate && !e.shiftKey;
|
||||
@@ -1071,7 +1078,7 @@ export const PromptBoxVideo = ({
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Describe what you want to happen in the video..."
|
||||
className="promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
className="promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-8 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
@@ -1080,6 +1087,7 @@ export const PromptBoxVideo = ({
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
)}
|
||||
<PromptFullscreenButton onClick={openFullscreen} />
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${isFinite(maxLen) && prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
|
||||
>
|
||||
@@ -1323,6 +1331,36 @@ export const PromptBoxVideo = ({
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
forceFilter="image"
|
||||
/>
|
||||
<PromptFullscreenModal
|
||||
isOpen={isFullscreen}
|
||||
onClose={closeFullscreen}
|
||||
promptLength={prompt.length}
|
||||
maxLength={maxLen}
|
||||
>
|
||||
{hasAnyMentionables ? (
|
||||
<MentionTextarea
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
mentionItems={allMentionItems}
|
||||
colorMap={mentionColorMap}
|
||||
placeholder={
|
||||
isReferenceMode
|
||||
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
|
||||
: "Describe what you want to happen in the video..."
|
||||
}
|
||||
className="promptbox-scrollbar text-md h-full w-full resize-none overflow-y-auto rounded bg-transparent text-base-fg"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
placeholder="Describe what you want to happen in the video..."
|
||||
className="promptbox-scrollbar text-md h-full w-full resize-none rounded bg-transparent text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
)}
|
||||
</PromptFullscreenModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUpRightAndDownLeftFromCenter } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
// Circular "expand to focus mode" icon, anchored top-right of a prompt editor.
|
||||
// Shared so all promptbox variants render an identical control.
|
||||
//
|
||||
// The absolute positioning lives on an OUTER wrapper (not the button) because
|
||||
// Tooltip wraps its child in its own `position: relative` div — putting the
|
||||
// absolute styles on the button would anchor it to that inline wrapper instead
|
||||
// of the editor, landing it in the wrong spot.
|
||||
|
||||
interface PromptFullscreenButtonProps {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PromptFullscreenButton = ({
|
||||
onClick,
|
||||
className,
|
||||
}: PromptFullscreenButtonProps) => {
|
||||
return (
|
||||
<div className={`absolute right-0 top-0 z-10 ${className ?? ""}`}>
|
||||
<Tooltip content="Focus mode" position="left">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand prompt to focus mode"
|
||||
onClick={onClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-base-fg/5 text-base-fg/50 transition-colors hover:bg-base-fg/10 hover:text-base-fg/90 focus:outline-none"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faUpRightAndDownLeftFromCenter}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { focusEditorAtEnd } from "./focusEditorAtEnd";
|
||||
|
||||
// Shared "focus mode" overlay for prompt editors. Reuses @storyteller/ui-modal
|
||||
// (Trello-card style: centered panel + blurred backdrop) and renders whatever
|
||||
// editor the caller passes — a fresh textarea / MentionTextarea instance bound
|
||||
// to the SAME store value + onChange, so text and mentions stay continuous
|
||||
// across open/close (only the caret resets, as expected for a new instance).
|
||||
|
||||
interface PromptFullscreenModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Current prompt length, for the character counter. */
|
||||
promptLength: number;
|
||||
/** Max prompt length (Infinity for unlimited). */
|
||||
maxLength: number;
|
||||
/**
|
||||
* The editor to render inside the overlay. Pass a fresh textarea /
|
||||
* MentionTextarea bound to the same value + onChange as the inline one.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PromptFullscreenModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
promptLength,
|
||||
maxLength,
|
||||
children,
|
||||
}: PromptFullscreenModalProps) => {
|
||||
const overLimit = isFinite(maxLength) && promptLength > maxLength;
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus the editor and drop the caret at the end once the overlay opens.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const id = window.setTimeout(() => focusEditorAtEnd(contentRef.current), 60);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
accessibleTitle="Prompt focus mode"
|
||||
closeOnOutsideClick
|
||||
closeOnEsc
|
||||
className="h-[70vh] w-full max-w-4xl"
|
||||
backdropClassName="backdrop-blur-md"
|
||||
>
|
||||
{/* Title lives inside the flex column (not the Modal title slot) so the
|
||||
editor + counter fit the fixed panel height cleanly. No overflow-hidden
|
||||
here — the mention autocomplete dropdown renders outside the editor
|
||||
bounds and must not be clipped. */}
|
||||
<div 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 ref={contentRef} className="flex min-h-0 flex-1 flex-col">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<span
|
||||
className={`text-[11px] tabular-nums ${
|
||||
overLimit ? "text-red-500" : "text-base-fg/40"
|
||||
}`}
|
||||
>
|
||||
{promptLength} / {isFinite(maxLength) ? maxLength : "∞"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
/** Holds open/close state for a prompt's fullscreen focus mode. */
|
||||
export function useFullscreenPrompt() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const openFullscreen = useCallback(() => setIsFullscreen(true), []);
|
||||
const closeFullscreen = useCallback(() => setIsFullscreen(false), []);
|
||||
return { isFullscreen, openFullscreen, closeFullscreen };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Focuses the first prompt editor inside `container` and places the caret at
|
||||
// the end of its content. Handles both a plain <textarea> and a contentEditable
|
||||
// div (the MentionTextarea), since the fullscreen overlay can hold either.
|
||||
|
||||
export function focusEditorAtEnd(container: HTMLElement | null): void {
|
||||
if (!container) return;
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
const end = textarea.value.length;
|
||||
textarea.setSelectionRange(end, end);
|
||||
return;
|
||||
}
|
||||
|
||||
const editable = container.querySelector<HTMLElement>(
|
||||
'[contenteditable="true"]',
|
||||
);
|
||||
if (editable) {
|
||||
editable.focus();
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editable);
|
||||
range.collapse(false); // collapse to the end
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// Reserves room for the inline action-buttons row plus the fixed
|
||||
// Model / Costs / Help row at the bottom of the page.
|
||||
const BOTTOM_SAFE_AREA_PX = 160;
|
||||
|
||||
// Collapsed auto-grow cap (~5.5em) so a long prompt doesn't fight manual resize.
|
||||
const COLLAPSED_MAX_PX = 88;
|
||||
|
||||
// Absolute ceiling shared by both collapsed and expanded states.
|
||||
const HARD_MAX_PX = 500;
|
||||
|
||||
// Viewport-relative expansion ceiling. Mirrors the historical
|
||||
// `clamp(120px, calc(100vh - 700px), 500px)` so the editor stretches the same
|
||||
// amount regardless of where the (bottom-fixed) prompt box sits on screen.
|
||||
export const EXPANDED_HEIGHT_CSS = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
|
||||
interface AutoGrowEditorHeight {
|
||||
isExpanded: boolean;
|
||||
toggleExpand: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a prompt editor's collapsed auto-grow + manual expand behavior.
|
||||
*
|
||||
* - Collapsed: caps height to the editor's actual position so a long prompt
|
||||
* never pushes the box under the fixed bottom action row.
|
||||
* - Expanded: stretches to a viewport-relative ceiling.
|
||||
*
|
||||
* Heights are recomputed on expand toggle, on content change (`contentKey`),
|
||||
* and on viewport changes (window resize / fullscreen / monitor move) — not on
|
||||
* every render, so it stays out of React's way and avoids layout thrash.
|
||||
*
|
||||
* @param editorRef ref to the textarea (or contenteditable) element being sized
|
||||
* @param contentKey a value that changes when the editor content changes
|
||||
* (e.g. the prompt string) so auto-grow re-runs on input
|
||||
*/
|
||||
export function useAutoGrowEditorHeight(
|
||||
editorRef: RefObject<HTMLElement | null>,
|
||||
contentKey: unknown,
|
||||
): AutoGrowEditorHeight {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const computeExpandedMaxPx = (): number =>
|
||||
Math.max(120, Math.min(window.innerHeight - 700, HARD_MAX_PX));
|
||||
|
||||
const computeCollapsedMaxPx = (el: HTMLElement): number => {
|
||||
const topFromViewport = el.getBoundingClientRect().top;
|
||||
return Math.max(
|
||||
COLLAPSED_MAX_PX,
|
||||
Math.min(
|
||||
HARD_MAX_PX,
|
||||
Math.floor(window.innerHeight - topFromViewport - BOTTOM_SAFE_AREA_PX),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const applyHeights = useCallback(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const maxH = isExpanded ? computeExpandedMaxPx() : computeCollapsedMaxPx(el);
|
||||
el.style.maxHeight = `${maxH}px`;
|
||||
el.style.minHeight = "0";
|
||||
if (!isExpanded) {
|
||||
const capped = Math.min(el.scrollHeight, COLLAPSED_MAX_PX);
|
||||
el.style.minHeight = `${capped}px`;
|
||||
}
|
||||
}, [editorRef, isExpanded]);
|
||||
|
||||
const toggleExpand = useCallback(() => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
const el = editorRef.current;
|
||||
if (el) {
|
||||
el.style.height = next ? EXPANDED_HEIGHT_CSS : "auto";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [editorRef]);
|
||||
|
||||
// Recompute on expand toggle and whenever the content changes (input/paste).
|
||||
useEffect(() => {
|
||||
applyHeights();
|
||||
}, [applyHeights, contentKey]);
|
||||
|
||||
// Re-apply on viewport changes. Held in a ref so the once-installed listener
|
||||
// always invokes the latest closure. Fires immediately + after a short settle
|
||||
// so getBoundingClientRect reads the final position, not a mid-tween one.
|
||||
const applyHeightsRef = useRef(applyHeights);
|
||||
applyHeightsRef.current = applyHeights;
|
||||
useEffect(() => {
|
||||
let settledId: number | undefined;
|
||||
const onResize = () => {
|
||||
applyHeightsRef.current();
|
||||
if (settledId !== undefined) window.clearTimeout(settledId);
|
||||
settledId = window.setTimeout(() => applyHeightsRef.current(), 100);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
if (settledId !== undefined) window.clearTimeout(settledId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isExpanded, toggleExpand };
|
||||
}
|
||||
@@ -263,23 +263,52 @@ export const Tooltip = ({
|
||||
onOpenChange?.(isShowing);
|
||||
}, [isShowing, onOpenChange]);
|
||||
|
||||
// Force-dismiss when the trigger may have moved out from under the cursor
|
||||
// without firing a pointerleave: window/focus loss (e.g. a tab/route change
|
||||
// that re-renders the element under the pointer) and component unmount. Without
|
||||
// this the tooltip stays stuck open until the user manually hovers + leaves.
|
||||
useEffect(() => {
|
||||
const forceClose = () => {
|
||||
setIsHoveringTrigger(false);
|
||||
setIsHoveringTooltip(false);
|
||||
setIsShowing(false);
|
||||
};
|
||||
window.addEventListener("blur", forceClose);
|
||||
document.addEventListener("visibilitychange", forceClose);
|
||||
return () => {
|
||||
window.removeEventListener("blur", forceClose);
|
||||
document.removeEventListener("visibilitychange", forceClose);
|
||||
forceClose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isFixed = strategy === "fixed";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onMouseEnter={() => {
|
||||
onPointerEnter={() => {
|
||||
setIsHoveringTrigger(true);
|
||||
if (!checkForOpenPopovers() && !disabled) {
|
||||
setIsShowing(true);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onPointerLeave={() => {
|
||||
setIsHoveringTrigger(false);
|
||||
if (!interactive) {
|
||||
setIsShowing(false);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setIsHoveringTrigger(false);
|
||||
setIsHoveringTooltip(false);
|
||||
setIsShowing(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsHoveringTrigger(false);
|
||||
setIsHoveringTooltip(false);
|
||||
setIsShowing(false);
|
||||
}}
|
||||
onClick={handleClick}
|
||||
className="relative"
|
||||
>
|
||||
@@ -298,8 +327,8 @@ export const Tooltip = ({
|
||||
>
|
||||
<div
|
||||
ref={setTooltipRef}
|
||||
onMouseEnter={() => interactive && setIsHoveringTooltip(true)}
|
||||
onMouseLeave={() => interactive && setIsHoveringTooltip(false)}
|
||||
onPointerEnter={() => interactive && setIsHoveringTooltip(true)}
|
||||
onPointerLeave={() => interactive && setIsHoveringTooltip(false)}
|
||||
onClick={() => {
|
||||
if (closeOnClick) {
|
||||
setIsShowing(false);
|
||||
|
||||
@@ -305,6 +305,9 @@ export function Viewer3D({
|
||||
});
|
||||
} else {
|
||||
const loader = new GLTFLoader();
|
||||
// Anonymous CORS keeps the WebGL canvas untainted so thumbnail capture
|
||||
// (offscreen toDataURL elsewhere) works on models loaded from the CDN.
|
||||
loader.setCrossOrigin("anonymous");
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
|
||||
Reference in New Issue
Block a user