Moodboard P3: canvas snapping, link previews, drag-from-library

2.6 — canvas drag snapping. A shared useNodeDrag hook snaps a dragged
node's near/center/far edges to nearby nodes (and the grid as a fallback)
and draws live alignment guides; a magnet toggle in the canvas toolbar
turns it on/off. Pure computeSnap helper, unit-tested.

2.7 — richer link cards. Show the site's own favicon (client-side, no
proxy; graceful link-glyph fallback) plus a domain line, in both the
grid card and the inspector.

2.8 — drag a library/gallery image onto the board. The grid gains a
drop listener mirroring the canvas's; both consume one event dispatched
by dispatchGalleryMoodboardDrop, which each app wires to gallery-modal's
onImageDrop. Bounds-checked against the mounted board surface.
This commit is contained in:
Tanish Manku committed 2026-06-15 19:02:29 -07:00
1 parent a7b887e11c
commit 9abbbd686e
22 files changed
+529 -63

No files matched your search

@@ -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 (
<div className="h-full w-full">
<MoodboardWorkspace adapter={webappMoodboardAdapter} />
@@ -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 = () => (
<MoodboardWorkspace adapter={desktopMoodboardAdapter} />
);
export const PageMoodboard = () => {
useGalleryDropBridge();
return <MoodboardWorkspace adapter={desktopMoodboardAdapter} />;
};
// 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);
};
}, []);
};
@@ -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,
@@ -37,7 +37,10 @@ export const MoodboardWorkspace = ({ adapter }: Props) => {
: [];
return (
<div className="relative h-full w-full overflow-hidden bg-ui-background">
<div
data-moodboard-root
className="relative h-full w-full overflow-hidden bg-ui-background"
>
{viewMode === "grid" ? (
<BoardGridView active={!presenting} adapter={adapter} />
) : (
@@ -0,0 +1,21 @@
// Client-side link metadata — no proxy, no network beyond the favicon <img>.
// 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 <img> errors.
export const faviconOf = (url: string): string | null => {
try {
const u = new URL(url);
return `${u.protocol}//${u.hostname}/favicon.ico`;
} catch {
return null;
}
};
@@ -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) => (
<Line
key={`guide-${i}`}
points={
g.axis === "x"
? [g.pos, g.from, g.pos, g.to]
: [g.from, g.pos, g.to, g.pos]
}
stroke="#f43f5e"
strokeWidth={1 / zoom}
listening={false}
/>
))}
</Layer>
</Stage>
);
@@ -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<string>;
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<MoodboardState>((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<MoodboardState>((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<MoodboardState>((set, get) => ({
editingTextId: null,
isPanning: false,
dragPreview: null,
guides: [],
},
});
},
@@ -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}
/>
<FloatingToolbarButton
icon={faMagnet}
label={snapEnabled ? "Snapping on" : "Snapping off"}
active={snapEnabled}
onClick={toggleSnap}
tooltipDelay={100}
/>
<FloatingToolbarDivider />
@@ -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]);
};
@@ -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<DragEvent>) => void;
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => 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],
);
};
@@ -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);
});
});
@@ -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 };
};
@@ -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. */}
<Rect
@@ -2,7 +2,7 @@ import { memo, useEffect, useRef, useState } from "react";
import Konva from "konva";
import { Image as KonvaImage, Rect } from "react-konva";
import { ImageNode as ImageNodeData } from "../types";
import { useMoodboardStore } from "../MoodboardStore";
import { useNodeDrag } from "../interactions/useNodeDrag";
interface Props {
node: ImageNodeData;
@@ -17,8 +17,7 @@ interface Props {
}
const ImageNodeInner = ({ 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<Konva.Image | null>(null);
const [img, setImg] = useState<HTMLImageElement | null>(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}
/>
</>
);
@@ -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}
/>
@@ -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<Konva.Image | null>(null);
const [video, setVideo] = useState<HTMLVideoElement | null>(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}
/>
</>
);
@@ -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;
};
@@ -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"
/>
)}
<div className="flex items-center gap-2 px-3 py-2">
<FontAwesomeIcon
icon={faLink}
className="h-3 w-3 shrink-0 text-base-fg/50"
/>
<span className="truncate text-xs font-medium text-base-fg/90">
{item.title || item.url}
<div className="flex items-center gap-2.5 px-3 py-2.5">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-base-fg/8">
<Favicon url={item.url} className="h-4 w-4" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium text-base-fg/90">
{item.title || hostnameOf(item.url)}
</span>
<span className="truncate text-[10px] text-base-fg/45">
{hostnameOf(item.url)}
</span>
</span>
</div>
</a>
@@ -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
@@ -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 (
<FontAwesomeIcon icon={faLink} className={`${className} text-base-fg/45`} />
);
}
return (
<img
src={src}
alt=""
width={16}
height={16}
draggable={false}
onError={() => setFailed(true)}
className={`${className} select-none rounded-sm object-contain`}
/>
);
};
@@ -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 && (
<img src={item.image} alt="" className="w-full object-cover" />
)}
<div className="p-5">
<p className="text-base font-medium text-base-fg">
{item.title || item.url}
</p>
<p className="mt-1 truncate text-sm text-base-fg/55">{item.url}</p>
<div className="flex items-start gap-3 p-5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-base-fg/8">
<Favicon url={item.url} className="h-5 w-5" />
</span>
<div className="min-w-0">
<p className="text-base font-medium text-base-fg">
{item.title || hostnameOf(item.url)}
</p>
<p className="mt-1 truncate text-sm text-base-fg/55">{item.url}</p>
</div>
</div>
</a>
);
@@ -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<GalleryMoodboardDropDetail>;
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]);
};