mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-25 19:29:29 -04:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fb1097eb1 | ||
|
|
f260e1cee4 | ||
|
|
9e07954d4f | ||
|
|
0d519023a2 | ||
|
|
181d2a36ca | ||
|
|
8d4346d566 | ||
|
|
acbd86a4af | ||
|
|
5fe4137634 | ||
|
|
1c9e9297cb | ||
|
|
982f3ad78d | ||
|
|
a24b154d52 |
+66
@@ -0,0 +1,66 @@
|
||||
import { ReactNode } from "react";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface FloatingToolbarProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FloatingToolbar = ({
|
||||
children,
|
||||
className,
|
||||
}: FloatingToolbarProps) => (
|
||||
<div className="pointer-events-none flex w-full justify-center pt-3">
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass pointer-events-auto rounded-xl p-1.5 text-base-fg shadow-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const FloatingToolbarDivider = () => (
|
||||
<span className="px-1 text-base text-base-fg/20" aria-hidden>
|
||||
|
|
||||
</span>
|
||||
);
|
||||
|
||||
interface FloatingToolbarButtonProps {
|
||||
icon: IconDefinition;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
tooltipDelay?: number;
|
||||
}
|
||||
|
||||
export const FloatingToolbarButton = ({
|
||||
icon,
|
||||
label,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
tooltipDelay = 300,
|
||||
}: FloatingToolbarButtonProps) => (
|
||||
<Tooltip content={label} position="bottom" delay={tooltipDelay} closeOnClick>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={icon}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={twMerge(
|
||||
"h-9 w-9 rounded-[10px] border-transparent p-0 shadow-none",
|
||||
active
|
||||
? "bg-primary/30 text-base-fg hover:bg-primary/40"
|
||||
: "bg-transparent text-base-fg/80 hover:bg-base-fg/10 hover:text-base-fg",
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FloatingToolbar";
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./FloatingToolbar";
|
||||
export * from "./UploadModal3D";
|
||||
export * from "./UploadModalMedia";
|
||||
export * from "./UploadModalSplat";
|
||||
|
||||
@@ -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";
|
||||
+61
@@ -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]);
|
||||
};
|
||||
+77
@@ -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]);
|
||||
};
|
||||
+74
@@ -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]);
|
||||
};
|
||||
+181
@@ -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>
|
||||
);
|
||||
};
|
||||
+393
@@ -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);
|
||||
};
|
||||
+30
@@ -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,
|
||||
});
|
||||
+80
@@ -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]);
|
||||
};
|
||||
+147
@@ -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 };
|
||||
};
|
||||
+69
@@ -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]);
|
||||
};
|
||||
+146
@@ -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]);
|
||||
};
|
||||
+164
@@ -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);
|
||||
|
||||
+118
-15
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user