mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 23:47:51 +00:00
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:
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 />
|
||||
|
||||
|
||||
+7
-9
@@ -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]);
|
||||
};
|
||||
Reference in new issue
Block a user