mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 01:18:34 +00:00
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:
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>
|
||||
);
|
||||
};
|
||||
+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";
|
||||
+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,
|
||||
});
|
||||
+61
@@ -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]);
|
||||
};
|
||||
+130
@@ -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 };
|
||||
};
|
||||
+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,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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user