Moodboard (HTML): card grouping with color picker and drop targeting

- CardNodeHtml: vertical-flex card container that owns its children's
  layout (children fill card width, heights follow natural aspect for
  images/videos or own height for text).
- cardLayout: pure helpers (DEFAULT_CARD_WIDTH/PADDING/COLOR,
  childRenderedHeight) used by the card node + store.
- MoodboardHtmlColorPicker: hex/rgb picker with preset swatches for
  card backgrounds.
- findDropTarget: top-level hit-test on pointer-up so a drag can form
  a new card with the target + dragged node, or append to an existing
  card.
This commit is contained in:
Tanish Manku committed 2026-04-29 12:33:10 -07:00
1 parent f260e1cee4
commit 2fb1097eb1
4 files changed
+555

No files matched your search

@@ -0,0 +1,37 @@
import { CardNode, MoodboardNode } from "./types";
export const DEFAULT_CARD_WIDTH = 280;
export const DEFAULT_CARD_PADDING = 8;
export const DEFAULT_CARD_COLOR = "#FFFFFF";
// Height of one child when laid out inside a card: child's native aspect
// ratio applied to (cardWidth - 2*padding), falling back to the child's own
// height when aspect isn't available (text nodes, nested containers).
export const childRenderedHeight = (
child: MoodboardNode,
innerWidth: number,
): number => {
if (child.kind === "image" || child.kind === "video") {
const ratio =
child.naturalH > 0 && child.naturalW > 0
? child.naturalH / child.naturalW
: child.height / Math.max(child.width, 1);
return innerWidth * ratio;
}
if (child.kind === "text") return child.height;
return child.height;
};
export const computeCardHeight = (
card: CardNode,
nodes: Record<string, MoodboardNode>,
): number => {
const innerW = Math.max(card.width - card.padding * 2, 1);
let total = card.padding;
for (const cid of card.childIds) {
const c = nodes[cid];
if (!c) continue;
total += childRenderedHeight(c, innerW) + card.padding;
}
return Math.max(total, card.padding * 2);
};
@@ -0,0 +1,247 @@
import { useEffect, useRef, useState } from "react";
import { HexColorPicker, RgbColorPicker } from "react-colorful";
import { useShallow } from "zustand/react/shallow";
import { useMoodboardStore } from "../PageMoodboard/MoodboardStore";
const PRESETS = [
"#FFFFFF",
"#F5F5F0",
"#FFF8E1",
"#E3F2FD",
"#E8F5E9",
"#FCE4EC",
"#F3E5F5",
"#ECEFF1",
"#1F2937",
];
type Mode = "hex" | "rgb";
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(full || "0", 16);
return {
r: (n >> 16) & 255,
g: (n >> 8) & 255,
b: n & 255,
};
};
const rgbToHex = (r: number, g: number, b: number): string => {
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
const h = (clamp(r) << 16) | (clamp(g) << 8) | clamp(b);
return `#${h.toString(16).padStart(6, "0").toUpperCase()}`;
};
// Floating color-picker toolbar, pinned to the bottom-left of the moodboard.
// Renders only when the selection contains exactly one card. Offers a row of
// preset swatches, the current color, and a toggleable advanced picker
// (hex / RGB).
export const MoodboardHtmlColorPicker = () => {
const { selectedIds, nodes } = useMoodboardStore(
useShallow((s) => ({ selectedIds: s.selectedIds, nodes: s.nodes })),
);
const setCardColor = useMoodboardStore((s) => s.setCardColor);
const [expanded, setExpanded] = useState(false);
const [mode, setMode] = useState<Mode>("hex");
const rootRef = useRef<HTMLDivElement | null>(null);
const ids = Array.from(selectedIds);
const only = ids.length === 1 ? nodes[ids[0]] : null;
const card = only && only.kind === "card" ? only : null;
// Collapse the advanced picker whenever the selection changes to a
// different card — keeps the UI from lingering open unexpectedly.
useEffect(() => {
setExpanded(false);
}, [card?.id]);
// Outside-click closes the advanced picker. The preset row stays visible
// regardless since it lives in the always-mounted toolbar.
useEffect(() => {
if (!expanded) return undefined;
const handler = (e: MouseEvent) => {
const el = rootRef.current;
if (!el) return;
if (e.target instanceof Node && el.contains(e.target)) return;
setExpanded(false);
};
window.addEventListener("mousedown", handler);
return () => window.removeEventListener("mousedown", handler);
}, [expanded]);
if (!card) return null;
const current = card.backgroundColor;
const rgb = hexToRgb(current);
return (
<div
ref={rootRef}
// Stop stage pointerdowns from clearing selection when the user
// interacts with the picker panel itself.
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
style={{
position: "absolute",
left: 16,
bottom: 16,
zIndex: 20,
display: "flex",
flexDirection: "column",
gap: 8,
padding: 10,
borderRadius: 10,
background: "rgba(24,24,27,0.92)",
boxShadow: "0 8px 24px rgba(0,0,0,0.35)",
color: "#e5e7eb",
fontSize: 12,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ opacity: 0.7 }}>Card color</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{PRESETS.map((c) => (
<button
key={c}
type="button"
aria-label={`Set color ${c}`}
onClick={() => setCardColor(card.id, c)}
style={{
width: 22,
height: 22,
borderRadius: "50%",
background: c,
border:
c.toLowerCase() === current.toLowerCase()
? "2px solid #3b82f6"
: "1px solid rgba(255,255,255,0.25)",
cursor: "pointer",
padding: 0,
}}
/>
))}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label="Custom color"
style={{
width: 22,
height: 22,
borderRadius: "50%",
background:
"conic-gradient(#f87171, #fbbf24, #34d399, #60a5fa, #a78bfa, #f472b6, #f87171)",
border: "1px solid rgba(255,255,255,0.35)",
cursor: "pointer",
padding: 0,
}}
/>
</div>
{expanded && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
paddingTop: 6,
}}
>
<div style={{ display: "flex", gap: 4 }}>
<ModeTab label="HEX" active={mode === "hex"} onClick={() => setMode("hex")} />
<ModeTab label="RGB" active={mode === "rgb"} onClick={() => setMode("rgb")} />
</div>
{mode === "hex" ? (
<>
<HexColorPicker
color={current}
onChange={(c) => setCardColor(card.id, c.toUpperCase())}
style={{ width: 180, height: 140 }}
/>
<input
type="text"
value={current}
onChange={(e) => {
const v = e.target.value.trim();
if (/^#?[0-9a-fA-F]{6}$/.test(v)) {
setCardColor(card.id, v.startsWith("#") ? v.toUpperCase() : `#${v.toUpperCase()}`);
}
}}
style={{
width: "100%",
background: "rgba(255,255,255,0.08)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 4,
padding: "4px 6px",
fontFamily: "monospace",
}}
/>
</>
) : (
<>
<RgbColorPicker
color={rgb}
onChange={(c) => setCardColor(card.id, rgbToHex(c.r, c.g, c.b))}
style={{ width: 180, height: 140 }}
/>
<div style={{ display: "flex", gap: 4 }}>
{(["r", "g", "b"] as const).map((key) => (
<input
key={key}
type="number"
min={0}
max={255}
value={rgb[key]}
onChange={(e) => {
const n = Math.max(0, Math.min(255, Number(e.target.value) || 0));
const next = { ...rgb, [key]: n };
setCardColor(card.id, rgbToHex(next.r, next.g, next.b));
}}
style={{
flex: 1,
background: "rgba(255,255,255,0.08)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 4,
padding: "4px 6px",
fontFamily: "monospace",
}}
/>
))}
</div>
</>
)}
</div>
)}
</div>
);
};
interface ModeTabProps {
label: string;
active: boolean;
onClick: () => void;
}
const ModeTab = ({ label, active, onClick }: ModeTabProps) => (
<button
type="button"
onClick={onClick}
style={{
flex: 1,
padding: "4px 8px",
borderRadius: 4,
fontSize: 11,
cursor: "pointer",
background: active ? "rgba(59,130,246,0.35)" : "rgba(255,255,255,0.06)",
border: active
? "1px solid #3b82f6"
: "1px solid rgba(255,255,255,0.1)",
color: "#fff",
}}
>
{label}
</button>
);
@@ -0,0 +1,79 @@
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
import { worldPointFromClient } from "./htmlStagePointer";
// Returns the id of the top-level node under the given client-space point, or
// null if the point is over bare canvas. Skips the dragged node, groups, and
// any nodes inside groups/cards.
//
// Used by node drag handlers on pointer-up to decide whether the release
// should either (a) form a new card with that target + the dragged node, or
// (b) append the dragged node to an existing card.
export const findDropTargetAt = (
clientX: number,
clientY: number,
draggedId: string,
): string | null => {
const container = document.querySelector<HTMLDivElement>(
"[data-moodboard-html-stage]",
);
if (!container) return null;
const { nodes, rootOrder, viewport } = useMoodboardStore.getState();
const p = worldPointFromClient(container, clientX, clientY, viewport);
if (!p) return null;
// Iterate in reverse z-order so the topmost overlap wins.
for (let i = rootOrder.length - 1; i >= 0; i--) {
const id = rootOrder[i];
if (id === draggedId) continue;
const n = nodes[id];
if (!n) continue;
if (n.kind === "group") continue;
if (
p.x >= n.x &&
p.x <= n.x + n.width &&
p.y >= n.y &&
p.y <= n.y + n.height
) {
return id;
}
}
return null;
};
// Applies the drop result: forms a new card (target is a regular node) or
// appends (target is already a card). Returns true if any mutation happened.
export const applyDropOnto = (
draggedId: string,
targetId: string,
): boolean => {
const state = useMoodboardStore.getState();
const target = state.nodes[targetId];
if (!target) return false;
if (target.kind === "card") {
state.appendToCard(targetId, draggedId);
return true;
}
if (target.kind === "image" || target.kind === "video" || target.kind === "text") {
state.createCardFromDrop(draggedId, targetId);
return true;
}
return false;
};
// Recomputes the hover target under the pointer and publishes the result to
// the store's transient `dragPreview` so overlay UI can react. Safe to call
// on every pointermove — the setter is a shallow equality in practice since
// we always emit a fresh object, but targets change rarely during a drag.
export const updateDragPreview = (
clientX: number,
clientY: number,
draggedId: string,
): void => {
const targetId = findDropTargetAt(clientX, clientY, draggedId);
useMoodboardStore
.getState()
.setDragPreview({ draggedId, targetId });
};
export const clearDragPreview = (): void => {
useMoodboardStore.getState().setDragPreview(null);
};
@@ -0,0 +1,192 @@
import { memo, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import {
CardNode as CardNodeData,
MoodboardNode,
} from "../../PageMoodboard/types";
import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore";
interface Props {
node: CardNodeData;
draggable: boolean;
onSelect: (id: string, additive: boolean) => void;
}
// Cards render their children in a vertical flex column. Children fill the
// card's inner width; their height follows the child's natural aspect ratio
// (images/videos) or own height (text). The card itself absorbs clicks —
// children inside a card aren't independently selectable, same pattern as
// GroupNodeHtml.
const CardNodeHtmlInner = ({ node, draggable, onSelect }: Props) => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const children = useMoodboardStore(
useShallow((s) =>
node.childIds
.map((id) => s.nodes[id])
.filter((n): n is MoodboardNode => Boolean(n)),
),
);
const dragStateRef = useRef<{
active: boolean;
startClient: { x: number; y: number };
startNode: { x: number; y: number };
moved: boolean;
} | null>(null);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (!draggable) return;
e.stopPropagation();
const additive = e.shiftKey;
onSelect(node.id, additive);
dragStateRef.current = {
active: true,
startClient: { x: e.clientX, y: e.clientY },
startNode: { x: node.x, y: node.y },
moved: false,
};
const onMove = (ev: PointerEvent) => {
const st = dragStateRef.current;
if (!st || !st.active) return;
const { zoom } = useMoodboardStore.getState().viewport;
const dx = (ev.clientX - st.startClient.x) / zoom;
const dy = (ev.clientY - st.startClient.y) / zoom;
if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) {
st.moved = true;
pushHistory();
}
if (st.moved) {
updateNode(node.id, {
x: st.startNode.x + dx,
y: st.startNode.y + dy,
});
}
};
const onUp = () => {
dragStateRef.current = null;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const innerW = Math.max(node.width - node.padding * 2, 1);
return (
<div
data-moodboard-html-card={node.id}
onPointerDown={handlePointerDown}
style={{
position: "absolute",
left: node.x,
top: node.y,
width: node.width,
height: node.height,
transform: `rotate(${node.rotation}deg)`,
transformOrigin: "0 0",
background: node.backgroundColor,
boxShadow: "0 2px 8px rgba(0,0,0,0.18)",
borderRadius: 6,
cursor: draggable ? "move" : "default",
userSelect: "none",
touchAction: "none",
boxSizing: "border-box",
padding: node.padding,
display: "flex",
flexDirection: "column",
gap: node.padding,
overflow: "hidden",
}}
>
{children.map((child) => (
<CardChildSlot
key={child.id}
child={child}
innerWidth={innerW}
/>
))}
</div>
);
};
interface SlotProps {
child: MoodboardNode;
innerWidth: number;
}
// Each card slot renders a single child at full card-inner-width. We don't
// reuse ImageNodeHtml/VideoNodeHtml here because those position via absolute
// x/y — inside a card we want flex-driven layout instead.
export const CardChildSlot = ({ child, innerWidth }: SlotProps) => {
if (child.kind === "image") {
const ratio =
child.naturalH > 0 && child.naturalW > 0
? child.naturalH / child.naturalW
: 1;
return (
<img
src={child.src}
draggable={false}
alt=""
style={{
width: innerWidth,
height: innerWidth * ratio,
display: "block",
objectFit: "fill",
userSelect: "none",
pointerEvents: "none",
borderRadius: 4,
}}
/>
);
}
if (child.kind === "video") {
const ratio =
child.naturalH > 0 && child.naturalW > 0
? child.naturalH / child.naturalW
: 1;
return (
<video
src={child.src}
autoPlay={child.autoplay}
muted={child.muted}
loop={child.loop}
playsInline
preload="metadata"
style={{
width: innerWidth,
height: innerWidth * ratio,
display: "block",
objectFit: "fill",
pointerEvents: "none",
borderRadius: 4,
}}
/>
);
}
if (child.kind === "text") {
return (
<div
style={{
width: innerWidth,
height: child.height,
padding: 4,
color: child.color,
fontSize: child.fontSize,
fontFamily: "inherit",
whiteSpace: "pre-wrap",
overflow: "hidden",
boxSizing: "border-box",
lineHeight: 1.2,
}}
>
{child.text || "Note"}
</div>
);
}
return null;
};
export const CardNodeHtml = memo(CardNodeHtmlInner);