From 2fb1097eb11f1e1cb43864a185fcd23f83bf9650 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 29 Apr 2026 12:33:10 -0700 Subject: [PATCH] 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. --- .../app/src/pages/PageMoodboard/cardLayout.ts | 37 +++ .../MoodboardHtmlColorPicker.tsx | 247 ++++++++++++++++++ .../interactions/findDropTarget.ts | 79 ++++++ .../PageMoodboardHtml/nodes/CardNodeHtml.tsx | 192 ++++++++++++++ 4 files changed, 555 insertions(+) create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboard/cardLayout.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlColorPicker.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/findDropTarget.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/CardNodeHtml.tsx diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/cardLayout.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboard/cardLayout.ts new file mode 100644 index 0000000000..e7ab159098 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/cardLayout.ts @@ -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, +): 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); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlColorPicker.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlColorPicker.tsx new file mode 100644 index 0000000000..e1b317a927 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlColorPicker.tsx @@ -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("hex"); + const rootRef = useRef(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 ( +
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, + }} + > +
+ Card color +
+
+ {PRESETS.map((c) => ( +
+ {expanded && ( +
+
+ setMode("hex")} /> + setMode("rgb")} /> +
+ {mode === "hex" ? ( + <> + setCardColor(card.id, c.toUpperCase())} + style={{ width: 180, height: 140 }} + /> + { + 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", + }} + /> + + ) : ( + <> + setCardColor(card.id, rgbToHex(c.r, c.g, c.b))} + style={{ width: 180, height: 140 }} + /> +
+ {(["r", "g", "b"] as const).map((key) => ( + { + 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", + }} + /> + ))} +
+ + )} +
+ )} +
+ ); +}; + +interface ModeTabProps { + label: string; + active: boolean; + onClick: () => void; +} + +const ModeTab = ({ label, active, onClick }: ModeTabProps) => ( + +); diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/findDropTarget.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/findDropTarget.ts new file mode 100644 index 0000000000..227edd3778 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/findDropTarget.ts @@ -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( + "[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); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/CardNodeHtml.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/CardNodeHtml.tsx new file mode 100644 index 0000000000..2bd971b9ae --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/CardNodeHtml.tsx @@ -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) => { + 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 ( +
+ {children.map((child) => ( + + ))} +
+ ); +}; + +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 ( + + ); + } + if (child.kind === "video") { + const ratio = + child.naturalH > 0 && child.naturalW > 0 + ? child.naturalH / child.naturalW + : 1; + return ( +