Compare commits

...
25 changed files with 755 additions and 56 deletions
@@ -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 };
}
+1 -1
View File
@@ -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) => {