mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 17:38:10 +00:00
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:
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);
|
||||
Reference in new issue
Block a user