From 8d4346d566c3752d2a149596fb4db2bbd3c2a410 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 23 Apr 2026 14:24:41 -0700 Subject: [PATCH] Moodboard (HTML): parallel experiment page, no canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stands up PageMoodboardHtml alongside PageMoodboard — renders the same moodboard content (image/text/group nodes) entirely with absolutely- positioned divs, CSS transforms, and pointer events. Shares the store with PageMoodboard so switching tabs preserves the board. - New page at app/src/pages/PageMoodboardHtml with HTML-only stage, background grid, nodes, overlay transformer (8 resize handles + 1 rotate), marquee + lasso selection, viewport pan/zoom, paste and gallery drop handlers, and a shortcut-aware text edit overlay. - Reuses MoodboardStore, layout utilities, MoodboardToolbar, TextEditOverlay, and the non-render interaction hooks from the existing Konva moodboard unchanged. - Experimental flag `moodboardHtmlPageEnabled` gates a new apps-menu entry and a Settings → Experimental toggle. - PageEditor: gallery drop targets the HTML stage element when the MOODBOARD_HTML tab is active. --- .../apps/artcraft/app/src/config/appMenu.tsx | 24 +- .../app/src/pages/PageEnigma/PageEditor.tsx | 30 +- .../pages/PageMoodboardHtml/MoodboardHtml.tsx | 64 +++ .../MoodboardHtmlBackground.tsx | 39 ++ .../PageMoodboardHtml/MoodboardHtmlStage.tsx | 342 +++++++++++++++ .../MoodboardHtmlTransformerOverlay.tsx | 393 ++++++++++++++++++ .../app/src/pages/PageMoodboardHtml/index.ts | 1 + .../interactions/htmlStagePointer.ts | 30 ++ .../interactions/useHtmlGalleryDropEvent.ts | 61 +++ .../useHtmlMoodboardImageEntry.tsx | 130 ++++++ .../interactions/useHtmlPasteHandler.ts | 69 +++ .../interactions/useHtmlSelection.ts | 146 +++++++ .../interactions/useHtmlViewportControls.ts | 164 ++++++++ .../PageMoodboardHtml/nodes/GroupNodeHtml.tsx | 127 ++++++ .../PageMoodboardHtml/nodes/ImageNodeHtml.tsx | 127 ++++++ .../PageMoodboardHtml/nodes/TextNodeHtml.tsx | 102 +++++ .../artcraft/app/src/pages/Stores/TabState.ts | 3 +- .../components/settings-modal/src/index.ts | 1 + .../src/lib/experimental-store.ts | 13 + .../lib/panes/ExperimentalSettingsPane.tsx | 52 +++ 20 files changed, 1907 insertions(+), 11 deletions(-) create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtml.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlBackground.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlStage.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlTransformerOverlay.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/index.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/htmlStagePointer.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlGalleryDropEvent.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlMoodboardImageEntry.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlPasteHandler.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlSelection.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlViewportControls.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/GroupNodeHtml.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/ImageNodeHtml.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/TextNodeHtml.tsx diff --git a/frontend/apps/artcraft/app/src/config/appMenu.tsx b/frontend/apps/artcraft/app/src/config/appMenu.tsx index ebc2f88260..695a930f57 100644 --- a/frontend/apps/artcraft/app/src/config/appMenu.tsx +++ b/frontend/apps/artcraft/app/src/config/appMenu.tsx @@ -17,6 +17,7 @@ import { useExperimentalStore, useStoryboardPageEnabled, useMoodboardPageEnabled, + useMoodboardHtmlPageEnabled, } from "@storyteller/ui-settings-modal"; import { useTabStore, TabId } from "~/pages/Stores/TabState"; import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor"; @@ -35,7 +36,8 @@ export type AppId = | "REMOVE_BACKGROUND" | "ANGLES" | "STORYBOARD" - | "MOODBOARD"; + | "MOODBOARD" + | "MOODBOARD_HTML"; export interface AppDescriptor { id: AppId; @@ -199,6 +201,16 @@ export const ALL_APPS: FullAppItem[] = [ color: "bg-orange-500/40", badge: "NEW", }, + { + id: "moodboard-html", + label: "Moodboard (HTML)", + description: "HTML-only variant of the moodboard (experiment)", + icon: faObjectGroup, + category: "generate", + action: "MOODBOARD_HTML", + color: "bg-cyan-500/40", + badge: "NEW", + }, { id: "2d-canvas", label: "Image Editor", @@ -227,14 +239,16 @@ export const EDIT_APPS = ALL_APPS.filter((app) => app.category === "edit"); export const useVisibleApps = (): FullAppItem[] => { const storyboardEnabled = useStoryboardPageEnabled(); const moodboardEnabled = useMoodboardPageEnabled(); + const moodboardHtmlEnabled = useMoodboardHtmlPageEnabled(); return useMemo( () => ALL_APPS.filter((app) => { if (app.action === "STORYBOARD") return storyboardEnabled; if (app.action === "MOODBOARD") return moodboardEnabled; + if (app.action === "MOODBOARD_HTML") return moodboardHtmlEnabled; return true; }), - [storyboardEnabled, moodboardEnabled], + [storyboardEnabled, moodboardEnabled, moodboardHtmlEnabled], ); }; @@ -284,6 +298,7 @@ export const goToApp = (action?: string) => { "ANGLES", "STORYBOARD", "MOODBOARD", + "MOODBOARD_HTML", ].includes(action) ) { if (action === "STORYBOARD") { @@ -296,6 +311,11 @@ export const goToApp = (action?: string) => { useExperimentalStore.getState(); if (!enabled || !moodboardPageEnabled) return; } + if (action === "MOODBOARD_HTML") { + const { enabled, moodboardHtmlPageEnabled } = + useExperimentalStore.getState(); + if (!enabled || !moodboardHtmlPageEnabled) return; + } if (action === "3D") { set3DPageMounted(true); } else { diff --git a/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx b/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx index f8aa0c51d8..61833de42a 100644 --- a/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx @@ -20,9 +20,11 @@ import { RemoveBackground } from "../PageRemoveBackground"; import { Angles } from "../PageAngles"; import { Storyboard } from "../PageStoryboard"; import { Moodboard } from "../PageMoodboard"; +import { MoodboardHtml } from "../PageMoodboardHtml"; import { useStoryboardPageEnabled, useMoodboardPageEnabled, + useMoodboardHtmlPageEnabled, } from "@storyteller/ui-settings-modal"; import { @@ -116,6 +118,7 @@ export const PageEditor = () => { const tabStore = useTabStore(); const storyboardPageEnabled = useStoryboardPageEnabled(); const moodboardPageEnabled = useMoodboardPageEnabled(); + const moodboardHtmlPageEnabled = useMoodboardHtmlPageEnabled(); const handleOverlayClick = (event: React.MouseEvent) => { event.stopPropagation(); }; @@ -393,23 +396,29 @@ export const PageEditor = () => { // 2D Drag and Drop Logic } else if ( tabStore.activeTabId === "2D" || - tabStore.activeTabId === "MOODBOARD" + tabStore.activeTabId === "MOODBOARD" || + tabStore.activeTabId === "MOODBOARD_HTML" ) { const eventName = - tabStore.activeTabId === "MOODBOARD" + tabStore.activeTabId === "MOODBOARD" || + tabStore.activeTabId === "MOODBOARD_HTML" ? "gallery-moodboard-drop" : "gallery-2d-drop"; + const activeTab = tabStore.activeTabId; handler = onImageDrop( (item: GalleryItem, position: { x: number; y: number }) => { - // Find the main Konva canvas element - get the first canvas (left panel) - const canvasElements = document.querySelectorAll("canvas"); - const canvasElement = canvasElements[0]; - if (!canvasElement) { - console.error("Could not find canvas element for canvas drop"); + // Find the drop target element. For the HTML moodboard tab there's + // no ; use the HTML stage container element instead. + const target: Element | null = + activeTab === "MOODBOARD_HTML" + ? document.querySelector("[data-moodboard-html-stage]") + : document.querySelectorAll("canvas")[0] ?? null; + if (!target) { + console.error("Could not find drop target element for canvas drop"); return; } - const rect = canvasElement.getBoundingClientRect(); + const rect = target.getBoundingClientRect(); const canvasX = position.x - rect.left; const canvasY = position.y - rect.top; @@ -654,6 +663,11 @@ export const PageEditor = () => { )} + {tabStore.activeTabId == "MOODBOARD_HTML" && moodboardHtmlPageEnabled && ( +
+ +
+ )} ); }; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtml.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtml.tsx new file mode 100644 index 0000000000..796ab613ea --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtml.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from "react"; +import { MoodboardToolbar } from "../PageMoodboard/MoodboardToolbar"; +import { useMoodboardStore } from "../PageMoodboard/MoodboardStore"; +import { useUndoRedo } from "../PageMoodboard/interactions/useUndoRedo"; +import { useKeyboardShortcuts } from "../PageMoodboard/interactions/useKeyboardShortcuts"; +import { useShortcutCheatsheet } from "../PageMoodboard/interactions/useShortcutCheatsheet"; +import { RecenterIndicator } from "../PageMoodboard/overlays/RecenterIndicator"; +import { ShortcutCheatsheet } from "../PageMoodboard/overlays/ShortcutCheatsheet"; +import { MoodboardHtmlStage } from "./MoodboardHtmlStage"; +import { useHtmlPasteHandler } from "./interactions/useHtmlPasteHandler"; +import { useHtmlGalleryDropEvent } from "./interactions/useHtmlGalleryDropEvent"; +import { useHtmlMoodboardImageEntry } from "./interactions/useHtmlMoodboardImageEntry"; + +// HTML-only variant of the moodboard. Shares the Zustand store with the Konva +// version in ../PageMoodboard, so nodes/selection/viewport persist when the +// user flips between the two experiment tabs. +export const MoodboardHtml = () => { + const containerRef = useRef(null); + const deleteSelected = useMoodboardStore((s) => s.deleteSelected); + const setSelection = useMoodboardStore((s) => s.setSelection); + const editingTextId = useMoodboardStore((s) => s.transient.editingTextId); + + useUndoRedo(true); + useHtmlPasteHandler(true); + useHtmlGalleryDropEvent(true, containerRef); + useKeyboardShortcuts(true); + const cheatsheetVisible = useShortcutCheatsheet(); + const { triggerUpload, triggerGallery, modals } = + useHtmlMoodboardImageEntry(); + + // Delete / Backspace removes selection; Escape clears it. Matches the + // Konva page's Moodboard.tsx handler so keyboard parity is 1:1. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && /input|textarea/i.test(target.tagName)) return; + if (target && target.isContentEditable) return; + if (editingTextId) return; + if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + deleteSelected(); + } else if (e.key === "Escape") { + setSelection([]); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [deleteSelected, setSelection, editingTextId]); + + return ( +
+ + + +
+ +
+ {modals} +
+ ); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlBackground.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlBackground.tsx new file mode 100644 index 0000000000..b888f572e3 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlBackground.tsx @@ -0,0 +1,39 @@ +import { memo } from "react"; +import { useThemeFgRgb } from "../PageMoodboard/useThemeFgRgb"; + +interface Props { + pan: { x: number; y: number }; + zoom: number; + gridSpacing: number; +} + +// Dotted grid background rendered as a pure CSS repeating radial-gradient. The +// pattern scales and pans with the viewport: background-size = spacing*zoom, +// background-position = pan. No JS loop per frame like the Konva sceneFunc. +const MoodboardHtmlBackgroundInner = ({ pan, zoom, gridSpacing }: Props) => { + const fgRgb = useThemeFgRgb(); + const effectiveSpacing = Math.max(gridSpacing * zoom, 1); + // Match the Konva version's ~1.2px radius on-screen, regardless of zoom. + const dotRadius = 1.2; + const dotColor = `rgba(${fgRgb + .split(" ") + .map((v) => v.trim()) + .join(",")},0.10)`; + + return ( +
+ ); +}; + +export const MoodboardHtmlBackground = memo(MoodboardHtmlBackgroundInner); diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlStage.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlStage.tsx new file mode 100644 index 0000000000..61e0fd6aa3 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlStage.tsx @@ -0,0 +1,342 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useMoodboardStore } from "../PageMoodboard/MoodboardStore"; +import { MoodboardNode } from "../PageMoodboard/types"; +import { MoodboardHtmlBackground } from "./MoodboardHtmlBackground"; +import { ImageNodeHtml } from "./nodes/ImageNodeHtml"; +import { TextNodeHtml } from "./nodes/TextNodeHtml"; +import { GroupNodeHtml } from "./nodes/GroupNodeHtml"; +import { useHtmlSelection } from "./interactions/useHtmlSelection"; +import { useHtmlViewportControls } from "./interactions/useHtmlViewportControls"; +import { worldPointFromClient } from "./interactions/htmlStagePointer"; +import { MoodboardHtmlTransformerOverlay } from "./MoodboardHtmlTransformerOverlay"; +import { TextEditOverlay } from "../PageMoodboard/TextEditOverlay"; + +interface Props { + containerRef: React.RefObject; +} + +export const MoodboardHtmlStage = ({ containerRef }: Props) => { + const nodes = useMoodboardStore((s) => s.nodes); + const rootOrder = useMoodboardStore((s) => s.rootOrder); + const selectedIds = useMoodboardStore((s) => s.selectedIds); + const tool = useMoodboardStore((s) => s.tool); + const gridSpacing = useMoodboardStore((s) => s.gridSpacing); + const { marquee, lassoPath } = useMoodboardStore( + useShallow((s) => ({ + marquee: s.transient.marquee, + lassoPath: s.transient.lassoPath, + })), + ); + const viewport = useMoodboardStore((s) => s.viewport); + const setLastDropPoint = useMoodboardStore((s) => s.setLastDropPoint); + const setCanvasSize = useMoodboardStore((s) => s.setCanvasSize); + const toggleInSelection = useMoodboardStore((s) => s.toggleInSelection); + const setEditingText = useMoodboardStore((s) => s.setEditingText); + const addText = useMoodboardStore((s) => s.addText); + + const zoom = viewport.zoom; + const pan = viewport.pan; + + // ResizeObserver keeps canvasSize in sync with the wrapper (used by the + // recenter indicator, fit-to-content, and drop-center calculations). + useEffect(() => { + const el = containerRef.current; + if (!el) return undefined; + const update = () => { + setCanvasSize({ width: el.clientWidth, height: el.clientHeight }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, [containerRef, setCanvasSize]); + + useHtmlSelection(containerRef); + useHtmlViewportControls(containerRef); + + // Stable onSelect closure for memoized nodes. + const handleNodeSelect = useCallback( + (id: string, additive: boolean) => { + toggleInSelection(id, additive); + }, + [toggleInSelection], + ); + + // Text tool: bare click on the stage drops a new text node at the pointer. + // Select/lasso tools: bare click without movement clears the selection — + // but the MIN_DRAG branch in useHtmlSelection.commit() already handles that, + // so we only need the text-tool branch here. + const handleStagePointerDown = useCallback( + (e: React.PointerEvent) => { + if (e.button !== 0) return; + const target = e.target as HTMLElement | null; + if (target && target.closest("[data-moodboard-html-node]")) return; + if (target && target.closest("[data-moodboard-html-group]")) return; + if (tool === "text") { + const pos = worldPointFromClient( + containerRef.current, + e.clientX, + e.clientY, + useMoodboardStore.getState().viewport, + ); + if (pos) addText(pos); + return; + } + // Any other bare-stage click clears the text-edit state so a second + // click on empty canvas commits the current edit. + setEditingText(null); + }, + [tool, containerRef, addText, setEditingText], + ); + + // Track pointer position (in world coords) to set lastDropPoint, so + // image paste / file drop can use "near the cursor" when available. + useEffect(() => { + const el = containerRef.current; + if (!el) return undefined; + const handle = (e: PointerEvent) => { + const pos = worldPointFromClient( + el, + e.clientX, + e.clientY, + useMoodboardStore.getState().viewport, + ); + if (pos) setLastDropPoint(pos); + }; + el.addEventListener("pointermove", handle); + return () => el.removeEventListener("pointermove", handle); + }, [containerRef, setLastDropPoint]); + + // Shallow-subscribed slice for selection outlines. Memoized so transient + // (marquee/lasso) updates don't re-render this block. + const selectionOutlines = useMemo(() => { + const arr: { + id: string; + kind: MoodboardNode["kind"]; + x: number; + y: number; + width: number; + height: number; + rotation: number; + }[] = []; + selectedIds.forEach((id) => { + const n = nodes[id]; + if (!n || n.parentId !== null) return; + if (n.kind === "group") { + // Union AABB of group's direct children in group-local coords. + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const cid of n.childIds) { + const c = nodes[cid]; + if (!c) continue; + minX = Math.min(minX, c.x); + minY = Math.min(minY, c.y); + maxX = Math.max(maxX, c.x + c.width); + maxY = Math.max(maxY, c.y + c.height); + } + if (!Number.isFinite(minX)) return; + // Place the outline at group's x,y + localMin and size it to the + // union bbox. Then rotate around the group's origin to match the + // group's rotation (same pattern as Konva MoodboardStage.tsx). + arr.push({ + id, + kind: n.kind, + x: n.x + minX * Math.cos((n.rotation * Math.PI) / 180) - + minY * Math.sin((n.rotation * Math.PI) / 180), + y: n.y + minX * Math.sin((n.rotation * Math.PI) / 180) + + minY * Math.cos((n.rotation * Math.PI) / 180), + width: maxX - minX, + height: maxY - minY, + rotation: n.rotation, + }); + } else { + arr.push({ + id, + kind: n.kind, + x: n.x, + y: n.y, + width: n.width, + height: n.height, + rotation: n.rotation, + }); + } + }); + return arr; + }, [selectedIds, nodes]); + + return ( +
+ {/* Background grid (pans/zooms with viewport via CSS bg-position/size). */} + + + {/* World-space layer: all nodes are positioned in world coords and the + whole subtree is translated+scaled by the viewport transform. */} +
+ {rootOrder.map((id) => { + const n = nodes[id]; + if (!n) return null; + const draggable = tool === "select"; + if (n.kind === "image") { + return ( + + ); + } + if (n.kind === "text") { + return ( + + ); + } + if (n.kind === "group") { + return ( + + ); + } + return null; + })} + + {/* Selection outlines render inside the world transform so their 1/zoom + border stays crisp at all zooms. */} + {selectionOutlines.map((o) => ( +
+ ))} + + {/* Marquee rect (axis-aligned, in world coords). */} + {marquee && ( +
+ )} + + {/* Lasso polyline as an SVG in world coords. Sizing: compute a bbox + so the SVG only takes the polyline's extent; the itself + positions at its min corner. */} + {lassoPath && lassoPath.length >= 2 && ( + + )} + + {/* Text-edit overlay lives inside the world div so its absolute + (node.x, node.y) positioning lands on the node at any pan/zoom. */} + +
+ + {/* Screen-space overlay for the transformer handles (resize + rotate). */} + {selectedIds.size > 0 && ( + <> + + + + )} +
+ ); +}; + +interface LassoPolylineProps { + path: { x: number; y: number }[]; + zoom: number; +} + +// Small helper to render the lasso polyline as an SVG polyline. SVG lives in +// world coords so its stroke widths can scale to 1/zoom px. +const LassoPolyline = ({ path, zoom }: LassoPolylineProps) => { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of path) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + const pad = 4 / zoom; + const x0 = minX - pad; + const y0 = minY - pad; + const w = maxX - minX + pad * 2; + const h = maxY - minY + pad * 2; + const points = path.map((p) => `${p.x - x0},${p.y - y0}`).join(" "); + return ( + + + + ); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlTransformerOverlay.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlTransformerOverlay.tsx new file mode 100644 index 0000000000..93458472fb --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlTransformerOverlay.tsx @@ -0,0 +1,393 @@ +import { useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useMoodboardStore } from "../PageMoodboard/MoodboardStore"; +import { MoodboardNode } from "../PageMoodboard/types"; + +interface Props { + // Stage container rect (client coords) is used for nothing directly — the + // overlay is mounted inside the container so its own 0,0 lines up — but + // keeping it here lets us hoist rect math later if we need it. + containerRef: React.RefObject; +} + +type HandleKey = + | "nw" + | "n" + | "ne" + | "e" + | "se" + | "s" + | "sw" + | "w" + | "rotate"; + +const HANDLE_SIZE = 10; +const ROTATE_OFFSET = 24; +const HANDLE_STROKE = "#3b82f6"; +const HANDLE_FILL = "#ffffff"; + +// Screen-space handles for resize + rotate on a single selected node. Multi- +// select resize isn't in scope for V1 — use Group (Cmd+G) to resize multiples. +// The overlay renders inside the stage container (non-transformed) so all +// coordinates below are in container-local screen px. +export const MoodboardHtmlTransformerOverlay = ({ containerRef: _containerRef }: Props) => { + const { nodes, selectedIds, viewport, editingTextId } = useMoodboardStore( + useShallow((s) => ({ + nodes: s.nodes, + selectedIds: s.selectedIds, + viewport: s.viewport, + editingTextId: s.transient.editingTextId, + })), + ); + const updateNode = useMoodboardStore((s) => s.updateNode); + const pushHistory = useMoodboardStore((s) => s.pushHistory); + + const selectedArr = useMemo(() => Array.from(selectedIds), [selectedIds]); + const onlyId = selectedArr.length === 1 ? selectedArr[0] : null; + const onlyNode = onlyId ? nodes[onlyId] : null; + + // Hide transformer while editing a text node (matches Konva page behavior). + if (editingTextId) return null; + if (!onlyNode) { + // Selection outlines for multi-select still render from MoodboardHtmlStage. + return null; + } + + // Compute the four rotated corners of the node in world coords, then map + // to container-screen coords via pan + zoom. This gives us the exact screen + // positions for the handles regardless of zoom/pan/rotation. + const rad = (onlyNode.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const worldCorner = (dx: number, dy: number) => ({ + x: onlyNode.x + dx * cos - dy * sin, + y: onlyNode.y + dx * sin + dy * cos, + }); + const toScreen = (p: { x: number; y: number }) => ({ + x: viewport.pan.x + p.x * viewport.zoom, + y: viewport.pan.y + p.y * viewport.zoom, + }); + const nwW = worldCorner(0, 0); + const neW = worldCorner(onlyNode.width, 0); + const seW = worldCorner(onlyNode.width, onlyNode.height); + const swW = worldCorner(0, onlyNode.height); + const nw = toScreen(nwW); + const ne = toScreen(neW); + const se = toScreen(seW); + const sw = toScreen(swW); + const mid = (a: { x: number; y: number }, b: { x: number; y: number }) => ({ + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, + }); + const nMid = mid(nw, ne); + const eMid = mid(ne, se); + const sMid = mid(se, sw); + const wMid = mid(sw, nw); + + // Unit vector along the top edge (for rotate handle placement perpendicular). + const topVec = { x: ne.x - nw.x, y: ne.y - nw.y }; + const topLen = Math.hypot(topVec.x, topVec.y) || 1; + const perpUnit = { x: -topVec.y / topLen, y: topVec.x / topLen }; + // Rotate handle sits "above" the top edge (outside the node, away from center). + // Center-of-node in screen coords: + const center = { + x: (nw.x + se.x) / 2, + y: (nw.y + se.y) / 2, + }; + // Flip the perpendicular so it points away from center. + const sign = + (nMid.x - center.x) * perpUnit.x + (nMid.y - center.y) * perpUnit.y >= 0 + ? 1 + : -1; + const rotateAnchor = { + x: nMid.x + perpUnit.x * sign * ROTATE_OFFSET, + y: nMid.y + perpUnit.y * sign * ROTATE_OFFSET, + }; + + const startDrag = ( + key: HandleKey, + startClient: { x: number; y: number }, + ) => { + pushHistory(); + const startNode: MoodboardNode = { ...onlyNode }; + const startRad = (startNode.rotation * Math.PI) / 180; + const sCos = Math.cos(startRad); + const sSin = Math.sin(startRad); + // Anchor in world coords: the corner opposite the handle being dragged + // stays fixed during resize. For edge handles, the opposite edge's + // midpoint is the anchor along that axis. + const { x: nx, y: ny, width: nw0, height: nh0 } = startNode; + // Convert a client-space delta to local-axis delta (the node's own axes). + const clientToLocal = (cdx: number, cdy: number) => { + const worldDx = cdx / viewport.zoom; + const worldDy = cdy / viewport.zoom; + return { + lx: worldDx * sCos + worldDy * sSin, + ly: -worldDx * sSin + worldDy * sCos, + }; + }; + + const onMove = (ev: PointerEvent) => { + const cdx = ev.clientX - startClient.x; + const cdy = ev.clientY - startClient.y; + if (key === "rotate") { + // Rotate around the node's center. Angle is measured from the center + // to the pointer, minus the initial angle. + const startCenterWorld = { + x: startNode.x + (nw0 / 2) * sCos - (nh0 / 2) * sSin, + y: startNode.y + (nw0 / 2) * sSin + (nh0 / 2) * sCos, + }; + // Need a ref rect for client coord conversion — use _containerRef. + const rect = _containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const centerScreen = { + x: viewport.pan.x + startCenterWorld.x * viewport.zoom + rect.left, + y: viewport.pan.y + startCenterWorld.y * viewport.zoom + rect.top, + }; + const startAngle = + (Math.atan2(startClient.y - centerScreen.y, startClient.x - centerScreen.x) * + 180) / + Math.PI; + const nowAngle = + (Math.atan2(ev.clientY - centerScreen.y, ev.clientX - centerScreen.x) * + 180) / + Math.PI; + const deltaDeg = nowAngle - startAngle; + // Rotate around the node center means the top-left must be repositioned + // to keep the center fixed. + const newRotation = startNode.rotation + deltaDeg; + const newRad = (newRotation * Math.PI) / 180; + const nCos = Math.cos(newRad); + const nSin = Math.sin(newRad); + // Keep center fixed: new (x, y) = center - rotate((w/2, h/2), newRotation) + const newX = startCenterWorld.x - (nw0 / 2) * nCos + (nh0 / 2) * nSin; + const newY = startCenterWorld.y - (nw0 / 2) * nSin - (nh0 / 2) * nCos; + updateNode(startNode.id, { + rotation: newRotation, + x: newX, + y: newY, + }); + return; + } + + // Live modifier state — re-read every move so toggling Shift/Alt + // mid-drag updates the behavior immediately (matches Figma). + const shift = ev.shiftKey; + const alt = ev.altKey; + + const { lx, ly } = clientToLocal(cdx, cdy); + + const isLeftHandle = key === "nw" || key === "w" || key === "sw"; + const isRightHandle = key === "ne" || key === "e" || key === "se"; + const isTopHandle = key === "nw" || key === "n" || key === "ne"; + const isBottomHandle = key === "sw" || key === "s" || key === "se"; + const affectsX = isLeftHandle || isRightHandle; + const affectsY = isTopHandle || isBottomHandle; + + // Step 1: raw size deltas from the handle's sides. + let dw = 0; + let dh = 0; + if (isRightHandle) dw = lx; + if (isLeftHandle) dw = -lx; + if (isBottomHandle) dh = ly; + if (isTopHandle) dh = -ly; + + // Step 2: Alt expands from center → both edges of the affected dim + // move, so the delta doubles. + if (alt) { + if (affectsX) dw *= 2; + if (affectsY) dh *= 2; + } + + // Step 3: Shift applies aspect-lock. + // Corner: unify via the dominant dim's fractional change. + // Edge: propagate the dragged dim to the perpendicular one via aspect. + if (shift) { + const aspect = nw0 / nh0; + if (affectsX && affectsY) { + const fracW = dw / nw0; + const fracH = dh / nh0; + const s = Math.abs(fracW) >= Math.abs(fracH) ? fracW : fracH; + dw = nw0 * s; + dh = nh0 * s; + } else if (affectsX) { + dh = dw / aspect; + } else if (affectsY) { + dw = dh * aspect; + } + } + + // Step 4: anchor per dim. Alt → center (0.5). Otherwise the opposite + // side (0 for right-handles, 1 for left-handles; 0 for bottom, 1 for + // top). Dims not affected by the handle default to 0.5, which is what + // Figma's edge+Shift does for the propagated perpendicular axis. + let ax = 0.5; + let ay = 0.5; + if (!alt) { + if (isLeftHandle) ax = 1; + else if (isRightHandle) ax = 0; + if (isTopHandle) ay = 1; + else if (isBottomHandle) ay = 0; + } + let dxLocal = -ax * dw; + let dyLocal = -ay * dh; + + // Enforce minimum size so the node doesn't invert. Re-derive dx/dy so + // the anchor stays correct after clamping (aspect may drift by a pixel + // at min size — acceptable edge case). + const MIN_SIZE = 8; + let newW = nw0 + dw; + let newH = nh0 + dh; + if (newW < MIN_SIZE) { + const adj = MIN_SIZE - newW; + newW = MIN_SIZE; + dw += adj; + dxLocal = -ax * dw; + } + if (newH < MIN_SIZE) { + const adj = MIN_SIZE - newH; + newH = MIN_SIZE; + dh += adj; + dyLocal = -ay * dh; + } + + // Convert local-axis translation back to world-axis to update (x, y). + const worldDx = dxLocal * sCos - dyLocal * sSin; + const worldDy = dxLocal * sSin + dyLocal * sCos; + const patch: Partial = { + x: nx + worldDx, + y: ny + worldDy, + width: newW, + height: newH, + }; + // Text nodes scale their fontSize uniformly to match Konva behavior. + if (startNode.kind === "text") { + const uniform = (newW / nw0 + newH / nh0) / 2; + (patch as Partial & { fontSize?: number }).fontSize = + startNode.fontSize * uniform; + } + updateNode(startNode.id, patch); + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + const handleStyle = (key: HandleKey, pos: { x: number; y: number }) => { + const cursorByKey: Record = { + nw: "nwse-resize", + ne: "nesw-resize", + se: "nwse-resize", + sw: "nesw-resize", + n: "ns-resize", + s: "ns-resize", + e: "ew-resize", + w: "ew-resize", + rotate: "grab", + }; + return { + position: "absolute" as const, + left: pos.x - HANDLE_SIZE / 2, + top: pos.y - HANDLE_SIZE / 2, + width: HANDLE_SIZE, + height: HANDLE_SIZE, + background: HANDLE_FILL, + border: `1px solid ${HANDLE_STROKE}`, + borderRadius: key === "rotate" ? "50%" : 2, + cursor: cursorByKey[key], + pointerEvents: "auto" as const, + zIndex: 3, + boxSizing: "border-box" as const, + }; + }; + + const onHandlePointerDown = + (key: HandleKey) => (e: React.PointerEvent) => { + if (e.button !== 0) return; + // React's stopPropagation only stops React's synthetic bubble. The + // data-moodboard-html-handle attribute on the element lets the native + // pointerdown listener in useHtmlSelection skip this click too, so we + // don't start a marquee while dragging a handle. + e.stopPropagation(); + e.preventDefault(); + startDrag(key, { x: e.clientX, y: e.clientY }); + }; + + return ( +
+ {/* Connector line from top-edge midpoint to rotate handle */} + + + +
+
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/index.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/index.ts new file mode 100644 index 0000000000..3db2bf318b --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/index.ts @@ -0,0 +1 @@ +export { MoodboardHtml } from "./MoodboardHtml"; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/htmlStagePointer.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/htmlStagePointer.ts new file mode 100644 index 0000000000..a8f1447841 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/htmlStagePointer.ts @@ -0,0 +1,30 @@ +import { Vec2 } from "../../PageMoodboard/types"; +import { Viewport } from "../../PageMoodboard/layout/geometry"; + +// Converts a DOM pointer event's client coords to world (stage) coords for the +// HTML moodboard. Mirrors the role of `stagePointerPos` in the Konva page but +// uses DOM rects and the viewport in the store instead of Konva's stage state. +export const worldPointFromClient = ( + containerEl: HTMLElement | null, + clientX: number, + clientY: number, + viewport: Viewport, +): Vec2 | null => { + if (!containerEl) return null; + const rect = containerEl.getBoundingClientRect(); + return { + x: (clientX - rect.left - viewport.pan.x) / viewport.zoom, + y: (clientY - rect.top - viewport.pan.y) / viewport.zoom, + }; +}; + +// Returns the center of the visible viewport in world coords. Used as a +// fallback drop point when no cursor position is available. +export const worldViewportCenter = ( + canvasWidth: number, + canvasHeight: number, + viewport: Viewport, +): Vec2 => ({ + x: (canvasWidth / 2 - viewport.pan.x) / viewport.zoom, + y: (canvasHeight / 2 - viewport.pan.y) / viewport.zoom, +}); diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlGalleryDropEvent.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlGalleryDropEvent.ts new file mode 100644 index 0000000000..04495e56cd --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlGalleryDropEvent.ts @@ -0,0 +1,61 @@ +import { useEffect } from "react"; +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; + +interface GalleryItem { + id?: string; + thumbnail?: string | null; + fullImage?: string | null; +} + +// canvasPosition from the PageEditor dispatcher is already container-local +// (the dispatcher subtracts the stage container's rect.left/top before +// dispatching). We only need to undo the pan/zoom to map to world coords. +interface GalleryMoodboardDropDetail { + item: GalleryItem; + canvasPosition: { x: number; y: number }; +} + +const measure = (url: string): Promise<{ w: number; h: number }> => + new Promise((resolve) => { + const i = new window.Image(); + i.crossOrigin = "anonymous"; + i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight }); + i.onerror = () => resolve({ w: 320, h: 320 }); + i.src = url; + }); + +// HTML analogue of useGalleryDropEvent.ts. Converts container-local drop +// coords to world coords by undoing pan/zoom — matches the Konva version +// (which uses stage.x()/scaleX()) but with store-derived viewport instead. +export const useHtmlGalleryDropEvent = ( + active: boolean, + _containerRef: React.RefObject, +) => { + const addImage = useMoodboardStore((s) => s.addImage); + + useEffect(() => { + if (!active) return undefined; + + const handler = async (e: Event) => { + const ce = e as CustomEvent; + const detail = ce.detail; + if (!detail) return; + const { item, canvasPosition } = detail; + const url = item.fullImage || item.thumbnail; + if (!url) return; + + const { viewport } = useMoodboardStore.getState(); + const stagePoint = { + x: (canvasPosition.x - viewport.pan.x) / viewport.zoom, + y: (canvasPosition.y - viewport.pan.y) / viewport.zoom, + }; + + const dims = await measure(url); + addImage(url, stagePoint, dims.w, dims.h, item.id ?? null); + }; + + window.addEventListener("gallery-moodboard-drop", handler); + return () => + window.removeEventListener("gallery-moodboard-drop", handler); + }, [active, addImage]); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlMoodboardImageEntry.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlMoodboardImageEntry.tsx new file mode 100644 index 0000000000..9ccdca8e97 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlMoodboardImageEntry.tsx @@ -0,0 +1,130 @@ +import { ReactNode, useCallback, useRef, useState } from "react"; +import toast from "react-hot-toast"; +import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi"; +import { + GalleryModal, + GalleryItem, +} from "@storyteller/ui-gallery-modal"; +import { uploadImage } from "~/components/reusable/UploadModalImage/utilities/uploadImage"; +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; +import { worldViewportCenter } from "./htmlStagePointer"; + +const measure = (url: string): Promise<{ w: number; h: number }> => + new Promise((resolve) => { + const img = new window.Image(); + img.crossOrigin = "anonymous"; + img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight }); + img.onerror = () => resolve({ w: 320, h: 320 }); + img.src = url; + }); + +interface UseReturn { + triggerUpload: () => void; + triggerGallery: () => void; + modals: ReactNode; +} + +// HTML analogue of useMoodboardImageEntry.tsx. Center is read from the store's +// canvasSize + viewport instead of a Konva stage, so no stageRef is needed. +export const useHtmlMoodboardImageEntry = (): UseReturn => { + const addImage = useMoodboardStore((s) => s.addImage); + const fileInputRef = useRef(null); + const [galleryOpen, setGalleryOpen] = useState(false); + const [selectedGalleryIds, setSelectedGalleryIds] = useState([]); + + const triggerUpload = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const triggerGallery = useCallback(() => { + setGalleryOpen(true); + }, []); + + const getCenter = () => { + const { canvasSize, viewport } = useMoodboardStore.getState(); + return worldViewportCenter(canvasSize.width, canvasSize.height, viewport); + }; + + const handleFiles = useCallback( + async (files: FileList) => { + const center = getCenter(); + for (const file of Array.from(files)) { + if (!file.type.startsWith("image/")) continue; + const blobUrl = URL.createObjectURL(file); + try { + const dims = await measure(blobUrl); + addImage(blobUrl, center, dims.w, dims.h, null); + } catch { + addImage(blobUrl, center, 320, 320, null); + } + uploadImage({ + title: file.name || "Moodboard image", + assetFile: file, + progressCallback: () => {}, + }).catch((err) => { + console.error("[MoodboardHtml] background upload failed", err); + }); + } + }, + [addImage], + ); + + const handleFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length) { + void handleFiles(files); + } + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleGalleryClose = () => { + setGalleryOpen(false); + setSelectedGalleryIds([]); + }; + + const handleGallerySelectItem = (id: string) => { + setSelectedGalleryIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + const handleUseSelected = async (items: GalleryItem[]) => { + if (items.length === 0) { + toast.error("No image selected"); + return; + } + const center = getCenter(); + for (const item of items) { + const url = item.fullImage || item.thumbnail; + if (!url) continue; + const dims = await measure(url); + addImage(url, center, dims.w, dims.h, item.id ?? null); + } + handleGalleryClose(); + }; + + const modals = ( + <> + + + + ); + + return { triggerUpload, triggerGallery, modals }; +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlPasteHandler.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlPasteHandler.ts new file mode 100644 index 0000000000..72f4ce3c86 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlPasteHandler.ts @@ -0,0 +1,69 @@ +import { useEffect } from "react"; +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; +import { uploadImage } from "~/components/reusable/UploadModalImage/utilities/uploadImage"; +import { worldViewportCenter } from "./htmlStagePointer"; + +const measure = (url: string): Promise<{ w: number; h: number }> => + new Promise((resolve, reject) => { + const i = new window.Image(); + i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight }); + i.onerror = reject; + i.src = url; + }); + +// HTML analogue of usePasteHandler.ts — drop pasted images at the viewport +// center (derived from canvasSize + viewport in the store, no stageRef needed). +export const useHtmlPasteHandler = (active: boolean) => { + const addImage = useMoodboardStore((s) => s.addImage); + + useEffect(() => { + if (!active) return undefined; + + const handler = async (e: ClipboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && /input|textarea/i.test(target.tagName)) return; + if (target && target.isContentEditable) return; + + const items = e.clipboardData?.items; + if (!items) return; + const fileItems: File[] = []; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (it.kind === "file" && it.type.startsWith("image/")) { + const f = it.getAsFile(); + if (f) fileItems.push(f); + } + } + if (fileItems.length === 0) return; + e.preventDefault(); + + const { canvasSize, viewport } = useMoodboardStore.getState(); + const center = worldViewportCenter( + canvasSize.width, + canvasSize.height, + viewport, + ); + + for (const file of fileItems) { + const blobUrl = URL.createObjectURL(file); + try { + const dims = await measure(blobUrl); + addImage(blobUrl, center, dims.w, dims.h, null); + } catch (err) { + console.error("[MoodboardHtml] paste measure failed", err); + addImage(blobUrl, center, 320, 320, null); + } + uploadImage({ + title: "Pasted image", + assetFile: file, + progressCallback: () => {}, + }).catch((err) => { + console.error("[MoodboardHtml] background upload failed", err); + }); + } + }; + + window.addEventListener("paste", handler); + return () => window.removeEventListener("paste", handler); + }, [active, addImage]); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlSelection.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlSelection.ts new file mode 100644 index 0000000000..ef05d393b2 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlSelection.ts @@ -0,0 +1,146 @@ +import { useEffect, useRef } from "react"; +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; +import { Vec2 } from "../../PageMoodboard/types"; +import { + rectCorners, + rectFromPoints, + rectsIntersect, +} from "../../PageMoodboard/layout/geometry"; +import { pointInPolygon } from "../../PageMoodboard/layout/pointInPolygon"; +import { polygonIntersectsRect } from "../../PageMoodboard/layout/segmentsIntersect"; +import { worldPointFromClient } from "./htmlStagePointer"; + +const MIN_DRAG = 4; + +interface DragState { + origin: Vec2; + path: Vec2[]; + active: boolean; + mode: "rect" | "lasso" | null; +} + +// HTML analogue of useSelection.ts. Listens on the stage container for bare +// pointerdowns (nodes stopPropagation before these fire), tracks the drag in +// world coords, and commits the same way the Konva version does — marquee +// uses rectsIntersect, lasso uses pointInPolygon / polygonIntersectsRect. +export const useHtmlSelection = ( + containerRef: React.RefObject, +) => { + const drag = useRef({ + origin: { x: 0, y: 0 }, + path: [], + active: false, + mode: null, + }); + const tool = useMoodboardStore((s) => s.tool); + const setMarquee = useMoodboardStore((s) => s.setMarquee); + const setLassoPath = useMoodboardStore((s) => s.setLassoPath); + const setSelection = useMoodboardStore((s) => s.setSelection); + + useEffect(() => { + if (tool !== "select" && tool !== "lasso") return; + const el = containerRef.current; + if (!el) return; + + const commit = () => { + const { nodes, rootOrder, transient } = useMoodboardStore.getState(); + const candidates = rootOrder + .map((id) => nodes[id]) + .filter((n): n is NonNullable => Boolean(n)); + if (drag.current.mode === "rect") { + const rect = transient.marquee; + if (!rect || (rect.width < MIN_DRAG && rect.height < MIN_DRAG)) { + setSelection([]); + } else { + const hit = candidates + .filter((n) => + rectsIntersect( + { x: n.x, y: n.y, width: n.width, height: n.height }, + rect, + ), + ) + .map((n) => n.id); + setSelection(hit); + } + setMarquee(null); + } else if (drag.current.mode === "lasso") { + const path = drag.current.path; + if (path.length < 3) { + setSelection([]); + } else { + const closed = [...path, path[0]]; + const hit = candidates + .filter((n) => { + const corners = rectCorners(n); + if (corners.some((c) => pointInPolygon(c, closed))) return true; + if (polygonIntersectsRect(closed, n)) return true; + return false; + }) + .map((n) => n.id); + setSelection(hit); + } + setLassoPath(null); + } + drag.current.active = false; + drag.current.mode = null; + drag.current.path = []; + }; + + const handlePointerDown = (e: PointerEvent) => { + // Nodes stopPropagation in their own pointerdown, so reaching here means + // the user clicked on bare stage. + if (e.button !== 0) return; + if (useMoodboardStore.getState().transient.isPanning) return; + // Space-held pan is handled before this; if that hook is active and panning, + // isPanning is true. We also ignore if the target was a node (defensive). + const target = e.target as HTMLElement | null; + if (target && target.closest("[data-moodboard-html-node]")) return; + if (target && target.closest("[data-moodboard-html-group]")) return; + // Transformer handles use React stopPropagation which can't stop this + // native listener, so filter on the data attribute instead — otherwise + // grabbing a resize/rotate handle also starts a marquee drag. + if (target && target.closest("[data-moodboard-html-handle]")) return; + const { viewport } = useMoodboardStore.getState(); + const pos = worldPointFromClient(el, e.clientX, e.clientY, viewport); + if (!pos) return; + drag.current = { + origin: pos, + path: tool === "lasso" ? [pos] : [], + active: true, + mode: tool === "lasso" ? "lasso" : "rect", + }; + }; + + const handlePointerMove = (e: PointerEvent) => { + if (!drag.current.active) return; + const { viewport } = useMoodboardStore.getState(); + const pos = worldPointFromClient(el, e.clientX, e.clientY, viewport); + if (!pos) return; + if (drag.current.mode === "rect") { + const dx = pos.x - drag.current.origin.x; + const dy = pos.y - drag.current.origin.y; + if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) return; + setMarquee(rectFromPoints(drag.current.origin, pos)); + } else if (drag.current.mode === "lasso") { + drag.current.path.push(pos); + setLassoPath([...drag.current.path]); + } + }; + + const handlePointerUp = () => { + if (!drag.current.active) return; + commit(); + }; + + // Bare-stage "click" (zero-area drag) should clear selection — the commit + // path handles that via the MIN_DRAG branch. + el.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + return () => { + el.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + }, [containerRef, tool, setMarquee, setLassoPath, setSelection]); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlViewportControls.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlViewportControls.ts new file mode 100644 index 0000000000..1216b5c0aa --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/useHtmlViewportControls.ts @@ -0,0 +1,164 @@ +import { useEffect, useRef } from "react"; +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; +import { Vec2 } from "../../PageMoodboard/types"; +import { + ZOOM_MAX, + ZOOM_MIN, + clamp, +} from "../../PageMoodboard/layout/geometry"; + +const ZOOM_STEP = 1.1; + +const isEditableTarget = (target: EventTarget | null): boolean => { + const el = target as HTMLElement | null; + if (!el) return false; + if (/input|textarea/i.test(el.tagName)) return true; + if (el.isContentEditable) return true; + return false; +}; + +// HTML analogue of useViewportControls.ts. Wheel zooms around the cursor; +// middle-mouse and space+left drag pan; Cmd/Ctrl+0 resets. Everything is +// derived from the container's bounding rect — no Konva stage required. +export const useHtmlViewportControls = ( + containerRef: React.RefObject, +) => { + const setZoom = useMoodboardStore((s) => s.setZoom); + const setPan = useMoodboardStore((s) => s.setPan); + const resetViewport = useMoodboardStore((s) => s.resetViewport); + const setIsPanning = useMoodboardStore((s) => s.setIsPanning); + const spaceHeld = useRef(false); + const panState = useRef<{ + active: boolean; + startScreen: Vec2; + startPan: Vec2; + }>({ + active: false, + startScreen: { x: 0, y: 0 }, + startPan: { x: 0, y: 0 }, + }); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const handleWheel = (e: WheelEvent) => { + if (isEditableTarget(document.activeElement)) return; + e.preventDefault(); + const { viewport } = useMoodboardStore.getState(); + const rect = el.getBoundingClientRect(); + const pointer = { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }; + const oldScale = viewport.zoom; + const mousePointTo = { + x: (pointer.x - viewport.pan.x) / oldScale, + y: (pointer.y - viewport.pan.y) / oldScale, + }; + const direction = e.deltaY > 0 ? -1 : 1; + const newScale = clamp( + direction > 0 ? oldScale * ZOOM_STEP : oldScale / ZOOM_STEP, + ZOOM_MIN, + ZOOM_MAX, + ); + const newPan = { + x: pointer.x - mousePointTo.x * newScale, + y: pointer.y - mousePointTo.y * newScale, + }; + setZoom(newScale); + setPan(newPan); + }; + + el.addEventListener("wheel", handleWheel, { passive: false }); + return () => { + el.removeEventListener("wheel", handleWheel); + }; + }, [containerRef, setZoom, setPan]); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const beginPan = (clientX: number, clientY: number) => { + panState.current = { + active: true, + startScreen: { x: clientX, y: clientY }, + startPan: { ...useMoodboardStore.getState().viewport.pan }, + }; + setIsPanning(true); + el.style.cursor = "grabbing"; + }; + + const endPan = () => { + if (!panState.current.active) return; + panState.current.active = false; + setIsPanning(false); + el.style.cursor = spaceHeld.current ? "grab" : ""; + }; + + const handleMouseDown = (e: MouseEvent) => { + if (e.button === 1) { + e.preventDefault(); + e.stopPropagation(); + beginPan(e.clientX, e.clientY); + return; + } + if (e.button === 0 && spaceHeld.current) { + e.preventDefault(); + e.stopPropagation(); + beginPan(e.clientX, e.clientY); + } + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!panState.current.active) return; + const dx = e.clientX - panState.current.startScreen.x; + const dy = e.clientY - panState.current.startScreen.y; + setPan({ + x: panState.current.startPan.x + dx, + y: panState.current.startPan.y + dy, + }); + }; + + const handleMouseUp = () => endPan(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (isEditableTarget(e.target)) return; + if (e.code === "Space" && !spaceHeld.current) { + spaceHeld.current = true; + if (!panState.current.active) el.style.cursor = "grab"; + e.preventDefault(); + } + const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); + const mod = isMac ? e.metaKey : e.ctrlKey; + if (mod && e.key === "0") { + e.preventDefault(); + resetViewport(); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.code === "Space") { + spaceHeld.current = false; + if (!panState.current.active) el.style.cursor = ""; + } + }; + + el.addEventListener("mousedown", handleMouseDown, { capture: true }); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keyup", handleKeyUp); + return () => { + el.removeEventListener("mousedown", handleMouseDown, { + capture: true, + } as EventListenerOptions); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keyup", handleKeyUp); + el.style.cursor = ""; + }; + }, [containerRef, setPan, resetViewport, setIsPanning]); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/GroupNodeHtml.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/GroupNodeHtml.tsx new file mode 100644 index 0000000000..5277c54912 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/GroupNodeHtml.tsx @@ -0,0 +1,127 @@ +import { memo, useRef } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { + GroupNode as GroupNodeData, + MoodboardNode, +} from "../../PageMoodboard/types"; +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; +import { ImageNodeHtml } from "./ImageNodeHtml"; +import { TextNodeHtml } from "./TextNodeHtml"; + +interface Props { + node: GroupNodeData; + onSelect: (id: string, additive: boolean) => void; +} + +// Children inside a group are rendered with their `parentId` set to the group. +// They don't directly fire onSelect — the group absorbs clicks and selects +// itself, which matches the Konva group's hit-rect behavior. +const NO_OP_SELECT = () => {}; + +const GroupNodeHtmlInner = ({ node, onSelect }: Props) => { + const updateNode = useMoodboardStore((s) => s.updateNode); + const pushHistory = useMoodboardStore((s) => s.pushHistory); + const children = useMoodboardStore( + useShallow((s) => + node.childIds + .map((id) => s.nodes[id]) + .filter((n): n is MoodboardNode => Boolean(n)), + ), + ); + const dragStateRef = useRef<{ + active: boolean; + startClient: { x: number; y: number }; + startNode: { x: number; y: number }; + moved: boolean; + } | null>(null); + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.stopPropagation(); + const additive = e.shiftKey; + onSelect(node.id, additive); + dragStateRef.current = { + active: true, + startClient: { x: e.clientX, y: e.clientY }, + startNode: { x: node.x, y: node.y }, + moved: false, + }; + const onMove = (ev: PointerEvent) => { + const st = dragStateRef.current; + if (!st || !st.active) return; + const { zoom } = useMoodboardStore.getState().viewport; + const dx = (ev.clientX - st.startClient.x) / zoom; + const dy = (ev.clientY - st.startClient.y) / zoom; + if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) { + st.moved = true; + pushHistory(); + } + if (st.moved) { + updateNode(node.id, { + x: st.startNode.x + dx, + y: st.startNode.y + dy, + }); + } + }; + const onUp = () => { + dragStateRef.current = null; + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + return ( +
+ {children.map((child) => { + if (child.kind === "image") { + return ( + + ); + } + if (child.kind === "text") { + return ( + + ); + } + if (child.kind === "group") { + return ( + + ); + } + return null; + })} +
+ ); +}; + +export const GroupNodeHtml = memo(GroupNodeHtmlInner); diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/ImageNodeHtml.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/ImageNodeHtml.tsx new file mode 100644 index 0000000000..342a6bd653 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/ImageNodeHtml.tsx @@ -0,0 +1,127 @@ +import { memo, useEffect, useRef, useState } from "react"; +import { ImageNode as ImageNodeData } from "../../PageMoodboard/types"; +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; + +interface Props { + node: ImageNodeData; + draggable: boolean; + selected: boolean; + onSelect: (id: string, additive: boolean) => void; +} + +const ImageNodeHtmlInner = ({ node, draggable, selected, onSelect }: Props) => { + const updateNode = useMoodboardStore((s) => s.updateNode); + const pushHistory = useMoodboardStore((s) => s.pushHistory); + const zoom = useMoodboardStore((s) => s.viewport.zoom); + const [loaded, setLoaded] = useState(false); + const dragStateRef = useRef<{ + active: boolean; + startClient: { x: number; y: number }; + startNode: { x: number; y: number }; + moved: boolean; + pointerId: number | null; + } | null>(null); + + useEffect(() => { + // Probe whether the image actually resolves; placeholder renders until then. + const i = new window.Image(); + i.crossOrigin = "anonymous"; + i.onload = () => setLoaded(true); + i.onerror = () => setLoaded(false); + i.src = node.src; + return () => { + i.onload = null; + i.onerror = null; + }; + }, [node.src]); + + const handlePointerDown = (e: React.PointerEvent) => { + // Middle-mouse is the pan hook's domain; ignore here. + if (e.button !== 0) return; + // When rendered as a group child (draggable=false), do not intercept — + // the click must bubble to GroupNodeHtml's handler so the group itself + // gets selected/dragged. Same reasoning when in lasso/text tool: let the + // stage receive the click. + if (!draggable) return; + e.stopPropagation(); + const additive = e.shiftKey; + onSelect(node.id, additive); + dragStateRef.current = { + active: true, + startClient: { x: e.clientX, y: e.clientY }, + startNode: { x: node.x, y: node.y }, + moved: false, + pointerId: e.pointerId, + }; + const onMove = (ev: PointerEvent) => { + const st = dragStateRef.current; + if (!st || !st.active) return; + const { zoom: z } = useMoodboardStore.getState().viewport; + const dx = (ev.clientX - st.startClient.x) / z; + const dy = (ev.clientY - st.startClient.y) / z; + if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) { + st.moved = true; + pushHistory(); + } + if (st.moved) { + updateNode(node.id, { + x: st.startNode.x + dx, + y: st.startNode.y + dy, + }); + } + }; + const onUp = () => { + dragStateRef.current = null; + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + return ( +
+ {loaded ? ( + + ) : ( +
+ )} +
+ ); +}; + +export const ImageNodeHtml = memo(ImageNodeHtmlInner); diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/TextNodeHtml.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/TextNodeHtml.tsx new file mode 100644 index 0000000000..a56507e800 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/TextNodeHtml.tsx @@ -0,0 +1,102 @@ +import { memo, useRef } from "react"; +import { TextNode as TextNodeData } from "../../PageMoodboard/types"; +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; + +interface Props { + node: TextNodeData; + draggable: boolean; + onSelect: (id: string, additive: boolean) => void; +} + +const TextNodeHtmlInner = ({ node, draggable, onSelect }: Props) => { + const updateNode = useMoodboardStore((s) => s.updateNode); + const pushHistory = useMoodboardStore((s) => s.pushHistory); + const setEditingText = useMoodboardStore((s) => s.setEditingText); + const isEditing = useMoodboardStore( + (s) => s.transient.editingTextId === node.id, + ); + const dragStateRef = useRef<{ + active: boolean; + startClient: { x: number; y: number }; + startNode: { x: number; y: number }; + moved: boolean; + } | null>(null); + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + // When rendered as a group child (draggable=false), do not intercept — + // the click must bubble to GroupNodeHtml's handler so the group itself + // gets selected/dragged. Same reasoning when in lasso/text tool. + if (!draggable) return; + e.stopPropagation(); + const additive = e.shiftKey; + onSelect(node.id, additive); + dragStateRef.current = { + active: true, + startClient: { x: e.clientX, y: e.clientY }, + startNode: { x: node.x, y: node.y }, + moved: false, + }; + const onMove = (ev: PointerEvent) => { + const st = dragStateRef.current; + if (!st || !st.active) return; + const { zoom } = useMoodboardStore.getState().viewport; + const dx = (ev.clientX - st.startClient.x) / zoom; + const dy = (ev.clientY - st.startClient.y) / zoom; + if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) { + st.moved = true; + pushHistory(); + } + if (st.moved) { + updateNode(node.id, { + x: st.startNode.x + dx, + y: st.startNode.y + dy, + }); + } + }; + const onUp = () => { + dragStateRef.current = null; + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + const handleDoubleClick = () => { + setEditingText(node.id); + }; + + return ( +
+ {node.text || "Note"} +
+ ); +}; + +export const TextNodeHtml = memo(TextNodeHtmlInner); diff --git a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts index b55d458a46..d26ac378b4 100644 --- a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts +++ b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts @@ -16,7 +16,8 @@ export type TabId = | "REMOVE_BACKGROUND" | "ANGLES" | "STORYBOARD" - | "MOODBOARD"; + | "MOODBOARD" + | "MOODBOARD_HTML"; const DEFAULT_TAB: TabId = "IMAGE"; diff --git a/frontend/libs/components/settings-modal/src/index.ts b/frontend/libs/components/settings-modal/src/index.ts index 11655a30f7..3ead794db7 100644 --- a/frontend/libs/components/settings-modal/src/index.ts +++ b/frontend/libs/components/settings-modal/src/index.ts @@ -4,4 +4,5 @@ export { useExperimentalStore, useStoryboardPageEnabled, useMoodboardPageEnabled, + useMoodboardHtmlPageEnabled, } from './lib/experimental-store'; diff --git a/frontend/libs/components/settings-modal/src/lib/experimental-store.ts b/frontend/libs/components/settings-modal/src/lib/experimental-store.ts index 1f9548f2dd..d7e419e3b7 100644 --- a/frontend/libs/components/settings-modal/src/lib/experimental-store.ts +++ b/frontend/libs/components/settings-modal/src/lib/experimental-store.ts @@ -3,6 +3,7 @@ import { create } from "zustand"; const ENABLED_STORAGE_KEY = "artcraft_experimental_enabled"; const STORYBOARD_STORAGE_KEY = "artcraft_experimental_storyboard_page"; const MOODBOARD_STORAGE_KEY = "artcraft_experimental_moodboard_page"; +const MOODBOARD_HTML_STORAGE_KEY = "artcraft_experimental_moodboard_html_page"; const readBoolFlag = (key: string): boolean => { if (typeof window === "undefined") return false; @@ -30,16 +31,19 @@ interface ExperimentalState { enabled: boolean; storyboardPageEnabled: boolean; moodboardPageEnabled: boolean; + moodboardHtmlPageEnabled: boolean; enable: () => void; disable: () => void; setStoryboardPageEnabled: (enabled: boolean) => void; setMoodboardPageEnabled: (enabled: boolean) => void; + setMoodboardHtmlPageEnabled: (enabled: boolean) => void; } export const useExperimentalStore = create((set) => ({ enabled: readBoolFlag(ENABLED_STORAGE_KEY), storyboardPageEnabled: readBoolFlag(STORYBOARD_STORAGE_KEY), moodboardPageEnabled: readBoolFlag(MOODBOARD_STORAGE_KEY), + moodboardHtmlPageEnabled: readBoolFlag(MOODBOARD_HTML_STORAGE_KEY), enable: () => { writeBoolFlag(ENABLED_STORAGE_KEY, true); set({ enabled: true }); @@ -49,10 +53,12 @@ export const useExperimentalStore = create((set) => ({ writeBoolFlag(ENABLED_STORAGE_KEY, false); writeBoolFlag(STORYBOARD_STORAGE_KEY, false); writeBoolFlag(MOODBOARD_STORAGE_KEY, false); + writeBoolFlag(MOODBOARD_HTML_STORAGE_KEY, false); set({ enabled: false, storyboardPageEnabled: false, moodboardPageEnabled: false, + moodboardHtmlPageEnabled: false, }); }, setStoryboardPageEnabled: (enabled: boolean) => { @@ -63,6 +69,10 @@ export const useExperimentalStore = create((set) => ({ writeBoolFlag(MOODBOARD_STORAGE_KEY, enabled); set({ moodboardPageEnabled: enabled }); }, + setMoodboardHtmlPageEnabled: (enabled: boolean) => { + writeBoolFlag(MOODBOARD_HTML_STORAGE_KEY, enabled); + set({ moodboardHtmlPageEnabled: enabled }); + }, })); export const useStoryboardPageEnabled = () => @@ -70,3 +80,6 @@ export const useStoryboardPageEnabled = () => export const useMoodboardPageEnabled = () => useExperimentalStore((s) => s.enabled && s.moodboardPageEnabled); + +export const useMoodboardHtmlPageEnabled = () => + useExperimentalStore((s) => s.enabled && s.moodboardHtmlPageEnabled); diff --git a/frontend/libs/components/settings-modal/src/lib/panes/ExperimentalSettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/ExperimentalSettingsPane.tsx index 5e0b5f55b6..7875a479e7 100644 --- a/frontend/libs/components/settings-modal/src/lib/panes/ExperimentalSettingsPane.tsx +++ b/frontend/libs/components/settings-modal/src/lib/panes/ExperimentalSettingsPane.tsx @@ -11,11 +11,14 @@ interface Props { onStoryboardPageDisable?: () => void; // Same contract as above for the moodboard page. onMoodboardPageDisable?: () => void; + // Same contract as above for the HTML moodboard page. + onMoodboardHtmlPageDisable?: () => void; } export const ExperimentalSettingsPane = ({ onStoryboardPageDisable, onMoodboardPageDisable, + onMoodboardHtmlPageDisable, }: Props) => { const storyboardPageEnabled = useExperimentalStore( (s) => s.storyboardPageEnabled, @@ -29,10 +32,20 @@ export const ExperimentalSettingsPane = ({ const setMoodboardPageEnabled = useExperimentalStore( (s) => s.setMoodboardPageEnabled, ); + const moodboardHtmlPageEnabled = useExperimentalStore( + (s) => s.moodboardHtmlPageEnabled, + ); + const setMoodboardHtmlPageEnabled = useExperimentalStore( + (s) => s.setMoodboardHtmlPageEnabled, + ); const [isStoryboardDisableConfirmOpen, setIsStoryboardDisableConfirmOpen] = useState(false); const [isMoodboardDisableConfirmOpen, setIsMoodboardDisableConfirmOpen] = useState(false); + const [ + isMoodboardHtmlDisableConfirmOpen, + setIsMoodboardHtmlDisableConfirmOpen, + ] = useState(false); const fireToggleEvent = (feature: string, enabled: boolean) => { gtagEvent("toggle_experimental_feature", { @@ -73,6 +86,22 @@ export const ExperimentalSettingsPane = ({ setIsMoodboardDisableConfirmOpen(false); }; + const handleMoodboardHtmlToggle = (enabled: boolean) => { + if (enabled) { + setMoodboardHtmlPageEnabled(true); + fireToggleEvent("moodboard_html_page", true); + } else { + setIsMoodboardHtmlDisableConfirmOpen(true); + } + }; + + const handleConfirmMoodboardHtmlDisable = () => { + onMoodboardHtmlPageDisable?.(); + setMoodboardHtmlPageEnabled(false); + fireToggleEvent("moodboard_html_page", false); + setIsMoodboardHtmlDisableConfirmOpen(false); + }; + return ( <>
@@ -102,6 +131,21 @@ export const ExperimentalSettingsPane = ({ setEnabled={handleMoodboardToggle} />
+
+
+ +

+ HTML-only render of the moodboard, no canvas. In-development. +

+
+ +
+ setIsMoodboardHtmlDisableConfirmOpen(false)} + onConfirm={handleConfirmMoodboardHtmlDisable} + title="Disable Moodboard (HTML)?" + text="The HTML Moodboard page will be reset and any unsaved changes will be lost." + confirmText="Disable" + /> ); };