Compare commits

...
Author SHA1 Message Date
Tanish Manku 2fb1097eb1 Moodboard (HTML): card grouping with color picker and drop targeting
- CardNodeHtml: vertical-flex card container that owns its children's
  layout (children fill card width, heights follow natural aspect for
  images/videos or own height for text).
- cardLayout: pure helpers (DEFAULT_CARD_WIDTH/PADDING/COLOR,
  childRenderedHeight) used by the card node + store.
- MoodboardHtmlColorPicker: hex/rgb picker with preset swatches for
  card backgrounds.
- findDropTarget: top-level hit-test on pointer-up so a drag can form
  a new card with the target + dragged node, or append to an existing
  card.
2026-04-29 12:33:10 -07:00
Tanish Manku f260e1cee4 PageEditor: drop video items only on the HTML moodboard tab
Early-returns in both gallery-drop handlers when the item's mediaClass
is "video" and the active tab can't render video:

- 3D tab: already branches image vs dimensional; video would fall into
  the image branch and try to uploadPlaneFromMediaToken against a video
  URL. Skip it.
- 2D / Konva moodboard: their drop listeners call addImage
  unconditionally, which would load the video URL into an HTMLImageElement
  and fail. Skip unless the active tab is MOODBOARD_HTML.

Gallery drag is globally enabled in the sibling commit, so this is the
failsafe that keeps drops on non-video tabs from producing broken state.
2026-04-23 14:28:11 -07:00
Tanish Manku 9e07954d4f Gallery: enable dragging video items
Removes the video-specific early-return that blocked the drag gesture
in GalleryDraggableItem's pointerdown and the matching video filter in
the pointerup emitter. Videos now get the same drag cursor, tooltip
("Drag to add"), and gallery-image-drop dispatch as images.

The HTML moodboard's gallery-drop listener already branches on
mediaClass to route videos into addVideo, so this unblocks the drag
source end of that path. Other tabs' drop listeners are gated in a
follow-up so dropping a video somewhere that can't render it no-ops
cleanly.
2026-04-23 14:27:44 -07:00
Tanish Manku 0d519023a2 Moodboard (HTML): video nodes with playback controls and scrub bar
Renders VideoNode items on the HTML moodboard with an HTML <video>
element plus a hover-fade overlay:

- Center play/pause button — always visible when paused, fades in on
  hover while playing.
- Bottom-right mute toggle — fades in on hover.
- Bottom scrub bar with a tall hit area — click or drag to seek,
  updates live from the video's timeupdate event.

Controls stop pointerdown propagation so clicking them doesn't start
a node-drag gesture. Gallery drops and the Library picker route to
addVideo or addImage based on the item's mediaClass, measuring video
dimensions via a metadata-only <video> probe. The Library picker's
forceFilter="image" is removed so videos and images can both be picked.
2026-04-23 14:27:06 -07:00
Tanish Manku 181d2a36ca Moodboard: VideoNode type + addVideo store action
Adds a video kind to the shared MoodboardNode union alongside image,
text, and group. `addVideo` mirrors `addImage` and defaults to a
Milanote-style silent looping autoplay (muted/loop/autoplay all true) —
browsers only permit autoplay when muted, so these three travel
together.

Group/delete/move/AABB all work on BaseNode fields, so videos pick up
grouping, multi-select, and undo for free. The existing Konva moodboard
renderer's node switch falls through unknown kinds, so it silently
ignores video nodes rather than crashing.
2026-04-23 14:25:18 -07:00
Tanish Manku 8d4346d566 Moodboard (HTML): parallel experiment page, no canvas
Stands up PageMoodboardHtml alongside PageMoodboard — renders the same
moodboard content (image/text/group nodes) entirely with absolutely-
positioned divs, CSS transforms, and pointer events. Shares the store
with PageMoodboard so switching tabs preserves the board.

- New page at app/src/pages/PageMoodboardHtml with HTML-only stage,
  background grid, nodes, overlay transformer (8 resize handles + 1
  rotate), marquee + lasso selection, viewport pan/zoom, paste and
  gallery drop handlers, and a shortcut-aware text edit overlay.
- Reuses MoodboardStore, layout utilities, MoodboardToolbar,
  TextEditOverlay, and the non-render interaction hooks from the
  existing Konva moodboard unchanged.
- Experimental flag `moodboardHtmlPageEnabled` gates a new apps-menu
  entry and a Settings → Experimental toggle.
- PageEditor: gallery drop targets the HTML stage element when the
  MOODBOARD_HTML tab is active.
2026-04-23 14:24:41 -07:00
BFlat acbd86a4af 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.
2026-04-22 18:12:04 +07:00
Tanish Manku 5fe4137634 Moodboard: move page-height class into Moodboard root
Every other tab applies its `h-[calc(100vh-56px)]` on its own root div;
the PageEditor wrappers stay bare. Moodboard was the outlier — wrapper
had `w-screen` + inline height of `calc(100vh - 68px)` (wrong 68px,
causing a 12px gap at the bottom). Move the class into `Moodboard.tsx`'s
root and drop the wrapper styling so it matches the rest.
2026-04-20 23:32:34 -07:00
Tanish Manku 1c9e9297cb Moodboard: disable Fit to grid when nothing is selected
Removes the old "fall back to all nodes" behavior — matches Figma/Sketch,
where layout actions require an explicit target.
2026-04-20 23:24:44 -07:00
Tanish Manku 982f3ad78d Moodboard: viewport state, recenter indicator, shortcut cheatsheet, canvas perf
Lifts `viewport` (zoom/pan) and `canvasSize` into the moodboard store so
overlays and a future minimap can read the camera without drilling refs.
Adds a recenter pill in the bottom-right that appears when nothing in the
current viewport intersects any content, and a translucent shortcut
cheatsheet that appears after holding Ctrl/Cmd alone for 3s.

New Figma-style shortcuts: V/L/T tool switches, Cmd/Ctrl+A select all,
Cmd/Ctrl+G group, Cmd/Ctrl+Shift+G ungroup, Shift+1 zoom-to-fit.

Consolidates all previously-duplicated rect / AABB / clamp / viewport math
into `layout/geometry.ts` (removes private copies from useSelection,
useViewportControls, MoodboardStore, and packCollage). Exports include
`computeContentBounds`, `computeVisibleRect`, `anyContentVisible`,
`fitViewportToBounds`, `panToCenterOn`, `ZOOM_MIN/MAX` — everything a
future minimap overlay will need.

Canvas perf pass:
- Level-of-detail dotted grid (doubles effective spacing as you zoom out
  so dot count stays bounded; dot radius tracks screen pixels).
- React.memo on ImageNode, TextNode, GroupNode, MoodboardBackground.
- Stable onSelect contract (children receive (id, e) and call a single
  parent callback, letting memo skip children on camera/selection-only
  renders).
- GroupNode subscribes only to its own children via useShallow, not the
  full nodes map.
- TextNode's editingTextId selector collapsed to a per-node boolean.
- MoodboardStage's transient subscription narrowed to {marquee, lassoPath}
  so setIsPanning / setEditingText don't churn the whole Stage.
- visibleViewport memoized so marquee/lasso drags don't force the
  background sceneFunc to re-run.
