styling/moodboard page (#1407)

## Moodboard polish

A pass to make the Moodboard feel consistent with the rest of the app.

### What's new
- **Floating toolbar** shared between the 3D Editor and Moodboard -
centered and no longer glued to the top edge.
- **Moodboard toolbar** rebuilt to match: mode picker (Select / Lasso /
Text), a primary `+` dropdown for **Upload Image** and **Pick from
Library**, plus Group, Fit-to-grid, Pack, Auto-group, Undo / Redo,
Delete.
- **Empty state CTA** in the center of a blank moodboard with the same
Select Image / Pick from Library flow PageDraw uses.
- **Theme support** across the moodboard - background, grid dots,
toolbar, and empty-state card all adapt to light / gray / black / aurora
/ sunset themes.
- **Platform-aware shortcut hints** in toolbar tooltips - `⌘G` on Mac,
`Ctrl+G` on Windows / Linux.
- **Topbar titles** added for Moodboard and Storyboard tabs.
This commit is contained in:
BFlat authored and GitHub committed 2026-04-22 18:12:04 +07:00
1 parent 5fe4137634
commit acbd86a4af
14 files changed
+590 -237

No files matched your search

@@ -0,0 +1,66 @@
import { ReactNode } from "react";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { twMerge } from "tailwind-merge";
interface FloatingToolbarProps {
children: ReactNode;
className?: string;
}
export const FloatingToolbar = ({
children,
className,
}: FloatingToolbarProps) => (
<div className="pointer-events-none flex w-full justify-center pt-3">
<div
className={twMerge(
"glass pointer-events-auto rounded-xl p-1.5 text-base-fg shadow-md",
className,
)}
>
<div className="flex items-center justify-center gap-2">{children}</div>
</div>
</div>
);
export const FloatingToolbarDivider = () => (
<span className="px-1 text-base text-base-fg/20" aria-hidden>
|
</span>
);
interface FloatingToolbarButtonProps {
icon: IconDefinition;
label: string;
active?: boolean;
disabled?: boolean;
onClick: () => void;
tooltipDelay?: number;
}
export const FloatingToolbarButton = ({
icon,
label,
active,
disabled,
onClick,
tooltipDelay = 300,
}: FloatingToolbarButtonProps) => (
<Tooltip content={label} position="bottom" delay={tooltipDelay} closeOnClick>
<Button
variant="ghost"
icon={icon}
aria-label={label}
onClick={onClick}
disabled={disabled}
className={twMerge(
"h-9 w-9 rounded-[10px] border-transparent p-0 shadow-none",
active
? "bg-primary/30 text-base-fg hover:bg-primary/40"
: "bg-transparent text-base-fg/80 hover:bg-base-fg/10 hover:text-base-fg",
)}
/>
</Tooltip>
);
@@ -0,0 +1 @@
export * from "./FloatingToolbar";
@@ -1,3 +1,4 @@
export * from "./FloatingToolbar";
export * from "./UploadModal3D";
export * from "./UploadModalMedia";
export * from "./UploadModalSplat";
@@ -502,6 +502,10 @@ export const TopBar = ({ pageName }: Props) => {
return "Image to 3D World";
case "APPS":
return "ArtCraft Apps";
case "STORYBOARD":
return "Storyboard";
case "MOODBOARD":
return "Moodboard";
default:
return "Artcraft";
}
@@ -37,6 +37,10 @@ import {
import { twMerge } from "tailwind-merge";
import { UploadModalImage } from "../../../../components/reusable/UploadModalImage";
import { UploadModalSplat } from "~/components/reusable/UploadModalSplat";
import {
FloatingToolbar,
FloatingToolbarDivider,
} from "~/components/reusable/FloatingToolbar";
export const Controls3D = () => {
useSignals();
@@ -205,151 +209,140 @@ export const Controls3D = () => {
return (
<>
<div className="flex justify-center">
<div className="glass rounded-b-xl p-1.5 pr-2 text-white shadow-md">
<div className="flex items-center justify-center gap-2.5">
<div className="flex items-center gap-1.5">
<div className="relative">
{showEmptySceneTooltip && (
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 transform whitespace-nowrap">
<div className="animate-bounce rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white shadow-lg">
Click + to add your first 3D asset!
<div className="absolute -top-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 transform bg-primary" />
</div>
</div>
)}
<Tooltip
content="Add an asset to scene"
position="bottom"
delay={300}
closeOnClick
className={twMerge(
showEmptySceneTooltip ? "hidden" : "block",
)}
>
<PopoverMenu
mode="button"
position="bottom"
panelTitle="Add an asset to scene"
onOpenChange={setIsAddAssetPopoverOpen}
items={[
{
label: "ArtCraft Presets (B)",
selected: false,
icon: (
<FontAwesomeIcon icon={faCube} className="h-4 w-4" />
),
action: "presets",
},
{
label: "My Library",
selected: false,
icon: (
<FontAwesomeIcon
icon={faImages}
className="h-4 w-4"
/>
),
action: "library",
divider: true,
},
{
label: "Upload 3D Model",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-3d",
},
{
label: "Upload Image",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-image",
},
{
label: "Upload Splat",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-splat",
},
]}
onPanelAction={handleAddAssetAction}
showIconsInList
buttonClassName={`h-9 w-9 rounded-[10px] text-lg ${showEmptySceneTooltip
? "bg-primary/90 hover:bg-primary/70"
: "border-transparent bg-primary/90 hover:bg-primary/70"
}`}
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
</Tooltip>
<FloatingToolbar className="pr-2">
<div className="flex items-center gap-1.5">
<div className="relative">
{showEmptySceneTooltip && (
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 transform whitespace-nowrap">
<div className="animate-bounce rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white shadow-lg">
Click + to add your first 3D asset!
<div className="absolute -top-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 transform bg-primary" />
</div>
</div>
<Tooltip
content="Create 3D model from image"
position="bottom"
delay={300}
closeOnClick
>
<Button
icon={faMagicWandSparkles}
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
variant="secondary"
onClick={handleOpenCreate3dModal}
/>
</Tooltip>
</div>
<span className="opacity-20">|</span>
<ButtonIconSelect
options={modes}
onOptionChange={handleModeChange}
selectedOption={selectedMode.value}
/>
{selectedMode.value === "scale" ? (
<Tooltip
content="Scale is always in local space"
position="bottom"
delay={300}
>
<button
disabled
className="h-9 rounded-[10px] px-2.5 text-[10px] font-semibold font-mono bg-white/15 uppercase tracking-wide opacity-40 cursor-not-allowed"
>
Local
</button>
</Tooltip>
) : (
<Tooltip
content={`Transform space: ${transformSpace.value} (X to toggle)`}
position="bottom"
delay={300}
>
<button
className="h-9 rounded-[10px] px-2.5 text-[10px] font-semibold font-mono bg-white/15 hover:bg-white/25 transition-colors uppercase tracking-wide"
onClick={() => editorEngine?.toggleTransformSpace()}
>
{transformSpace.value === "world" ? "World" : "Local"}
</button>
</Tooltip>
)}
<Tooltip
content="Add an asset to scene"
position="bottom"
delay={300}
closeOnClick
className={twMerge(showEmptySceneTooltip ? "hidden" : "block")}
>
<PopoverMenu
mode="button"
position="bottom"
panelTitle="Add an asset to scene"
onOpenChange={setIsAddAssetPopoverOpen}
items={[
{
label: "ArtCraft Presets (B)",
selected: false,
icon: (
<FontAwesomeIcon icon={faCube} className="h-4 w-4" />
),
action: "presets",
},
{
label: "My Library",
selected: false,
icon: (
<FontAwesomeIcon icon={faImages} className="h-4 w-4" />
),
action: "library",
divider: true,
},
{
label: "Upload 3D Model",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-3d",
},
{
label: "Upload Image",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-image",
},
{
label: "Upload Splat",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-splat",
},
]}
onPanelAction={handleAddAssetAction}
showIconsInList
buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-primary/90 text-lg hover:bg-primary/70"
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
</Tooltip>
</div>
<Tooltip
content="Create 3D model from image"
position="bottom"
delay={300}
closeOnClick
>
<Button
icon={faMagicWandSparkles}
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
variant="secondary"
onClick={handleOpenCreate3dModal}
/>
</Tooltip>
</div>
</div>
<FloatingToolbarDivider />
<ButtonIconSelect
options={modes}
onOptionChange={handleModeChange}
selectedOption={selectedMode.value}
/>
{selectedMode.value === "scale" ? (
<Tooltip
content="Scale is always in local space"
position="bottom"
delay={300}
>
<button
disabled
className="h-9 cursor-not-allowed rounded-[10px] bg-white/15 px-2.5 font-mono text-[10px] font-semibold uppercase tracking-wide opacity-40"
>
Local
</button>
</Tooltip>
) : (
<Tooltip
content={`Transform space: ${transformSpace.value} (X to toggle)`}
position="bottom"
delay={300}
>
<button
className="h-9 rounded-[10px] bg-white/15 px-2.5 font-mono text-[10px] font-semibold uppercase tracking-wide transition-colors hover:bg-white/25"
onClick={() => editorEngine?.toggleTransformSpace()}
>
{transformSpace.value === "world" ? "World" : "Local"}
</button>
</Tooltip>
)}
</FloatingToolbar>
<AssetModal />
@@ -0,0 +1,47 @@
import { faUpload, faImages } from "@fortawesome/pro-solid-svg-icons";
import { faThumbtack } from "@fortawesome/pro-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
interface Props {
onUploadClick: () => void;
onGalleryClick: () => void;
}
export const EmptyMoodboardCTA = ({ onUploadClick, onGalleryClick }: Props) => (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-8">
<div className="glass pointer-events-auto flex w-full max-w-md flex-col items-center gap-5 rounded-2xl p-8 text-center shadow-xl">
<FontAwesomeIcon
icon={faThumbtack}
className="-rotate-12 text-4xl text-base-fg/60"
/>
<div className="space-y-1.5">
<h3 className="text-xl font-semibold tracking-tight text-base-fg">
Start your moodboard
</h3>
<p className="mx-auto max-w-xs text-sm leading-relaxed text-base-fg/60">
Drop, paste, or pick images from your library to start collecting
inspiration.
</p>
</div>
<div className="flex flex-wrap justify-center gap-3">
<Button
variant="primary"
icon={faUpload}
onClick={onUploadClick}
className="px-5 py-2.5 text-sm font-semibold shadow-lg"
>
Select Image
</Button>
<Button
variant="action"
icon={faImages}
onClick={onGalleryClick}
className="border-2 px-5 py-2.5 text-sm font-semibold"
>
Pick from Library
</Button>
</div>
</div>
</div>
);
@@ -9,8 +9,10 @@ import { usePasteHandler } from "./interactions/usePasteHandler";
import { useGalleryDropEvent } from "./interactions/useGalleryDropEvent";
import { useKeyboardShortcuts } from "./interactions/useKeyboardShortcuts";
import { useShortcutCheatsheet } from "./interactions/useShortcutCheatsheet";
import { useMoodboardImageEntry } from "./useMoodboardImageEntry";
import { RecenterIndicator } from "./overlays/RecenterIndicator";
import { ShortcutCheatsheet } from "./overlays/ShortcutCheatsheet";
// import { EmptyMoodboardCTA } from "./EmptyMoodboardCTA";
export const Moodboard = () => {
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -18,12 +20,15 @@ export const Moodboard = () => {
const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
const setSelection = useMoodboardStore((s) => s.setSelection);
const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
// const isEmpty = useMoodboardStore((s) => s.rootOrder.length === 0);
useUndoRedo(true);
usePasteHandler(true, stageRef);
useGalleryDropEvent(true, stageRef);
useKeyboardShortcuts(true);
const cheatsheetVisible = useShortcutCheatsheet();
const { triggerUpload, triggerGallery, modals } =
useMoodboardImageEntry(stageRef);
// Delete / Backspace removes the current selection. Skip when typing in
// an input or while a text node is in edit mode.
@@ -45,14 +50,27 @@ export const Moodboard = () => {
}, [deleteSelected, setSelection, editingTextId]);
return (
<div className="flex h-[calc(100vh-56px)] w-screen flex-col bg-[#0b0b0e]">
<MoodboardToolbar />
<div ref={containerRef} className="relative flex-1 overflow-hidden">
<MoodboardStage containerRef={containerRef} stageRef={stageRef} />
<TextEditOverlay containerRef={containerRef} />
<RecenterIndicator />
<ShortcutCheatsheet visible={cheatsheetVisible} />
<div
ref={containerRef}
className="relative h-[calc(100vh-56px)] w-screen overflow-hidden bg-ui-panel"
>
<MoodboardStage containerRef={containerRef} stageRef={stageRef} />
<TextEditOverlay containerRef={containerRef} />
<RecenterIndicator />
<ShortcutCheatsheet visible={cheatsheetVisible} />
{/* {isEmpty && (
<EmptyMoodboardCTA
onUploadClick={triggerUpload}
onGalleryClick={triggerGallery}
/>
)} */}
<div className="absolute left-0 right-0 top-0 z-10">
<MoodboardToolbar
onUploadClick={triggerUpload}
onGalleryClick={triggerGallery}
/>
</div>
{modals}
</div>
);
};
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo } from "react";
import Konva from "konva";
import { Stage, Layer, Rect, Line, Group, Transformer } from "react-konva";
import { useThemeFgRgb } from "./useThemeFgRgb";
import { useShallow } from "zustand/react/shallow";
import { useMoodboardStore } from "./MoodboardStore";
import { MoodboardBackground } from "./MoodboardBackground";
@@ -44,6 +45,7 @@ export const MoodboardStage = ({ containerRef, stageRef }: Props) => {
const zoom = viewport.zoom;
const pan = viewport.pan;
const fgRgb = useThemeFgRgb();
// Track wrapper size with ResizeObserver so the Stage fills its container.
// The size lives in the store so overlays (recenter indicator, future
@@ -135,17 +137,11 @@ export const MoodboardStage = ({ containerRef, stageRef }: Props) => {
style={{ cursor: tool === "text" ? "text" : "default" }}
>
<Layer listening={false}>
<Rect
x={visibleViewport.x}
y={visibleViewport.y}
width={visibleViewport.width}
height={visibleViewport.height}
fill="#0f0f12"
/>
<MoodboardBackground
viewport={visibleViewport}
spacing={gridSpacing}
zoom={zoom}
dotColor={`rgb(${fgRgb} / 0.10)`}
/>
</Layer>
<Layer>
@@ -1,8 +1,6 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faMousePointer,
faDrawPolygon,
faTextHeight,
faObjectGroup,
faObjectUngroup,
faGripVertical,
@@ -11,22 +9,44 @@ import {
faRotateLeft,
faRotateRight,
faTrash,
faPlus,
faImages,
faArrowUpFromBracket,
faText,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
import { PopoverMenu } from "@storyteller/ui-popover";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
FloatingToolbar,
FloatingToolbarButton,
FloatingToolbarDivider,
} from "~/components/reusable/FloatingToolbar";
import { useMoodboardStore } from "./MoodboardStore";
import { Tool } from "./types";
import { MOD, fmtShortcut } from "./overlays/shortcuts";
import { computeFitToGridPatches } from "./layout/fitToGrid";
import { computeAABB } from "./layout/geometry";
import { computePackPatches } from "./layout/packCollage";
import { clusterByProximity } from "./layout/clusterProximity";
const TOOLS: Array<{ id: Tool; icon: typeof faMousePointer; label: string }> = [
{ id: "select", icon: faMousePointer, label: "Select" },
{ id: "lasso", icon: faDrawPolygon, label: "Lasso" },
{ id: "text", icon: faTextHeight, label: "Text" },
const MODES: Array<{
value: Tool;
icon: typeof faMousePointer;
tooltip: string;
}> = [
{ value: "select", icon: faMousePointer, tooltip: "Select (V)" },
{ value: "lasso", icon: faDrawPolygon, tooltip: "Lasso (L)" },
{ value: "text", icon: faText, tooltip: "Text (T)" },
];
export const MoodboardToolbar = () => {
interface Props {
onUploadClick: () => void;
onGalleryClick: () => void;
}
export const MoodboardToolbar = ({ onUploadClick, onGalleryClick }: Props) => {
const tool = useMoodboardStore((s) => s.tool);
const setTool = useMoodboardStore((s) => s.setTool);
const group = useMoodboardStore((s) => s.group);
@@ -50,8 +70,7 @@ export const MoodboardToolbar = () => {
};
const handlePackCollage = () => {
const ids =
selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
const targetNodes = ids
.map((id) => nodes[id])
.filter((n) => n && n.parentId === null);
@@ -62,101 +81,128 @@ export const MoodboardToolbar = () => {
};
const handleAutoGroup = () => {
const ids =
selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
const targetNodes = ids
.map((id) => nodes[id])
.filter((n) => n && n.parentId === null);
if (targetNodes.length < 2) return;
const clusters = clusterByProximity(targetNodes);
const clusterIds = clusters.map((c) => c.map((n) => n.id));
// One atomic store mutation: single history entry, selection ends on
// the freshly-created groups so the user immediately sees their effect.
groupClusters(clusterIds);
};
const handleAddAction = (action: string) => {
if (action === "upload") onUploadClick();
else if (action === "library") onGalleryClick();
};
return (
<div className="flex w-full items-center gap-2 border-b border-ui-panel-border bg-ui-modal/60 px-3 py-2 text-base-fg backdrop-blur">
<div className="flex items-center gap-1">
{TOOLS.map((t) => (
<ToolbarButton
key={t.id}
icon={t.icon}
label={t.label}
active={tool === t.id}
onClick={() => setTool(t.id)}
/>
))}
</div>
<Divider />
<ToolbarButton
<FloatingToolbar>
<Tooltip
content="Add an image"
position="bottom"
delay={300}
closeOnClick
>
<PopoverMenu
mode="button"
position="bottom"
panelTitle="Add an image"
items={[
{
label: "Upload Image",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload",
},
{
label: "Pick from Library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "library",
},
]}
onPanelAction={handleAddAction}
showIconsInList
buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-primary/90 text-lg text-white hover:bg-primary/70"
triggerIcon={<FontAwesomeIcon icon={faPlus} className="text-xl" />}
/>
</Tooltip>
<FloatingToolbarDivider />
<ButtonIconSelect
options={MODES}
onOptionChange={(value) => setTool(value as Tool)}
selectedOption={tool}
tooltipDelay={100}
/>
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faObjectGroup}
label="Group"
label={`Group (${fmtShortcut([MOD, "G"])})`}
onClick={() => group()}
disabled={selectedIds.size < 2}
tooltipDelay={100}
/>
<ToolbarButton
<FloatingToolbarButton
icon={faObjectUngroup}
label="Ungroup"
label={`Ungroup (${fmtShortcut([MOD, "Shift", "G"])})`}
onClick={() => ungroup()}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
<Divider />
<ToolbarButton
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faTableCells}
label="Fit to grid"
onClick={handleFitToGrid}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
<ToolbarButton
<FloatingToolbarButton
icon={faGripVertical}
label="Pack collage"
onClick={handlePackCollage}
tooltipDelay={100}
/>
<ToolbarButton
<FloatingToolbarButton
icon={faDiagramProject}
label="Auto-group by proximity"
onClick={handleAutoGroup}
tooltipDelay={100}
/>
<div className="ml-auto flex items-center gap-1">
<ToolbarButton icon={faRotateLeft} label="Undo" onClick={undo} />
<ToolbarButton icon={faRotateRight} label="Redo" onClick={redo} />
<ToolbarButton
icon={faTrash}
label="Delete"
onClick={deleteSelected}
disabled={selectedIds.size === 0}
/>
</div>
</div>
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faRotateLeft}
label={`Undo (${fmtShortcut([MOD, "Z"])})`}
onClick={undo}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faRotateRight}
label={`Redo (${fmtShortcut([MOD, "Shift", "Z"])})`}
onClick={redo}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faTrash}
label="Delete (Del)"
onClick={deleteSelected}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
</FloatingToolbar>
);
};
interface ButtonProps {
icon: typeof faMousePointer;
label: string;
active?: boolean;
disabled?: boolean;
onClick: () => void;
}
const ToolbarButton = ({ icon, label, active, disabled, onClick }: ButtonProps) => (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
disabled={disabled}
className={twMerge(
"flex h-8 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"hover:bg-white/10",
active ? "bg-white/15 text-white" : "text-white/70",
disabled ? "cursor-not-allowed opacity-40 hover:bg-transparent" : "",
)}
>
<FontAwesomeIcon icon={icon} className="text-sm" />
<span className="hidden sm:inline">{label}</span>
</button>
);
const Divider = () => <div className="h-5 w-px bg-white/10" />;
@@ -16,6 +16,14 @@ const isMac =
export const MOD = isMac ? "⌘" : "Ctrl";
// Renders a key combo as a compact tooltip label. Mac uses adjacent symbols
// (⌘⇧Z); other platforms use "+" separators (Ctrl+Shift+Z). Pass keys in
// order, e.g. [MOD, "Shift", "Z"].
export const fmtShortcut = (keys: string[]): string =>
isMac
? keys.map((k) => (k === "Shift" ? "⇧" : k)).join("")
: keys.join("+");
export const SHORTCUTS: ShortcutRow[] = [
// Tools
{ section: "Tools", label: "Select tool", keys: ["V"] },
@@ -0,0 +1,138 @@
import { ReactNode, useCallback, useRef, useState } from "react";
import Konva from "konva";
import toast from "react-hot-toast";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import {
GalleryModal,
GalleryItem,
} from "@storyteller/ui-gallery-modal";
import { uploadImage } from "~/components/reusable/UploadModalImage/utilities/uploadImage";
import { useMoodboardStore } from "./MoodboardStore";
import { Vec2 } from "./types";
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
img.onerror = () => resolve({ w: 320, h: 320 });
img.src = url;
});
const stageCenter = (stage: Konva.Stage | null): Vec2 => {
if (!stage) return { x: 400, y: 400 };
return {
x: (stage.width() / 2 - stage.x()) / stage.scaleX(),
y: (stage.height() / 2 - stage.y()) / stage.scaleY(),
};
};
interface UseMoodboardImageEntryReturn {
triggerUpload: () => void;
triggerGallery: () => void;
modals: ReactNode;
}
// Owns the file input + GalleryModal that feed the moodboard. Both the empty
// state CTA and the toolbar `+` dropdown drive the same triggers, so the
// upload + library flows are defined once here.
export const useMoodboardImageEntry = (
stageRef: React.RefObject<Konva.Stage | null>,
): UseMoodboardImageEntryReturn => {
const addImage = useMoodboardStore((s) => s.addImage);
const fileInputRef = useRef<HTMLInputElement>(null);
const [galleryOpen, setGalleryOpen] = useState(false);
const [selectedGalleryIds, setSelectedGalleryIds] = useState<string[]>([]);
const triggerUpload = useCallback(() => {
fileInputRef.current?.click();
}, []);
const triggerGallery = useCallback(() => {
setGalleryOpen(true);
}, []);
const handleFiles = useCallback(
async (files: FileList) => {
const center = stageCenter(stageRef.current);
for (const file of Array.from(files)) {
if (!file.type.startsWith("image/")) continue;
const blobUrl = URL.createObjectURL(file);
try {
const dims = await measure(blobUrl);
addImage(blobUrl, center, dims.w, dims.h, null);
} catch {
addImage(blobUrl, center, 320, 320, null);
}
// Background upload so the image lands in the user's library too.
uploadImage({
title: file.name || "Moodboard image",
assetFile: file,
progressCallback: () => {},
}).catch((err) => {
console.error("[Moodboard] background upload failed", err);
});
}
},
[addImage, stageRef],
);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length) {
void handleFiles(files);
}
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleGalleryClose = () => {
setGalleryOpen(false);
setSelectedGalleryIds([]);
};
const handleGallerySelectItem = (id: string) => {
setSelectedGalleryIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleUseSelected = async (items: GalleryItem[]) => {
if (items.length === 0) {
toast.error("No image selected");
return;
}
const center = stageCenter(stageRef.current);
for (const item of items) {
const url = item.fullImage || item.thumbnail;
if (!url) continue;
const dims = await measure(url);
addImage(url, center, dims.w, dims.h, item.id ?? null);
}
handleGalleryClose();
};
const modals = (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
<GalleryModal
isOpen={galleryOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryIds}
onSelectItem={handleGallerySelectItem}
onUseSelected={handleUseSelected}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
return { triggerUpload, triggerGallery, modals };
};
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
// Reads the active theme's foreground color (`--st-fg-rgb`) as a "R G B" string,
// e.g. "255 255 255" for dark themes / "17 24 39" for light. Konva fills don't
// resolve `var()`, so we expand it once and re-read whenever the theme class on
// `<html>` changes.
const readFgRgb = (): string => {
if (typeof window === "undefined") return "255 255 255";
const v = getComputedStyle(document.documentElement)
.getPropertyValue("--st-fg-rgb")
.trim();
return v || "255 255 255";
};
export const useThemeFgRgb = (): string => {
const [fgRgb, setFgRgb] = useState(readFgRgb);
useEffect(() => {
const update = () => setFgRgb(readFgRgb());
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName === "class") {
update();
return;
}
}
});
observer.observe(document.documentElement, { attributes: true });
return () => observer.disconnect();
}, []);
return fgRgb;
};
@@ -15,15 +15,17 @@ interface ButtonIconSelectProps {
options: Option[];
onOptionChange?: (value: string) => void;
selectedOption?: string;
tooltipDelay?: number;
}
export function ButtonIconSelect({
options,
onOptionChange,
selectedOption,
tooltipDelay = 300,
}: ButtonIconSelectProps) {
const [internalSelectedOption, setInternalSelectedOption] = useState<string>(
selectedOption || options[0].value
selectedOption || options[0].value,
);
useEffect(() => {
@@ -45,7 +47,7 @@ export function ButtonIconSelect({
key={value}
content={tooltip}
position="bottom"
delay={300}
delay={tooltipDelay}
closeOnClick
>
<button
@@ -53,8 +55,8 @@ export function ButtonIconSelect({
"flex h-9 items-center justify-center rounded-lg border text-sm outline-none transition-all duration-150 focus:outline-none",
text ? "h-auto w-auto gap-2 px-3 py-1.5" : "w-9",
internalSelectedOption === value
? "border-brand-primary bg-brand-primary/20"
: "border-transparent hover:bg-ui-panel/[0.4]"
? "border-brand-primary bg-brand-primary/20 text-base-fg"
: "border-transparent text-base-fg/70 hover:bg-base-fg/10 hover:text-base-fg",
)}
onClick={() => handleOptionChange(value)}
>
@@ -72,7 +74,7 @@ export function ButtonIconSelect({
text ? "h-auto w-auto gap-2 px-3 py-1.5" : "w-9",
internalSelectedOption === value
? "border-brand-primary bg-brand-primary/20"
: "border-transparent hover:bg-ui-panel/[0.4]"
: "border-transparent text-white/80 hover:bg-white/15 hover:text-white",
)}
onClick={() => handleOptionChange(value)}
>
@@ -81,7 +83,7 @@ export function ButtonIconSelect({
<span className="text-nowrap text-sm font-medium">{text}</span>
)}
</button>
)
),
)}
</div>
);
@@ -288,7 +288,7 @@ export const Tooltip = ({
show={isShowing}
enter={twMerge(
"transition ease-out duration-200",
delay ? `delay-[${delay}ms]` : "delay-[300ms]",
delay ? `delay-[${delay}ms]` : "delay-[200ms]",
)}
enterFrom="opacity-0"
enterTo="opacity-100"