diff --git a/frontend/apps/artcraft-webapp/src/pages/moodboard/MoodboardPage.tsx b/frontend/apps/artcraft-webapp/src/pages/moodboard/MoodboardPage.tsx index 253e87f959..1c31850ba4 100644 --- a/frontend/apps/artcraft-webapp/src/pages/moodboard/MoodboardPage.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/moodboard/MoodboardPage.tsx @@ -1,6 +1,14 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { MoodboardWorkspace } from "@storyteller/ui-moodboard"; +import { + MoodboardWorkspace, + dispatchGalleryMoodboardDrop, +} from "@storyteller/ui-moodboard"; +import { + GalleryItem, + onImageDrop, + removeImageDropListener, +} from "@storyteller/ui-gallery-modal"; import { webappMoodboardAdapter, setMoodboardNavigate, @@ -16,6 +24,19 @@ export default function MoodboardPage() { setMoodboardNavigate((path) => navigate(path)); }, [navigate]); + // Drag a library image onto the grid/canvas to add it to the board. + useEffect(() => { + const handler = onImageDrop( + (item: GalleryItem, position: { x: number; y: number }) => { + dispatchGalleryMoodboardDrop(item, position); + }, + ); + return () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (handler) removeImageDropListener(handler as any); + }; + }, []); + return (
diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/PageMoodboard.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/PageMoodboard.tsx index 1b1740d780..60376a18ce 100644 --- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/PageMoodboard.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/PageMoodboard.tsx @@ -1,8 +1,35 @@ -import { MoodboardWorkspace } from "@storyteller/ui-moodboard"; +import { useEffect } from "react"; +import { + MoodboardWorkspace, + dispatchGalleryMoodboardDrop, +} from "@storyteller/ui-moodboard"; +import { + GalleryItem, + onImageDrop, + removeImageDropListener, +} from "@storyteller/ui-gallery-modal"; import { desktopMoodboardAdapter } from "./desktopMoodboardAdapter"; // Desktop moodboard tab. The shared workspace fills its parent; MainApp's // TabBody wraps this in a sized container. -export const PageMoodboard = () => ( - -); +export const PageMoodboard = () => { + useGalleryDropBridge(); + return ; +}; + +// Bridges gallery-modal's onImageDrop into the moodboard's board-drop event, so +// dragging a library image onto the grid/canvas adds it. Mounted only while the +// moodboard tab is active, so it doesn't race the 2D/3D tab drop handlers. +const useGalleryDropBridge = () => { + useEffect(() => { + const handler = onImageDrop( + (item: GalleryItem, position: { x: number; y: number }) => { + dispatchGalleryMoodboardDrop(item, position); + }, + ); + return () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (handler) removeImageDropListener(handler as any); + }; + }, []); +}; diff --git a/frontend/libs/components/moodboard/src/index.ts b/frontend/libs/components/moodboard/src/index.ts index b7f740a0be..b486bd732a 100644 --- a/frontend/libs/components/moodboard/src/index.ts +++ b/frontend/libs/components/moodboard/src/index.ts @@ -25,6 +25,14 @@ export type { MoodboardLibraryPickerProps, } from "./lib/adapter"; +// Gallery → board drop bridge. Host apps wire their gallery's drop callback to +// this; the grid + canvas views listen for the event it dispatches. +export { + dispatchGalleryMoodboardDrop, + GALLERY_MOODBOARD_DROP_EVENT, +} from "./lib/galleryDrop"; +export type { DroppedGalleryItem } from "./lib/galleryDrop"; + // Board model types export type { Board, diff --git a/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx b/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx index cf49443b99..9108601e67 100644 --- a/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx +++ b/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx @@ -37,7 +37,10 @@ export const MoodboardWorkspace = ({ adapter }: Props) => { : []; return ( -
+
{viewMode === "grid" ? ( ) : ( diff --git a/frontend/libs/components/moodboard/src/lib/boards/linkMeta.ts b/frontend/libs/components/moodboard/src/lib/boards/linkMeta.ts new file mode 100644 index 0000000000..686245283e --- /dev/null +++ b/frontend/libs/components/moodboard/src/lib/boards/linkMeta.ts @@ -0,0 +1,21 @@ +// Client-side link metadata — no proxy, no network beyond the favicon . + +// Bare display host: strips the scheme and a leading "www.". +export const hostnameOf = (url: string): string => { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +}; + +// The site's own favicon, served from its origin (no third-party proxy). Falls +// back to a link glyph at the render site when the errors. +export const faviconOf = (url: string): string | null => { + try { + const u = new URL(url); + return `${u.protocol}//${u.hostname}/favicon.ico`; + } catch { + return null; + } +}; diff --git a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStage.tsx b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStage.tsx index e706f39deb..1c9fa3803d 100644 --- a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStage.tsx +++ b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStage.tsx @@ -30,10 +30,11 @@ export const MoodboardStage = ({ containerRef, stageRef }: Props) => { // Only subscribe to the transient fields this component actually renders // (marquee + lasso). Skips re-renders on `editingTextId` and `isPanning` // toggles, which don't affect Stage JSX. - const { marquee, lassoPath } = useMoodboardStore( + const { marquee, lassoPath, guides } = useMoodboardStore( useShallow((s) => ({ marquee: s.transient.marquee, lassoPath: s.transient.lassoPath, + guides: s.transient.guides, })), ); const viewport = useMoodboardStore((s) => s.viewport); @@ -281,6 +282,19 @@ export const MoodboardStage = ({ containerRef, stageRef }: Props) => { closed={false} /> )} + {guides.map((g, i) => ( + + ))} ); diff --git a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStore.ts b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStore.ts index 3e80b59a3c..6b3e010e5b 100644 --- a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStore.ts +++ b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStore.ts @@ -26,6 +26,7 @@ import { computeContentBounds, fitViewportToBounds, } from "./layout/geometry"; +import { SnapGuide } from "./layout/snapping"; // Hover state published during a node drag so overlay UI can highlight the card // (or to-be card) the dragged node would land in. `targetId` is null when the @@ -41,6 +42,8 @@ interface Transient { editingTextId: string | null; isPanning: boolean; dragPreview: DragPreview | null; + // Alignment guides shown live during a snapped drag. + guides: SnapGuide[]; } interface MoodboardState { @@ -49,12 +52,16 @@ interface MoodboardState { selectedIds: Set; tool: Tool; gridSpacing: number; + // Snap a dragged node to nearby node edges/centers + the grid. + snapEnabled: boolean; lastDropPoint: Vec2; viewport: Viewport; canvasSize: CanvasSize; transient: Transient; setTool: (t: Tool) => void; + toggleSnap: () => void; + setGuides: (guides: SnapGuide[]) => void; setLastDropPoint: (p: Vec2) => void; setZoom: (z: number) => void; setPan: (p: Vec2) => void; @@ -137,6 +144,7 @@ export const useMoodboardStore = create((set, get) => ({ selectedIds: new Set(), tool: "select", gridSpacing: 32, + snapEnabled: true, lastDropPoint: { x: 200, y: 200 }, viewport: { zoom: 1, pan: { x: 0, y: 0 } }, canvasSize: { width: 800, height: 600 }, @@ -146,9 +154,13 @@ export const useMoodboardStore = create((set, get) => ({ editingTextId: null, isPanning: false, dragPreview: null, + guides: [], }, setTool: (t) => set({ tool: t }), + toggleSnap: () => set((s) => ({ snapEnabled: !s.snapEnabled })), + setGuides: (guides) => + set((s) => ({ transient: { ...s.transient, guides } })), setLastDropPoint: (p) => set({ lastDropPoint: p }), setZoom: (z) => set((s) => ({ viewport: { ...s.viewport, zoom: z } })), setPan: (p) => set((s) => ({ viewport: { ...s.viewport, pan: p } })), @@ -640,6 +652,7 @@ export const useMoodboardStore = create((set, get) => ({ editingTextId: null, isPanning: false, dragPreview: null, + guides: [], }, }); }, diff --git a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx index 5478e311f9..97b0f3c61c 100644 --- a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx +++ b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx @@ -13,6 +13,7 @@ import { faImages, faArrowUpFromBracket, faText, + faMagnet, } from "@fortawesome/pro-solid-svg-icons"; import { ButtonIconSelect } from "@storyteller/ui-button-icon-select"; import { PopoverMenu } from "@storyteller/ui-popover"; @@ -60,6 +61,8 @@ export const MoodboardToolbar = ({ onUploadClick, onGalleryClick }: Props) => { const nodes = useMoodboardStore((s) => s.nodes); const rootOrder = useMoodboardStore((s) => s.rootOrder); const gridSpacing = useMoodboardStore((s) => s.gridSpacing); + const snapEnabled = useMoodboardStore((s) => s.snapEnabled); + const toggleSnap = useMoodboardStore((s) => s.toggleSnap); const handleFitToGrid = () => { if (selectedIds.size === 0) return; @@ -180,6 +183,13 @@ export const MoodboardToolbar = ({ onUploadClick, onGalleryClick }: Props) => { onClick={handleAutoGroup} tooltipDelay={100} /> + diff --git a/frontend/libs/components/moodboard/src/lib/canvas/interactions/useGalleryDropEvent.ts b/frontend/libs/components/moodboard/src/lib/canvas/interactions/useGalleryDropEvent.ts index bc350b1521..3b2e902a99 100644 --- a/frontend/libs/components/moodboard/src/lib/canvas/interactions/useGalleryDropEvent.ts +++ b/frontend/libs/components/moodboard/src/lib/canvas/interactions/useGalleryDropEvent.ts @@ -1,15 +1,13 @@ import { useEffect } from "react"; import Konva from "konva"; import { useMoodboardStore } from "../MoodboardStore"; - -interface GalleryItem { - id?: string; - thumbnail?: string | null; - fullImage?: string | null; -} +import { + DroppedGalleryItem, + GALLERY_MOODBOARD_DROP_EVENT, +} from "../../galleryDrop"; interface GalleryMoodboardDropDetail { - item: GalleryItem; + item: DroppedGalleryItem; canvasPosition: { x: number; y: number }; } @@ -54,8 +52,8 @@ export const useGalleryDropEvent = ( addImage(url, stagePoint, dims.w, dims.h, item.id ?? null); }; - window.addEventListener("gallery-moodboard-drop", handler); + window.addEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler); return () => - window.removeEventListener("gallery-moodboard-drop", handler); + window.removeEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler); }, [active, addImage, stageRef]); }; diff --git a/frontend/libs/components/moodboard/src/lib/canvas/interactions/useNodeDrag.ts b/frontend/libs/components/moodboard/src/lib/canvas/interactions/useNodeDrag.ts new file mode 100644 index 0000000000..414c721564 --- /dev/null +++ b/frontend/libs/components/moodboard/src/lib/canvas/interactions/useNodeDrag.ts @@ -0,0 +1,61 @@ +import { useMemo } from "react"; +import Konva from "konva"; +import { useMoodboardStore } from "../MoodboardStore"; +import { computeSnap, SnapBox } from "../layout/snapping"; + +// Screen-space snap radius; divided by zoom so the feel is constant at any zoom. +const SNAP_SCREEN_PX = 6; + +export interface NodeDragHandlers { + onDragStart: () => void; + onDragMove: (e: Konva.KonvaEventObject) => void; + onDragEnd: (e: Konva.KonvaEventObject) => void; +} + +// Shared drag wiring for every canvas node. onDragStart snapshots history; +// onDragMove snaps the live position to nearby node edges/centers + the grid and +// publishes alignment guides; onDragEnd commits the final position and clears +// the guides. Reads the store imperatively inside handlers so the hook itself +// doesn't re-render on every node move. +export const useNodeDrag = (nodeId: string): NodeDragHandlers => { + const updateNode = useMoodboardStore((s) => s.updateNode); + const pushHistory = useMoodboardStore((s) => s.pushHistory); + const setGuides = useMoodboardStore((s) => s.setGuides); + + return useMemo( + () => ({ + onDragStart: () => pushHistory(), + onDragMove: (e) => { + const target = e.target; + const state = useMoodboardStore.getState(); + if (!state.snapEnabled) return; + const node = state.nodes[nodeId]; + if (!node) return; + + const box: SnapBox = { + x: target.x(), + y: target.y(), + width: node.width, + height: node.height, + }; + const others: SnapBox[] = []; + for (const id of state.rootOrder) { + if (id === nodeId) continue; + const n = state.nodes[id]; + if (n) others.push({ x: n.x, y: n.y, width: n.width, height: n.height }); + } + + const threshold = SNAP_SCREEN_PX / state.viewport.zoom; + const result = computeSnap(box, others, threshold, state.gridSpacing); + target.x(result.x); + target.y(result.y); + setGuides(result.guides); + }, + onDragEnd: (e) => { + updateNode(nodeId, { x: e.target.x(), y: e.target.y() }); + setGuides([]); + }, + }), + [nodeId, updateNode, pushHistory, setGuides], + ); +}; diff --git a/frontend/libs/components/moodboard/src/lib/canvas/layout/snapping.spec.ts b/frontend/libs/components/moodboard/src/lib/canvas/layout/snapping.spec.ts new file mode 100644 index 0000000000..ef8f2d7c3e --- /dev/null +++ b/frontend/libs/components/moodboard/src/lib/canvas/layout/snapping.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { SnapBox, computeSnap } from "./snapping"; + +const box = (x: number, y: number, w = 100, h = 100): SnapBox => ({ + x, + y, + width: w, + height: h, +}); + +describe("computeSnap", () => { + it("snaps a near-aligned left edge onto another node's left edge", () => { + const moving = box(103, 400); + const result = computeSnap(moving, [box(100, 0)], 6, null); + expect(result.x).toBe(100); + expect(result.guides.some((g) => g.axis === "x" && g.pos === 100)).toBe(true); + }); + + it("snaps centers together", () => { + // Other center-x = 150. Moving (w=100) center aligns when x = 100. + const moving = box(98, 400); + const result = computeSnap(moving, [box(100, 0)], 6, null); + expect(result.x).toBe(100); + }); + + it("leaves position untouched beyond the threshold", () => { + const moving = box(120, 400); + const result = computeSnap(moving, [box(100, 0)], 6, null); + expect(result.x).toBe(120); + expect(result.guides).toHaveLength(0); + }); + + it("falls back to the grid when no node aligns", () => { + const moving = box(33, 65); + const result = computeSnap(moving, [], 6, 32); + expect(result.x).toBe(32); + expect(result.y).toBe(64); + }); + + it("prefers node alignment over the grid on a given axis", () => { + // x has a node edge at 100 (moving from 97) within threshold; grid=32 would + // pull to 96 — node alignment must win. + const moving = box(97, 400); + const result = computeSnap(moving, [box(100, 0)], 6, 32); + expect(result.x).toBe(100); + }); + + it("emits a guide spanning both boxes on the shared axis", () => { + const moving = box(100, 300); + const result = computeSnap(moving, [box(100, 0)], 6, null); + const guide = result.guides.find((g) => g.axis === "x"); + expect(guide).toBeDefined(); + expect(guide?.from).toBe(0); + expect(guide?.to).toBe(400); + }); +}); diff --git a/frontend/libs/components/moodboard/src/lib/canvas/layout/snapping.ts b/frontend/libs/components/moodboard/src/lib/canvas/layout/snapping.ts new file mode 100644 index 0000000000..2fcdc86676 --- /dev/null +++ b/frontend/libs/components/moodboard/src/lib/canvas/layout/snapping.ts @@ -0,0 +1,110 @@ +// Drag snapping for the freeform canvas. A moving node's near/center/far edges +// align to the same edges of nearby nodes (and, as a fallback, the background +// grid). Pure + deterministic so it can be unit-tested without Konva. + +export interface SnapBox { + x: number; + y: number; + width: number; + height: number; +} + +export interface SnapGuide { + axis: "x" | "y"; + // Stage coordinate of the guide line on its axis. + pos: number; + // Cross-axis extent the guide spans (so it visually connects the two nodes). + from: number; + to: number; +} + +export interface SnapResult { + x: number; + y: number; + guides: SnapGuide[]; +} + +// near edge, center, far edge — the three alignment anchors per axis. +const xAnchors = (b: SnapBox): number[] => [b.x, b.x + b.width / 2, b.x + b.width]; +const yAnchors = (b: SnapBox): number[] => [b.y, b.y + b.height / 2, b.y + b.height]; + +// Snaps `moving` against `others` within `threshold` (stage units). `grid`, when +// set, snaps the top-left to the grid on any axis that found no node alignment. +export const computeSnap = ( + moving: SnapBox, + others: SnapBox[], + threshold: number, + grid: number | null, +): SnapResult => { + let bestDX = threshold + 1; + let snapX: number | null = null; + let alignX = 0; + let otherX: SnapBox | null = null; + + let bestDY = threshold + 1; + let snapY: number | null = null; + let alignY = 0; + let otherY: SnapBox | null = null; + + const mxs = xAnchors(moving); + const mys = yAnchors(moving); + + for (const o of others) { + for (const ox of xAnchors(o)) { + for (const mx of mxs) { + const d = Math.abs(ox - mx); + if (d < bestDX) { + bestDX = d; + snapX = moving.x + (ox - mx); + alignX = ox; + otherX = o; + } + } + } + for (const oy of yAnchors(o)) { + for (const my of mys) { + const d = Math.abs(oy - my); + if (d < bestDY) { + bestDY = d; + snapY = moving.y + (oy - my); + alignY = oy; + otherY = o; + } + } + } + } + + // Grid fallback per axis (no guide line — the dotted grid is the reference). + if (snapX === null && grid) { + const gx = Math.round(moving.x / grid) * grid; + if (Math.abs(gx - moving.x) <= threshold) snapX = gx; + } + if (snapY === null && grid) { + const gy = Math.round(moving.y / grid) * grid; + if (Math.abs(gy - moving.y) <= threshold) snapY = gy; + } + + const finalX = snapX ?? moving.x; + const finalY = snapY ?? moving.y; + const snapped: SnapBox = { ...moving, x: finalX, y: finalY }; + + const guides: SnapGuide[] = []; + if (otherX) { + guides.push({ + axis: "x", + pos: alignX, + from: Math.min(snapped.y, otherX.y), + to: Math.max(snapped.y + snapped.height, otherX.y + otherX.height), + }); + } + if (otherY) { + guides.push({ + axis: "y", + pos: alignY, + from: Math.min(snapped.x, otherY.x), + to: Math.max(snapped.x + snapped.width, otherY.x + otherY.width), + }); + } + + return { x: finalX, y: finalY, guides }; +}; diff --git a/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.tsx b/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.tsx index 1dbf6f4aee..594b7bd1e9 100644 --- a/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.tsx +++ b/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.tsx @@ -4,6 +4,7 @@ import { Group, Rect } from "react-konva"; import { useShallow } from "zustand/react/shallow"; import { GroupNode as GroupNodeData, MoodboardNode } from "../types"; import { useMoodboardStore } from "../MoodboardStore"; +import { useNodeDrag } from "../interactions/useNodeDrag"; import { ImageNode } from "./ImageNode"; import { TextNode } from "./TextNode"; @@ -23,8 +24,7 @@ interface Props { const NO_OP_SELECT = () => {}; const GroupNodeInner = ({ node, selected, onSelect }: Props) => { - const updateNode = useMoodboardStore((s) => s.updateNode); - const pushHistory = useMoodboardStore((s) => s.pushHistory); + const drag = useNodeDrag(node.id); // Subscribe only to this group's own children, with a shallow compare on // the returned array. Avoids re-rendering every group whenever any node @@ -53,12 +53,9 @@ const GroupNodeInner = ({ node, selected, onSelect }: Props) => { draggable onMouseDown={handleSelect} onTouchStart={handleSelect} - onDragStart={() => { - pushHistory(); - }} - onDragEnd={(e) => { - updateNode(node.id, { x: e.target.x(), y: e.target.y() }); - }} + onDragStart={drag.onDragStart} + onDragMove={drag.onDragMove} + onDragEnd={drag.onDragEnd} > {/* Transparent hit/transform target so the Transformer has stable bounds. */} { - const updateNode = useMoodboardStore((s) => s.updateNode); - const pushHistory = useMoodboardStore((s) => s.pushHistory); + const drag = useNodeDrag(node.id); const ref = useRef(null); const [img, setImg] = useState(null); @@ -64,12 +63,9 @@ const ImageNodeInner = ({ node, draggable, selected, onSelect }: Props) => { draggable={draggable} onMouseDown={handleSelect} onTouchStart={handleSelect} - onDragStart={() => { - pushHistory(); - }} - onDragEnd={(e) => { - updateNode(node.id, { x: e.target.x(), y: e.target.y() }); - }} + onDragStart={drag.onDragStart} + onDragMove={drag.onDragMove} + onDragEnd={drag.onDragEnd} /> ); diff --git a/frontend/libs/components/moodboard/src/lib/canvas/nodes/TextNode.tsx b/frontend/libs/components/moodboard/src/lib/canvas/nodes/TextNode.tsx index 090c09140a..0a5821d6bf 100644 --- a/frontend/libs/components/moodboard/src/lib/canvas/nodes/TextNode.tsx +++ b/frontend/libs/components/moodboard/src/lib/canvas/nodes/TextNode.tsx @@ -3,6 +3,7 @@ import Konva from "konva"; import { Text as KonvaText } from "react-konva"; import { TextNode as TextNodeData } from "../types"; import { useMoodboardStore } from "../MoodboardStore"; +import { useNodeDrag } from "../interactions/useNodeDrag"; interface Props { node: TextNodeData; @@ -15,8 +16,7 @@ interface Props { } const TextNodeInner = ({ node, draggable, selected, onSelect }: Props) => { - const updateNode = useMoodboardStore((s) => s.updateNode); - const pushHistory = useMoodboardStore((s) => s.pushHistory); + const drag = useNodeDrag(node.id); const setEditingText = useMoodboardStore((s) => s.setEditingText); // Selector compares a single id — only the TextNode whose isEditing // actually flips will re-render when someone enters/exits text edit. @@ -45,12 +45,9 @@ const TextNodeInner = ({ node, draggable, selected, onSelect }: Props) => { onTouchStart={handleSelect} onDblClick={() => setEditingText(node.id)} onDblTap={() => setEditingText(node.id)} - onDragStart={() => { - pushHistory(); - }} - onDragEnd={(e) => { - updateNode(node.id, { x: e.target.x(), y: e.target.y() }); - }} + onDragStart={drag.onDragStart} + onDragMove={drag.onDragMove} + onDragEnd={drag.onDragEnd} stroke={selected ? "#3b82f6" : undefined} strokeWidth={selected ? 0.5 : 0} /> diff --git a/frontend/libs/components/moodboard/src/lib/canvas/nodes/VideoNode.tsx b/frontend/libs/components/moodboard/src/lib/canvas/nodes/VideoNode.tsx index 070d83e05c..aa248dc990 100644 --- a/frontend/libs/components/moodboard/src/lib/canvas/nodes/VideoNode.tsx +++ b/frontend/libs/components/moodboard/src/lib/canvas/nodes/VideoNode.tsx @@ -2,7 +2,7 @@ import { memo, useEffect, useRef, useState } from "react"; import Konva from "konva"; import { Image as KonvaImage, Rect } from "react-konva"; import { VideoNode as VideoNodeData } from "../types"; -import { useMoodboardStore } from "../MoodboardStore"; +import { useNodeDrag } from "../interactions/useNodeDrag"; interface Props { node: VideoNodeData; @@ -21,8 +21,7 @@ interface Props { // element imperatively and run a Konva.Animation tied to the node's layer to // repaint each frame while the video plays. This mirrors ImageNode otherwise. const VideoNodeInner = ({ node, draggable, selected, onSelect }: Props) => { - const updateNode = useMoodboardStore((s) => s.updateNode); - const pushHistory = useMoodboardStore((s) => s.pushHistory); + const drag = useNodeDrag(node.id); const ref = useRef(null); const [video, setVideo] = useState(null); @@ -95,12 +94,9 @@ const VideoNodeInner = ({ node, draggable, selected, onSelect }: Props) => { draggable={draggable} onMouseDown={handleSelect} onTouchStart={handleSelect} - onDragStart={() => { - pushHistory(); - }} - onDragEnd={(e) => { - updateNode(node.id, { x: e.target.x(), y: e.target.y() }); - }} + onDragStart={drag.onDragStart} + onDragMove={drag.onDragMove} + onDragEnd={drag.onDragEnd} /> ); diff --git a/frontend/libs/components/moodboard/src/lib/galleryDrop.ts b/frontend/libs/components/moodboard/src/lib/galleryDrop.ts new file mode 100644 index 0000000000..5139bdf470 --- /dev/null +++ b/frontend/libs/components/moodboard/src/lib/galleryDrop.ts @@ -0,0 +1,46 @@ +// Bridge for "drag an item from the gallery/library onto the board". The host +// app owns the gallery (and its drag source), so it can't reach into this lib's +// views directly. Instead the app calls this on its gallery drop callback; the +// grid + canvas views listen for the dispatched event. Keeping the dispatch +// here (rather than in the app) means the event name + payload shape stay +// owned by the lib that consumes them. + +export interface DroppedGalleryItem { + id?: string; + thumbnail?: string | null; + fullImage?: string | null; +} + +export const GALLERY_MOODBOARD_DROP_EVENT = "gallery-moodboard-drop"; + +// Dispatches a board-drop for `item` if `screenPosition` (viewport coords) lands +// over the mounted moodboard surface. Returns whether it was accepted, so the +// caller can fall back to its own handling when the drop missed the board. +export const dispatchGalleryMoodboardDrop = ( + item: DroppedGalleryItem, + screenPosition: { x: number; y: number }, +): boolean => { + const root = document.querySelector("[data-moodboard-root]"); + if (!root) return false; + const rect = root.getBoundingClientRect(); + if ( + screenPosition.x < rect.left || + screenPosition.x > rect.right || + screenPosition.y < rect.top || + screenPosition.y > rect.bottom + ) { + return false; + } + window.dispatchEvent( + new CustomEvent(GALLERY_MOODBOARD_DROP_EVENT, { + detail: { + item, + canvasPosition: { + x: screenPosition.x - rect.left, + y: screenPosition.y - rect.top, + }, + }, + }), + ); + return true; +}; diff --git a/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx b/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx index aa8ce067ed..cf1605eb32 100644 --- a/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx +++ b/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx @@ -4,12 +4,13 @@ import { faUpRightAndDownLeftFromCenter, faTrashCan, faCheck, - faLink, faWandMagicSparkles, } from "@fortawesome/pro-regular-svg-icons"; import { faStar } from "@fortawesome/pro-solid-svg-icons"; import { BoardItem } from "../boards/boardTypes"; +import { hostnameOf } from "../boards/linkMeta"; import { PositionedItem } from "./gridLayout"; +import { Favicon } from "./Favicon"; interface Props { item: BoardItem; @@ -266,13 +267,17 @@ const CardContent = ({ item }: { item: BoardItem }) => { className="min-h-0 flex-1 select-none object-cover" /> )} -
- - - {item.title || item.url} +
+ + + + + + {item.title || hostnameOf(item.url)} + + + {hostnameOf(item.url)} +
diff --git a/frontend/libs/components/moodboard/src/lib/grid/BoardGridView.tsx b/frontend/libs/components/moodboard/src/lib/grid/BoardGridView.tsx index 32e33546c1..98feb47fab 100644 --- a/frontend/libs/components/moodboard/src/lib/grid/BoardGridView.tsx +++ b/frontend/libs/components/moodboard/src/lib/grid/BoardGridView.tsx @@ -9,6 +9,7 @@ import { import toast from "react-hot-toast"; import { useBoardItemEntry } from "../boards/useBoardItemEntry"; import { measureImage } from "../boards/measureMedia"; +import { useGalleryBoardDrop } from "./useGalleryBoardDrop"; import { BoardImageItem, BoardItem } from "../boards/boardTypes"; import type { MoodboardAdapter } from "../adapter"; import { BoardGrid } from "./BoardGrid"; @@ -70,6 +71,9 @@ export const BoardGridView = ({ active, adapter }: Props) => { if (active) ensureActiveBoard(); }, [active, ensureActiveBoard]); + // Accept gallery items dragged onto the grid (mirrors the canvas listener). + useGalleryBoardDrop(active); + const filtered = useMemo( () => board diff --git a/frontend/libs/components/moodboard/src/lib/grid/Favicon.tsx b/frontend/libs/components/moodboard/src/lib/grid/Favicon.tsx new file mode 100644 index 0000000000..bdddea48b2 --- /dev/null +++ b/frontend/libs/components/moodboard/src/lib/grid/Favicon.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faLink } from "@fortawesome/pro-regular-svg-icons"; +import { faviconOf } from "../boards/linkMeta"; + +interface Props { + url: string; + className?: string; +} + +// A site's own favicon with a graceful link-glyph fallback when it 404s or +// isn't an image. Reset on url change so a recycled card shows the right icon. +export const Favicon = ({ url, className = "h-4 w-4" }: Props) => { + const src = faviconOf(url); + const [failed, setFailed] = useState(false); + + useEffect(() => setFailed(false), [url]); + + if (!src || failed) { + return ( + + ); + } + return ( + setFailed(true)} + className={`${className} select-none rounded-sm object-contain`} + /> + ); +}; diff --git a/frontend/libs/components/moodboard/src/lib/grid/ItemInspector.tsx b/frontend/libs/components/moodboard/src/lib/grid/ItemInspector.tsx index 391b57e89c..9ca88abc15 100644 --- a/frontend/libs/components/moodboard/src/lib/grid/ItemInspector.tsx +++ b/frontend/libs/components/moodboard/src/lib/grid/ItemInspector.tsx @@ -11,7 +11,9 @@ import { } from "@fortawesome/pro-regular-svg-icons"; import { faStar } from "@fortawesome/pro-solid-svg-icons"; import { BoardItem } from "../boards/boardTypes"; +import { hostnameOf } from "../boards/linkMeta"; import { extractPalette } from "../boards/palette"; +import { Favicon } from "./Favicon"; interface Props { item: BoardItem; @@ -314,11 +316,16 @@ const Media = ({ item }: { item: BoardItem }) => { {item.image && ( )} -
-

- {item.title || item.url} -

-

{item.url}

+
+ + + +
+

+ {item.title || hostnameOf(item.url)} +

+

{item.url}

+
); diff --git a/frontend/libs/components/moodboard/src/lib/grid/useGalleryBoardDrop.ts b/frontend/libs/components/moodboard/src/lib/grid/useGalleryBoardDrop.ts new file mode 100644 index 0000000000..3c63214305 --- /dev/null +++ b/frontend/libs/components/moodboard/src/lib/grid/useGalleryBoardDrop.ts @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { useBoardLibraryStore } from "../boards/BoardLibraryStore"; +import { measureImage } from "../boards/measureMedia"; +import { + DroppedGalleryItem, + GALLERY_MOODBOARD_DROP_EVENT, +} from "../galleryDrop"; + +interface GalleryMoodboardDropDetail { + item: DroppedGalleryItem; +} + +// Grid counterpart to the canvas's useGalleryDropEvent: appends a dropped +// gallery item to the active board. The masonry is position-independent, so we +// ignore the drop coordinates the canvas uses for placement. +export const useGalleryBoardDrop = (active: boolean) => { + useEffect(() => { + if (!active) return undefined; + + const handler = async (e: Event) => { + const ce = e as CustomEvent; + const item = ce.detail?.item; + const url = item?.fullImage || item?.thumbnail; + if (!url) return; + + const store = useBoardLibraryStore.getState(); + const boardId = store.ensureActiveBoard(); + const dims = await measureImage(url); + store.addImageItem(boardId, { + src: url, + mediaId: item.id ?? null, + naturalW: dims.w, + naturalH: dims.h, + }); + }; + + window.addEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler); + return () => + window.removeEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler); + }, [active]); +};