2026-04-20 23:24:38 -07:00
Tanish Manku a24b154d52 Experimental: Moodboard page
New Konva-based 2D canvas for collaging reference imagery, gated behind an
experimental flag mirroring Storyboard. Supports drag-drop from OS, gallery,
and clipboard; rectangle marquee and lasso selection; grouping with
group-local coordinates so rotation/scale transforms children correctly;
fit-to-grid, justified-rows collage pack, and DBSCAN proximity auto-group;
wheel-zoom around cursor and middle-mouse / spacebar pan; per-item dashed
outlines to distinguish grouped vs individually-selected; 50-entry snapshot
undo/redo. Also fixes a pre-existing bug where disabling the experimental
flag didn't reset gated page state.
2026-04-20 20:40:18 -07:00
71 changed files with 6605 additions and 216 deletions
@@ -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";
@@ -60,6 +60,8 @@ import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
import { useStoryboardStore } from "~/pages/PageStoryboard";
import { useMoodboardStore } from "~/pages/PageMoodboard";
import { useExperimentalStore } from "@storyteller/ui-settings-modal";
import { useSceneStore } from "@storyteller/ui-pagedraw";
import {
is3DEditorInitialized,
@@ -126,6 +128,36 @@ export const TopBar = ({ pageName }: Props) => {
const [settingsSection, setSettingsSection] =
useState<SettingsSection>("general");
// Reset gated page state when its experimental flag flips off — including
// the global "Reset Experimental" path that bypasses the per-toggle confirm
// modal. Without this, store contents and the tab survive the disable.
const storyboardPageEnabled = useExperimentalStore(
(s) => s.enabled && s.storyboardPageEnabled,
);
const moodboardPageEnabled = useExperimentalStore(
(s) => s.enabled && s.moodboardPageEnabled,
);
const prevStoryboardEnabled = useRef(storyboardPageEnabled);
const prevMoodboardEnabled = useRef(moodboardPageEnabled);
useEffect(() => {
if (prevStoryboardEnabled.current && !storyboardPageEnabled) {
useStoryboardStore.getState().reset();
if (useTabStore.getState().activeTabId === "STORYBOARD") {
goToApp("IMAGE");
}
}
prevStoryboardEnabled.current = storyboardPageEnabled;
}, [storyboardPageEnabled]);
useEffect(() => {
if (prevMoodboardEnabled.current && !moodboardPageEnabled) {
useMoodboardStore.getState().reset();
if (useTabStore.getState().activeTabId === "MOODBOARD") {
goToApp("IMAGE");
}
}
prevMoodboardEnabled.current = moodboardPageEnabled;
}, [moodboardPageEnabled]);
const { isDesktop, isMaximized, minimize, toggleMaximize, close } =
useTauriWindowControls();
const platform = useTauriPlatform();
@@ -470,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";
}
@@ -770,6 +806,10 @@ export const TopBar = ({ pageName }: Props) => {
useStoryboardStore.getState().reset();
goToApp("IMAGE");
}}
onMoodboardPageDisable={() => {
useMoodboardStore.getState().reset();
goToApp("IMAGE");
}}
initialSection={settingsSection}
/>
@@ -10,11 +10,14 @@ import {
faWandMagicSparkles,
faPenNib,
faCrosshairs,
faObjectGroup,
} from "@fortawesome/pro-solid-svg-icons";
import { useMemo } from "react";
import {
useExperimentalStore,
useStoryboardPageEnabled,
useMoodboardPageEnabled,
useMoodboardHtmlPageEnabled,
} from "@storyteller/ui-settings-modal";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
@@ -32,7 +35,9 @@ export type AppId =
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND"
| "ANGLES"
| "STORYBOARD";
| "STORYBOARD"
| "MOODBOARD"
| "MOODBOARD_HTML";
export interface AppDescriptor {
id: AppId;
@@ -186,6 +191,26 @@ export const ALL_APPS: FullAppItem[] = [
color: "bg-fuchsia-600/40",
badge: "NEW",
},
{
id: "moodboard",
label: "Moodboard",
description: "Collage references on a 2D canvas",
icon: faObjectGroup,
category: "generate",
action: "MOODBOARD",
color: "bg-orange-500/40",
badge: "NEW",
},
{
id: "moodboard-html",
label: "Moodboard (HTML)",
description: "HTML-only variant of the moodboard (experiment)",
icon: faObjectGroup,
category: "generate",
action: "MOODBOARD_HTML",
color: "bg-cyan-500/40",
badge: "NEW",
},
{
id: "2d-canvas",
label: "Image Editor",
@@ -213,12 +238,17 @@ export const EDIT_APPS = ALL_APPS.filter((app) => app.category === "edit");
export const useVisibleApps = (): FullAppItem[] => {
const storyboardEnabled = useStoryboardPageEnabled();
const moodboardEnabled = useMoodboardPageEnabled();
const moodboardHtmlEnabled = useMoodboardHtmlPageEnabled();
return useMemo(
() =>
ALL_APPS.filter((app) =>
app.action === "STORYBOARD" ? storyboardEnabled : true,
),
[storyboardEnabled],
ALL_APPS.filter((app) => {
if (app.action === "STORYBOARD") return storyboardEnabled;
if (app.action === "MOODBOARD") return moodboardEnabled;
if (app.action === "MOODBOARD_HTML") return moodboardHtmlEnabled;
return true;
}),
[storyboardEnabled, moodboardEnabled, moodboardHtmlEnabled],
);
};
@@ -267,6 +297,8 @@ export const goToApp = (action?: string) => {
"REMOVE_BACKGROUND",
"ANGLES",
"STORYBOARD",
"MOODBOARD",
"MOODBOARD_HTML",
].includes(action)
) {
if (action === "STORYBOARD") {
@@ -274,6 +306,16 @@ export const goToApp = (action?: string) => {
useExperimentalStore.getState();
if (!enabled || !storyboardPageEnabled) return;
}
if (action === "MOODBOARD") {
const { enabled, moodboardPageEnabled } =
useExperimentalStore.getState();
if (!enabled || !moodboardPageEnabled) return;
}
if (action === "MOODBOARD_HTML") {
const { enabled, moodboardHtmlPageEnabled } =
useExperimentalStore.getState();
if (!enabled || !moodboardHtmlPageEnabled) return;
}
if (action === "3D") {
set3DPageMounted(true);
} else {
@@ -19,7 +19,13 @@ import { ImageTo3DWorld } from "../PageImageTo3DWorld";
import { RemoveBackground } from "../PageRemoveBackground";
import { Angles } from "../PageAngles";
import { Storyboard } from "../PageStoryboard";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import { Moodboard } from "../PageMoodboard";
import { MoodboardHtml } from "../PageMoodboardHtml";
import {
useStoryboardPageEnabled,
useMoodboardPageEnabled,
useMoodboardHtmlPageEnabled,
} from "@storyteller/ui-settings-modal";
import {
timelineHeight,
@@ -111,6 +117,8 @@ export const PageEditor = () => {
const tabStore = useTabStore();
const storyboardPageEnabled = useStoryboardPageEnabled();
const moodboardPageEnabled = useMoodboardPageEnabled();
const moodboardHtmlPageEnabled = useMoodboardHtmlPageEnabled();
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
};
@@ -307,6 +315,10 @@ export const PageEditor = () => {
if (tabStore.activeTabId === "3D") {
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
// Video drops are only handled on the HTML moodboard. Drop on 3D
// silently — the gallery allows the drag globally, but only tabs
// that know how to render video should consume it.
if (item.mediaClass === "video") return;
console.log("3D Drop debug (event):", {
editorEngine,
camera: editorEngine?.camera,
@@ -386,55 +398,60 @@ export const PageEditor = () => {
);
// 2D Drag and Drop Logic
} else if (tabStore.activeTabId === "2D") {
} else if (
tabStore.activeTabId === "2D" ||
tabStore.activeTabId === "MOODBOARD" ||
tabStore.activeTabId === "MOODBOARD_HTML"
) {
const eventName =
tabStore.activeTabId === "MOODBOARD" ||
tabStore.activeTabId === "MOODBOARD_HTML"
? "gallery-moodboard-drop"
: "gallery-2d-drop";
const activeTab = tabStore.activeTabId;
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
console.log("2D Drop debug (event):", {
item,
position,
});
// Find the main Konva canvas element - get the first canvas (left panel)
const canvasElements = document.querySelectorAll("canvas");
const canvasElement = canvasElements[0]; // Get the main drawing canvas (left panel)
if (!canvasElement) {
console.error("Could not find canvas element for 2D drop");
// Only the HTML moodboard renders videos. Silently ignore video
// drops on the Konva 2D canvas and the Konva moodboard, which
// would otherwise try to load the video URL as an image.
if (item.mediaClass === "video" && activeTab !== "MOODBOARD_HTML") {
return;
}
// Find the drop target element. For the HTML moodboard tab there's
// no <canvas>; use the HTML stage container element instead.
const target: Element | null =
activeTab === "MOODBOARD_HTML"
? document.querySelector("[data-moodboard-html-stage]")
: document.querySelectorAll("canvas")[0] ?? null;
if (!target) {
console.error("Could not find drop target element for canvas drop");
return;
}
const rect = canvasElement.getBoundingClientRect();
// Convert screen coordinates to canvas coordinates
const rect = target.getBoundingClientRect();
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
// Ensure the drop position is within canvas bounds
if (
canvasX < 0 ||
canvasY < 0 ||
canvasX > rect.width ||
canvasY > rect.height
) {
console.log("Drop position outside canvas bounds");
return;
}
console.log("Canvas drop position:", { canvasX, canvasY });
(async () => {
try {
// event that PageDraw listens for
const dropEvent = new CustomEvent("gallery-2d-drop", {
detail: {
item,
canvasPosition: { x: canvasX, y: canvasY },
},
});
window.dispatchEvent(dropEvent);
} catch (err) {
console.error("Failed to add image to 2D canvas:", err);
}
})();
try {
const dropEvent = new CustomEvent(eventName, {
detail: {
item,
canvasPosition: { x: canvasX, y: canvasY },
},
});
window.dispatchEvent(dropEvent);
} catch (err) {
console.error("Failed to dispatch canvas drop event:", err);
}
},
);
}
@@ -651,6 +668,16 @@ export const PageEditor = () => {
<Storyboard />
</div>
)}
{tabStore.activeTabId == "MOODBOARD" && moodboardPageEnabled && (
<div>
<Moodboard />
</div>
)}
{tabStore.activeTabId == "MOODBOARD_HTML" && moodboardHtmlPageEnabled && (
<div>
<MoodboardHtml />
</div>
)}
</div>
);
};
@@ -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>
);
@@ -0,0 +1,76 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { MoodboardToolbar } from "./MoodboardToolbar";
import { MoodboardStage } from "./MoodboardStage";
import { TextEditOverlay } from "./TextEditOverlay";
import { useMoodboardStore } from "./MoodboardStore";
import { useUndoRedo } from "./interactions/useUndoRedo";
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);
const stageRef = useRef<Konva.Stage | null>(null);
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.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
if (editingTextId) return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
deleteSelected();
} else if (e.key === "Escape") {
setSelection([]);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [deleteSelected, setSelection, editingTextId]);
return (
<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>
);
};
@@ -0,0 +1,66 @@
import { memo } from "react";
import { Shape } from "react-konva";
import Konva from "konva";
import { Rect } from "./types";
interface Props {
// Visible region in stage (document) coordinates. Driven by pan/zoom.
viewport: Rect;
spacing: number;
zoom: number;
dotColor?: string;
// On-screen radius in CSS pixels. Converted to stage units so dots stay
// the same visual size regardless of zoom.
dotRadiusPx?: number;
}
// Minimum on-screen gap between dots before we drop to a coarser grid.
// At zoom 1× with spacing 32, dots are 32px apart; as you zoom out, the
// LOD step doubles `spacing` so density never drops below this bound.
const MIN_SCREEN_SPACING = 16;
// Vector dotted grid that follows the visible viewport. Dots stay anchored
// at fixed stage coords (multiples of the effective spacing); zooming out
// coarsens the grid in powers of 2 so we never draw tens of thousands of
// arcs per frame.
const MoodboardBackgroundInner = ({
viewport,
spacing,
zoom,
dotColor = "rgba(255,255,255,0.18)",
dotRadiusPx = 1.2,
}: Props) => {
const baseOnScreen = spacing * zoom;
const lodStep =
baseOnScreen >= MIN_SCREEN_SPACING
? 0
: Math.ceil(Math.log2(MIN_SCREEN_SPACING / Math.max(baseOnScreen, 1e-6)));
const effectiveSpacing = spacing * Math.pow(2, lodStep);
const radius = dotRadiusPx / Math.max(zoom, 1e-6);
return (
<Shape
x={0}
y={0}
listening={false}
sceneFunc={(ctx: Konva.Context, shape) => {
const startX = Math.floor(viewport.x / effectiveSpacing) * effectiveSpacing;
const startY = Math.floor(viewport.y / effectiveSpacing) * effectiveSpacing;
const endX = viewport.x + viewport.width;
const endY = viewport.y + viewport.height;
ctx.fillStyle = dotColor;
ctx.beginPath();
for (let x = startX; x <= endX; x += effectiveSpacing) {
for (let y = startY; y <= endY; y += effectiveSpacing) {
ctx.moveTo(x + radius, y);
ctx.arc(x, y, radius, 0, Math.PI * 2);
}
}
ctx.fill();
ctx.fillStrokeShape(shape);
}}
/>
);
};
export const MoodboardBackground = memo(MoodboardBackgroundInner);
@@ -0,0 +1,47 @@
import { create } from "zustand";
import { MoodboardSnapshot } from "./types";
const MAX_HISTORY = 50;
interface MoodboardHistoryState {
past: MoodboardSnapshot[];
future: MoodboardSnapshot[];
push: (snapshot: MoodboardSnapshot) => void;
undo: (current: MoodboardSnapshot) => MoodboardSnapshot | null;
redo: (current: MoodboardSnapshot) => MoodboardSnapshot | null;
clear: () => void;
}
export const useMoodboardHistoryStore = create<MoodboardHistoryState>(
(set, get) => ({
past: [],
future: [],
push: (snapshot) => {
const past = get().past;
const next = [...past, snapshot];
if (next.length > MAX_HISTORY) next.shift();
set({ past: next, future: [] });
},
undo: (current) => {
const { past, future } = get();
if (past.length === 0) return null;
const prior = past[past.length - 1];
set({
past: past.slice(0, -1),
future: [...future, current],
});
return prior;
},
redo: (current) => {
const { past, future } = get();
if (future.length === 0) return null;
const next = future[future.length - 1];
set({
past: [...past, current],
future: future.slice(0, -1),
});
return next;
},
clear: () => set({ past: [], future: [] }),
}),
);
@@ -0,0 +1,275 @@
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";
import { ImageNode } from "./nodes/ImageNode";
import { TextNode } from "./nodes/TextNode";
import { GroupNode } from "./nodes/GroupNode";
import { useSelection } from "./interactions/useSelection";
import { useTransformer } from "./interactions/useTransformer";
import { useViewportControls } from "./interactions/useViewportControls";
import { stagePointerPos } from "./interactions/useStagePointer";
interface Props {
containerRef: React.RefObject<HTMLDivElement | null>;
stageRef: React.MutableRefObject<Konva.Stage | null>;
}
export const MoodboardStage = ({ containerRef, stageRef }: Props) => {
const transformerRef = useTransformer(stageRef);
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
const tool = useMoodboardStore((s) => s.tool);
const gridSpacing = useMoodboardStore((s) => s.gridSpacing);
// Only subscribe to the transient fields this component actually renders
// (marquee + lasso). Skips re-renders on `editingTextId` and `isPanning`
// toggles, which don't affect Stage JSX.
const { marquee, lassoPath } = useMoodboardStore(
useShallow((s) => ({
marquee: s.transient.marquee,
lassoPath: s.transient.lassoPath,
})),
);
const viewport = useMoodboardStore((s) => s.viewport);
const canvasSize = useMoodboardStore((s) => s.canvasSize);
const setLastDropPoint = useMoodboardStore((s) => s.setLastDropPoint);
const setCanvasSize = useMoodboardStore((s) => s.setCanvasSize);
const toggleInSelection = useMoodboardStore((s) => s.toggleInSelection);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
const addText = useMoodboardStore((s) => s.addText);
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
// minimap) can read it without drilling refs through the tree.
useEffect(() => {
const el = containerRef.current;
if (!el) return undefined;
const updateSize = () => {
setCanvasSize({ width: el.clientWidth, height: el.clientHeight });
};
updateSize();
const ro = new ResizeObserver(updateSize);
ro.observe(el);
return () => ro.disconnect();
}, [containerRef, setCanvasSize]);
useSelection(stageRef);
useViewportControls(stageRef);
// Update lastDropPoint as the cursor moves across the stage so external
// drops (uploads, paste fallbacks) land near the cursor.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const handle = () => {
const pos = stagePointerPos(stage);
if (pos) setLastDropPoint(pos);
};
stage.on("mousemove.moodboardCursor", handle);
return () => {
stage.off("mousemove.moodboardCursor");
};
}, [setLastDropPoint, stageRef]);
// Stable across renders so memoized node children see an unchanged
// onSelect prop and skip re-render when only camera/selection state
// changes. Depends only on a Zustand action, which itself is stable.
const handleNodeSelect = useCallback(
(id: string, e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
const evt = e.evt as MouseEvent | TouchEvent;
const additive = "shiftKey" in evt && evt.shiftKey;
toggleInSelection(id, additive);
},
[toggleInSelection],
);
const handleStageClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (e.target !== stageRef.current) return;
if (tool === "text") {
const pos = stagePointerPos(stageRef.current);
if (pos) addText(pos);
return;
}
// Don't setSelection([]) here: Konva fires `click` after any marquee
// drag that ends on bare canvas, which would stomp on the selection
// `useSelection` just committed. The zero-area branch in `useSelection`
// handles the "bare click with no drag" case.
setEditingText(null);
};
// Visible region in stage coords; drives the dotted background grid so
// it follows pan/zoom rather than rendering a fixed (0,0,w,h) block.
// Memoized so transient store updates (marquee, lasso, isPanning) don't
// churn its identity and force `MoodboardBackground` to re-run sceneFunc
// every frame of a marquee drag.
const visibleViewport = useMemo(
() => ({
x: -pan.x / zoom,
y: -pan.y / zoom,
width: canvasSize.width / zoom,
height: canvasSize.height / zoom,
}),
[pan.x, pan.y, zoom, canvasSize.width, canvasSize.height],
);
return (
<Stage
ref={(s) => {
stageRef.current = s;
}}
width={canvasSize.width}
height={canvasSize.height}
x={pan.x}
y={pan.y}
scaleX={zoom}
scaleY={zoom}
onClick={handleStageClick}
onTap={handleStageClick}
style={{ cursor: tool === "text" ? "text" : "default" }}
>
<Layer listening={false}>
<MoodboardBackground
viewport={visibleViewport}
spacing={gridSpacing}
zoom={zoom}
dotColor={`rgb(${fgRgb} / 0.10)`}
/>
</Layer>
<Layer>
{rootOrder.map((id) => {
const n = nodes[id];
if (!n) return null;
const selected = selectedIds.has(id);
const draggable = tool === "select";
if (n.kind === "image") {
return (
<ImageNode
key={id}
node={n}
draggable={draggable}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "text") {
return (
<TextNode
key={id}
node={n}
draggable={draggable}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "group") {
return (
<GroupNode
key={id}
node={n}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
return null;
})}
<Transformer
ref={(t) => {
transformerRef.current = t;
}}
rotateEnabled
keepRatio={false}
/>
</Layer>
<Layer listening={false}>
{Array.from(selectedIds).map((id) => {
const n = nodes[id];
if (!n || n.parentId !== null) return null;
if (n.kind === "group") {
// Union AABB of children in group-local coords, wrapped in a
// Konva Group so the outline rotates around the group's origin.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const cid of n.childIds) {
const c = nodes[cid];
if (!c) continue;
minX = Math.min(minX, c.x);
minY = Math.min(minY, c.y);
maxX = Math.max(maxX, c.x + c.width);
maxY = Math.max(maxY, c.y + c.height);
}
if (!Number.isFinite(minX)) return null;
return (
<Group
key={`outline-${id}`}
x={n.x}
y={n.y}
rotation={n.rotation}
listening={false}
>
<Rect
x={minX}
y={minY}
width={maxX - minX}
height={maxY - minY}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
listening={false}
/>
</Group>
);
}
return (
<Rect
key={`outline-${id}`}
x={n.x}
y={n.y}
width={n.width}
height={n.height}
rotation={n.rotation}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
listening={false}
/>
);
})}
{marquee && (
<Rect
x={marquee.x}
y={marquee.y}
width={marquee.width}
height={marquee.height}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
fill="rgba(59,130,246,0.08)"
/>
)}
{lassoPath && lassoPath.length >= 2 && (
<Line
points={lassoPath.flatMap((p) => [p.x, p.y])}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
closed={false}
/>
)}
</Layer>
</Stage>
);
};
@@ -0,0 +1,570 @@
import { create } from "zustand";
import { v4 as uuidv4 } from "uuid";
import {
GroupNode,
ImageNode,
MoodboardNode,
MoodboardSnapshot,
Rect,
TextNode,
Tool,
Vec2,
VideoNode,
} from "./types";
import { useMoodboardHistoryStore } from "./MoodboardHistoryStore";
import {
CanvasSize,
Viewport,
computeAABBById,
computeContentBounds,
fitViewportToBounds,
} from "./layout/geometry";
interface Transient {
marquee: Rect | null;
lassoPath: Vec2[] | null;
editingTextId: string | null;
isPanning: boolean;
}
interface MoodboardState {
nodes: Record<string, MoodboardNode>;
rootOrder: string[];
selectedIds: Set<string>;
tool: Tool;
gridSpacing: number;
lastDropPoint: Vec2;
viewport: Viewport;
canvasSize: CanvasSize;
transient: Transient;
setTool: (t: Tool) => void;
setLastDropPoint: (p: Vec2) => void;
setZoom: (z: number) => void;
setPan: (p: Vec2) => void;
setCanvasSize: (s: CanvasSize) => void;
resetViewport: () => void;
fitToContent: (padding?: number) => void;
selectAll: () => void;
addImage: (
src: string,
pos: Vec2,
naturalW: number,
naturalH: number,
mediaId?: string | null,
) => string;
addVideo: (
src: string,
pos: Vec2,
naturalW: number,
naturalH: number,
mediaId?: string | null,
) => string;
addText: (pos: Vec2) => string;
updateNode: (id: string, patch: Partial<MoodboardNode>) => void;
deleteSelected: () => void;
applyLayoutPatches: (
patches: Array<{ id: string; patch: Partial<MoodboardNode> }>,
) => void;
setSelection: (ids: Iterable<string>) => void;
toggleInSelection: (id: string, additive: boolean) => void;
clearSelection: () => void;
group: () => string | null;
ungroup: () => void;
// Atomic multi-cluster grouping for "auto-group by proximity": creates one
// group per provided cluster of ids, with a single history entry covering
// all of them. Returns the new group ids.
groupClusters: (clusters: string[][]) => string[];
setMarquee: (r: Rect | null) => void;
setLassoPath: (p: Vec2[] | null) => void;
setEditingText: (id: string | null) => void;
setIsPanning: (v: boolean) => void;
// Snapshot helpers used by history wiring (do NOT push to history themselves).
snapshot: () => MoodboardSnapshot;
applySnapshot: (snap: MoodboardSnapshot) => void;
pushHistory: () => void;
undo: () => void;
redo: () => void;
reset: () => void;
}
const DEFAULT_TEXT_W = 200;
const DEFAULT_TEXT_H = 60;
const DEFAULT_FONT_SIZE = 24;
const DEFAULT_TEXT_COLOR = "#FFFFFF";
const MAX_IMAGE_DIMENSION = 480;
const fitWithinBox = (w: number, h: number, max: number): { w: number; h: number } => {
if (w <= max && h <= max) return { w, h };
const ratio = Math.min(max / w, max / h);
return { w: w * ratio, h: h * ratio };
};
export const useMoodboardStore = create<MoodboardState>((set, get) => ({
nodes: {},
rootOrder: [],
selectedIds: new Set(),
tool: "select",
gridSpacing: 32,
lastDropPoint: { x: 200, y: 200 },
viewport: { zoom: 1, pan: { x: 0, y: 0 } },
canvasSize: { width: 800, height: 600 },
transient: {
marquee: null,
lassoPath: null,
editingTextId: null,
isPanning: false,
},
setTool: (t) => set({ tool: t }),
setLastDropPoint: (p) => set({ lastDropPoint: p }),
setZoom: (z) => set((s) => ({ viewport: { ...s.viewport, zoom: z } })),
setPan: (p) => set((s) => ({ viewport: { ...s.viewport, pan: p } })),
setCanvasSize: (sz) => set({ canvasSize: sz }),
resetViewport: () =>
set({ viewport: { zoom: 1, pan: { x: 0, y: 0 } } }),
fitToContent: (padding = 48) => {
const { nodes, rootOrder, canvasSize } = get();
const bounds = computeContentBounds(nodes, rootOrder);
if (!bounds) return;
set({ viewport: fitViewportToBounds(bounds, canvasSize, padding) });
},
selectAll: () => {
const { rootOrder } = get();
if (rootOrder.length === 0) return;
set({ selectedIds: new Set(rootOrder) });
},
addImage: (src, pos, naturalW, naturalH, mediaId = null) => {
get().pushHistory();
const id = uuidv4();
const fit = fitWithinBox(naturalW || 320, naturalH || 320, MAX_IMAGE_DIMENSION);
const node: ImageNode = {
id,
kind: "image",
parentId: null,
x: pos.x - fit.w / 2,
y: pos.y - fit.h / 2,
width: fit.w,
height: fit.h,
rotation: 0,
zIndex: get().rootOrder.length,
src,
mediaId,
naturalW,
naturalH,
};
set((s) => ({
nodes: { ...s.nodes, [id]: node },
rootOrder: [...s.rootOrder, id],
selectedIds: new Set([id]),
}));
return id;
},
addVideo: (src, pos, naturalW, naturalH, mediaId = null) => {
get().pushHistory();
const id = uuidv4();
const fit = fitWithinBox(naturalW || 320, naturalH || 320, MAX_IMAGE_DIMENSION);
const node: VideoNode = {
id,
kind: "video",
parentId: null,
x: pos.x - fit.w / 2,
y: pos.y - fit.h / 2,
width: fit.w,
height: fit.h,
rotation: 0,
zIndex: get().rootOrder.length,
src,
mediaId,
naturalW,
naturalH,
// Silent looping autoplay is the Milanote-style default — browsers
// only permit autoplay when muted, so these three travel together.
muted: true,
loop: true,
autoplay: true,
};
set((s) => ({
nodes: { ...s.nodes, [id]: node },
rootOrder: [...s.rootOrder, id],
selectedIds: new Set([id]),
}));
return id;
},
addText: (pos) => {
get().pushHistory();
const id = uuidv4();
const node: TextNode = {
id,
kind: "text",
parentId: null,
x: pos.x - DEFAULT_TEXT_W / 2,
y: pos.y - DEFAULT_TEXT_H / 2,
width: DEFAULT_TEXT_W,
height: DEFAULT_TEXT_H,
rotation: 0,
zIndex: get().rootOrder.length,
text: "Note",
fontSize: DEFAULT_FONT_SIZE,
color: DEFAULT_TEXT_COLOR,
};
set((s) => ({
nodes: { ...s.nodes, [id]: node },
rootOrder: [...s.rootOrder, id],
selectedIds: new Set([id]),
transient: { ...s.transient, editingTextId: id },
}));
return id;
},
updateNode: (id, patch) => {
set((s) => {
const existing = s.nodes[id];
if (!existing) return s;
// Keep discriminant intact by spreading then overlaying with patch fields.
const merged = { ...existing, ...patch } as MoodboardNode;
return { nodes: { ...s.nodes, [id]: merged } };
});
},
deleteSelected: () => {
const { selectedIds } = get();
if (selectedIds.size === 0) return;
get().pushHistory();
set((s) => {
const removed = new Set<string>();
const collect = (id: string) => {
const n = s.nodes[id];
if (!n) return;
removed.add(id);
if (n.kind === "group") n.childIds.forEach(collect);
};
selectedIds.forEach(collect);
const nodes: Record<string, MoodboardNode> = {};
Object.entries(s.nodes).forEach(([id, n]) => {
if (removed.has(id)) return;
// Filter dangling child references in group nodes.
if (n.kind === "group") {
nodes[id] = {
...n,
childIds: n.childIds.filter((c) => !removed.has(c)),
};
} else {
nodes[id] = n;
}
});
return {
nodes,
rootOrder: s.rootOrder.filter((id) => !removed.has(id)),
selectedIds: new Set(),
};
});
},
applyLayoutPatches: (patches) => {
if (patches.length === 0) return;
get().pushHistory();
set((s) => {
const nodes = { ...s.nodes };
patches.forEach(({ id, patch }) => {
const existing = nodes[id];
if (!existing) return;
nodes[id] = { ...existing, ...patch } as MoodboardNode;
});
return { nodes };
});
},
setSelection: (ids) => {
set({ selectedIds: stripAncestorsAndDescendants(get().nodes, new Set(ids)) });
},
toggleInSelection: (id, additive) => {
set((s) => {
const next = additive ? new Set(s.selectedIds) : new Set<string>();
if (additive && s.selectedIds.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { selectedIds: stripAncestorsAndDescendants(s.nodes, next) };
});
},
clearSelection: () => set({ selectedIds: new Set() }),
group: () => {
const { selectedIds, nodes } = get();
const topLevelIds = Array.from(selectedIds).filter((id) => {
const n = nodes[id];
return n && n.parentId === null;
});
if (topLevelIds.length < 2) return null;
get().pushHistory();
const groupId = uuidv4();
const aabb = computeAABBById(nodes, topLevelIds);
if (!aabb) return null;
const groupNode: GroupNode = {
id: groupId,
kind: "group",
parentId: null,
x: aabb.x,
y: aabb.y,
width: aabb.width,
height: aabb.height,
rotation: 0,
zIndex: get().rootOrder.length,
childIds: topLevelIds,
};
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
// Rebase each child position to be group-local.
topLevelIds.forEach((id) => {
const n = newNodes[id];
if (!n) return;
newNodes[id] = {
...n,
parentId: groupId,
x: n.x - aabb.x,
y: n.y - aabb.y,
} as MoodboardNode;
});
newNodes[groupId] = groupNode;
return {
nodes: newNodes,
rootOrder: [
...s.rootOrder.filter((id) => !topLevelIds.includes(id)),
groupId,
],
selectedIds: new Set([groupId]),
};
});
return groupId;
},
groupClusters: (clusters) => {
// Single-shot atomic version of group() that creates one group per
// cluster of ids, with one history entry covering all of them. Used by
// "Auto-group by proximity" so undo collapses to a single step.
const validClusters = clusters.filter((cluster) => cluster.length >= 2);
if (validClusters.length === 0) return [];
get().pushHistory();
const newGroupIds: string[] = [];
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
let newRootOrder = [...s.rootOrder];
for (const cluster of validClusters) {
const topLevelIds = cluster.filter((id) => {
const n = newNodes[id];
return n && n.parentId === null;
});
if (topLevelIds.length < 2) continue;
const aabb = computeAABBById(newNodes, topLevelIds);
if (!aabb) continue;
const groupId = uuidv4();
const groupNode: GroupNode = {
id: groupId,
kind: "group",
parentId: null,
x: aabb.x,
y: aabb.y,
width: aabb.width,
height: aabb.height,
rotation: 0,
zIndex: newRootOrder.length,
childIds: topLevelIds,
};
topLevelIds.forEach((id) => {
const n = newNodes[id];
if (!n) return;
newNodes[id] = {
...n,
parentId: groupId,
x: n.x - aabb.x,
y: n.y - aabb.y,
} as MoodboardNode;
});
newNodes[groupId] = groupNode;
newRootOrder = [
...newRootOrder.filter((id) => !topLevelIds.includes(id)),
groupId,
];
newGroupIds.push(groupId);
}
return {
nodes: newNodes,
rootOrder: newRootOrder,
selectedIds: new Set(newGroupIds),
};
});
return newGroupIds;
},
ungroup: () => {
const { selectedIds, nodes } = get();
const groupIds = Array.from(selectedIds).filter(
(id) => nodes[id]?.kind === "group",
);
if (groupIds.length === 0) return;
get().pushHistory();
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
const newRootOrder = [...s.rootOrder];
const releasedChildIds: string[] = [];
groupIds.forEach((gid) => {
const g = newNodes[gid];
if (!g || g.kind !== "group") return;
// Rebase children back to stage-local using group's transform.
g.childIds.forEach((cid) => {
const child = newNodes[cid];
if (!child) return;
const local = { x: child.x, y: child.y };
const stagePoint = applyGroupTransform(local, g);
newNodes[cid] = {
...child,
parentId: null,
x: stagePoint.x,
y: stagePoint.y,
// Carry the group's rotation onto the child so the visual stays put.
rotation: child.rotation + g.rotation,
} as MoodboardNode;
releasedChildIds.push(cid);
});
delete newNodes[gid];
const idx = newRootOrder.indexOf(gid);
if (idx >= 0) newRootOrder.splice(idx, 1);
});
releasedChildIds.forEach((cid) => newRootOrder.push(cid));
return {
nodes: newNodes,
rootOrder: newRootOrder,
selectedIds: new Set(releasedChildIds),
};
});
},
setMarquee: (r) =>
set((s) => ({ transient: { ...s.transient, marquee: r } })),
setLassoPath: (p) =>
set((s) => ({ transient: { ...s.transient, lassoPath: p } })),
setEditingText: (id) =>
set((s) => ({ transient: { ...s.transient, editingTextId: id } })),
setIsPanning: (v) =>
set((s) => ({ transient: { ...s.transient, isPanning: v } })),
snapshot: () => {
const { nodes, rootOrder, selectedIds } = get();
return {
nodes: structuredClone(nodes),
rootOrder: [...rootOrder],
selectedIds: Array.from(selectedIds),
};
},
applySnapshot: (snap) => {
set({
nodes: structuredClone(snap.nodes),
rootOrder: [...snap.rootOrder],
selectedIds: new Set(snap.selectedIds),
});
},
pushHistory: () => {
useMoodboardHistoryStore.getState().push(get().snapshot());
},
undo: () => {
const current = get().snapshot();
const prior = useMoodboardHistoryStore.getState().undo(current);
if (prior) get().applySnapshot(prior);
},
redo: () => {
const current = get().snapshot();
const next = useMoodboardHistoryStore.getState().redo(current);
if (next) get().applySnapshot(next);
},
reset: () => {
useMoodboardHistoryStore.getState().clear();
set({
nodes: {},
rootOrder: [],
selectedIds: new Set(),
tool: "select",
lastDropPoint: { x: 200, y: 200 },
viewport: { zoom: 1, pan: { x: 0, y: 0 } },
transient: {
marquee: null,
lassoPath: null,
editingTextId: null,
isPanning: false,
},
});
},
}));
// ---------- helpers ----------
const stripAncestorsAndDescendants = (
nodes: Record<string, MoodboardNode>,
ids: Set<string>,
): Set<string> => {
const result = new Set(ids);
// Drop any node whose ancestor (parent chain up to root) is in the set.
ids.forEach((id) => {
let cur = nodes[id];
while (cur && cur.parentId) {
if (result.has(cur.parentId)) {
result.delete(id);
break;
}
cur = nodes[cur.parentId];
}
});
// Drop any descendant whose ancestor group is in the set.
ids.forEach((id) => {
const n = nodes[id];
if (n && n.kind === "group") {
const stack = [...n.childIds];
while (stack.length) {
const c = stack.pop()!;
if (result.has(c)) result.delete(c);
const cn = nodes[c];
if (cn && cn.kind === "group") stack.push(...cn.childIds);
}
}
});
return result;
};
// Maps a group-local point to stage-local coords using the group's transform.
const applyGroupTransform = (local: Vec2, g: GroupNode): Vec2 => {
const rad = (g.rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: g.x + local.x * cos - local.y * sin,
y: g.y + local.x * sin + local.y * cos,
};
};
@@ -0,0 +1,208 @@
import {
faMousePointer,
faDrawPolygon,
faObjectGroup,
faObjectUngroup,
faGripVertical,
faTableCells,
faDiagramProject,
faRotateLeft,
faRotateRight,
faTrash,
faPlus,
faImages,
faArrowUpFromBracket,
faText,
} from "@fortawesome/pro-solid-svg-icons";
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 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)" },
];
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);
const groupClusters = useMoodboardStore((s) => s.groupClusters);
const ungroup = useMoodboardStore((s) => s.ungroup);
const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
const undo = useMoodboardStore((s) => s.undo);
const redo = useMoodboardStore((s) => s.redo);
const applyLayoutPatches = useMoodboardStore((s) => s.applyLayoutPatches);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const gridSpacing = useMoodboardStore((s) => s.gridSpacing);
const handleFitToGrid = () => {
if (selectedIds.size === 0) return;
const targetNodes = Array.from(selectedIds)
.map((id) => nodes[id])
.filter(Boolean);
applyLayoutPatches(computeFitToGridPatches(targetNodes, gridSpacing));
};
const handlePackCollage = () => {
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 aabb = computeAABB(targetNodes);
if (!aabb) return;
applyLayoutPatches(computePackPatches(targetNodes, aabb));
};
const handleAutoGroup = () => {
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));
groupClusters(clusterIds);
};
const handleAddAction = (action: string) => {
if (action === "upload") onUploadClick();
else if (action === "library") onGalleryClick();
};
return (
<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 (${fmtShortcut([MOD, "G"])})`}
onClick={() => group()}
disabled={selectedIds.size < 2}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faObjectUngroup}
label={`Ungroup (${fmtShortcut([MOD, "Shift", "G"])})`}
onClick={() => ungroup()}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faTableCells}
label="Fit to grid"
onClick={handleFitToGrid}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faGripVertical}
label="Pack collage"
onClick={handlePackCollage}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faDiagramProject}
label="Auto-group by proximity"
onClick={handleAutoGroup}
tooltipDelay={100}
/>
<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>
);
};
@@ -0,0 +1,82 @@
import { useEffect, useRef } from "react";
import { useMoodboardStore } from "./MoodboardStore";
interface Props {
containerRef: React.RefObject<HTMLDivElement | null>;
}
// Floats a positioned <textarea> over the editing text node so the user can
// type directly. Konva Text has no native edit mode; this is the standard
// react-konva pattern. Phase 1 has no zoom/pan, so absolute positioning is
// simply (node.x, node.y) within the container.
export const TextEditOverlay = ({ containerRef }: Props) => {
const editingId = useMoodboardStore((s) => s.transient.editingTextId);
const node = useMoodboardStore((s) =>
editingId ? s.nodes[editingId] : null,
);
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const initialTextRef = useRef<string>("");
useEffect(() => {
if (editingId && node && node.kind === "text") {
initialTextRef.current = node.text;
requestAnimationFrame(() => {
taRef.current?.focus();
taRef.current?.select();
});
}
}, [editingId, node]);
if (!editingId || !node || node.kind !== "text") return null;
const commit = () => {
if (!taRef.current) return;
const newText = taRef.current.value;
if (newText !== initialTextRef.current) {
pushHistory();
updateNode(node.id, { text: newText });
}
setEditingText(null);
};
const cancel = () => {
setEditingText(null);
};
return (
<textarea
ref={taRef}
defaultValue={node.text}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
}
}}
style={{
position: "absolute",
left: node.x,
top: node.y,
width: node.width,
height: node.height,
background: "rgba(15,15,18,0.85)",
color: node.color,
border: "1px dashed rgba(59,130,246,0.6)",
outline: "none",
padding: 6,
fontSize: node.fontSize,
fontFamily: "inherit",
resize: "none",
overflow: "hidden",
zIndex: 5,
}}
/>
);
};
@@ -0,0 +1,37 @@
import { CardNode, MoodboardNode } from "./types";
export const DEFAULT_CARD_WIDTH = 280;
export const DEFAULT_CARD_PADDING = 8;
export const DEFAULT_CARD_COLOR = "#FFFFFF";
// Height of one child when laid out inside a card: child's native aspect
// ratio applied to (cardWidth - 2*padding), falling back to the child's own
// height when aspect isn't available (text nodes, nested containers).
export const childRenderedHeight = (
child: MoodboardNode,
innerWidth: number,
): number => {
if (child.kind === "image" || child.kind === "video") {
const ratio =
child.naturalH > 0 && child.naturalW > 0
? child.naturalH / child.naturalW
: child.height / Math.max(child.width, 1);
return innerWidth * ratio;
}
if (child.kind === "text") return child.height;
return child.height;
};
export const computeCardHeight = (
card: CardNode,
nodes: Record<string, MoodboardNode>,
): number => {
const innerW = Math.max(card.width - card.padding * 2, 1);
let total = card.padding;
for (const cid of card.childIds) {
const c = nodes[cid];
if (!c) continue;
total += childRenderedHeight(c, innerW) + card.padding;
}
return Math.max(total, card.padding * 2);
};
@@ -0,0 +1,3 @@
export { Moodboard } from "./Moodboard";
export { useMoodboardStore } from "./MoodboardStore";
export { useMoodboardHistoryStore } from "./MoodboardHistoryStore";
@@ -0,0 +1,61 @@
import { useEffect } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
interface GalleryItem {
id?: string;
thumbnail?: string | null;
fullImage?: string | null;
}
interface GalleryMoodboardDropDetail {
item: GalleryItem;
canvasPosition: { x: number; y: number };
}
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const i = new window.Image();
i.crossOrigin = "anonymous";
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = () => resolve({ w: 320, h: 320 });
i.src = url;
});
// Listens for gallery-moodboard-drop CustomEvents dispatched by the
// PageEditor when the user drops a gallery item over the canvas. Mirrors
// PageDraw's gallery-2d-drop listener pattern but scoped to moodboard.
export const useGalleryDropEvent = (
active: boolean,
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const addImage = useMoodboardStore((s) => s.addImage);
useEffect(() => {
if (!active) return undefined;
const handler = async (e: Event) => {
const ce = e as CustomEvent<GalleryMoodboardDropDetail>;
const detail = ce.detail;
if (!detail) return;
const { item, canvasPosition } = detail;
const url = item.fullImage || item.thumbnail;
if (!url) return;
const stage = stageRef.current;
const stagePoint = stage
? {
x: (canvasPosition.x - stage.x()) / stage.scaleX(),
y: (canvasPosition.y - stage.y()) / stage.scaleY(),
}
: canvasPosition;
const dims = await measure(url);
addImage(url, stagePoint, dims.w, dims.h, item.id ?? null);
};
window.addEventListener("gallery-moodboard-drop", handler);
return () =>
window.removeEventListener("gallery-moodboard-drop", handler);
}, [active, addImage, stageRef]);
};
@@ -0,0 +1,77 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../MoodboardStore";
const isEditableTarget = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
if (/input|textarea/i.test(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
};
// Figma-style shortcuts that aren't already wired elsewhere.
// Cmd+Z / Cmd+Shift+Z live in useUndoRedo; Cmd+0 + Space-pan live in
// useViewportControls; Delete / Escape live in Moodboard.tsx.
export const useKeyboardShortcuts = (active: boolean) => {
const setTool = useMoodboardStore((s) => s.setTool);
const selectAll = useMoodboardStore((s) => s.selectAll);
const group = useMoodboardStore((s) => s.group);
const ungroup = useMoodboardStore((s) => s.ungroup);
const fitToContent = useMoodboardStore((s) => s.fitToContent);
useEffect(() => {
if (!active) return undefined;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const handler = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return;
// Skip while typing in the text-edit overlay (covered by isEditableTarget
// too, but belt-and-suspenders in case the overlay loses target focus).
if (useMoodboardStore.getState().transient.editingTextId) return;
const mod = isMac ? e.metaKey : e.ctrlKey;
const k = e.key.toLowerCase();
// Modifier combos
if (mod && !e.shiftKey && k === "a") {
e.preventDefault();
selectAll();
return;
}
if (mod && !e.shiftKey && k === "g") {
e.preventDefault();
group();
return;
}
if (mod && e.shiftKey && k === "g") {
e.preventDefault();
ungroup();
return;
}
// Shift-only combos
if (e.shiftKey && !mod && !e.altKey && k === "1") {
e.preventDefault();
fitToContent();
return;
}
// Plain (no modifiers) tool switches — skip if any modifier is held
// so Cmd+V (paste) etc. never triggers the Text tool.
if (mod || e.shiftKey || e.altKey) return;
if (k === "v") {
e.preventDefault();
setTool("select");
} else if (k === "l") {
e.preventDefault();
setTool("lasso");
} else if (k === "t") {
e.preventDefault();
setTool("text");
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [active, setTool, selectAll, group, ungroup, fitToContent]);
};
@@ -0,0 +1,74 @@
import { useEffect } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { uploadImage } from "~/components/reusable/UploadModalImage/utilities/uploadImage";
// Loads natural dimensions for an image URL.
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve, reject) => {
const i = new window.Image();
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = reject;
i.src = url;
});
// Listens for paste events while the moodboard page is mounted. For each
// image entry on the clipboard, immediately drops a blob-URL image onto the
// canvas at the viewport center, then asynchronously uploads it to the
// library so it lives in the user's gallery.
export const usePasteHandler = (
active: boolean,
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const addImage = useMoodboardStore((s) => s.addImage);
useEffect(() => {
if (!active) return undefined;
const handler = async (e: ClipboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
const items = e.clipboardData?.items;
if (!items) return;
const fileItems: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === "file" && it.type.startsWith("image/")) {
const f = it.getAsFile();
if (f) fileItems.push(f);
}
}
if (fileItems.length === 0) return;
e.preventDefault();
const stage = stageRef.current;
const center = stage
? { x: stage.width() / 2, y: stage.height() / 2 }
: { x: 400, y: 400 };
for (const file of fileItems) {
const blobUrl = URL.createObjectURL(file);
try {
const dims = await measure(blobUrl);
addImage(blobUrl, center, dims.w, dims.h, null);
} catch (err) {
console.error("[Moodboard] paste measure failed", err);
addImage(blobUrl, center, 320, 320, null);
}
// Fire-and-forget: also push to library so it appears in gallery.
uploadImage({
title: "Pasted image",
assetFile: file,
progressCallback: () => {},
}).catch((err) => {
console.error("[Moodboard] background upload failed", err);
});
}
};
window.addEventListener("paste", handler);
return () => window.removeEventListener("paste", handler);
}, [active, addImage, stageRef]);
};
@@ -0,0 +1,141 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { Vec2 } from "../types";
import { stagePointerPos } from "./useStagePointer";
import { pointInPolygon } from "../layout/pointInPolygon";
import { polygonIntersectsRect } from "../layout/segmentsIntersect";
import { rectCorners, rectFromPoints, rectsIntersect } from "../layout/geometry";
const MIN_DRAG = 4; // screen px before a press becomes a drag
interface DragState {
origin: Vec2;
path: Vec2[];
active: boolean;
mode: "rect" | "lasso" | null;
}
// Selection state machine for rectangle marquee (Select tool) and free-form
// lasso (Lasso tool). Pan gestures (middle-mouse, spacebar+left) are owned
// by `useViewportControls` and stop the event at the capture phase, so
// they never reach Konva's mousedown and therefore never reach this hook.
export const useSelection = (
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const drag = useRef<DragState>({
origin: { x: 0, y: 0 },
path: [],
active: false,
mode: null,
});
const tool = useMoodboardStore((s) => s.tool);
const setMarquee = useMoodboardStore((s) => s.setMarquee);
const setLassoPath = useMoodboardStore((s) => s.setLassoPath);
const setSelection = useMoodboardStore((s) => s.setSelection);
useEffect(() => {
if (tool !== "select" && tool !== "lasso") return;
const stage = stageRef.current;
if (!stage) return;
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
// Only on bare canvas — clicks on nodes are handled by the node's
// own onMouseDown for single-select.
if (e.target !== stage) return;
// Primary button only; middle-mouse is the pan hook's domain.
if (e.evt.button !== 0) return;
const pos = stagePointerPos(stage);
if (!pos) return;
drag.current = {
origin: pos,
path: tool === "lasso" ? [pos] : [],
active: true,
mode: tool === "lasso" ? "lasso" : "rect",
};
};
const handleMouseMove = () => {
if (!drag.current.active) return;
const pos = stagePointerPos(stage);
if (!pos) return;
if (drag.current.mode === "rect") {
// Suppress marquee until the drag exceeds a threshold so bare clicks
// don't leave a zero-area rect behind.
const dx = pos.x - drag.current.origin.x;
const dy = pos.y - drag.current.origin.y;
if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) return;
setMarquee(rectFromPoints(drag.current.origin, pos));
} else if (drag.current.mode === "lasso") {
drag.current.path.push(pos);
// Slice copy so subscribers see a new identity.
setLassoPath([...drag.current.path]);
}
};
const commitSelection = () => {
const { nodes, rootOrder } = useMoodboardStore.getState();
const candidates = rootOrder
.map((id) => nodes[id])
.filter((n): n is NonNullable<typeof n> => Boolean(n));
if (drag.current.mode === "rect") {
const rect = useMoodboardStore.getState().transient.marquee;
if (!rect || (rect.width < MIN_DRAG && rect.height < MIN_DRAG)) {
setSelection([]);
} else {
const hit = candidates
.filter((n) =>
rectsIntersect(
{ x: n.x, y: n.y, width: n.width, height: n.height },
rect,
),
)
.map((n) => n.id);
setSelection(hit);
}
setMarquee(null);
} else if (drag.current.mode === "lasso") {
const path = drag.current.path;
if (path.length < 3) {
setSelection([]);
} else {
const closed = [...path, path[0]];
const hit = candidates
.filter((n) => {
const corners = rectCorners(n);
if (corners.some((c) => pointInPolygon(c, closed))) return true;
if (polygonIntersectsRect(closed, n)) return true;
return false;
})
.map((n) => n.id);
setSelection(hit);
}
setLassoPath(null);
}
drag.current.active = false;
drag.current.mode = null;
drag.current.path = [];
};
const handleMouseUp = () => {
if (!drag.current.active) return;
commitSelection();
};
stage.on("mousedown.moodboardSelection", handleMouseDown);
stage.on("mousemove.moodboardSelection", handleMouseMove);
stage.on("mouseup.moodboardSelection", handleMouseUp);
const onWindowUp = () => {
if (drag.current.active) commitSelection();
};
window.addEventListener("mouseup", onWindowUp);
return () => {
stage.off("mousedown.moodboardSelection");
stage.off("mousemove.moodboardSelection");
stage.off("mouseup.moodboardSelection");
window.removeEventListener("mouseup", onWindowUp);
};
}, [stageRef, tool, setMarquee, setLassoPath, setSelection]);
};
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
const HOLD_MS = 3000;
// Shows the cheatsheet when the user holds Ctrl (or Cmd on Mac) alone for
// HOLD_MS with no other key pressed. Any other keydown cancels the timer,
// so a real `Ctrl+C` / `Cmd+X` never trips it. Releasing the modifier hides
// the cheatsheet immediately.
export const useShortcutCheatsheet = (): boolean => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const modKey = isMac ? "Meta" : "Control";
let timer: ReturnType<typeof setTimeout> | null = null;
let modHeld = false;
const clearTimer = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const hide = () => {
setVisible((v) => (v ? false : v));
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === modKey) {
// Ignore auto-repeat keydown while already held.
if (modHeld) return;
modHeld = true;
clearTimer();
timer = setTimeout(() => {
timer = null;
setVisible(true);
}, HOLD_MS);
return;
}
// Any non-modifier key press while the modifier is held cancels the
// planned cheatsheet — the user is running a real combo.
clearTimer();
hide();
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === modKey) {
modHeld = false;
clearTimer();
hide();
}
};
// Losing focus while holding the key should also cancel.
const onBlur = () => {
modHeld = false;
clearTimer();
hide();
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
clearTimer();
};
}, []);
return visible;
};
@@ -0,0 +1,17 @@
import Konva from "konva";
import { Vec2 } from "../types";
// Converts a raw KonvaEventObject pointer position to stage-local coordinates,
// accounting for stage offset and scale. Phase 1 has no zoom/pan so this is a
// thin wrapper, but encapsulating it keeps interaction hooks consistent.
export const stagePointerPos = (
stage: Konva.Stage | null,
): Vec2 | null => {
if (!stage) return null;
const pointer = stage.getPointerPosition();
if (!pointer) return null;
return {
x: (pointer.x - stage.x()) / stage.scaleX(),
y: (pointer.y - stage.y()) / stage.scaleY(),
};
};
@@ -0,0 +1,153 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { MoodboardNode } from "../types";
interface NodePatch {
id: string;
patch: Partial<MoodboardNode>;
}
// Owns a single Konva.Transformer ref. Re-attaches its `nodes()` whenever
// the selection OR the root structure changes (e.g. group/ungroup
// reparented the previously-selected ids). On transformend, bakes
// scaleX/scaleY into width/height — and for groups, propagates the scale
// into each child's position/size so the resize sticks (Figma/Excalidraw
// behavior) instead of snapping back when the Group's scale resets to 1.
export const useTransformer = (stageRef: React.RefObject<Konva.Stage | null>) => {
const transformerRef = useRef<Konva.Transformer | null>(null);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
// rootOrder is included so a re-attach happens after group()/ungroup()
// reparents Konva nodes — without it, findOne can return stale results.
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
useEffect(() => {
const stage = stageRef.current;
const transformer = transformerRef.current;
if (!stage || !transformer) return;
// Defer one frame so any pending Konva re-render commits before we
// findOne — otherwise the new Group node may not exist yet.
const raf = requestAnimationFrame(() => {
const selectedNodes: Konva.Node[] = [];
selectedIds.forEach((id) => {
// Plain `#id` lookup — Konva's selector parser strips all spaces, so
// a CSS.escape'd id (e.g. `\38 abc...` for a UUID starting with `8`)
// ends up being compared as `\38abc...` and never matches. Our ids
// are UUIDs (hex + dashes), which are already safe selectors.
const found = stage.findOne(`#${id}`);
if (found) selectedNodes.push(found);
});
transformer.nodes(selectedNodes);
transformer.forceUpdate();
transformer.getLayer()?.batchDraw();
});
return () => cancelAnimationFrame(raf);
}, [selectedIds, rootOrder, stageRef]);
useEffect(() => {
const transformer = transformerRef.current;
if (!transformer) return;
const handleTransformEnd = () => {
const store = useMoodboardStore.getState();
store.pushHistory();
const patches: NodePatch[] = [];
transformer.nodes().forEach((node) => {
const id = node.id();
if (!id) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const storeNode = store.nodes[id];
if (!storeNode) return;
// Groups: bake the scale into each child's position + size so the
// resize survives the Group's own scale reset. Konva's <Group>
// doesn't store width/height in a way that lets the children just
// inherit the scale — we have to push it down explicitly.
if (storeNode.kind === "group") {
const uniformScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2;
for (const childId of storeNode.childIds) {
const child = store.nodes[childId];
if (!child) continue;
const childPatch: Partial<MoodboardNode> = {
x: child.x * scaleX,
y: child.y * scaleY,
width: child.width * scaleX,
height: child.height * scaleY,
};
// Text inside a scaled group needs fontSize to track the scale,
// otherwise the text stays the same size and overflows / floats.
if (child.kind === "text") {
(childPatch as Partial<MoodboardNode> & { fontSize?: number })
.fontSize = child.fontSize * uniformScale;
}
patches.push({ id: childId, patch: childPatch });
}
}
// Bake the scale into the node's own width/height too, then reset
// the Konva scale so subsequent transforms compose cleanly.
const baseW = node.width() || 1;
const baseH = node.height() || 1;
const newWidth = baseW * scaleX;
const newHeight = baseH * scaleY;
node.scaleX(1);
node.scaleY(1);
node.width(newWidth);
node.height(newHeight);
patches.push({
id,
patch: {
x: node.x(),
y: node.y(),
width: newWidth,
height: newHeight,
rotation: node.rotation(),
},
});
});
// Apply all patches in one store mutation so the tree only renders
// once — keeps the visual transition from "scaled preview" to "baked
// result" tight, with no flicker.
if (patches.length === 0) return;
useMoodboardStore.setState((s) => {
const nodes = { ...s.nodes };
for (const { id, patch } of patches) {
const existing = nodes[id];
if (!existing) continue;
nodes[id] = { ...existing, ...patch } as MoodboardNode;
}
return { nodes };
});
// The Transformer caches its bbox from `getClientRect()` of the
// attached node. For a Konva.Group, that walks children — but the
// children haven't React-re-rendered to their new sizes yet, so a
// sync forceUpdate would read the OLD bounds and the box would snap
// back. Defer one frame so the children commit first.
requestAnimationFrame(() => {
transformer.forceUpdate();
transformer.getLayer()?.batchDraw();
});
};
transformer.on("transformend", handleTransformEnd);
return () => {
transformer.off("transformend", handleTransformEnd);
};
}, []);
// Hide transformer entirely while editing a text node (avoid handle conflicts).
useEffect(() => {
const transformer = transformerRef.current;
if (!transformer) return;
transformer.visible(editingTextId === null);
transformer.getLayer()?.batchDraw();
}, [editingTextId]);
return transformerRef;
};
@@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../MoodboardStore";
// Standard editor hotkeys:
// - Cmd+Z (Mac) / Ctrl+Z (Win) → undo
// - Cmd+Shift+Z (Mac), Ctrl+Y or Ctrl+Shift+Z (Win) → redo
export const useUndoRedo = (active: boolean) => {
const undo = useMoodboardStore((s) => s.undo);
const redo = useMoodboardStore((s) => s.redo);
useEffect(() => {
if (!active) return undefined;
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === "z" && !e.shiftKey) {
e.preventDefault();
undo();
} else if (
(isMac && k === "z" && e.shiftKey) ||
(!isMac && k === "y") ||
(!isMac && k === "z" && e.shiftKey)
) {
e.preventDefault();
redo();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [active, undo, redo]);
};
@@ -0,0 +1,181 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { Vec2 } from "../types";
import { ZOOM_MAX, ZOOM_MIN, clamp } from "../layout/geometry";
const ZOOM_STEP = 1.1;
const isEditableTarget = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
if (/input|textarea/i.test(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
};
// Centralizes wheel-zoom and three pan gestures (drag-empty, spacebar+drag,
// middle-click drag) for the moodboard canvas. Reads/writes viewport from
// the store directly — no more ref props, which keeps a future minimap or
// other camera-driving UI (recenter button, Shift+1 fit, etc.) able to
// co-exist with these gestures.
export const useViewportControls = (
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const setZoom = useMoodboardStore((s) => s.setZoom);
const setPan = useMoodboardStore((s) => s.setPan);
const resetViewport = useMoodboardStore((s) => s.resetViewport);
const setIsPanning = useMoodboardStore((s) => s.setIsPanning);
// Track spacebar so any subsequent mousedown becomes a pan, not a select.
const spaceHeld = useRef(false);
// Whether a pan drag is currently in flight, plus its origin in screen px
// and the pan offset captured at drag start.
const panState = useRef<{
active: boolean;
startScreen: Vec2;
startPan: Vec2;
}>({
active: false,
startScreen: { x: 0, y: 0 },
startPan: { x: 0, y: 0 },
});
// Wheel: zoom around cursor.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const container = stage.container();
const handleWheel = (e: WheelEvent) => {
// Ignore zoom if the user is typing in the text-edit overlay or an input.
if (isEditableTarget(document.activeElement)) return;
e.preventDefault();
const { viewport } = useMoodboardStore.getState();
const oldScale = viewport.zoom;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const stagePos = viewport.pan;
const mousePointTo = {
x: (pointer.x - stagePos.x) / oldScale,
y: (pointer.y - stagePos.y) / oldScale,
};
const direction = e.deltaY > 0 ? -1 : 1;
const newScale = clamp(
direction > 0 ? oldScale * ZOOM_STEP : oldScale / ZOOM_STEP,
ZOOM_MIN,
ZOOM_MAX,
);
const newPan = {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
setZoom(newScale);
setPan(newPan);
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => {
container.removeEventListener("wheel", handleWheel);
};
}, [stageRef, setZoom, setPan]);
// Pan gestures: middle-mouse drag, or spacebar+left-drag. Plain left
// click is reserved for selection (clicking nodes, lasso, future marquee)
// so it never triggers pan, even on empty canvas.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const container = stage.container();
const beginPan = (clientX: number, clientY: number) => {
panState.current = {
active: true,
startScreen: { x: clientX, y: clientY },
startPan: { ...useMoodboardStore.getState().viewport.pan },
};
setIsPanning(true);
container.style.cursor = "grabbing";
};
const endPan = () => {
if (!panState.current.active) return;
panState.current.active = false;
setIsPanning(false);
container.style.cursor = spaceHeld.current ? "grab" : "";
};
const handleMouseDown = (e: MouseEvent) => {
// Middle-mouse always pans. stopPropagation so Konva's internal
// mousedown dispatcher never runs — otherwise `useSelection` would
// start a marquee underneath the pan.
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
return;
}
// Spacebar + left-click pans (Figma convention).
if (e.button === 0 && spaceHeld.current) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!panState.current.active) return;
const dx = e.clientX - panState.current.startScreen.x;
const dy = e.clientY - panState.current.startScreen.y;
setPan({
x: panState.current.startPan.x + dx,
y: panState.current.startPan.y + dy,
});
};
const handleMouseUp = () => endPan();
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return;
if (e.code === "Space" && !spaceHeld.current) {
spaceHeld.current = true;
if (!panState.current.active) container.style.cursor = "grab";
// Prevent page from scrolling on spacebar.
e.preventDefault();
}
// Cmd/Ctrl+0 → reset zoom + pan.
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod && e.key === "0") {
e.preventDefault();
resetViewport();
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") {
spaceHeld.current = false;
if (!panState.current.active) container.style.cursor = "";
}
};
// Capture-phase so we run BEFORE Konva's internal mousedown dispatch.
// stopPropagation inside pan-claim branches then prevents Konva from
// forwarding the event to node/stage handlers.
container.addEventListener("mousedown", handleMouseDown, { capture: true });
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
container.removeEventListener("mousedown", handleMouseDown, {
capture: true,
} as EventListenerOptions);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
container.style.cursor = "";
};
}, [stageRef, setPan, resetViewport, setIsPanning]);
};
@@ -0,0 +1,64 @@
import { MoodboardNode, Vec2 } from "../types";
const center = (n: MoodboardNode): Vec2 => ({
x: n.x + n.width / 2,
y: n.y + n.height / 2,
});
const median = (xs: number[]): number => {
if (xs.length === 0) return 0;
const sorted = [...xs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
};
export const defaultEpsForNodes = (nodes: MoodboardNode[]): number => {
if (nodes.length === 0) return 0;
const dims = nodes.map((n) => Math.max(n.width, n.height));
return median(dims) * 1.5;
};
// DBSCAN with minPts = 1 → equivalent to connected-components on the
// eps-graph. Every input node ends up in some cluster (no noise points).
export const clusterByProximity = (
nodes: MoodboardNode[],
eps?: number,
): MoodboardNode[][] => {
if (nodes.length === 0) return [];
const e = eps ?? defaultEpsForNodes(nodes);
const e2 = e * e;
const centers = nodes.map(center);
const visited = new Array(nodes.length).fill(false);
const clusters: MoodboardNode[][] = [];
const neighbors = (i: number): number[] => {
const ci = centers[i];
const out: number[] = [];
for (let j = 0; j < centers.length; j++) {
if (j === i) continue;
const dx = centers[j].x - ci.x;
const dy = centers[j].y - ci.y;
if (dx * dx + dy * dy <= e2) out.push(j);
}
return out;
};
for (let i = 0; i < nodes.length; i++) {
if (visited[i]) continue;
const cluster: MoodboardNode[] = [];
const queue = [i];
while (queue.length) {
const idx = queue.shift()!;
if (visited[idx]) continue;
visited[idx] = true;
cluster.push(nodes[idx]);
for (const n of neighbors(idx)) {
if (!visited[n]) queue.push(n);
}
}
clusters.push(cluster);
}
return clusters;
};
@@ -0,0 +1,23 @@
import { MoodboardNode } from "../types";
export interface LayoutPatch {
id: string;
patch: Partial<MoodboardNode>;
}
export const computeFitToGridPatches = (
nodes: MoodboardNode[],
gridSpacing: number,
): LayoutPatch[] => {
if (gridSpacing <= 0) return [];
const patches: LayoutPatch[] = [];
for (const n of nodes) {
if (n.parentId !== null) continue; // only snap top-level nodes
const snappedX = Math.round(n.x / gridSpacing) * gridSpacing;
const snappedY = Math.round(n.y / gridSpacing) * gridSpacing;
if (snappedX !== n.x || snappedY !== n.y) {
patches.push({ id: n.id, patch: { x: snappedX, y: snappedY } });
}
}
return patches;
};
@@ -0,0 +1,174 @@
import { MoodboardNode, Rect, Vec2 } from "../types";
export interface Viewport {
zoom: number;
pan: Vec2;
}
export interface CanvasSize {
width: number;
height: number;
}
// Global zoom clamps — shared by gesture handlers, fit-to-content, and any
// future minimap so the camera can never land outside a consistent range.
export const ZOOM_MIN = 0.1;
export const ZOOM_MAX = 8;
// ---------- scalar + rect primitives ----------
export const clamp = (v: number, lo: number, hi: number): number =>
Math.max(lo, Math.min(hi, v));
export const rectFromPoints = (a: Vec2, b: Vec2): Rect => ({
x: Math.min(a.x, b.x),
y: Math.min(a.y, b.y),
width: Math.abs(b.x - a.x),
height: Math.abs(b.y - a.y),
});
export const rectCorners = (r: {
x: number;
y: number;
width: number;
height: number;
}): Vec2[] => [
{ x: r.x, y: r.y },
{ x: r.x + r.width, y: r.y },
{ x: r.x + r.width, y: r.y + r.height },
{ x: r.x, y: r.y + r.height },
];
export const rectsIntersect = (a: Rect, b: Rect): boolean =>
!(
a.x + a.width < b.x ||
a.x > b.x + b.width ||
a.y + a.height < b.y ||
a.y > b.y + b.height
);
// ---------- AABB over a set of nodes ----------
export const computeAABB = (nodes: MoodboardNode[]): Rect | null => {
if (nodes.length === 0) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of nodes) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + n.width);
maxY = Math.max(maxY, n.y + n.height);
}
if (!Number.isFinite(minX)) return null;
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
};
export const computeAABBById = (
nodes: Record<string, MoodboardNode>,
ids: string[],
): Rect | null => {
const picked: MoodboardNode[] = [];
for (const id of ids) {
const n = nodes[id];
if (n) picked.push(n);
}
return computeAABB(picked);
};
// AABB of every top-level node. Treats rotated nodes as their own
// (x, y, w, h) rect for simplicity — fine for "is content on screen?"
// checks and minimap overviews.
export const computeContentBounds = (
nodes: Record<string, MoodboardNode>,
rootOrder: string[],
): Rect | null => computeAABBById(nodes, rootOrder);
// ---------- viewport math ----------
// Visible stage-coord rect = what the camera currently sees.
// canvas_px = pan + stage_coord * zoom, so the visible stage region maps to
// (-pan / zoom) .. (-pan / zoom + canvas / zoom).
export const computeVisibleRect = (
viewport: Viewport,
canvas: CanvasSize,
): Rect => ({
x: -viewport.pan.x / viewport.zoom,
y: -viewport.pan.y / viewport.zoom,
width: canvas.width / viewport.zoom,
height: canvas.height / viewport.zoom,
});
// True if any top-level node's AABB intersects the visible viewport.
// Used by the recenter indicator; a minimap can use this to style the
// viewport rectangle differently when off-content.
export const anyContentVisible = (
nodes: Record<string, MoodboardNode>,
rootOrder: string[],
viewport: Viewport,
canvas: CanvasSize,
): boolean => {
const visible = computeVisibleRect(viewport, canvas);
for (const id of rootOrder) {
const n = nodes[id];
if (!n) continue;
if (
rectsIntersect(
{ x: n.x, y: n.y, width: n.width, height: n.height },
visible,
)
) {
return true;
}
}
return false;
};
// Returns the viewport that frames `bounds` inside `canvas` with `padding`
// screen pixels of margin on all sides, clamped to [ZOOM_MIN, ZOOM_MAX].
// Degenerate bounds (zero width/height) fall back to zoom=1 centered on
// the point.
export const fitViewportToBounds = (
bounds: Rect,
canvas: CanvasSize,
padding: number,
): Viewport => {
if (bounds.width <= 0 || bounds.height <= 0) {
return {
zoom: 1,
pan: {
x: canvas.width / 2 - bounds.x,
y: canvas.height / 2 - bounds.y,
},
};
}
const availW = Math.max(canvas.width - 2 * padding, 1);
const availH = Math.max(canvas.height - 2 * padding, 1);
const zoom = clamp(
Math.min(availW / bounds.width, availH / bounds.height),
ZOOM_MIN,
ZOOM_MAX,
);
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
zoom,
pan: {
x: canvas.width / 2 - centerX * zoom,
y: canvas.height / 2 - centerY * zoom,
},
};
};
// Returns the pan that centers the viewport on a given stage-coord point
// at the current zoom. Handy for minimap drag-to-navigate: stage point
// under the minimap cursor → pan such that it lands in the canvas center.
export const panToCenterOn = (
stagePoint: Vec2,
zoom: number,
canvas: CanvasSize,
): Vec2 => ({
x: canvas.width / 2 - stagePoint.x * zoom,
y: canvas.height / 2 - stagePoint.y * zoom,
});
@@ -0,0 +1,72 @@
import { MoodboardNode, Rect } from "../types";
import { LayoutPatch } from "./fitToGrid";
export interface PackOptions {
rowHeight: number;
gap: number;
}
const DEFAULTS: PackOptions = { rowHeight: 180, gap: 8 };
export const computePackPatches = (
nodes: MoodboardNode[],
bounds: Rect,
opts: Partial<PackOptions> = {},
): LayoutPatch[] => {
const { rowHeight, gap } = { ...DEFAULTS, ...opts };
if (nodes.length === 0 || bounds.width <= 0) return [];
const aspects = nodes.map((n) =>
n.height > 0 ? n.width / n.height : 1,
);
// Greedy row break: stop adding when summed aspect (at target rowHeight)
// would overflow the bounds width including per-item gaps.
type Row = { indices: number[]; aspectSum: number };
const rows: Row[] = [];
let current: Row = { indices: [], aspectSum: 0 };
const targetAspect = bounds.width / rowHeight;
for (let i = 0; i < nodes.length; i++) {
const a = aspects[i];
const projected = current.aspectSum + a;
const projectedGapAspect =
(current.indices.length * gap) / rowHeight;
if (
current.indices.length > 0 &&
projected + projectedGapAspect >= targetAspect
) {
rows.push(current);
current = { indices: [], aspectSum: 0 };
}
current.indices.push(i);
current.aspectSum += a;
}
if (current.indices.length > 0) rows.push(current);
const patches: LayoutPatch[] = [];
let y = bounds.y;
rows.forEach((row, rowIdx) => {
const isLast = rowIdx === rows.length - 1;
const gapsTotal = (row.indices.length - 1) * gap;
const availW = Math.max(1, bounds.width - gapsTotal);
let rowH = availW / row.aspectSum;
if (isLast && rowH > rowHeight * 1.5) {
rowH = rowHeight;
}
let x = bounds.x;
for (const i of row.indices) {
const w = aspects[i] * rowH;
patches.push({
id: nodes[i].id,
patch: { x, y, width: w, height: rowH, rotation: 0 },
});
x += w + gap;
}
y += rowH + gap;
});
return patches;
};
@@ -0,0 +1,17 @@
import { Vec2 } from "../types";
export const pointInPolygon = (point: Vec2, polygon: Vec2[]): boolean => {
if (polygon.length < 3) return false;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
const intersect =
yi > point.y !== yj > point.y &&
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) inside = !inside;
}
return inside;
};
@@ -0,0 +1,46 @@
import { Vec2 } from "../types";
const cross = (ax: number, ay: number, bx: number, by: number) =>
ax * by - ay * bx;
export const segmentsIntersect = (
p1: Vec2,
p2: Vec2,
p3: Vec2,
p4: Vec2,
): boolean => {
const d1x = p2.x - p1.x;
const d1y = p2.y - p1.y;
const d2x = p4.x - p3.x;
const d2y = p4.y - p3.y;
const denom = cross(d1x, d1y, d2x, d2y);
if (denom === 0) return false; // parallel or collinear
const dx = p3.x - p1.x;
const dy = p3.y - p1.y;
const t = cross(dx, dy, d2x, d2y) / denom;
const u = cross(dx, dy, d1x, d1y) / denom;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
};
export const polygonIntersectsRect = (
polygon: Vec2[],
rect: { x: number; y: number; width: number; height: number },
): boolean => {
if (polygon.length < 2) return false;
const corners: Vec2[] = [
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.width, y: rect.y },
{ x: rect.x + rect.width, y: rect.y + rect.height },
{ x: rect.x, y: rect.y + rect.height },
];
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i];
const b = polygon[(i + 1) % polygon.length];
for (let j = 0; j < 4; j++) {
const c = corners[j];
const d = corners[(j + 1) % 4];
if (segmentsIntersect(a, b, c, d)) return true;
}
}
return false;
};
@@ -0,0 +1,115 @@
import { memo } from "react";
import Konva from "konva";
import { Group, Rect } from "react-konva";
import { useShallow } from "zustand/react/shallow";
import { GroupNode as GroupNodeData, MoodboardNode } from "../types";
import { useMoodboardStore } from "../MoodboardStore";
import { ImageNode } from "./ImageNode";
import { TextNode } from "./TextNode";
interface Props {
node: GroupNodeData;
selected: boolean;
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
// Shared no-op for child renders: the group absorbs all clicks, so child
// nodes never fire `onSelect`. Using a module-level constant keeps the
// reference stable across renders, which lets the memoized child nodes
// skip work when nothing else about them changed.
const NO_OP_SELECT = () => {};
const GroupNodeInner = ({ node, selected, onSelect }: Props) => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
// Subscribe only to this group's own children, with a shallow compare on
// the returned array. Avoids re-rendering every group whenever any node
// anywhere in the board updates (which would otherwise mutate the whole
// `nodes` map reference).
const children = useMoodboardStore(
useShallow((s) =>
node.childIds
.map((id) => s.nodes[id])
.filter((n): n is MoodboardNode => Boolean(n)),
),
);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<Group
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
draggable
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDragStart={() => {
pushHistory();
}}
onDragEnd={(e) => {
updateNode(node.id, { x: e.target.x(), y: e.target.y() });
}}
>
{/* Transparent hit/transform target so the Transformer has stable bounds. */}
<Rect
x={0}
y={0}
width={node.width}
height={node.height}
fill="transparent"
stroke={selected ? "#3b82f6" : "transparent"}
strokeWidth={1}
dash={selected ? [4, 4] : undefined}
listening={false}
/>
{children.map((child) => {
if (child.kind === "image") {
return (
<ImageNode
key={child.id}
node={child}
draggable={false}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
if (child.kind === "text") {
return (
<TextNode
key={child.id}
node={child}
draggable={false}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
// Nested groups not exercised in phase 1, but render recursively for future.
if (child.kind === "group") {
return (
<GroupNode
key={child.id}
node={child}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
return null;
})}
</Group>
);
};
export const GroupNode = memo(GroupNodeInner);
@@ -0,0 +1,78 @@
import { memo, useEffect, useRef, useState } from "react";
import Konva from "konva";
import { Image as KonvaImage, Rect } from "react-konva";
import { ImageNode as ImageNodeData } from "../types";
import { useMoodboardStore } from "../MoodboardStore";
interface Props {
node: ImageNodeData;
draggable: boolean;
selected: boolean;
// Receives the node id so parents can pass one stable callback to every
// sibling rather than allocating a per-item closure on each render.
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
const ImageNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const ref = useRef<Konva.Image | null>(null);
const [img, setImg] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const i = new window.Image();
i.crossOrigin = "anonymous";
i.onload = () => setImg(i);
i.src = node.src;
return () => {
i.onload = null;
};
}, [node.src]);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<>
{!img && (
<Rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
fill="#222"
stroke={selected ? "#3b82f6" : "transparent"}
strokeWidth={1}
/>
)}
<KonvaImage
id={node.id}
ref={(n) => {
ref.current = n;
}}
image={img ?? undefined}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
draggable={draggable}
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDragStart={() => {
pushHistory();
}}
onDragEnd={(e) => {
updateNode(node.id, { x: e.target.x(), y: e.target.y() });
}}
/>
</>
);
};
export const ImageNode = memo(ImageNodeInner);
@@ -0,0 +1,60 @@
import { memo } from "react";
import Konva from "konva";
import { Text as KonvaText } from "react-konva";
import { TextNode as TextNodeData } from "../types";
import { useMoodboardStore } from "../MoodboardStore";
interface Props {
node: TextNodeData;
draggable: boolean;
selected: boolean;
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
const TextNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
// Selector compares a single id — only the TextNode whose isEditing
// actually flips will re-render when someone enters/exits text edit.
const isEditing = useMoodboardStore(
(s) => s.transient.editingTextId === node.id,
);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<KonvaText
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
text={isEditing ? "" : node.text || "Note"}
fontSize={node.fontSize}
fill={node.color}
padding={6}
draggable={draggable}
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDblClick={() => setEditingText(node.id)}
onDblTap={() => setEditingText(node.id)}
onDragStart={() => {
pushHistory();
}}
onDragEnd={(e) => {
updateNode(node.id, { x: e.target.x(), y: e.target.y() });
}}
stroke={selected ? "#3b82f6" : undefined}
strokeWidth={selected ? 0.5 : 0}
/>
);
};
export const TextNode = memo(TextNodeInner);
@@ -0,0 +1,32 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCompress } from "@fortawesome/pro-solid-svg-icons";
import { useMoodboardStore } from "../MoodboardStore";
import { anyContentVisible } from "../layout/geometry";
// Small pill button in the bottom-right that appears when the user has
// panned or zoomed so far that no content is on screen. Clicking it calls
// `fitToContent` to frame all nodes back into view. Hidden on an empty
// board (nothing to recenter on) and whenever any node intersects the
// visible viewport.
export const RecenterIndicator = () => {
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const viewport = useMoodboardStore((s) => s.viewport);
const canvasSize = useMoodboardStore((s) => s.canvasSize);
const fitToContent = useMoodboardStore((s) => s.fitToContent);
if (rootOrder.length === 0) return null;
if (anyContentVisible(nodes, rootOrder, viewport, canvasSize)) return null;
return (
<button
type="button"
onClick={() => fitToContent()}
title="Zoom to fit (Shift+1)"
className="pointer-events-auto absolute bottom-4 right-4 z-20 flex items-center gap-2 rounded-full border border-white/20 bg-black/70 px-3 py-2 text-sm text-white/90 shadow-lg backdrop-blur-sm transition-colors hover:bg-black/80"
>
<FontAwesomeIcon icon={faCompress} />
<span>Content off-screen — recenter</span>
</button>
);
};
@@ -0,0 +1,76 @@
import { SHORTCUTS, ShortcutRow } from "./shortcuts";
interface Props {
visible: boolean;
}
const SECTION_ORDER: ShortcutRow["section"][] = [
"Tools",
"Selection",
"Editing",
"Viewport",
"History",
];
// Translucent, non-interactive overlay that lists all keyboard shortcuts.
// Shown while the user holds Ctrl/Cmd alone for 3 s (see
// `useShortcutCheatsheet.ts`). `pointer-events: none` so it never steals
// focus — the user can keep panning / zooming / typing while it's visible.
export const ShortcutCheatsheet = ({ visible }: Props) => {
if (!visible) return null;
// Group rows by section, preserving declaration order within each.
const bySection = new Map<ShortcutRow["section"], ShortcutRow[]>();
for (const row of SHORTCUTS) {
const arr = bySection.get(row.section) ?? [];
arr.push(row);
bySection.set(row.section, arr);
}
return (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"
>
<div className="max-h-[80%] w-[min(640px,90%)] overflow-auto rounded-xl border border-white/15 bg-black/70 p-6 text-white/90 shadow-2xl backdrop-blur-sm">
<div className="mb-3 text-xs uppercase tracking-widest text-white/50">
Keyboard shortcuts
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
{SECTION_ORDER.map((section) => {
const rows = bySection.get(section);
if (!rows) return null;
return (
<div key={section}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-white/60">
{section}
</div>
<ul className="flex flex-col gap-1.5">
{rows.map((row) => (
<li
key={row.label}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="text-white/80">{row.label}</span>
<span className="flex items-center gap-1">
{row.keys.map((k, i) => (
<Key key={`${row.label}-${i}`} label={k} />
))}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
</div>
);
};
const Key = ({ label }: { label: string }) => (
<kbd className="rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[11px] text-white/90">
{label}
</kbd>
);
@@ -0,0 +1,54 @@
// Single source of truth for moodboard keyboard shortcuts. Consumed by
// `ShortcutCheatsheet.tsx` (rendering) and indirectly by
// `useKeyboardShortcuts.ts` (wiring). The `keys` array is the label shown
// to the user; the actual matching logic lives in the hook because some
// shortcuts are modifier-sensitive and platform-specific.
export interface ShortcutRow {
label: string;
keys: string[];
section: "Tools" | "Selection" | "Editing" | "Viewport" | "History";
}
const isMac =
typeof navigator !== "undefined" &&
/Mac|iPod|iPhone|iPad/.test(navigator.platform);
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"] },
{ section: "Tools", label: "Lasso tool", keys: ["L"] },
{ section: "Tools", label: "Text tool", keys: ["T"] },
// Selection
{ section: "Selection", label: "Select all", keys: [MOD, "A"] },
{ section: "Selection", label: "Clear selection", keys: ["Esc"] },
{ section: "Selection", label: "Add / remove from selection", keys: ["Shift", "Click"] },
// Editing
{ section: "Editing", label: "Delete selection", keys: ["Delete"] },
{ section: "Editing", label: "Group selection", keys: [MOD, "G"] },
{ section: "Editing", label: "Ungroup selection", keys: [MOD, "Shift", "G"] },
{ section: "Editing", label: "Paste image", keys: [MOD, "V"] },
// Viewport
{ section: "Viewport", label: "Pan", keys: ["Middle-click", "drag"] },
{ section: "Viewport", label: "Pan (alt)", keys: ["Space", "drag"] },
{ section: "Viewport", label: "Zoom", keys: ["Scroll"] },
{ section: "Viewport", label: "Reset zoom", keys: [MOD, "0"] },
{ section: "Viewport", label: "Zoom to fit", keys: ["Shift", "1"] },
// History
{ section: "History", label: "Undo", keys: [MOD, "Z"] },
{ section: "History", label: "Redo", keys: [MOD, "Shift", "Z"] },
];
@@ -0,0 +1,55 @@
export type Vec2 = { x: number; y: number };
export type Rect = { x: number; y: number; width: number; height: number };
export type Tool = "select" | "lasso" | "text";
interface BaseNode {
id: string;
parentId: string | null;
// Stage-local for top-level nodes; group-local for children of a group.
x: number;
y: number;
width: number;
height: number;
// Degrees, Konva convention.
rotation: number;
zIndex: number;
}
export type ImageNode = BaseNode & {
kind: "image";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
};
export type VideoNode = BaseNode & {
kind: "video";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
muted: boolean;
loop: boolean;
autoplay: boolean;
};
export type TextNode = BaseNode & {
kind: "text";
text: string;
fontSize: number;
color: string;
};
export type GroupNode = BaseNode & {
kind: "group";
childIds: string[];
};
export type MoodboardNode = ImageNode | VideoNode | TextNode | GroupNode;
export interface MoodboardSnapshot {
nodes: Record<string, MoodboardNode>;
rootOrder: string[];
selectedIds: string[];
}
@@ -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;
};
@@ -0,0 +1,64 @@
import { useEffect, useRef } from "react";
import { MoodboardToolbar } from "../PageMoodboard/MoodboardToolbar";
import { useMoodboardStore } from "../PageMoodboard/MoodboardStore";
import { useUndoRedo } from "../PageMoodboard/interactions/useUndoRedo";
import { useKeyboardShortcuts } from "../PageMoodboard/interactions/useKeyboardShortcuts";
import { useShortcutCheatsheet } from "../PageMoodboard/interactions/useShortcutCheatsheet";
import { RecenterIndicator } from "../PageMoodboard/overlays/RecenterIndicator";
import { ShortcutCheatsheet } from "../PageMoodboard/overlays/ShortcutCheatsheet";
import { MoodboardHtmlStage } from "./MoodboardHtmlStage";
import { useHtmlPasteHandler } from "./interactions/useHtmlPasteHandler";
import { useHtmlGalleryDropEvent } from "./interactions/useHtmlGalleryDropEvent";
import { useHtmlMoodboardImageEntry } from "./interactions/useHtmlMoodboardImageEntry";
// HTML-only variant of the moodboard. Shares the Zustand store with the Konva
// version in ../PageMoodboard, so nodes/selection/viewport persist when the
// user flips between the two experiment tabs.
export const MoodboardHtml = () => {
const containerRef = useRef<HTMLDivElement | null>(null);
const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
const setSelection = useMoodboardStore((s) => s.setSelection);
const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
useUndoRedo(true);
useHtmlPasteHandler(true);
useHtmlGalleryDropEvent(true, containerRef);
useKeyboardShortcuts(true);
const cheatsheetVisible = useShortcutCheatsheet();
const { triggerUpload, triggerGallery, modals } =
useHtmlMoodboardImageEntry();
// Delete / Backspace removes selection; Escape clears it. Matches the
// Konva page's Moodboard.tsx handler so keyboard parity is 1:1.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
if (editingTextId) return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
deleteSelected();
} else if (e.key === "Escape") {
setSelection([]);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [deleteSelected, setSelection, editingTextId]);
return (
<div className="relative h-[calc(100vh-56px)] w-screen overflow-hidden bg-ui-panel">
<MoodboardHtmlStage containerRef={containerRef} />
<RecenterIndicator />
<ShortcutCheatsheet visible={cheatsheetVisible} />
<div className="absolute left-0 right-0 top-0 z-10">
<MoodboardToolbar
onUploadClick={triggerUpload}
onGalleryClick={triggerGallery}
/>
</div>
{modals}
</div>
);
};
@@ -0,0 +1,39 @@
import { memo } from "react";
import { useThemeFgRgb } from "../PageMoodboard/useThemeFgRgb";
interface Props {
pan: { x: number; y: number };
zoom: number;
gridSpacing: number;
}
// Dotted grid background rendered as a pure CSS repeating radial-gradient. The
// pattern scales and pans with the viewport: background-size = spacing*zoom,
// background-position = pan. No JS loop per frame like the Konva sceneFunc.
const MoodboardHtmlBackgroundInner = ({ pan, zoom, gridSpacing }: Props) => {
const fgRgb = useThemeFgRgb();
const effectiveSpacing = Math.max(gridSpacing * zoom, 1);
// Match the Konva version's ~1.2px radius on-screen, regardless of zoom.
const dotRadius = 1.2;
const dotColor = `rgba(${fgRgb
.split(" ")
.map((v) => v.trim())
.join(",")},0.10)`;
return (
<div
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
backgroundImage: `radial-gradient(circle, ${dotColor} ${dotRadius}px, transparent ${
dotRadius + 0.5
}px)`,
backgroundSize: `${effectiveSpacing}px ${effectiveSpacing}px`,
backgroundPosition: `${pan.x}px ${pan.y}px`,
}}
/>
);
};
export const MoodboardHtmlBackground = memo(MoodboardHtmlBackgroundInner);
@@ -0,0 +1,247 @@
import { useEffect, useRef, useState } from "react";
import { HexColorPicker, RgbColorPicker } from "react-colorful";
import { useShallow } from "zustand/react/shallow";
import { useMoodboardStore } from "../PageMoodboard/MoodboardStore";
const PRESETS = [
"#FFFFFF",
"#F5F5F0",
"#FFF8E1",
"#E3F2FD",
"#E8F5E9",
"#FCE4EC",
"#F3E5F5",
"#ECEFF1",
"#1F2937",
];
type Mode = "hex" | "rgb";
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(full || "0", 16);
return {
r: (n >> 16) & 255,
g: (n >> 8) & 255,
b: n & 255,
};
};
const rgbToHex = (r: number, g: number, b: number): string => {
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
const h = (clamp(r) << 16) | (clamp(g) << 8) | clamp(b);
return `#${h.toString(16).padStart(6, "0").toUpperCase()}`;
};
// Floating color-picker toolbar, pinned to the bottom-left of the moodboard.
// Renders only when the selection contains exactly one card. Offers a row of
// preset swatches, the current color, and a toggleable advanced picker
// (hex / RGB).
export const MoodboardHtmlColorPicker = () => {
const { selectedIds, nodes } = useMoodboardStore(
useShallow((s) => ({ selectedIds: s.selectedIds, nodes: s.nodes })),
);
const setCardColor = useMoodboardStore((s) => s.setCardColor);
const [expanded, setExpanded] = useState(false);
const [mode, setMode] = useState<Mode>("hex");
const rootRef = useRef<HTMLDivElement | null>(null);
const ids = Array.from(selectedIds);
const only = ids.length === 1 ? nodes[ids[0]] : null;
const card = only && only.kind === "card" ? only : null;
// Collapse the advanced picker whenever the selection changes to a
// different card — keeps the UI from lingering open unexpectedly.
useEffect(() => {
setExpanded(false);
}, [card?.id]);
// Outside-click closes the advanced picker. The preset row stays visible
// regardless since it lives in the always-mounted toolbar.
useEffect(() => {
if (!expanded) return undefined;
const handler = (e: MouseEvent) => {
const el = rootRef.current;
if (!el) return;
if (e.target instanceof Node && el.contains(e.target)) return;
setExpanded(false);
};
window.addEventListener("mousedown", handler);
return () => window.removeEventListener("mousedown", handler);
}, [expanded]);
if (!card) return null;
const current = card.backgroundColor;
const rgb = hexToRgb(current);
return (
<div
ref={rootRef}
// Stop stage pointerdowns from clearing selection when the user
// interacts with the picker panel itself.
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
style={{
position: "absolute",
left: 16,
bottom: 16,
zIndex: 20,
display: "flex",
flexDirection: "column",
gap: 8,
padding: 10,
borderRadius: 10,
background: "rgba(24,24,27,0.92)",
boxShadow: "0 8px 24px rgba(0,0,0,0.35)",
color: "#e5e7eb",
fontSize: 12,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ opacity: 0.7 }}>Card color</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{PRESETS.map((c) => (
<button
key={c}
type="button"
aria-label={`Set color ${c}`}
onClick={() => setCardColor(card.id, c)}
style={{
width: 22,
height: 22,
borderRadius: "50%",
background: c,
border:
c.toLowerCase() === current.toLowerCase()
? "2px solid #3b82f6"
: "1px solid rgba(255,255,255,0.25)",
cursor: "pointer",
padding: 0,
}}
/>
))}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label="Custom color"
style={{
width: 22,
height: 22,
borderRadius: "50%",
background:
"conic-gradient(#f87171, #fbbf24, #34d399, #60a5fa, #a78bfa, #f472b6, #f87171)",
border: "1px solid rgba(255,255,255,0.35)",
cursor: "pointer",
padding: 0,
}}
/>
</div>
{expanded && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
paddingTop: 6,
}}
>
<div style={{ display: "flex", gap: 4 }}>
<ModeTab label="HEX" active={mode === "hex"} onClick={() => setMode("hex")} />
<ModeTab label="RGB" active={mode === "rgb"} onClick={() => setMode("rgb")} />
</div>
{mode === "hex" ? (
<>
<HexColorPicker
color={current}
onChange={(c) => setCardColor(card.id, c.toUpperCase())}
style={{ width: 180, height: 140 }}
/>
<input
type="text"
value={current}
onChange={(e) => {
const v = e.target.value.trim();
if (/^#?[0-9a-fA-F]{6}$/.test(v)) {
setCardColor(card.id, v.startsWith("#") ? v.toUpperCase() : `#${v.toUpperCase()}`);
}
}}
style={{
width: "100%",
background: "rgba(255,255,255,0.08)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 4,
padding: "4px 6px",
fontFamily: "monospace",
}}
/>
</>
) : (
<>
<RgbColorPicker
color={rgb}
onChange={(c) => setCardColor(card.id, rgbToHex(c.r, c.g, c.b))}
style={{ width: 180, height: 140 }}
/>
<div style={{ display: "flex", gap: 4 }}>
{(["r", "g", "b"] as const).map((key) => (
<input
key={key}
type="number"
min={0}
max={255}
value={rgb[key]}
onChange={(e) => {
const n = Math.max(0, Math.min(255, Number(e.target.value) || 0));
const next = { ...rgb, [key]: n };
setCardColor(card.id, rgbToHex(next.r, next.g, next.b));
}}
style={{
flex: 1,
background: "rgba(255,255,255,0.08)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 4,
padding: "4px 6px",
fontFamily: "monospace",
}}
/>
))}
</div>
</>
)}
</div>
)}
</div>
);
};
interface ModeTabProps {
label: string;
active: boolean;
onClick: () => void;
}
const ModeTab = ({ label, active, onClick }: ModeTabProps) => (
<button
type="button"
onClick={onClick}
style={{
flex: 1,
padding: "4px 8px",
borderRadius: 4,
fontSize: 11,
cursor: "pointer",
background: active ? "rgba(59,130,246,0.35)" : "rgba(255,255,255,0.06)",
border: active
? "1px solid #3b82f6"
: "1px solid rgba(255,255,255,0.1)",
color: "#fff",
}}
>
{label}
</button>
);
@@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMoodboardStore } from "../PageMoodboard/MoodboardStore";
import { MoodboardNode } from "../PageMoodboard/types";
import { MoodboardHtmlBackground } from "./MoodboardHtmlBackground";
import { ImageNodeHtml } from "./nodes/ImageNodeHtml";
import { TextNodeHtml } from "./nodes/TextNodeHtml";
import { GroupNodeHtml } from "./nodes/GroupNodeHtml";
import { VideoNodeHtml } from "./nodes/VideoNodeHtml";
import { useHtmlSelection } from "./interactions/useHtmlSelection";
import { useHtmlViewportControls } from "./interactions/useHtmlViewportControls";
import { worldPointFromClient } from "./interactions/htmlStagePointer";
import { MoodboardHtmlTransformerOverlay } from "./MoodboardHtmlTransformerOverlay";
import { TextEditOverlay } from "../PageMoodboard/TextEditOverlay";
interface Props {
containerRef: React.RefObject<HTMLDivElement | null>;
}
export const MoodboardHtmlStage = ({ containerRef }: Props) => {
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
const tool = useMoodboardStore((s) => s.tool);
const gridSpacing = useMoodboardStore((s) => s.gridSpacing);
const { marquee, lassoPath } = useMoodboardStore(
useShallow((s) => ({
marquee: s.transient.marquee,
lassoPath: s.transient.lassoPath,
})),
);
const viewport = useMoodboardStore((s) => s.viewport);
const setLastDropPoint = useMoodboardStore((s) => s.setLastDropPoint);
const setCanvasSize = useMoodboardStore((s) => s.setCanvasSize);
const toggleInSelection = useMoodboardStore((s) => s.toggleInSelection);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
const addText = useMoodboardStore((s) => s.addText);
const zoom = viewport.zoom;
const pan = viewport.pan;
// ResizeObserver keeps canvasSize in sync with the wrapper (used by the
// recenter indicator, fit-to-content, and drop-center calculations).
useEffect(() => {
const el = containerRef.current;
if (!el) return undefined;
const update = () => {
setCanvasSize({ width: el.clientWidth, height: el.clientHeight });
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [containerRef, setCanvasSize]);
useHtmlSelection(containerRef);
useHtmlViewportControls(containerRef);
// Stable onSelect closure for memoized nodes.
const handleNodeSelect = useCallback(
(id: string, additive: boolean) => {
toggleInSelection(id, additive);
},
[toggleInSelection],
);
// Text tool: bare click on the stage drops a new text node at the pointer.
// Select/lasso tools: bare click without movement clears the selection —
// but the MIN_DRAG branch in useHtmlSelection.commit() already handles that,
// so we only need the text-tool branch here.
const handleStagePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement | null;
if (target && target.closest("[data-moodboard-html-node]")) return;
if (target && target.closest("[data-moodboard-html-group]")) return;
if (tool === "text") {
const pos = worldPointFromClient(
containerRef.current,
e.clientX,
e.clientY,
useMoodboardStore.getState().viewport,
);
if (pos) addText(pos);
return;
}
// Any other bare-stage click clears the text-edit state so a second
// click on empty canvas commits the current edit.
setEditingText(null);
},
[tool, containerRef, addText, setEditingText],
);
// Track pointer position (in world coords) to set lastDropPoint, so
// image paste / file drop can use "near the cursor" when available.
useEffect(() => {
const el = containerRef.current;
if (!el) return undefined;
const handle = (e: PointerEvent) => {
const pos = worldPointFromClient(
el,
e.clientX,
e.clientY,
useMoodboardStore.getState().viewport,
);
if (pos) setLastDropPoint(pos);
};
el.addEventListener("pointermove", handle);
return () => el.removeEventListener("pointermove", handle);
}, [containerRef, setLastDropPoint]);
// Shallow-subscribed slice for selection outlines. Memoized so transient
// (marquee/lasso) updates don't re-render this block.
const selectionOutlines = useMemo(() => {
const arr: {
id: string;
kind: MoodboardNode["kind"];
x: number;
y: number;
width: number;
height: number;
rotation: number;
}[] = [];
selectedIds.forEach((id) => {
const n = nodes[id];
if (!n || n.parentId !== null) return;
if (n.kind === "group") {
// Union AABB of group's direct children in group-local coords.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const cid of n.childIds) {
const c = nodes[cid];
if (!c) continue;
minX = Math.min(minX, c.x);
minY = Math.min(minY, c.y);
maxX = Math.max(maxX, c.x + c.width);
maxY = Math.max(maxY, c.y + c.height);
}
if (!Number.isFinite(minX)) return;
// Place the outline at group's x,y + localMin and size it to the
// union bbox. Then rotate around the group's origin to match the
// group's rotation (same pattern as Konva MoodboardStage.tsx).
arr.push({
id,
kind: n.kind,
x: n.x + minX * Math.cos((n.rotation * Math.PI) / 180) -
minY * Math.sin((n.rotation * Math.PI) / 180),
y: n.y + minX * Math.sin((n.rotation * Math.PI) / 180) +
minY * Math.cos((n.rotation * Math.PI) / 180),
width: maxX - minX,
height: maxY - minY,
rotation: n.rotation,
});
} else {
arr.push({
id,
kind: n.kind,
x: n.x,
y: n.y,
width: n.width,
height: n.height,
rotation: n.rotation,
});
}
});
return arr;
}, [selectedIds, nodes]);
return (
<div
ref={containerRef}
data-moodboard-html-stage
onPointerDown={handleStagePointerDown}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
touchAction: "none",
cursor: tool === "text" ? "text" : "default",
}}
>
{/* Background grid (pans/zooms with viewport via CSS bg-position/size). */}
<MoodboardHtmlBackground
pan={pan}
zoom={zoom}
gridSpacing={gridSpacing}
/>
{/* World-space layer: all nodes are positioned in world coords and the
whole subtree is translated+scaled by the viewport transform. */}
<div
style={{
position: "absolute",
inset: 0,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "0 0",
willChange: "transform",
}}
>
{rootOrder.map((id) => {
const n = nodes[id];
if (!n) return null;
const draggable = tool === "select";
if (n.kind === "image") {
return (
<ImageNodeHtml
key={id}
node={n}
draggable={draggable}
selected={selectedIds.has(id)}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "video") {
return (
<VideoNodeHtml
key={id}
node={n}
draggable={draggable}
selected={selectedIds.has(id)}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "text") {
return (
<TextNodeHtml
key={id}
node={n}
draggable={draggable}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "group") {
return (
<GroupNodeHtml
key={id}
node={n}
onSelect={handleNodeSelect}
/>
);
}
return null;
})}
{/* Selection outlines render inside the world transform so their 1/zoom
border stays crisp at all zooms. */}
{selectionOutlines.map((o) => (
<div
key={`outline-${o.id}`}
style={{
position: "absolute",
left: o.x,
top: o.y,
width: o.width,
height: o.height,
transform: `rotate(${o.rotation}deg)`,
transformOrigin: "0 0",
border: `${1 / zoom}px dashed #3b82f6`,
pointerEvents: "none",
}}
/>
))}
{/* Marquee rect (axis-aligned, in world coords). */}
{marquee && (
<div
style={{
position: "absolute",
left: marquee.x,
top: marquee.y,
width: marquee.width,
height: marquee.height,
border: `${1 / zoom}px dashed #3b82f6`,
background: "rgba(59,130,246,0.08)",
pointerEvents: "none",
}}
/>
)}
{/* Lasso polyline as an SVG in world coords. Sizing: compute a bbox
so the SVG only takes the polyline's extent; the <svg> itself
positions at its min corner. */}
{lassoPath && lassoPath.length >= 2 && (
<LassoPolyline path={lassoPath} zoom={zoom} />
)}
{/* Text-edit overlay lives inside the world div so its absolute
(node.x, node.y) positioning lands on the node at any pan/zoom. */}
<TextEditOverlay containerRef={containerRef} />
</div>
{/* Screen-space overlay for the transformer handles (resize + rotate). */}
{selectedIds.size > 0 && (
<>
<span style={{ display: "none" }} />
<MoodboardHtmlTransformerOverlay containerRef={containerRef} />
</>
)}
</div>
);
};
interface LassoPolylineProps {
path: { x: number; y: number }[];
zoom: number;
}
// Small helper to render the lasso polyline as an SVG polyline. SVG lives in
// world coords so its stroke widths can scale to 1/zoom px.
const LassoPolyline = ({ path, zoom }: LassoPolylineProps) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of path) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
const pad = 4 / zoom;
const x0 = minX - pad;
const y0 = minY - pad;
const w = maxX - minX + pad * 2;
const h = maxY - minY + pad * 2;
const points = path.map((p) => `${p.x - x0},${p.y - y0}`).join(" ");
return (
<svg
style={{
position: "absolute",
left: x0,
top: y0,
width: w,
height: h,
overflow: "visible",
pointerEvents: "none",
}}
>
<polyline
points={points}
fill="none"
stroke="#3b82f6"
strokeWidth={1 / zoom}
strokeDasharray={`${4 / zoom},${4 / zoom}`}
/>
</svg>
);
};
@@ -0,0 +1,393 @@
import { useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMoodboardStore } from "../PageMoodboard/MoodboardStore";
import { MoodboardNode } from "../PageMoodboard/types";
interface Props {
// Stage container rect (client coords) is used for nothing directly — the
// overlay is mounted inside the container so its own 0,0 lines up — but
// keeping it here lets us hoist rect math later if we need it.
containerRef: React.RefObject<HTMLDivElement | null>;
}
type HandleKey =
| "nw"
| "n"
| "ne"
| "e"
| "se"
| "s"
| "sw"
| "w"
| "rotate";
const HANDLE_SIZE = 10;
const ROTATE_OFFSET = 24;
const HANDLE_STROKE = "#3b82f6";
const HANDLE_FILL = "#ffffff";
// Screen-space handles for resize + rotate on a single selected node. Multi-
// select resize isn't in scope for V1 — use Group (Cmd+G) to resize multiples.
// The overlay renders inside the stage container (non-transformed) so all
// coordinates below are in container-local screen px.
export const MoodboardHtmlTransformerOverlay = ({ containerRef: _containerRef }: Props) => {
const { nodes, selectedIds, viewport, editingTextId } = useMoodboardStore(
useShallow((s) => ({
nodes: s.nodes,
selectedIds: s.selectedIds,
viewport: s.viewport,
editingTextId: s.transient.editingTextId,
})),
);
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const selectedArr = useMemo(() => Array.from(selectedIds), [selectedIds]);
const onlyId = selectedArr.length === 1 ? selectedArr[0] : null;
const onlyNode = onlyId ? nodes[onlyId] : null;
// Hide transformer while editing a text node (matches Konva page behavior).
if (editingTextId) return null;
if (!onlyNode) {
// Selection outlines for multi-select still render from MoodboardHtmlStage.
return null;
}
// Compute the four rotated corners of the node in world coords, then map
// to container-screen coords via pan + zoom. This gives us the exact screen
// positions for the handles regardless of zoom/pan/rotation.
const rad = (onlyNode.rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const worldCorner = (dx: number, dy: number) => ({
x: onlyNode.x + dx * cos - dy * sin,
y: onlyNode.y + dx * sin + dy * cos,
});
const toScreen = (p: { x: number; y: number }) => ({
x: viewport.pan.x + p.x * viewport.zoom,
y: viewport.pan.y + p.y * viewport.zoom,
});
const nwW = worldCorner(0, 0);
const neW = worldCorner(onlyNode.width, 0);
const seW = worldCorner(onlyNode.width, onlyNode.height);
const swW = worldCorner(0, onlyNode.height);
const nw = toScreen(nwW);
const ne = toScreen(neW);
const se = toScreen(seW);
const sw = toScreen(swW);
const mid = (a: { x: number; y: number }, b: { x: number; y: number }) => ({
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
});
const nMid = mid(nw, ne);
const eMid = mid(ne, se);
const sMid = mid(se, sw);
const wMid = mid(sw, nw);
// Unit vector along the top edge (for rotate handle placement perpendicular).
const topVec = { x: ne.x - nw.x, y: ne.y - nw.y };
const topLen = Math.hypot(topVec.x, topVec.y) || 1;
const perpUnit = { x: -topVec.y / topLen, y: topVec.x / topLen };
// Rotate handle sits "above" the top edge (outside the node, away from center).
// Center-of-node in screen coords:
const center = {
x: (nw.x + se.x) / 2,
y: (nw.y + se.y) / 2,
};
// Flip the perpendicular so it points away from center.
const sign =
(nMid.x - center.x) * perpUnit.x + (nMid.y - center.y) * perpUnit.y >= 0
? 1
: -1;
const rotateAnchor = {
x: nMid.x + perpUnit.x * sign * ROTATE_OFFSET,
y: nMid.y + perpUnit.y * sign * ROTATE_OFFSET,
};
const startDrag = (
key: HandleKey,
startClient: { x: number; y: number },
) => {
pushHistory();
const startNode: MoodboardNode = { ...onlyNode };
const startRad = (startNode.rotation * Math.PI) / 180;
const sCos = Math.cos(startRad);
const sSin = Math.sin(startRad);
// Anchor in world coords: the corner opposite the handle being dragged
// stays fixed during resize. For edge handles, the opposite edge's
// midpoint is the anchor along that axis.
const { x: nx, y: ny, width: nw0, height: nh0 } = startNode;
// Convert a client-space delta to local-axis delta (the node's own axes).
const clientToLocal = (cdx: number, cdy: number) => {
const worldDx = cdx / viewport.zoom;
const worldDy = cdy / viewport.zoom;
return {
lx: worldDx * sCos + worldDy * sSin,
ly: -worldDx * sSin + worldDy * sCos,
};
};
const onMove = (ev: PointerEvent) => {
const cdx = ev.clientX - startClient.x;
const cdy = ev.clientY - startClient.y;
if (key === "rotate") {
// Rotate around the node's center. Angle is measured from the center
// to the pointer, minus the initial angle.
const startCenterWorld = {
x: startNode.x + (nw0 / 2) * sCos - (nh0 / 2) * sSin,
y: startNode.y + (nw0 / 2) * sSin + (nh0 / 2) * sCos,
};
// Need a ref rect for client coord conversion — use _containerRef.
const rect = _containerRef.current?.getBoundingClientRect();
if (!rect) return;
const centerScreen = {
x: viewport.pan.x + startCenterWorld.x * viewport.zoom + rect.left,
y: viewport.pan.y + startCenterWorld.y * viewport.zoom + rect.top,
};
const startAngle =
(Math.atan2(startClient.y - centerScreen.y, startClient.x - centerScreen.x) *
180) /
Math.PI;
const nowAngle =
(Math.atan2(ev.clientY - centerScreen.y, ev.clientX - centerScreen.x) *
180) /
Math.PI;
const deltaDeg = nowAngle - startAngle;
// Rotate around the node center means the top-left must be repositioned
// to keep the center fixed.
const newRotation = startNode.rotation + deltaDeg;
const newRad = (newRotation * Math.PI) / 180;
const nCos = Math.cos(newRad);
const nSin = Math.sin(newRad);
// Keep center fixed: new (x, y) = center - rotate((w/2, h/2), newRotation)
const newX = startCenterWorld.x - (nw0 / 2) * nCos + (nh0 / 2) * nSin;
const newY = startCenterWorld.y - (nw0 / 2) * nSin - (nh0 / 2) * nCos;
updateNode(startNode.id, {
rotation: newRotation,
x: newX,
y: newY,
});
return;
}
// Live modifier state — re-read every move so toggling Shift/Alt
// mid-drag updates the behavior immediately (matches Figma).
const shift = ev.shiftKey;
const alt = ev.altKey;
const { lx, ly } = clientToLocal(cdx, cdy);
const isLeftHandle = key === "nw" || key === "w" || key === "sw";
const isRightHandle = key === "ne" || key === "e" || key === "se";
const isTopHandle = key === "nw" || key === "n" || key === "ne";
const isBottomHandle = key === "sw" || key === "s" || key === "se";
const affectsX = isLeftHandle || isRightHandle;
const affectsY = isTopHandle || isBottomHandle;
// Step 1: raw size deltas from the handle's sides.
let dw = 0;
let dh = 0;
if (isRightHandle) dw = lx;
if (isLeftHandle) dw = -lx;
if (isBottomHandle) dh = ly;
if (isTopHandle) dh = -ly;
// Step 2: Alt expands from center → both edges of the affected dim
// move, so the delta doubles.
if (alt) {
if (affectsX) dw *= 2;
if (affectsY) dh *= 2;
}
// Step 3: Shift applies aspect-lock.
// Corner: unify via the dominant dim's fractional change.
// Edge: propagate the dragged dim to the perpendicular one via aspect.
if (shift) {
const aspect = nw0 / nh0;
if (affectsX && affectsY) {
const fracW = dw / nw0;
const fracH = dh / nh0;
const s = Math.abs(fracW) >= Math.abs(fracH) ? fracW : fracH;
dw = nw0 * s;
dh = nh0 * s;
} else if (affectsX) {
dh = dw / aspect;
} else if (affectsY) {
dw = dh * aspect;
}
}
// Step 4: anchor per dim. Alt → center (0.5). Otherwise the opposite
// side (0 for right-handles, 1 for left-handles; 0 for bottom, 1 for
// top). Dims not affected by the handle default to 0.5, which is what
// Figma's edge+Shift does for the propagated perpendicular axis.
let ax = 0.5;
let ay = 0.5;
if (!alt) {
if (isLeftHandle) ax = 1;
else if (isRightHandle) ax = 0;
if (isTopHandle) ay = 1;
else if (isBottomHandle) ay = 0;
}
let dxLocal = -ax * dw;
let dyLocal = -ay * dh;
// Enforce minimum size so the node doesn't invert. Re-derive dx/dy so
// the anchor stays correct after clamping (aspect may drift by a pixel
// at min size — acceptable edge case).
const MIN_SIZE = 8;
let newW = nw0 + dw;
let newH = nh0 + dh;
if (newW < MIN_SIZE) {
const adj = MIN_SIZE - newW;
newW = MIN_SIZE;
dw += adj;
dxLocal = -ax * dw;
}
if (newH < MIN_SIZE) {
const adj = MIN_SIZE - newH;
newH = MIN_SIZE;
dh += adj;
dyLocal = -ay * dh;
}
// Convert local-axis translation back to world-axis to update (x, y).
const worldDx = dxLocal * sCos - dyLocal * sSin;
const worldDy = dxLocal * sSin + dyLocal * sCos;
const patch: Partial<MoodboardNode> = {
x: nx + worldDx,
y: ny + worldDy,
width: newW,
height: newH,
};
// Text nodes scale their fontSize uniformly to match Konva behavior.
if (startNode.kind === "text") {
const uniform = (newW / nw0 + newH / nh0) / 2;
(patch as Partial<MoodboardNode> & { fontSize?: number }).fontSize =
startNode.fontSize * uniform;
}
updateNode(startNode.id, patch);
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const handleStyle = (key: HandleKey, pos: { x: number; y: number }) => {
const cursorByKey: Record<HandleKey, string> = {
nw: "nwse-resize",
ne: "nesw-resize",
se: "nwse-resize",
sw: "nesw-resize",
n: "ns-resize",
s: "ns-resize",
e: "ew-resize",
w: "ew-resize",
rotate: "grab",
};
return {
position: "absolute" as const,
left: pos.x - HANDLE_SIZE / 2,
top: pos.y - HANDLE_SIZE / 2,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
background: HANDLE_FILL,
border: `1px solid ${HANDLE_STROKE}`,
borderRadius: key === "rotate" ? "50%" : 2,
cursor: cursorByKey[key],
pointerEvents: "auto" as const,
zIndex: 3,
boxSizing: "border-box" as const,
};
};
const onHandlePointerDown =
(key: HandleKey) => (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
// React's stopPropagation only stops React's synthetic bubble. The
// data-moodboard-html-handle attribute on the element lets the native
// pointerdown listener in useHtmlSelection skip this click too, so we
// don't start a marquee while dragging a handle.
e.stopPropagation();
e.preventDefault();
startDrag(key, { x: e.clientX, y: e.clientY });
};
return (
<div
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
{/* Connector line from top-edge midpoint to rotate handle */}
<svg
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
>
<line
x1={nMid.x}
y1={nMid.y}
x2={rotateAnchor.x}
y2={rotateAnchor.y}
stroke={HANDLE_STROKE}
strokeWidth={1}
/>
</svg>
<div
data-moodboard-html-handle="nw"
style={handleStyle("nw", nw)}
onPointerDown={onHandlePointerDown("nw")}
/>
<div
data-moodboard-html-handle="n"
style={handleStyle("n", nMid)}
onPointerDown={onHandlePointerDown("n")}
/>
<div
data-moodboard-html-handle="ne"
style={handleStyle("ne", ne)}
onPointerDown={onHandlePointerDown("ne")}
/>
<div
data-moodboard-html-handle="e"
style={handleStyle("e", eMid)}
onPointerDown={onHandlePointerDown("e")}
/>
<div
data-moodboard-html-handle="se"
style={handleStyle("se", se)}
onPointerDown={onHandlePointerDown("se")}
/>
<div
data-moodboard-html-handle="s"
style={handleStyle("s", sMid)}
onPointerDown={onHandlePointerDown("s")}
/>
<div
data-moodboard-html-handle="sw"
style={handleStyle("sw", sw)}
onPointerDown={onHandlePointerDown("sw")}
/>
<div
data-moodboard-html-handle="w"
style={handleStyle("w", wMid)}
onPointerDown={onHandlePointerDown("w")}
/>
<div
data-moodboard-html-handle="rotate"
style={handleStyle("rotate", rotateAnchor)}
onPointerDown={onHandlePointerDown("rotate")}
/>
</div>
);
};
@@ -0,0 +1 @@
export { MoodboardHtml } from "./MoodboardHtml";
@@ -0,0 +1,79 @@
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
import { worldPointFromClient } from "./htmlStagePointer";
// Returns the id of the top-level node under the given client-space point, or
// null if the point is over bare canvas. Skips the dragged node, groups, and
// any nodes inside groups/cards.
//
// Used by node drag handlers on pointer-up to decide whether the release
// should either (a) form a new card with that target + the dragged node, or
// (b) append the dragged node to an existing card.
export const findDropTargetAt = (
clientX: number,
clientY: number,
draggedId: string,
): string | null => {
const container = document.querySelector<HTMLDivElement>(
"[data-moodboard-html-stage]",
);
if (!container) return null;
const { nodes, rootOrder, viewport } = useMoodboardStore.getState();
const p = worldPointFromClient(container, clientX, clientY, viewport);
if (!p) return null;
// Iterate in reverse z-order so the topmost overlap wins.
for (let i = rootOrder.length - 1; i >= 0; i--) {
const id = rootOrder[i];
if (id === draggedId) continue;
const n = nodes[id];
if (!n) continue;
if (n.kind === "group") continue;
if (
p.x >= n.x &&
p.x <= n.x + n.width &&
p.y >= n.y &&
p.y <= n.y + n.height
) {
return id;
}
}
return null;
};
// Applies the drop result: forms a new card (target is a regular node) or
// appends (target is already a card). Returns true if any mutation happened.
export const applyDropOnto = (
draggedId: string,
targetId: string,
): boolean => {
const state = useMoodboardStore.getState();
const target = state.nodes[targetId];
if (!target) return false;
if (target.kind === "card") {
state.appendToCard(targetId, draggedId);
return true;
}
if (target.kind === "image" || target.kind === "video" || target.kind === "text") {
state.createCardFromDrop(draggedId, targetId);
return true;
}
return false;
};
// Recomputes the hover target under the pointer and publishes the result to
// the store's transient `dragPreview` so overlay UI can react. Safe to call
// on every pointermove — the setter is a shallow equality in practice since
// we always emit a fresh object, but targets change rarely during a drag.
export const updateDragPreview = (
clientX: number,
clientY: number,
draggedId: string,
): void => {
const targetId = findDropTargetAt(clientX, clientY, draggedId);
useMoodboardStore
.getState()
.setDragPreview({ draggedId, targetId });
};
export const clearDragPreview = (): void => {
useMoodboardStore.getState().setDragPreview(null);
};
@@ -0,0 +1,30 @@
import { Vec2 } from "../../PageMoodboard/types";
import { Viewport } from "../../PageMoodboard/layout/geometry";
// Converts a DOM pointer event's client coords to world (stage) coords for the
// HTML moodboard. Mirrors the role of `stagePointerPos` in the Konva page but
// uses DOM rects and the viewport in the store instead of Konva's stage state.
export const worldPointFromClient = (
containerEl: HTMLElement | null,
clientX: number,
clientY: number,
viewport: Viewport,
): Vec2 | null => {
if (!containerEl) return null;
const rect = containerEl.getBoundingClientRect();
return {
x: (clientX - rect.left - viewport.pan.x) / viewport.zoom,
y: (clientY - rect.top - viewport.pan.y) / viewport.zoom,
};
};
// Returns the center of the visible viewport in world coords. Used as a
// fallback drop point when no cursor position is available.
export const worldViewportCenter = (
canvasWidth: number,
canvasHeight: number,
viewport: Viewport,
): Vec2 => ({
x: (canvasWidth / 2 - viewport.pan.x) / viewport.zoom,
y: (canvasHeight / 2 - viewport.pan.y) / viewport.zoom,
});
@@ -0,0 +1,80 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
interface GalleryItem {
id?: string;
thumbnail?: string | null;
fullImage?: string | null;
mediaClass?: string;
}
// canvasPosition from the PageEditor dispatcher is already container-local
// (the dispatcher subtracts the stage container's rect.left/top before
// dispatching). We only need to undo the pan/zoom to map to world coords.
interface GalleryMoodboardDropDetail {
item: GalleryItem;
canvasPosition: { x: number; y: number };
}
const measureImage = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const i = new window.Image();
i.crossOrigin = "anonymous";
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = () => resolve({ w: 320, h: 320 });
i.src = url;
});
const measureVideo = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const v = document.createElement("video");
v.preload = "metadata";
v.crossOrigin = "anonymous";
v.muted = true;
v.onloadedmetadata = () =>
resolve({ w: v.videoWidth || 320, h: v.videoHeight || 320 });
v.onerror = () => resolve({ w: 320, h: 320 });
v.src = url;
});
// HTML analogue of useGalleryDropEvent.ts. Converts container-local drop
// coords to world coords by undoing pan/zoom — matches the Konva version
// (which uses stage.x()/scaleX()) but with store-derived viewport instead.
export const useHtmlGalleryDropEvent = (
active: boolean,
_containerRef: React.RefObject<HTMLDivElement | null>,
) => {
const addImage = useMoodboardStore((s) => s.addImage);
const addVideo = useMoodboardStore((s) => s.addVideo);
useEffect(() => {
if (!active) return undefined;
const handler = async (e: Event) => {
const ce = e as CustomEvent<GalleryMoodboardDropDetail>;
const detail = ce.detail;
if (!detail) return;
const { item, canvasPosition } = detail;
const url = item.fullImage || item.thumbnail;
if (!url) return;
const { viewport } = useMoodboardStore.getState();
const stagePoint = {
x: (canvasPosition.x - viewport.pan.x) / viewport.zoom,
y: (canvasPosition.y - viewport.pan.y) / viewport.zoom,
};
if (item.mediaClass === "video") {
const dims = await measureVideo(url);
addVideo(url, stagePoint, dims.w, dims.h, item.id ?? null);
} else {
const dims = await measureImage(url);
addImage(url, stagePoint, dims.w, dims.h, item.id ?? null);
}
};
window.addEventListener("gallery-moodboard-drop", handler);
return () =>
window.removeEventListener("gallery-moodboard-drop", handler);
}, [active, addImage, addVideo]);
};
@@ -0,0 +1,147 @@
import { ReactNode, useCallback, useRef, useState } from "react";
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 "../../PageMoodboard/MoodboardStore";
import { worldViewportCenter } from "./htmlStagePointer";
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 measureVideo = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const v = document.createElement("video");
v.preload = "metadata";
v.crossOrigin = "anonymous";
v.muted = true;
v.onloadedmetadata = () =>
resolve({ w: v.videoWidth || 320, h: v.videoHeight || 320 });
v.onerror = () => resolve({ w: 320, h: 320 });
v.src = url;
});
interface UseReturn {
triggerUpload: () => void;
triggerGallery: () => void;
modals: ReactNode;
}
// HTML analogue of useMoodboardImageEntry.tsx. Center is read from the store's
// canvasSize + viewport instead of a Konva stage, so no stageRef is needed.
export const useHtmlMoodboardImageEntry = (): UseReturn => {
const addImage = useMoodboardStore((s) => s.addImage);
const addVideo = useMoodboardStore((s) => s.addVideo);
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 getCenter = () => {
const { canvasSize, viewport } = useMoodboardStore.getState();
return worldViewportCenter(canvasSize.width, canvasSize.height, viewport);
};
const handleFiles = useCallback(
async (files: FileList) => {
const center = getCenter();
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);
}
uploadImage({
title: file.name || "Moodboard image",
assetFile: file,
progressCallback: () => {},
}).catch((err) => {
console.error("[MoodboardHtml] background upload failed", err);
});
}
},
[addImage],
);
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("Nothing selected");
return;
}
const center = getCenter();
for (const item of items) {
const url = item.fullImage || item.thumbnail;
if (!url) continue;
if (item.mediaClass === "video") {
const dims = await measureVideo(url);
addVideo(url, center, dims.w, dims.h, item.id ?? null);
} else {
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}
/>
</>
);
return { triggerUpload, triggerGallery, modals };
};
@@ -0,0 +1,69 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
import { uploadImage } from "~/components/reusable/UploadModalImage/utilities/uploadImage";
import { worldViewportCenter } from "./htmlStagePointer";
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve, reject) => {
const i = new window.Image();
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = reject;
i.src = url;
});
// HTML analogue of usePasteHandler.ts — drop pasted images at the viewport
// center (derived from canvasSize + viewport in the store, no stageRef needed).
export const useHtmlPasteHandler = (active: boolean) => {
const addImage = useMoodboardStore((s) => s.addImage);
useEffect(() => {
if (!active) return undefined;
const handler = async (e: ClipboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
const items = e.clipboardData?.items;
if (!items) return;
const fileItems: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === "file" && it.type.startsWith("image/")) {
const f = it.getAsFile();
if (f) fileItems.push(f);
}
}
if (fileItems.length === 0) return;
e.preventDefault();
const { canvasSize, viewport } = useMoodboardStore.getState();
const center = worldViewportCenter(
canvasSize.width,
canvasSize.height,
viewport,
);
for (const file of fileItems) {
const blobUrl = URL.createObjectURL(file);
try {
const dims = await measure(blobUrl);
addImage(blobUrl, center, dims.w, dims.h, null);
} catch (err) {
console.error("[MoodboardHtml] paste measure failed", err);
addImage(blobUrl, center, 320, 320, null);
}
uploadImage({
title: "Pasted image",
assetFile: file,
progressCallback: () => {},
}).catch((err) => {
console.error("[MoodboardHtml] background upload failed", err);
});
}
};
window.addEventListener("paste", handler);
return () => window.removeEventListener("paste", handler);
}, [active, addImage]);
};
@@ -0,0 +1,146 @@
import { useEffect, useRef } from "react";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
import { Vec2 } from "../../PageMoodboard/types";
import {
rectCorners,
rectFromPoints,
rectsIntersect,
} from "../../PageMoodboard/layout/geometry";
import { pointInPolygon } from "../../PageMoodboard/layout/pointInPolygon";
import { polygonIntersectsRect } from "../../PageMoodboard/layout/segmentsIntersect";
import { worldPointFromClient } from "./htmlStagePointer";
const MIN_DRAG = 4;
interface DragState {
origin: Vec2;
path: Vec2[];
active: boolean;
mode: "rect" | "lasso" | null;
}
// HTML analogue of useSelection.ts. Listens on the stage container for bare
// pointerdowns (nodes stopPropagation before these fire), tracks the drag in
// world coords, and commits the same way the Konva version does — marquee
// uses rectsIntersect, lasso uses pointInPolygon / polygonIntersectsRect.
export const useHtmlSelection = (
containerRef: React.RefObject<HTMLDivElement | null>,
) => {
const drag = useRef<DragState>({
origin: { x: 0, y: 0 },
path: [],
active: false,
mode: null,
});
const tool = useMoodboardStore((s) => s.tool);
const setMarquee = useMoodboardStore((s) => s.setMarquee);
const setLassoPath = useMoodboardStore((s) => s.setLassoPath);
const setSelection = useMoodboardStore((s) => s.setSelection);
useEffect(() => {
if (tool !== "select" && tool !== "lasso") return;
const el = containerRef.current;
if (!el) return;
const commit = () => {
const { nodes, rootOrder, transient } = useMoodboardStore.getState();
const candidates = rootOrder
.map((id) => nodes[id])
.filter((n): n is NonNullable<typeof n> => Boolean(n));
if (drag.current.mode === "rect") {
const rect = transient.marquee;
if (!rect || (rect.width < MIN_DRAG && rect.height < MIN_DRAG)) {
setSelection([]);
} else {
const hit = candidates
.filter((n) =>
rectsIntersect(
{ x: n.x, y: n.y, width: n.width, height: n.height },
rect,
),
)
.map((n) => n.id);
setSelection(hit);
}
setMarquee(null);
} else if (drag.current.mode === "lasso") {
const path = drag.current.path;
if (path.length < 3) {
setSelection([]);
} else {
const closed = [...path, path[0]];
const hit = candidates
.filter((n) => {
const corners = rectCorners(n);
if (corners.some((c) => pointInPolygon(c, closed))) return true;
if (polygonIntersectsRect(closed, n)) return true;
return false;
})
.map((n) => n.id);
setSelection(hit);
}
setLassoPath(null);
}
drag.current.active = false;
drag.current.mode = null;
drag.current.path = [];
};
const handlePointerDown = (e: PointerEvent) => {
// Nodes stopPropagation in their own pointerdown, so reaching here means
// the user clicked on bare stage.
if (e.button !== 0) return;
if (useMoodboardStore.getState().transient.isPanning) return;
// Space-held pan is handled before this; if that hook is active and panning,
// isPanning is true. We also ignore if the target was a node (defensive).
const target = e.target as HTMLElement | null;
if (target && target.closest("[data-moodboard-html-node]")) return;
if (target && target.closest("[data-moodboard-html-group]")) return;
// Transformer handles use React stopPropagation which can't stop this
// native listener, so filter on the data attribute instead — otherwise
// grabbing a resize/rotate handle also starts a marquee drag.
if (target && target.closest("[data-moodboard-html-handle]")) return;
const { viewport } = useMoodboardStore.getState();
const pos = worldPointFromClient(el, e.clientX, e.clientY, viewport);
if (!pos) return;
drag.current = {
origin: pos,
path: tool === "lasso" ? [pos] : [],
active: true,
mode: tool === "lasso" ? "lasso" : "rect",
};
};
const handlePointerMove = (e: PointerEvent) => {
if (!drag.current.active) return;
const { viewport } = useMoodboardStore.getState();
const pos = worldPointFromClient(el, e.clientX, e.clientY, viewport);
if (!pos) return;
if (drag.current.mode === "rect") {
const dx = pos.x - drag.current.origin.x;
const dy = pos.y - drag.current.origin.y;
if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) return;
setMarquee(rectFromPoints(drag.current.origin, pos));
} else if (drag.current.mode === "lasso") {
drag.current.path.push(pos);
setLassoPath([...drag.current.path]);
}
};
const handlePointerUp = () => {
if (!drag.current.active) return;
commit();
};
// Bare-stage "click" (zero-area drag) should clear selection — the commit
// path handles that via the MIN_DRAG branch.
el.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
return () => {
el.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [containerRef, tool, setMarquee, setLassoPath, setSelection]);
};
@@ -0,0 +1,164 @@
import { useEffect, useRef } from "react";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
import { Vec2 } from "../../PageMoodboard/types";
import {
ZOOM_MAX,
ZOOM_MIN,
clamp,
} from "../../PageMoodboard/layout/geometry";
const ZOOM_STEP = 1.1;
const isEditableTarget = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
if (/input|textarea/i.test(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
};
// HTML analogue of useViewportControls.ts. Wheel zooms around the cursor;
// middle-mouse and space+left drag pan; Cmd/Ctrl+0 resets. Everything is
// derived from the container's bounding rect — no Konva stage required.
export const useHtmlViewportControls = (
containerRef: React.RefObject<HTMLDivElement | null>,
) => {
const setZoom = useMoodboardStore((s) => s.setZoom);
const setPan = useMoodboardStore((s) => s.setPan);
const resetViewport = useMoodboardStore((s) => s.resetViewport);
const setIsPanning = useMoodboardStore((s) => s.setIsPanning);
const spaceHeld = useRef(false);
const panState = useRef<{
active: boolean;
startScreen: Vec2;
startPan: Vec2;
}>({
active: false,
startScreen: { x: 0, y: 0 },
startPan: { x: 0, y: 0 },
});
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleWheel = (e: WheelEvent) => {
if (isEditableTarget(document.activeElement)) return;
e.preventDefault();
const { viewport } = useMoodboardStore.getState();
const rect = el.getBoundingClientRect();
const pointer = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
const oldScale = viewport.zoom;
const mousePointTo = {
x: (pointer.x - viewport.pan.x) / oldScale,
y: (pointer.y - viewport.pan.y) / oldScale,
};
const direction = e.deltaY > 0 ? -1 : 1;
const newScale = clamp(
direction > 0 ? oldScale * ZOOM_STEP : oldScale / ZOOM_STEP,
ZOOM_MIN,
ZOOM_MAX,
);
const newPan = {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
setZoom(newScale);
setPan(newPan);
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => {
el.removeEventListener("wheel", handleWheel);
};
}, [containerRef, setZoom, setPan]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const beginPan = (clientX: number, clientY: number) => {
panState.current = {
active: true,
startScreen: { x: clientX, y: clientY },
startPan: { ...useMoodboardStore.getState().viewport.pan },
};
setIsPanning(true);
el.style.cursor = "grabbing";
};
const endPan = () => {
if (!panState.current.active) return;
panState.current.active = false;
setIsPanning(false);
el.style.cursor = spaceHeld.current ? "grab" : "";
};
const handleMouseDown = (e: MouseEvent) => {
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
return;
}
if (e.button === 0 && spaceHeld.current) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!panState.current.active) return;
const dx = e.clientX - panState.current.startScreen.x;
const dy = e.clientY - panState.current.startScreen.y;
setPan({
x: panState.current.startPan.x + dx,
y: panState.current.startPan.y + dy,
});
};
const handleMouseUp = () => endPan();
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return;
if (e.code === "Space" && !spaceHeld.current) {
spaceHeld.current = true;
if (!panState.current.active) el.style.cursor = "grab";
e.preventDefault();
}
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod && e.key === "0") {
e.preventDefault();
resetViewport();
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") {
spaceHeld.current = false;
if (!panState.current.active) el.style.cursor = "";
}
};
el.addEventListener("mousedown", handleMouseDown, { capture: true });
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
el.removeEventListener("mousedown", handleMouseDown, {
capture: true,
} as EventListenerOptions);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
el.style.cursor = "";
};
}, [containerRef, setPan, resetViewport, setIsPanning]);
};
@@ -0,0 +1,192 @@
import { memo, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import {
CardNode as CardNodeData,
MoodboardNode,
} from "../../PageMoodboard/types";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
interface Props {
node: CardNodeData;
draggable: boolean;
onSelect: (id: string, additive: boolean) => void;
}
// Cards render their children in a vertical flex column. Children fill the
// card's inner width; their height follows the child's natural aspect ratio
// (images/videos) or own height (text). The card itself absorbs clicks —
// children inside a card aren't independently selectable, same pattern as
// GroupNodeHtml.
const CardNodeHtmlInner = ({ node, draggable, onSelect }: Props) => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const children = useMoodboardStore(
useShallow((s) =>
node.childIds
.map((id) => s.nodes[id])
.filter((n): n is MoodboardNode => Boolean(n)),
),
);
const dragStateRef = useRef<{
active: boolean;
startClient: { x: number; y: number };
startNode: { x: number; y: number };
moved: boolean;
} | null>(null);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (!draggable) return;
e.stopPropagation();
const additive = e.shiftKey;
onSelect(node.id, additive);
dragStateRef.current = {
active: true,
startClient: { x: e.clientX, y: e.clientY },
startNode: { x: node.x, y: node.y },
moved: false,
};
const onMove = (ev: PointerEvent) => {
const st = dragStateRef.current;
if (!st || !st.active) return;
const { zoom } = useMoodboardStore.getState().viewport;
const dx = (ev.clientX - st.startClient.x) / zoom;
const dy = (ev.clientY - st.startClient.y) / zoom;
if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) {
st.moved = true;
pushHistory();
}
if (st.moved) {
updateNode(node.id, {
x: st.startNode.x + dx,
y: st.startNode.y + dy,
});
}
};
const onUp = () => {
dragStateRef.current = null;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const innerW = Math.max(node.width - node.padding * 2, 1);
return (
<div
data-moodboard-html-card={node.id}
onPointerDown={handlePointerDown}
style={{
position: "absolute",
left: node.x,
top: node.y,
width: node.width,
height: node.height,
transform: `rotate(${node.rotation}deg)`,
transformOrigin: "0 0",
background: node.backgroundColor,
boxShadow: "0 2px 8px rgba(0,0,0,0.18)",
borderRadius: 6,
cursor: draggable ? "move" : "default",
userSelect: "none",
touchAction: "none",
boxSizing: "border-box",
padding: node.padding,
display: "flex",
flexDirection: "column",
gap: node.padding,
overflow: "hidden",
}}
>
{children.map((child) => (
<CardChildSlot
key={child.id}
child={child}
innerWidth={innerW}
/>
))}
</div>
);
};
interface SlotProps {
child: MoodboardNode;
innerWidth: number;
}
// Each card slot renders a single child at full card-inner-width. We don't
// reuse ImageNodeHtml/VideoNodeHtml here because those position via absolute
// x/y — inside a card we want flex-driven layout instead.
export const CardChildSlot = ({ child, innerWidth }: SlotProps) => {
if (child.kind === "image") {
const ratio =
child.naturalH > 0 && child.naturalW > 0
? child.naturalH / child.naturalW
: 1;
return (
<img
src={child.src}
draggable={false}
alt=""
style={{
width: innerWidth,
height: innerWidth * ratio,
display: "block",
objectFit: "fill",
userSelect: "none",
pointerEvents: "none",
borderRadius: 4,
}}
/>
);
}
if (child.kind === "video") {
const ratio =
child.naturalH > 0 && child.naturalW > 0
? child.naturalH / child.naturalW
: 1;
return (
<video
src={child.src}
autoPlay={child.autoplay}
muted={child.muted}
loop={child.loop}
playsInline
preload="metadata"
style={{
width: innerWidth,
height: innerWidth * ratio,
display: "block",
objectFit: "fill",
pointerEvents: "none",
borderRadius: 4,
}}
/>
);
}
if (child.kind === "text") {
return (
<div
style={{
width: innerWidth,
height: child.height,
padding: 4,
color: child.color,
fontSize: child.fontSize,
fontFamily: "inherit",
whiteSpace: "pre-wrap",
overflow: "hidden",
boxSizing: "border-box",
lineHeight: 1.2,
}}
>
{child.text || "Note"}
</div>
);
}
return null;
};
export const CardNodeHtml = memo(CardNodeHtmlInner);
@@ -0,0 +1,139 @@
import { memo, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import {
GroupNode as GroupNodeData,
MoodboardNode,
} from "../../PageMoodboard/types";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
import { ImageNodeHtml } from "./ImageNodeHtml";
import { TextNodeHtml } from "./TextNodeHtml";
import { VideoNodeHtml } from "./VideoNodeHtml";
interface Props {
node: GroupNodeData;
onSelect: (id: string, additive: boolean) => void;
}
// Children inside a group are rendered with their `parentId` set to the group.
// They don't directly fire onSelect — the group absorbs clicks and selects
// itself, which matches the Konva group's hit-rect behavior.
const NO_OP_SELECT = () => {};
const GroupNodeHtmlInner = ({ node, onSelect }: Props) => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const children = useMoodboardStore(
useShallow((s) =>
node.childIds
.map((id) => s.nodes[id])
.filter((n): n is MoodboardNode => Boolean(n)),
),
);
const dragStateRef = useRef<{
active: boolean;
startClient: { x: number; y: number };
startNode: { x: number; y: number };
moved: boolean;
} | null>(null);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.stopPropagation();
const additive = e.shiftKey;
onSelect(node.id, additive);
dragStateRef.current = {
active: true,
startClient: { x: e.clientX, y: e.clientY },
startNode: { x: node.x, y: node.y },
moved: false,
};
const onMove = (ev: PointerEvent) => {
const st = dragStateRef.current;
if (!st || !st.active) return;
const { zoom } = useMoodboardStore.getState().viewport;
const dx = (ev.clientX - st.startClient.x) / zoom;
const dy = (ev.clientY - st.startClient.y) / zoom;
if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) {
st.moved = true;
pushHistory();
}
if (st.moved) {
updateNode(node.id, {
x: st.startNode.x + dx,
y: st.startNode.y + dy,
});
}
};
const onUp = () => {
dragStateRef.current = null;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
return (
<div
data-moodboard-html-group={node.id}
onPointerDown={handlePointerDown}
style={{
position: "absolute",
left: node.x,
top: node.y,
width: node.width,
height: node.height,
transform: `rotate(${node.rotation}deg)`,
transformOrigin: "0 0",
touchAction: "none",
}}
>
{children.map((child) => {
if (child.kind === "image") {
return (
<ImageNodeHtml
key={child.id}
node={child}
draggable={false}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
if (child.kind === "video") {
return (
<VideoNodeHtml
key={child.id}
node={child}
draggable={false}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
if (child.kind === "text") {
return (
<TextNodeHtml
key={child.id}
node={child}
draggable={false}
onSelect={NO_OP_SELECT}
/>
);
}
if (child.kind === "group") {
return (
<GroupNodeHtml
key={child.id}
node={child}
onSelect={NO_OP_SELECT}
/>
);
}
return null;
})}
</div>
);
};
export const GroupNodeHtml = memo(GroupNodeHtmlInner);
@@ -0,0 +1,127 @@
import { memo, useEffect, useRef, useState } from "react";
import { ImageNode as ImageNodeData } from "../../PageMoodboard/types";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
interface Props {
node: ImageNodeData;
draggable: boolean;
selected: boolean;
onSelect: (id: string, additive: boolean) => void;
}
const ImageNodeHtmlInner = ({ node, draggable, selected, onSelect }: Props) => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const zoom = useMoodboardStore((s) => s.viewport.zoom);
const [loaded, setLoaded] = useState(false);
const dragStateRef = useRef<{
active: boolean;
startClient: { x: number; y: number };
startNode: { x: number; y: number };
moved: boolean;
pointerId: number | null;
} | null>(null);
useEffect(() => {
// Probe whether the image actually resolves; placeholder renders until then.
const i = new window.Image();
i.crossOrigin = "anonymous";
i.onload = () => setLoaded(true);
i.onerror = () => setLoaded(false);
i.src = node.src;
return () => {
i.onload = null;
i.onerror = null;
};
}, [node.src]);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
// Middle-mouse is the pan hook's domain; ignore here.
if (e.button !== 0) return;
// When rendered as a group child (draggable=false), do not intercept —
// the click must bubble to GroupNodeHtml's handler so the group itself
// gets selected/dragged. Same reasoning when in lasso/text tool: let the
// stage receive the click.
if (!draggable) return;
e.stopPropagation();
const additive = e.shiftKey;
onSelect(node.id, additive);
dragStateRef.current = {
active: true,
startClient: { x: e.clientX, y: e.clientY },
startNode: { x: node.x, y: node.y },
moved: false,
pointerId: e.pointerId,
};
const onMove = (ev: PointerEvent) => {
const st = dragStateRef.current;
if (!st || !st.active) return;
const { zoom: z } = useMoodboardStore.getState().viewport;
const dx = (ev.clientX - st.startClient.x) / z;
const dy = (ev.clientY - st.startClient.y) / z;
if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) {
st.moved = true;
pushHistory();
}
if (st.moved) {
updateNode(node.id, {
x: st.startNode.x + dx,
y: st.startNode.y + dy,
});
}
};
const onUp = () => {
dragStateRef.current = null;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
return (
<div
data-moodboard-html-node={node.id}
onPointerDown={handlePointerDown}
style={{
position: "absolute",
left: node.x,
top: node.y,
width: node.width,
height: node.height,
transform: `rotate(${node.rotation}deg)`,
transformOrigin: "0 0",
cursor: draggable ? "move" : "default",
userSelect: "none",
touchAction: "none",
}}
>
{loaded ? (
<img
src={node.src}
draggable={false}
alt=""
style={{
width: "100%",
height: "100%",
display: "block",
objectFit: "fill",
userSelect: "none",
pointerEvents: "none",
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
background: "rgba(255,255,255,0.06)",
border: selected ? `${1 / zoom}px solid #3b82f6` : "none",
}}
/>
)}
</div>
);
};
export const ImageNodeHtml = memo(ImageNodeHtmlInner);
@@ -0,0 +1,102 @@
import { memo, useRef } from "react";
import { TextNode as TextNodeData } from "../../PageMoodboard/types";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
interface Props {
node: TextNodeData;
draggable: boolean;
onSelect: (id: string, additive: boolean) => void;
}
const TextNodeHtmlInner = ({ node, draggable, onSelect }: Props) => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
const isEditing = useMoodboardStore(
(s) => s.transient.editingTextId === node.id,
);
const dragStateRef = useRef<{
active: boolean;
startClient: { x: number; y: number };
startNode: { x: number; y: number };
moved: boolean;
} | null>(null);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
// When rendered as a group child (draggable=false), do not intercept —
// the click must bubble to GroupNodeHtml's handler so the group itself
// gets selected/dragged. Same reasoning when in lasso/text tool.
if (!draggable) return;
e.stopPropagation();
const additive = e.shiftKey;
onSelect(node.id, additive);
dragStateRef.current = {
active: true,
startClient: { x: e.clientX, y: e.clientY },
startNode: { x: node.x, y: node.y },
moved: false,
};
const onMove = (ev: PointerEvent) => {
const st = dragStateRef.current;
if (!st || !st.active) return;
const { zoom } = useMoodboardStore.getState().viewport;
const dx = (ev.clientX - st.startClient.x) / zoom;
const dy = (ev.clientY - st.startClient.y) / zoom;
if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) {
st.moved = true;
pushHistory();
}
if (st.moved) {
updateNode(node.id, {
x: st.startNode.x + dx,
y: st.startNode.y + dy,
});
}
};
const onUp = () => {
dragStateRef.current = null;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const handleDoubleClick = () => {
setEditingText(node.id);
};
return (
<div
data-moodboard-html-node={node.id}
onPointerDown={handlePointerDown}
onDoubleClick={handleDoubleClick}
style={{
position: "absolute",
left: node.x,
top: node.y,
width: node.width,
height: node.height,
transform: `rotate(${node.rotation}deg)`,
transformOrigin: "0 0",
padding: 6,
fontSize: node.fontSize,
color: node.color,
fontFamily: "inherit",
whiteSpace: "pre-wrap",
overflow: "hidden",
boxSizing: "border-box",
userSelect: "none",
cursor: draggable ? "move" : "default",
touchAction: "none",
visibility: isEditing ? "hidden" : "visible",
lineHeight: 1.2,
}}
>
{node.text || "Note"}
</div>
);
};
export const TextNodeHtml = memo(TextNodeHtmlInner);
@@ -0,0 +1,273 @@
import { memo, useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlay,
faPause,
faVolumeHigh,
faVolumeXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { VideoNode as VideoNodeData } from "../../PageMoodboard/types";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
interface Props {
node: VideoNodeData;
draggable: boolean;
selected: boolean;
onSelect: (id: string, additive: boolean) => void;
}
const VideoNodeHtmlInner = ({ node, draggable, selected, onSelect }: Props) => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const zoom = useMoodboardStore((s) => s.viewport.zoom);
const videoRef = useRef<HTMLVideoElement | null>(null);
const scrubHitRef = useRef<HTMLDivElement | null>(null);
const [isPlaying, setIsPlaying] = useState(node.autoplay);
const [isHovered, setIsHovered] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const dragStateRef = useRef<{
active: boolean;
startClient: { x: number; y: number };
startNode: { x: number; y: number };
moved: boolean;
} | null>(null);
useEffect(() => {
const v = videoRef.current;
if (!v) return undefined;
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onTime = () => setCurrentTime(v.currentTime);
const onDur = () => setDuration(Number.isFinite(v.duration) ? v.duration : 0);
v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause);
v.addEventListener("timeupdate", onTime);
v.addEventListener("durationchange", onDur);
v.addEventListener("loadedmetadata", onDur);
return () => {
v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause);
v.removeEventListener("timeupdate", onTime);
v.removeEventListener("durationchange", onDur);
v.removeEventListener("loadedmetadata", onDur);
};
}, []);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (!draggable) return;
e.stopPropagation();
const additive = e.shiftKey;
onSelect(node.id, additive);
dragStateRef.current = {
active: true,
startClient: { x: e.clientX, y: e.clientY },
startNode: { x: node.x, y: node.y },
moved: false,
};
const onMove = (ev: PointerEvent) => {
const st = dragStateRef.current;
if (!st || !st.active) return;
const { zoom: z } = useMoodboardStore.getState().viewport;
const dx = (ev.clientX - st.startClient.x) / z;
const dy = (ev.clientY - st.startClient.y) / z;
if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) {
st.moved = true;
pushHistory();
}
if (st.moved) {
updateNode(node.id, {
x: st.startNode.x + dx,
y: st.startNode.y + dy,
});
}
};
const onUp = () => {
dragStateRef.current = null;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const togglePlay = (e: React.MouseEvent) => {
e.stopPropagation();
const v = videoRef.current;
if (!v) return;
if (v.paused) {
void v.play().catch(() => {});
} else {
v.pause();
}
};
const toggleMute = (e: React.MouseEvent) => {
e.stopPropagation();
updateNode(node.id, { muted: !node.muted });
};
// Stop pointerdown on control buttons so clicking a control doesn't
// also start the node-drag gesture wired on the outer div.
const stopDown = (e: React.PointerEvent) => e.stopPropagation();
// Seek to the clientX position within the scrub hit-area. Returns early
// if duration is unknown (metadata still loading).
const seekToClientX = (clientX: number) => {
const el = scrubHitRef.current;
const v = videoRef.current;
if (!el || !v || !duration) return;
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return;
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
v.currentTime = pct * duration;
};
const handleScrubDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.stopPropagation();
seekToClientX(e.clientX);
const onMove = (ev: PointerEvent) => seekToClientX(ev.clientX);
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const controlBtnStyle = (
visible: boolean,
size: number,
): React.CSSProperties => ({
position: "absolute",
width: size,
height: size,
borderRadius: "50%",
border: "none",
background: "rgba(0,0,0,0.55)",
color: "#fff",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: size * 0.42,
opacity: visible ? 1 : 0,
pointerEvents: visible ? "auto" : "none",
transition: "opacity 180ms ease",
padding: 0,
});
const showPlayBtn = !isPlaying || isHovered;
return (
<div
data-moodboard-html-node={node.id}
onPointerDown={handlePointerDown}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
position: "absolute",
left: node.x,
top: node.y,
width: node.width,
height: node.height,
transform: `rotate(${node.rotation}deg)`,
transformOrigin: "0 0",
cursor: draggable ? "move" : "default",
userSelect: "none",
touchAction: "none",
overflow: "hidden",
background: "rgba(255,255,255,0.06)",
border: selected ? `${1 / zoom}px solid #3b82f6` : "none",
boxSizing: "border-box",
}}
>
<video
ref={videoRef}
src={node.src}
autoPlay={node.autoplay}
muted={node.muted}
loop={node.loop}
playsInline
preload="metadata"
style={{
width: "100%",
height: "100%",
display: "block",
objectFit: "fill",
pointerEvents: "none",
}}
/>
<button
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
onPointerDown={stopDown}
onClick={togglePlay}
style={{
...controlBtnStyle(showPlayBtn, 56),
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
}}
>
<FontAwesomeIcon icon={isPlaying ? faPause : faPlay} />
</button>
<button
type="button"
aria-label={node.muted ? "Unmute" : "Mute"}
onPointerDown={stopDown}
onClick={toggleMute}
style={{
...controlBtnStyle(isHovered, 32),
right: 8,
bottom: 28,
}}
>
<FontAwesomeIcon icon={node.muted ? faVolumeXmark : faVolumeHigh} />
</button>
{/* Scrub bar — bottom strip with a tall hit area so it's easy to grab. */}
<div
ref={scrubHitRef}
onPointerDown={handleScrubDown}
style={{
position: "absolute",
left: 8,
right: 8,
bottom: 4,
height: 16,
display: "flex",
alignItems: "center",
cursor: "pointer",
opacity: isHovered ? 1 : 0,
pointerEvents: isHovered ? "auto" : "none",
transition: "opacity 180ms ease",
}}
>
<div
style={{
position: "relative",
width: "100%",
height: 4,
background: "rgba(255,255,255,0.25)",
borderRadius: 2,
overflow: "hidden",
}}
>
<div
style={{
width: `${progressPct}%`,
height: "100%",
background: "#3b82f6",
}}
/>
</div>
</div>
</div>
);
};
export const VideoNodeHtml = memo(VideoNodeHtmlInner);
@@ -15,7 +15,9 @@ export type TabId =
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND"
| "ANGLES"
| "STORYBOARD";
| "STORYBOARD"
| "MOODBOARD"
| "MOODBOARD_HTML";
const DEFAULT_TAB: TabId = "IMAGE";
@@ -0,0 +1,51 @@
import {
clusterByProximity,
defaultEpsForNodes,
} from "~/pages/PageMoodboard/layout/clusterProximity";
import { MoodboardNode } from "~/pages/PageMoodboard/types";
const node = (id: string, x: number, y: number, size = 50): MoodboardNode => ({
id,
kind: "image",
parentId: null,
x,
y,
width: size,
height: size,
rotation: 0,
zIndex: 0,
src: "",
mediaId: null,
naturalW: size,
naturalH: size,
});
describe("clusterByProximity", () => {
it("returns no clusters for empty input", () => {
expect(clusterByProximity([])).toEqual([]);
});
it("groups close neighbors and separates far ones", () => {
const nodes = [
node("a", 0, 0, 50),
node("b", 30, 30, 50),
node("c", 1000, 1000, 50),
];
const clusters = clusterByProximity(nodes, 100);
expect(clusters.length).toBe(2);
const sizes = clusters.map((c) => c.length).sort();
expect(sizes).toEqual([1, 2]);
});
it("treats every input as a singleton when eps is tiny", () => {
const nodes = [node("a", 0, 0), node("b", 5, 5), node("c", 10, 10)];
const clusters = clusterByProximity(nodes, 0.0001);
expect(clusters.length).toBe(3);
expect(clusters.every((c) => c.length === 1)).toBe(true);
});
it("derives a positive default eps from median size", () => {
const eps = defaultEpsForNodes([node("a", 0, 0, 100), node("b", 0, 0, 200)]);
expect(eps).toBeGreaterThan(0);
});
});
@@ -0,0 +1,67 @@
import { computeAABB } from "~/pages/PageMoodboard/layout/geometry";
import { computePackPatches } from "~/pages/PageMoodboard/layout/packCollage";
import { MoodboardNode } from "~/pages/PageMoodboard/types";
const img = (
id: string,
x: number,
y: number,
width: number,
height: number,
): MoodboardNode => ({
id,
kind: "image",
parentId: null,
x,
y,
width,
height,
rotation: 0,
zIndex: 0,
src: "",
mediaId: null,
naturalW: width,
naturalH: height,
});
describe("packCollage", () => {
it("returns no patches for empty input", () => {
const patches = computePackPatches([], { x: 0, y: 0, width: 800, height: 600 });
expect(patches).toEqual([]);
});
it("packs three equal-aspect images into a single row that fills width with gaps", () => {
const nodes = [
img("a", 0, 0, 200, 200),
img("b", 0, 0, 200, 200),
img("c", 0, 0, 200, 200),
];
const bounds = { x: 0, y: 0, width: 600, height: 400 };
const patches = computePackPatches(nodes, bounds, { rowHeight: 180, gap: 8 });
expect(patches.length).toBe(3);
// All in one row → same y, identical heights, total widths + 2 gaps == bounds.width.
const ys = patches.map((p) => p.patch.y!);
expect(ys[0]).toBe(0);
expect(ys.every((y) => y === ys[0])).toBe(true);
const widths = patches.map((p) => p.patch.width!);
const totalW = widths.reduce((a, b) => a + b, 0) + 2 * 8;
expect(totalW).toBeCloseTo(600, 1);
const heights = patches.map((p) => p.patch.height!);
expect(heights.every((h) => Math.abs(h - heights[0]) < 1e-6)).toBe(true);
});
it("breaks into multiple rows when aspects sum exceeds target", () => {
const wide = (id: string) => img(id, 0, 0, 400, 100); // aspect 4
const nodes = [wide("a"), wide("b"), wide("c"), wide("d")];
const bounds = { x: 0, y: 0, width: 400, height: 600 };
const patches = computePackPatches(nodes, bounds, { rowHeight: 100, gap: 0 });
const distinctYs = new Set(patches.map((p) => p.patch.y!));
expect(distinctYs.size).toBeGreaterThan(1);
});
it("computeAABB returns null on empty and a tight box on inputs", () => {
expect(computeAABB([])).toBeNull();
const aabb = computeAABB([img("a", 10, 20, 100, 50), img("b", 60, 0, 80, 200)]);
expect(aabb).toEqual({ x: 10, y: 0, width: 130, height: 200 });
});
});
@@ -0,0 +1,54 @@
import { pointInPolygon } from "~/pages/PageMoodboard/layout/pointInPolygon";
import {
segmentsIntersect,
polygonIntersectsRect,
} from "~/pages/PageMoodboard/layout/segmentsIntersect";
const square = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
];
describe("pointInPolygon", () => {
it("returns true for points strictly inside", () => {
expect(pointInPolygon({ x: 5, y: 5 }, square)).toBe(true);
});
it("returns false for points outside", () => {
expect(pointInPolygon({ x: 20, y: 5 }, square)).toBe(false);
});
it("returns false for degenerate polygons", () => {
expect(pointInPolygon({ x: 0, y: 0 }, [{ x: 0, y: 0 }, { x: 1, y: 1 }])).toBe(false);
});
});
describe("segmentsIntersect", () => {
it("detects crossing segments", () => {
expect(
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 }),
).toBe(true);
});
it("rejects non-crossing segments", () => {
expect(
segmentsIntersect({ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 5, y: 5 }, { x: 6, y: 6 }),
).toBe(false);
});
});
describe("polygonIntersectsRect", () => {
it("returns true when an edge crosses a rect side", () => {
const polyline = [
{ x: -5, y: 5 },
{ x: 15, y: 5 },
];
expect(polygonIntersectsRect(polyline, { x: 0, y: 0, width: 10, height: 10 })).toBe(true);
});
it("returns false when polygon is entirely outside and does not touch", () => {
const polyline = [
{ x: 100, y: 100 },
{ x: 200, y: 100 },
];
expect(polygonIntersectsRect(polyline, { x: 0, y: 0, width: 10, height: 10 })).toBe(false);
});
});
@@ -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>
);
@@ -124,8 +124,6 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
};
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
// Disable dragging for video items, but allow clicks in select mode
if (item.mediaClass === "video" && mode !== "select") return;
// In bulk selection mode, skip drag — clicks toggle selection
if (bulkSelectionMode) {
dragStarted.current = false;
@@ -186,7 +184,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
: disableTooltipAndBadge
? "border-transparent hover:border-primary/80"
: "border-transparent hover:border-primary",
mode === "select" || item.mediaClass === "video" || bulkSelectionMode
mode === "select" || bulkSelectionMode
? "cursor-pointer"
: disableTooltipAndBadge
? "cursor-pointer"
@@ -346,16 +344,14 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
className="-mt-3 bg-ui-controls text-base-fg border border-ui-panel-border"
content={
<div className="flex flex-col items-center text-xs whitespace-nowrap">
{item.mediaClass !== "video" && (
<span>
<span className="font-bold">Drag</span>
<span className="opacity-50">
{item.mediaClass === "dimensional"
? " to add to scene"
: " to add"}
</span>
<span>
<span className="font-bold">Drag</span>
<span className="opacity-50">
{item.mediaClass === "dimensional"
? " to add to scene"
: " to add"}
</span>
)}
</span>
<span>
<span className="font-bold">Click</span>
<span className="opacity-50"> to view</span>
@@ -82,7 +82,8 @@ function onPointerUp(event: PointerEvent) {
dragState.item &&
dragState.isDragging &&
(dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional")
dragState.item.mediaClass === "dimensional" ||
dragState.item.mediaClass === "video")
) {
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
}
@@ -3,4 +3,6 @@ export * from './lib/settings-modal';
export {
useExperimentalStore,
useStoryboardPageEnabled,
useMoodboardPageEnabled,
useMoodboardHtmlPageEnabled,
} from './lib/experimental-store';
@@ -2,6 +2,8 @@ import { create } from "zustand";
const ENABLED_STORAGE_KEY = "artcraft_experimental_enabled";
const STORYBOARD_STORAGE_KEY = "artcraft_experimental_storyboard_page";
const MOODBOARD_STORAGE_KEY = "artcraft_experimental_moodboard_page";
const MOODBOARD_HTML_STORAGE_KEY = "artcraft_experimental_moodboard_html_page";
const readBoolFlag = (key: string): boolean => {
if (typeof window === "undefined") return false;
@@ -28,14 +30,20 @@ const writeBoolFlag = (key: string, enabled: boolean) => {
interface ExperimentalState {
enabled: boolean;
storyboardPageEnabled: boolean;
moodboardPageEnabled: boolean;
moodboardHtmlPageEnabled: boolean;
enable: () => void;
disable: () => void;
setStoryboardPageEnabled: (enabled: boolean) => void;
setMoodboardPageEnabled: (enabled: boolean) => void;
setMoodboardHtmlPageEnabled: (enabled: boolean) => void;
}
export const useExperimentalStore = create<ExperimentalState>((set) => ({
enabled: readBoolFlag(ENABLED_STORAGE_KEY),
storyboardPageEnabled: readBoolFlag(STORYBOARD_STORAGE_KEY),
moodboardPageEnabled: readBoolFlag(MOODBOARD_STORAGE_KEY),
moodboardHtmlPageEnabled: readBoolFlag(MOODBOARD_HTML_STORAGE_KEY),
enable: () => {
writeBoolFlag(ENABLED_STORAGE_KEY, true);
set({ enabled: true });
@@ -44,13 +52,34 @@ export const useExperimentalStore = create<ExperimentalState>((set) => ({
// Resetting experimental clears every gated feature flag too.
writeBoolFlag(ENABLED_STORAGE_KEY, false);
writeBoolFlag(STORYBOARD_STORAGE_KEY, false);
set({ enabled: false, storyboardPageEnabled: false });
writeBoolFlag(MOODBOARD_STORAGE_KEY, false);
writeBoolFlag(MOODBOARD_HTML_STORAGE_KEY, false);
set({
enabled: false,
storyboardPageEnabled: false,
moodboardPageEnabled: false,
moodboardHtmlPageEnabled: false,
});
},
setStoryboardPageEnabled: (enabled: boolean) => {
writeBoolFlag(STORYBOARD_STORAGE_KEY, enabled);
set({ storyboardPageEnabled: enabled });
},
setMoodboardPageEnabled: (enabled: boolean) => {
writeBoolFlag(MOODBOARD_STORAGE_KEY, enabled);
set({ moodboardPageEnabled: enabled });
},
setMoodboardHtmlPageEnabled: (enabled: boolean) => {
writeBoolFlag(MOODBOARD_HTML_STORAGE_KEY, enabled);
set({ moodboardHtmlPageEnabled: enabled });
},
}));
export const useStoryboardPageEnabled = () =>
useExperimentalStore((s) => s.enabled && s.storyboardPageEnabled);
export const useMoodboardPageEnabled = () =>
useExperimentalStore((s) => s.enabled && s.moodboardPageEnabled);
export const useMoodboardHtmlPageEnabled = () =>
useExperimentalStore((s) => s.enabled && s.moodboardHtmlPageEnabled);
@@ -9,20 +9,47 @@ interface Props {
// Called immediately before the storyboard flag flips off, so the host app
// can reset page state and navigate away from the soon-to-be-hidden tab.
onStoryboardPageDisable?: () => void;
// Same contract as above for the moodboard page.
onMoodboardPageDisable?: () => void;
// Same contract as above for the HTML moodboard page.
onMoodboardHtmlPageDisable?: () => void;
}
export const ExperimentalSettingsPane = ({ onStoryboardPageDisable }: Props) => {
export const ExperimentalSettingsPane = ({
onStoryboardPageDisable,
onMoodboardPageDisable,
onMoodboardHtmlPageDisable,
}: Props) => {
const storyboardPageEnabled = useExperimentalStore(
(s) => s.storyboardPageEnabled,
);
const setStoryboardPageEnabled = useExperimentalStore(
(s) => s.setStoryboardPageEnabled,
);
const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false);
const moodboardPageEnabled = useExperimentalStore(
(s) => s.moodboardPageEnabled,
);
const setMoodboardPageEnabled = useExperimentalStore(
(s) => s.setMoodboardPageEnabled,
);
const moodboardHtmlPageEnabled = useExperimentalStore(
(s) => s.moodboardHtmlPageEnabled,
);
const setMoodboardHtmlPageEnabled = useExperimentalStore(
(s) => s.setMoodboardHtmlPageEnabled,
);
const [isStoryboardDisableConfirmOpen, setIsStoryboardDisableConfirmOpen] =
useState(false);
const [isMoodboardDisableConfirmOpen, setIsMoodboardDisableConfirmOpen] =
useState(false);
const [
isMoodboardHtmlDisableConfirmOpen,
setIsMoodboardHtmlDisableConfirmOpen,
] = useState(false);
const fireToggleEvent = (enabled: boolean) => {
const fireToggleEvent = (feature: string, enabled: boolean) => {
gtagEvent("toggle_experimental_feature", {
feature: "storyboard_page",
feature,
enabled: String(enabled),
});
};
@@ -30,44 +57,120 @@ export const ExperimentalSettingsPane = ({ onStoryboardPageDisable }: Props) =>
const handleStoryboardToggle = (enabled: boolean) => {
if (enabled) {
setStoryboardPageEnabled(true);
fireToggleEvent(true);
fireToggleEvent("storyboard_page", true);
} else {
setIsDisableConfirmOpen(true);
setIsStoryboardDisableConfirmOpen(true);
}
};
const handleConfirmDisable = () => {
const handleConfirmStoryboardDisable = () => {
onStoryboardPageDisable?.();
setStoryboardPageEnabled(false);
fireToggleEvent(false);
setIsDisableConfirmOpen(false);
fireToggleEvent("storyboard_page", false);
setIsStoryboardDisableConfirmOpen(false);
};
const handleMoodboardToggle = (enabled: boolean) => {
if (enabled) {
setMoodboardPageEnabled(true);
fireToggleEvent("moodboard_page", true);
} else {
setIsMoodboardDisableConfirmOpen(true);
}
};
const handleConfirmMoodboardDisable = () => {
onMoodboardPageDisable?.();
setMoodboardPageEnabled(false);
fireToggleEvent("moodboard_page", false);
setIsMoodboardDisableConfirmOpen(false);
};
const handleMoodboardHtmlToggle = (enabled: boolean) => {
if (enabled) {
setMoodboardHtmlPageEnabled(true);
fireToggleEvent("moodboard_html_page", true);
} else {
setIsMoodboardHtmlDisableConfirmOpen(true);
}
};
const handleConfirmMoodboardHtmlDisable = () => {
onMoodboardHtmlPageDisable?.();
setMoodboardHtmlPageEnabled(false);
fireToggleEvent("moodboard_html_page", false);
setIsMoodboardHtmlDisableConfirmOpen(false);
};
return (
<>
<div className="space-y-4 text-base-fg">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5">
<div className="flex min-w-0 flex-col gap-0.5">
<Label htmlFor="experimental-storyboard-page">Storyboard page</Label>
<p className="text-xs opacity-70">
Show the Storyboard page in the apps menu. In-development; expect
rough edges.
Plan shots on a visual storyboard. In-development.
</p>
</div>
<Switch
className="shrink-0"
enabled={storyboardPageEnabled}
setEnabled={handleStoryboardToggle}
/>
</div>
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 flex-col gap-0.5">
<Label htmlFor="experimental-moodboard-page">Moodboard page</Label>
<p className="text-xs opacity-70">
Collage references on a 2D canvas. In-development.
</p>
</div>
<Switch
className="shrink-0"
enabled={moodboardPageEnabled}
setEnabled={handleMoodboardToggle}
/>
</div>
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 flex-col gap-0.5">
<Label htmlFor="experimental-moodboard-html-page">
Moodboard (HTML)
</Label>
<p className="text-xs opacity-70">
HTML-only render of the moodboard, no canvas. In-development.
</p>
</div>
<Switch
className="shrink-0"
enabled={moodboardHtmlPageEnabled}
setEnabled={handleMoodboardHtmlToggle}
/>
</div>
</div>
<ExperimentalConfirmModal
isOpen={isDisableConfirmOpen}
onClose={() => setIsDisableConfirmOpen(false)}
onConfirm={handleConfirmDisable}
isOpen={isStoryboardDisableConfirmOpen}
onClose={() => setIsStoryboardDisableConfirmOpen(false)}
onConfirm={handleConfirmStoryboardDisable}
title="Disable Storyboard page?"
text="The Storyboard page will be reset and any unsaved changes will be lost."
confirmText="Disable"
/>
<ExperimentalConfirmModal
isOpen={isMoodboardDisableConfirmOpen}
onClose={() => setIsMoodboardDisableConfirmOpen(false)}
onConfirm={handleConfirmMoodboardDisable}
title="Disable Moodboard page?"
text="The Moodboard page will be reset and any unsaved changes will be lost."
confirmText="Disable"
/>
<ExperimentalConfirmModal
isOpen={isMoodboardHtmlDisableConfirmOpen}
onClose={() => setIsMoodboardHtmlDisableConfirmOpen(false)}
onConfirm={handleConfirmMoodboardHtmlDisable}
title="Disable Moodboard (HTML)?"
text="The HTML Moodboard page will be reset and any unsaved changes will be lost."
confirmText="Disable"
/>
</>
);
};
@@ -29,6 +29,7 @@ interface SettingsModalProps {
onClose: () => void;
globalAccountLogoutCallback: () => void;
onStoryboardPageDisable?: () => void;
onMoodboardPageDisable?: () => void;
initialSection?: SettingsSection;
}
@@ -47,6 +48,7 @@ export const SettingsModal = ({
onClose,
globalAccountLogoutCallback,
onStoryboardPageDisable,
onMoodboardPageDisable,
initialSection = "general",
}: SettingsModalProps) => {
const [selectedSection, setSelectedSection] =
@@ -115,6 +117,7 @@ export const SettingsModal = ({
return (
<ExperimentalSettingsPane
onStoryboardPageDisable={onStoryboardPageDisable}
onMoodboardPageDisable={onMoodboardPageDisable}
/>
);
}
@@ -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"