mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-10 20:11:49 +00:00
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:
1 parent
5fe4137634
commit
acbd86a4af
14 files changed
+590
-237
No files matched your search
+66
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user