Moodboard (HTML): parallel experiment page, no canvas

Stands up PageMoodboardHtml alongside PageMoodboard — renders the same
moodboard content (image/text/group nodes) entirely with absolutely-
positioned divs, CSS transforms, and pointer events. Shares the store
with PageMoodboard so switching tabs preserves the board.

- New page at app/src/pages/PageMoodboardHtml with HTML-only stage,
  background grid, nodes, overlay transformer (8 resize handles + 1
  rotate), marquee + lasso selection, viewport pan/zoom, paste and
  gallery drop handlers, and a shortcut-aware text edit overlay.
- Reuses MoodboardStore, layout utilities, MoodboardToolbar,
  TextEditOverlay, and the non-render interaction hooks from the
  existing Konva moodboard unchanged.
- Experimental flag `moodboardHtmlPageEnabled` gates a new apps-menu
  entry and a Settings → Experimental toggle.
- PageEditor: gallery drop targets the HTML stage element when the
  MOODBOARD_HTML tab is active.
This commit is contained in:
Tanish Manku committed 2026-04-23 14:24:41 -07:00
1 parent acbd86a4af
commit 8d4346d566
20 files changed
+1907 -11

No files matched your search

@@ -17,6 +17,7 @@ import {
useExperimentalStore,
useStoryboardPageEnabled,
useMoodboardPageEnabled,
useMoodboardHtmlPageEnabled,
} from "@storyteller/ui-settings-modal";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
@@ -35,7 +36,8 @@ export type AppId =
| "REMOVE_BACKGROUND"
| "ANGLES"
| "STORYBOARD"
| "MOODBOARD";
| "MOODBOARD"
| "MOODBOARD_HTML";
export interface AppDescriptor {
id: AppId;
@@ -199,6 +201,16 @@ export const ALL_APPS: FullAppItem[] = [
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",
@@ -227,14 +239,16 @@ 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) => {
if (app.action === "STORYBOARD") return storyboardEnabled;
if (app.action === "MOODBOARD") return moodboardEnabled;
if (app.action === "MOODBOARD_HTML") return moodboardHtmlEnabled;
return true;
}),
[storyboardEnabled, moodboardEnabled],
[storyboardEnabled, moodboardEnabled, moodboardHtmlEnabled],
);
};
@@ -284,6 +298,7 @@ export const goToApp = (action?: string) => {
"ANGLES",
"STORYBOARD",
"MOODBOARD",
"MOODBOARD_HTML",
].includes(action)
) {
if (action === "STORYBOARD") {
@@ -296,6 +311,11 @@ export const goToApp = (action?: string) => {
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 {
@@ -20,9 +20,11 @@ import { RemoveBackground } from "../PageRemoveBackground";
import { Angles } from "../PageAngles";
import { Storyboard } from "../PageStoryboard";
import { Moodboard } from "../PageMoodboard";
import { MoodboardHtml } from "../PageMoodboardHtml";
import {
useStoryboardPageEnabled,
useMoodboardPageEnabled,
useMoodboardHtmlPageEnabled,
} from "@storyteller/ui-settings-modal";
import {
@@ -116,6 +118,7 @@ export const PageEditor = () => {
const tabStore = useTabStore();
const storyboardPageEnabled = useStoryboardPageEnabled();
const moodboardPageEnabled = useMoodboardPageEnabled();
const moodboardHtmlPageEnabled = useMoodboardHtmlPageEnabled();
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
};
@@ -393,23 +396,29 @@ export const PageEditor = () => {
// 2D Drag and Drop Logic
} else if (
tabStore.activeTabId === "2D" ||
tabStore.activeTabId === "MOODBOARD"
tabStore.activeTabId === "MOODBOARD" ||
tabStore.activeTabId === "MOODBOARD_HTML"
) {
const eventName =
tabStore.activeTabId === "MOODBOARD"
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 }) => {
// Find the main Konva canvas element - get the first canvas (left panel)
const canvasElements = document.querySelectorAll("canvas");
const canvasElement = canvasElements[0];
if (!canvasElement) {
console.error("Could not find canvas element for canvas drop");
// 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();
const rect = target.getBoundingClientRect();
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
@@ -654,6 +663,11 @@ export const PageEditor = () => {
<Moodboard />
</div>
)}
{tabStore.activeTabId == "MOODBOARD_HTML" && moodboardHtmlPageEnabled && (
<div>
<MoodboardHtml />
</div>
)}
</div>
);
};
@@ -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,342 @@
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 { 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 === "text") {
return (
<TextNodeHtml
key={id}
node={n}
draggable={draggable}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "group") {
return (
<GroupNodeHtml
key={id}
node={n}
onSelect={handleNodeSelect}
/>
);
}
return null;
})}
{/* Selection outlines render inside the world transform so their 1/zoom
border stays crisp at all zooms. */}
{selectionOutlines.map((o) => (
<div
key={`outline-${o.id}`}
style={{
position: "absolute",
left: o.x,
top: o.y,
width: o.width,
height: o.height,
transform: `rotate(${o.rotation}deg)`,
transformOrigin: "0 0",
border: `${1 / zoom}px dashed #3b82f6`,
pointerEvents: "none",
}}
/>
))}
{/* Marquee rect (axis-aligned, in world coords). */}
{marquee && (
<div
style={{
position: "absolute",
left: marquee.x,
top: marquee.y,
width: marquee.width,
height: marquee.height,
border: `${1 / zoom}px dashed #3b82f6`,
background: "rgba(59,130,246,0.08)",
pointerEvents: "none",
}}
/>
)}
{/* Lasso polyline as an SVG in world coords. Sizing: compute a bbox
so the SVG only takes the polyline's extent; the <svg> itself
positions at its min corner. */}
{lassoPath && lassoPath.length >= 2 && (
<LassoPolyline path={lassoPath} zoom={zoom} />
)}
{/* Text-edit overlay lives inside the world div so its absolute
(node.x, node.y) positioning lands on the node at any pan/zoom. */}
<TextEditOverlay containerRef={containerRef} />
</div>
{/* Screen-space overlay for the transformer handles (resize + rotate). */}
{selectedIds.size > 0 && (
<>
<span style={{ display: "none" }} />
<MoodboardHtmlTransformerOverlay containerRef={containerRef} />
</>
)}
</div>
);
};
interface LassoPolylineProps {
path: { x: number; y: number }[];
zoom: number;
}
// Small helper to render the lasso polyline as an SVG polyline. SVG lives in
// world coords so its stroke widths can scale to 1/zoom px.
const LassoPolyline = ({ path, zoom }: LassoPolylineProps) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of path) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
const pad = 4 / zoom;
const x0 = minX - pad;
const y0 = minY - pad;
const w = maxX - minX + pad * 2;
const h = maxY - minY + pad * 2;
const points = path.map((p) => `${p.x - x0},${p.y - y0}`).join(" ");
return (
<svg
style={{
position: "absolute",
left: x0,
top: y0,
width: w,
height: h,
overflow: "visible",
pointerEvents: "none",
}}
>
<polyline
points={points}
fill="none"
stroke="#3b82f6"
strokeWidth={1 / zoom}
strokeDasharray={`${4 / zoom},${4 / zoom}`}
/>
</svg>
);
};
@@ -0,0 +1,393 @@
import { useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMoodboardStore } from "../PageMoodboard/MoodboardStore";
import { MoodboardNode } from "../PageMoodboard/types";
interface Props {
// Stage container rect (client coords) is used for nothing directly — the
// overlay is mounted inside the container so its own 0,0 lines up — but
// keeping it here lets us hoist rect math later if we need it.
containerRef: React.RefObject<HTMLDivElement | null>;
}
type HandleKey =
| "nw"
| "n"
| "ne"
| "e"
| "se"
| "s"
| "sw"
| "w"
| "rotate";
const HANDLE_SIZE = 10;
const ROTATE_OFFSET = 24;
const HANDLE_STROKE = "#3b82f6";
const HANDLE_FILL = "#ffffff";
// Screen-space handles for resize + rotate on a single selected node. Multi-
// select resize isn't in scope for V1 — use Group (Cmd+G) to resize multiples.
// The overlay renders inside the stage container (non-transformed) so all
// coordinates below are in container-local screen px.
export const MoodboardHtmlTransformerOverlay = ({ containerRef: _containerRef }: Props) => {
const { nodes, selectedIds, viewport, editingTextId } = useMoodboardStore(
useShallow((s) => ({
nodes: s.nodes,
selectedIds: s.selectedIds,
viewport: s.viewport,
editingTextId: s.transient.editingTextId,
})),
);
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const selectedArr = useMemo(() => Array.from(selectedIds), [selectedIds]);
const onlyId = selectedArr.length === 1 ? selectedArr[0] : null;
const onlyNode = onlyId ? nodes[onlyId] : null;
// Hide transformer while editing a text node (matches Konva page behavior).
if (editingTextId) return null;
if (!onlyNode) {
// Selection outlines for multi-select still render from MoodboardHtmlStage.
return null;
}
// Compute the four rotated corners of the node in world coords, then map
// to container-screen coords via pan + zoom. This gives us the exact screen
// positions for the handles regardless of zoom/pan/rotation.
const rad = (onlyNode.rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const worldCorner = (dx: number, dy: number) => ({
x: onlyNode.x + dx * cos - dy * sin,
y: onlyNode.y + dx * sin + dy * cos,
});
const toScreen = (p: { x: number; y: number }) => ({
x: viewport.pan.x + p.x * viewport.zoom,
y: viewport.pan.y + p.y * viewport.zoom,
});
const nwW = worldCorner(0, 0);
const neW = worldCorner(onlyNode.width, 0);
const seW = worldCorner(onlyNode.width, onlyNode.height);
const swW = worldCorner(0, onlyNode.height);
const nw = toScreen(nwW);
const ne = toScreen(neW);
const se = toScreen(seW);
const sw = toScreen(swW);
const mid = (a: { x: number; y: number }, b: { x: number; y: number }) => ({
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
});
const nMid = mid(nw, ne);
const eMid = mid(ne, se);
const sMid = mid(se, sw);
const wMid = mid(sw, nw);
// Unit vector along the top edge (for rotate handle placement perpendicular).
const topVec = { x: ne.x - nw.x, y: ne.y - nw.y };
const topLen = Math.hypot(topVec.x, topVec.y) || 1;
const perpUnit = { x: -topVec.y / topLen, y: topVec.x / topLen };
// Rotate handle sits "above" the top edge (outside the node, away from center).
// Center-of-node in screen coords:
const center = {
x: (nw.x + se.x) / 2,
y: (nw.y + se.y) / 2,
};
// Flip the perpendicular so it points away from center.
const sign =
(nMid.x - center.x) * perpUnit.x + (nMid.y - center.y) * perpUnit.y >= 0
? 1
: -1;
const rotateAnchor = {
x: nMid.x + perpUnit.x * sign * ROTATE_OFFSET,
y: nMid.y + perpUnit.y * sign * ROTATE_OFFSET,
};
const startDrag = (
key: HandleKey,
startClient: { x: number; y: number },
) => {
pushHistory();
const startNode: MoodboardNode = { ...onlyNode };
const startRad = (startNode.rotation * Math.PI) / 180;
const sCos = Math.cos(startRad);
const sSin = Math.sin(startRad);
// Anchor in world coords: the corner opposite the handle being dragged
// stays fixed during resize. For edge handles, the opposite edge's
// midpoint is the anchor along that axis.
const { x: nx, y: ny, width: nw0, height: nh0 } = startNode;
// Convert a client-space delta to local-axis delta (the node's own axes).
const clientToLocal = (cdx: number, cdy: number) => {
const worldDx = cdx / viewport.zoom;
const worldDy = cdy / viewport.zoom;
return {
lx: worldDx * sCos + worldDy * sSin,
ly: -worldDx * sSin + worldDy * sCos,
};
};
const onMove = (ev: PointerEvent) => {
const cdx = ev.clientX - startClient.x;
const cdy = ev.clientY - startClient.y;
if (key === "rotate") {
// Rotate around the node's center. Angle is measured from the center
// to the pointer, minus the initial angle.
const startCenterWorld = {
x: startNode.x + (nw0 / 2) * sCos - (nh0 / 2) * sSin,
y: startNode.y + (nw0 / 2) * sSin + (nh0 / 2) * sCos,
};
// Need a ref rect for client coord conversion — use _containerRef.
const rect = _containerRef.current?.getBoundingClientRect();
if (!rect) return;
const centerScreen = {
x: viewport.pan.x + startCenterWorld.x * viewport.zoom + rect.left,
y: viewport.pan.y + startCenterWorld.y * viewport.zoom + rect.top,
};
const startAngle =
(Math.atan2(startClient.y - centerScreen.y, startClient.x - centerScreen.x) *
180) /
Math.PI;
const nowAngle =
(Math.atan2(ev.clientY - centerScreen.y, ev.clientX - centerScreen.x) *
180) /
Math.PI;
const deltaDeg = nowAngle - startAngle;
// Rotate around the node center means the top-left must be repositioned
// to keep the center fixed.
const newRotation = startNode.rotation + deltaDeg;
const newRad = (newRotation * Math.PI) / 180;
const nCos = Math.cos(newRad);
const nSin = Math.sin(newRad);
// Keep center fixed: new (x, y) = center - rotate((w/2, h/2), newRotation)
const newX = startCenterWorld.x - (nw0 / 2) * nCos + (nh0 / 2) * nSin;
const newY = startCenterWorld.y - (nw0 / 2) * nSin - (nh0 / 2) * nCos;
updateNode(startNode.id, {
rotation: newRotation,
x: newX,
y: newY,
});
return;
}
// Live modifier state — re-read every move so toggling Shift/Alt
// mid-drag updates the behavior immediately (matches Figma).
const shift = ev.shiftKey;
const alt = ev.altKey;
const { lx, ly } = clientToLocal(cdx, cdy);
const isLeftHandle = key === "nw" || key === "w" || key === "sw";
const isRightHandle = key === "ne" || key === "e" || key === "se";
const isTopHandle = key === "nw" || key === "n" || key === "ne";
const isBottomHandle = key === "sw" || key === "s" || key === "se";
const affectsX = isLeftHandle || isRightHandle;
const affectsY = isTopHandle || isBottomHandle;
// Step 1: raw size deltas from the handle's sides.
let dw = 0;
let dh = 0;
if (isRightHandle) dw = lx;
if (isLeftHandle) dw = -lx;
if (isBottomHandle) dh = ly;
if (isTopHandle) dh = -ly;
// Step 2: Alt expands from center → both edges of the affected dim
// move, so the delta doubles.
if (alt) {
if (affectsX) dw *= 2;
if (affectsY) dh *= 2;
}
// Step 3: Shift applies aspect-lock.
// Corner: unify via the dominant dim's fractional change.
// Edge: propagate the dragged dim to the perpendicular one via aspect.
if (shift) {
const aspect = nw0 / nh0;
if (affectsX && affectsY) {
const fracW = dw / nw0;
const fracH = dh / nh0;
const s = Math.abs(fracW) >= Math.abs(fracH) ? fracW : fracH;
dw = nw0 * s;
dh = nh0 * s;
} else if (affectsX) {
dh = dw / aspect;
} else if (affectsY) {
dw = dh * aspect;
}
}
// Step 4: anchor per dim. Alt → center (0.5). Otherwise the opposite
// side (0 for right-handles, 1 for left-handles; 0 for bottom, 1 for
// top). Dims not affected by the handle default to 0.5, which is what
// Figma's edge+Shift does for the propagated perpendicular axis.
let ax = 0.5;
let ay = 0.5;
if (!alt) {
if (isLeftHandle) ax = 1;
else if (isRightHandle) ax = 0;
if (isTopHandle) ay = 1;
else if (isBottomHandle) ay = 0;
}
let dxLocal = -ax * dw;
let dyLocal = -ay * dh;
// Enforce minimum size so the node doesn't invert. Re-derive dx/dy so
// the anchor stays correct after clamping (aspect may drift by a pixel
// at min size — acceptable edge case).
const MIN_SIZE = 8;
let newW = nw0 + dw;
let newH = nh0 + dh;
if (newW < MIN_SIZE) {
const adj = MIN_SIZE - newW;
newW = MIN_SIZE;
dw += adj;
dxLocal = -ax * dw;
}
if (newH < MIN_SIZE) {
const adj = MIN_SIZE - newH;
newH = MIN_SIZE;
dh += adj;
dyLocal = -ay * dh;
}
// Convert local-axis translation back to world-axis to update (x, y).
const worldDx = dxLocal * sCos - dyLocal * sSin;
const worldDy = dxLocal * sSin + dyLocal * sCos;
const patch: Partial<MoodboardNode> = {
x: nx + worldDx,
y: ny + worldDy,
width: newW,
height: newH,
};
// Text nodes scale their fontSize uniformly to match Konva behavior.
if (startNode.kind === "text") {
const uniform = (newW / nw0 + newH / nh0) / 2;
(patch as Partial<MoodboardNode> & { fontSize?: number }).fontSize =
startNode.fontSize * uniform;
}
updateNode(startNode.id, patch);
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const handleStyle = (key: HandleKey, pos: { x: number; y: number }) => {
const cursorByKey: Record<HandleKey, string> = {
nw: "nwse-resize",
ne: "nesw-resize",
se: "nwse-resize",
sw: "nesw-resize",
n: "ns-resize",
s: "ns-resize",
e: "ew-resize",
w: "ew-resize",
rotate: "grab",
};
return {
position: "absolute" as const,
left: pos.x - HANDLE_SIZE / 2,
top: pos.y - HANDLE_SIZE / 2,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
background: HANDLE_FILL,
border: `1px solid ${HANDLE_STROKE}`,
borderRadius: key === "rotate" ? "50%" : 2,
cursor: cursorByKey[key],
pointerEvents: "auto" as const,
zIndex: 3,
boxSizing: "border-box" as const,
};
};
const onHandlePointerDown =
(key: HandleKey) => (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
// React's stopPropagation only stops React's synthetic bubble. The
// data-moodboard-html-handle attribute on the element lets the native
// pointerdown listener in useHtmlSelection skip this click too, so we
// don't start a marquee while dragging a handle.
e.stopPropagation();
e.preventDefault();
startDrag(key, { x: e.clientX, y: e.clientY });
};
return (
<div
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
{/* Connector line from top-edge midpoint to rotate handle */}
<svg
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
>
<line
x1={nMid.x}
y1={nMid.y}
x2={rotateAnchor.x}
y2={rotateAnchor.y}
stroke={HANDLE_STROKE}
strokeWidth={1}
/>
</svg>
<div
data-moodboard-html-handle="nw"
style={handleStyle("nw", nw)}
onPointerDown={onHandlePointerDown("nw")}
/>
<div
data-moodboard-html-handle="n"
style={handleStyle("n", nMid)}
onPointerDown={onHandlePointerDown("n")}
/>
<div
data-moodboard-html-handle="ne"
style={handleStyle("ne", ne)}
onPointerDown={onHandlePointerDown("ne")}
/>
<div
data-moodboard-html-handle="e"
style={handleStyle("e", eMid)}
onPointerDown={onHandlePointerDown("e")}
/>
<div
data-moodboard-html-handle="se"
style={handleStyle("se", se)}
onPointerDown={onHandlePointerDown("se")}
/>
<div
data-moodboard-html-handle="s"
style={handleStyle("s", sMid)}
onPointerDown={onHandlePointerDown("s")}
/>
<div
data-moodboard-html-handle="sw"
style={handleStyle("sw", sw)}
onPointerDown={onHandlePointerDown("sw")}
/>
<div
data-moodboard-html-handle="w"
style={handleStyle("w", wMid)}
onPointerDown={onHandlePointerDown("w")}
/>
<div
data-moodboard-html-handle="rotate"
style={handleStyle("rotate", rotateAnchor)}
onPointerDown={onHandlePointerDown("rotate")}
/>
</div>
);
};
@@ -0,0 +1 @@
export { MoodboardHtml } from "./MoodboardHtml";
@@ -0,0 +1,30 @@
import { Vec2 } from "../../PageMoodboard/types";
import { Viewport } from "../../PageMoodboard/layout/geometry";
// Converts a DOM pointer event's client coords to world (stage) coords for the
// HTML moodboard. Mirrors the role of `stagePointerPos` in the Konva page but
// uses DOM rects and the viewport in the store instead of Konva's stage state.
export const worldPointFromClient = (
containerEl: HTMLElement | null,
clientX: number,
clientY: number,
viewport: Viewport,
): Vec2 | null => {
if (!containerEl) return null;
const rect = containerEl.getBoundingClientRect();
return {
x: (clientX - rect.left - viewport.pan.x) / viewport.zoom,
y: (clientY - rect.top - viewport.pan.y) / viewport.zoom,
};
};
// Returns the center of the visible viewport in world coords. Used as a
// fallback drop point when no cursor position is available.
export const worldViewportCenter = (
canvasWidth: number,
canvasHeight: number,
viewport: Viewport,
): Vec2 => ({
x: (canvasWidth / 2 - viewport.pan.x) / viewport.zoom,
y: (canvasHeight / 2 - viewport.pan.y) / viewport.zoom,
});
@@ -0,0 +1,61 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
interface GalleryItem {
id?: string;
thumbnail?: string | null;
fullImage?: string | null;
}
// 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 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;
});
// 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);
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,
};
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]);
};
@@ -0,0 +1,130 @@
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;
});
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 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("No image selected");
return;
}
const center = getCenter();
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,69 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
import { uploadImage } from "~/components/reusable/UploadModalImage/utilities/uploadImage";
import { worldViewportCenter } from "./htmlStagePointer";
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve, reject) => {
const i = new window.Image();
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = reject;
i.src = url;
});
// HTML analogue of usePasteHandler.ts — drop pasted images at the viewport
// center (derived from canvasSize + viewport in the store, no stageRef needed).
export const useHtmlPasteHandler = (active: boolean) => {
const addImage = useMoodboardStore((s) => s.addImage);
useEffect(() => {
if (!active) return undefined;
const handler = async (e: ClipboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
const items = e.clipboardData?.items;
if (!items) return;
const fileItems: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === "file" && it.type.startsWith("image/")) {
const f = it.getAsFile();
if (f) fileItems.push(f);
}
}
if (fileItems.length === 0) return;
e.preventDefault();
const { canvasSize, viewport } = useMoodboardStore.getState();
const center = worldViewportCenter(
canvasSize.width,
canvasSize.height,
viewport,
);
for (const file of fileItems) {
const blobUrl = URL.createObjectURL(file);
try {
const dims = await measure(blobUrl);
addImage(blobUrl, center, dims.w, dims.h, null);
} catch (err) {
console.error("[MoodboardHtml] paste measure failed", err);
addImage(blobUrl, center, 320, 320, null);
}
uploadImage({
title: "Pasted image",
assetFile: file,
progressCallback: () => {},
}).catch((err) => {
console.error("[MoodboardHtml] background upload failed", err);
});
}
};
window.addEventListener("paste", handler);
return () => window.removeEventListener("paste", handler);
}, [active, addImage]);
};
@@ -0,0 +1,146 @@
import { useEffect, useRef } from "react";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
import { Vec2 } from "../../PageMoodboard/types";
import {
rectCorners,
rectFromPoints,
rectsIntersect,
} from "../../PageMoodboard/layout/geometry";
import { pointInPolygon } from "../../PageMoodboard/layout/pointInPolygon";
import { polygonIntersectsRect } from "../../PageMoodboard/layout/segmentsIntersect";
import { worldPointFromClient } from "./htmlStagePointer";
const MIN_DRAG = 4;
interface DragState {
origin: Vec2;
path: Vec2[];
active: boolean;
mode: "rect" | "lasso" | null;
}
// HTML analogue of useSelection.ts. Listens on the stage container for bare
// pointerdowns (nodes stopPropagation before these fire), tracks the drag in
// world coords, and commits the same way the Konva version does — marquee
// uses rectsIntersect, lasso uses pointInPolygon / polygonIntersectsRect.
export const useHtmlSelection = (
containerRef: React.RefObject<HTMLDivElement | null>,
) => {
const drag = useRef<DragState>({
origin: { x: 0, y: 0 },
path: [],
active: false,
mode: null,
});
const tool = useMoodboardStore((s) => s.tool);
const setMarquee = useMoodboardStore((s) => s.setMarquee);
const setLassoPath = useMoodboardStore((s) => s.setLassoPath);
const setSelection = useMoodboardStore((s) => s.setSelection);
useEffect(() => {
if (tool !== "select" && tool !== "lasso") return;
const el = containerRef.current;
if (!el) return;
const commit = () => {
const { nodes, rootOrder, transient } = useMoodboardStore.getState();
const candidates = rootOrder
.map((id) => nodes[id])
.filter((n): n is NonNullable<typeof n> => Boolean(n));
if (drag.current.mode === "rect") {
const rect = transient.marquee;
if (!rect || (rect.width < MIN_DRAG && rect.height < MIN_DRAG)) {
setSelection([]);
} else {
const hit = candidates
.filter((n) =>
rectsIntersect(
{ x: n.x, y: n.y, width: n.width, height: n.height },
rect,
),
)
.map((n) => n.id);
setSelection(hit);
}
setMarquee(null);
} else if (drag.current.mode === "lasso") {
const path = drag.current.path;
if (path.length < 3) {
setSelection([]);
} else {
const closed = [...path, path[0]];
const hit = candidates
.filter((n) => {
const corners = rectCorners(n);
if (corners.some((c) => pointInPolygon(c, closed))) return true;
if (polygonIntersectsRect(closed, n)) return true;
return false;
})
.map((n) => n.id);
setSelection(hit);
}
setLassoPath(null);
}
drag.current.active = false;
drag.current.mode = null;
drag.current.path = [];
};
const handlePointerDown = (e: PointerEvent) => {
// Nodes stopPropagation in their own pointerdown, so reaching here means
// the user clicked on bare stage.
if (e.button !== 0) return;
if (useMoodboardStore.getState().transient.isPanning) return;
// Space-held pan is handled before this; if that hook is active and panning,
// isPanning is true. We also ignore if the target was a node (defensive).
const target = e.target as HTMLElement | null;
if (target && target.closest("[data-moodboard-html-node]")) return;
if (target && target.closest("[data-moodboard-html-group]")) return;
// Transformer handles use React stopPropagation which can't stop this
// native listener, so filter on the data attribute instead — otherwise
// grabbing a resize/rotate handle also starts a marquee drag.
if (target && target.closest("[data-moodboard-html-handle]")) return;
const { viewport } = useMoodboardStore.getState();
const pos = worldPointFromClient(el, e.clientX, e.clientY, viewport);
if (!pos) return;
drag.current = {
origin: pos,
path: tool === "lasso" ? [pos] : [],
active: true,
mode: tool === "lasso" ? "lasso" : "rect",
};
};
const handlePointerMove = (e: PointerEvent) => {
if (!drag.current.active) return;
const { viewport } = useMoodboardStore.getState();
const pos = worldPointFromClient(el, e.clientX, e.clientY, viewport);
if (!pos) return;
if (drag.current.mode === "rect") {
const dx = pos.x - drag.current.origin.x;
const dy = pos.y - drag.current.origin.y;
if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) return;
setMarquee(rectFromPoints(drag.current.origin, pos));
} else if (drag.current.mode === "lasso") {
drag.current.path.push(pos);
setLassoPath([...drag.current.path]);
}
};
const handlePointerUp = () => {
if (!drag.current.active) return;
commit();
};
// Bare-stage "click" (zero-area drag) should clear selection — the commit
// path handles that via the MIN_DRAG branch.
el.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
return () => {
el.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [containerRef, tool, setMarquee, setLassoPath, setSelection]);
};
@@ -0,0 +1,164 @@
import { useEffect, useRef } from "react";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
import { Vec2 } from "../../PageMoodboard/types";
import {
ZOOM_MAX,
ZOOM_MIN,
clamp,
} from "../../PageMoodboard/layout/geometry";
const ZOOM_STEP = 1.1;
const isEditableTarget = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
if (/input|textarea/i.test(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
};
// HTML analogue of useViewportControls.ts. Wheel zooms around the cursor;
// middle-mouse and space+left drag pan; Cmd/Ctrl+0 resets. Everything is
// derived from the container's bounding rect — no Konva stage required.
export const useHtmlViewportControls = (
containerRef: React.RefObject<HTMLDivElement | null>,
) => {
const setZoom = useMoodboardStore((s) => s.setZoom);
const setPan = useMoodboardStore((s) => s.setPan);
const resetViewport = useMoodboardStore((s) => s.resetViewport);
const setIsPanning = useMoodboardStore((s) => s.setIsPanning);
const spaceHeld = useRef(false);
const panState = useRef<{
active: boolean;
startScreen: Vec2;
startPan: Vec2;
}>({
active: false,
startScreen: { x: 0, y: 0 },
startPan: { x: 0, y: 0 },
});
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleWheel = (e: WheelEvent) => {
if (isEditableTarget(document.activeElement)) return;
e.preventDefault();
const { viewport } = useMoodboardStore.getState();
const rect = el.getBoundingClientRect();
const pointer = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
const oldScale = viewport.zoom;
const mousePointTo = {
x: (pointer.x - viewport.pan.x) / oldScale,
y: (pointer.y - viewport.pan.y) / oldScale,
};
const direction = e.deltaY > 0 ? -1 : 1;
const newScale = clamp(
direction > 0 ? oldScale * ZOOM_STEP : oldScale / ZOOM_STEP,
ZOOM_MIN,
ZOOM_MAX,
);
const newPan = {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
setZoom(newScale);
setPan(newPan);
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => {
el.removeEventListener("wheel", handleWheel);
};
}, [containerRef, setZoom, setPan]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const beginPan = (clientX: number, clientY: number) => {
panState.current = {
active: true,
startScreen: { x: clientX, y: clientY },
startPan: { ...useMoodboardStore.getState().viewport.pan },
};
setIsPanning(true);
el.style.cursor = "grabbing";
};
const endPan = () => {
if (!panState.current.active) return;
panState.current.active = false;
setIsPanning(false);
el.style.cursor = spaceHeld.current ? "grab" : "";
};
const handleMouseDown = (e: MouseEvent) => {
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
return;
}
if (e.button === 0 && spaceHeld.current) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!panState.current.active) return;
const dx = e.clientX - panState.current.startScreen.x;
const dy = e.clientY - panState.current.startScreen.y;
setPan({
x: panState.current.startPan.x + dx,
y: panState.current.startPan.y + dy,
});
};
const handleMouseUp = () => endPan();
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return;
if (e.code === "Space" && !spaceHeld.current) {
spaceHeld.current = true;
if (!panState.current.active) el.style.cursor = "grab";
e.preventDefault();
}
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod && e.key === "0") {
e.preventDefault();
resetViewport();
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") {
spaceHeld.current = false;
if (!panState.current.active) el.style.cursor = "";
}
};
el.addEventListener("mousedown", handleMouseDown, { capture: true });
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
el.removeEventListener("mousedown", handleMouseDown, {
capture: true,
} as EventListenerOptions);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
el.style.cursor = "";
};
}, [containerRef, setPan, resetViewport, setIsPanning]);
};
@@ -0,0 +1,127 @@
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";
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 === "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);
@@ -16,7 +16,8 @@ export type TabId =
| "REMOVE_BACKGROUND"
| "ANGLES"
| "STORYBOARD"
| "MOODBOARD";
| "MOODBOARD"
| "MOODBOARD_HTML";
const DEFAULT_TAB: TabId = "IMAGE";
@@ -4,4 +4,5 @@ export {
useExperimentalStore,
useStoryboardPageEnabled,
useMoodboardPageEnabled,
useMoodboardHtmlPageEnabled,
} from './lib/experimental-store';
@@ -3,6 +3,7 @@ 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;
@@ -30,16 +31,19 @@ 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 });
@@ -49,10 +53,12 @@ export const useExperimentalStore = create<ExperimentalState>((set) => ({
writeBoolFlag(ENABLED_STORAGE_KEY, false);
writeBoolFlag(STORYBOARD_STORAGE_KEY, false);
writeBoolFlag(MOODBOARD_STORAGE_KEY, false);
writeBoolFlag(MOODBOARD_HTML_STORAGE_KEY, false);
set({
enabled: false,
storyboardPageEnabled: false,
moodboardPageEnabled: false,
moodboardHtmlPageEnabled: false,
});
},
setStoryboardPageEnabled: (enabled: boolean) => {
@@ -63,6 +69,10 @@ export const useExperimentalStore = create<ExperimentalState>((set) => ({
writeBoolFlag(MOODBOARD_STORAGE_KEY, enabled);
set({ moodboardPageEnabled: enabled });
},
setMoodboardHtmlPageEnabled: (enabled: boolean) => {
writeBoolFlag(MOODBOARD_HTML_STORAGE_KEY, enabled);
set({ moodboardHtmlPageEnabled: enabled });
},
}));
export const useStoryboardPageEnabled = () =>
@@ -70,3 +80,6 @@ export const useStoryboardPageEnabled = () =>
export const useMoodboardPageEnabled = () =>
useExperimentalStore((s) => s.enabled && s.moodboardPageEnabled);
export const useMoodboardHtmlPageEnabled = () =>
useExperimentalStore((s) => s.enabled && s.moodboardHtmlPageEnabled);
@@ -11,11 +11,14 @@ interface Props {
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,
onMoodboardPageDisable,
onMoodboardHtmlPageDisable,
}: Props) => {
const storyboardPageEnabled = useExperimentalStore(
(s) => s.storyboardPageEnabled,
@@ -29,10 +32,20 @@ export const ExperimentalSettingsPane = ({
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 = (feature: string, enabled: boolean) => {
gtagEvent("toggle_experimental_feature", {
@@ -73,6 +86,22 @@ export const ExperimentalSettingsPane = ({
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">
@@ -102,6 +131,21 @@ export const ExperimentalSettingsPane = ({
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={isStoryboardDisableConfirmOpen}
@@ -119,6 +163,14 @@ export const ExperimentalSettingsPane = ({
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"
/>
</>
);
};