-
-
-
-
+
+
+
+
+
+ {/* {isEmpty && (
+
+ )} */}
+
+
+ {modals}
);
};
diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardStage.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardStage.tsx
index 6965ad2a94..eda179128a 100644
--- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardStage.tsx
+++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardStage.tsx
@@ -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" }}
>
-
diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardToolbar.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardToolbar.tsx
index 3bb767dda1..95a4a5dae7 100644
--- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardToolbar.tsx
+++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardToolbar.tsx
@@ -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 (
-
-
- {TOOLS.map((t) => (
- setTool(t.id)}
- />
- ))}
-
-
-
+
+
+ ),
+ action: "upload",
+ },
+ {
+ label: "Pick from Library",
+ selected: false,
+ icon: ,
+ 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={}
+ />
+
+
+
+
+ setTool(value as Tool)}
+ selectedOption={tool}
+ tooltipDelay={100}
+ />
+
+
+
+ group()}
disabled={selectedIds.size < 2}
+ tooltipDelay={100}
/>
- ungroup()}
disabled={selectedIds.size === 0}
+ tooltipDelay={100}
/>
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
};
-
-interface ButtonProps {
- icon: typeof faMousePointer;
- label: string;
- active?: boolean;
- disabled?: boolean;
- onClick: () => void;
-}
-
-const ToolbarButton = ({ icon, label, active, disabled, onClick }: ButtonProps) => (
-
-);
-
-const Divider = () => ;
diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/overlays/shortcuts.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboard/overlays/shortcuts.ts
index cd2cea4f50..b7e46e2bf0 100644
--- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/overlays/shortcuts.ts
+++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/overlays/shortcuts.ts
@@ -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"] },
diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/useMoodboardImageEntry.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/useMoodboardImageEntry.tsx
new file mode 100644
index 0000000000..d2216f0635
--- /dev/null
+++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/useMoodboardImageEntry.tsx
@@ -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,
+): UseMoodboardImageEntryReturn => {
+ const addImage = useMoodboardStore((s) => s.addImage);
+ const fileInputRef = useRef(null);
+ const [galleryOpen, setGalleryOpen] = useState(false);
+ const [selectedGalleryIds, setSelectedGalleryIds] = useState([]);
+
+ 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) => {
+ 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 = (
+ <>
+
+
+ >
+ );
+
+ return { triggerUpload, triggerGallery, modals };
+};
diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/useThemeFgRgb.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboard/useThemeFgRgb.ts
new file mode 100644
index 0000000000..137d00787e
--- /dev/null
+++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/useThemeFgRgb.ts
@@ -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
+// `` 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;
+};
diff --git a/frontend/libs/components/button-icon-select/src/lib/button-icon-select.tsx b/frontend/libs/components/button-icon-select/src/lib/button-icon-select.tsx
index d905e9ad49..049bbbd858 100644
--- a/frontend/libs/components/button-icon-select/src/lib/button-icon-select.tsx
+++ b/frontend/libs/components/button-icon-select/src/lib/button-icon-select.tsx
@@ -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(
- 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
>
- )
+ ),
)}
);
diff --git a/frontend/libs/components/tooltip/src/lib/tooltip.tsx b/frontend/libs/components/tooltip/src/lib/tooltip.tsx
index bfbda485d8..088f5c4e53 100644
--- a/frontend/libs/components/tooltip/src/lib/tooltip.tsx
+++ b/frontend/libs/components/tooltip/src/lib/tooltip.tsx
@@ -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"