diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts index c9be914986..3ee28a61f2 100644 --- a/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts @@ -1,6 +1,6 @@ import { ApiManager, ApiResponse } from "./ApiManager"; import { BetaKey } from "~/models"; -import { Pagination } from "~/pages/PageEnigma/models"; +import { Pagination } from "~/pages/PageScene/models"; export class BetaKeysApi extends ApiManager { public CreateBetaKey({ diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts index 79d857ffb3..55ac7e2f67 100644 --- a/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts @@ -3,7 +3,7 @@ import { MediaInfo, Pagination, PaginationInfinite, -} from "~/pages/PageEnigma/models"; +} from "~/pages/PageScene/models"; import { ApiManager, ApiResponse } from "./ApiManager"; import { authentication } from "~/signals"; import type { Property } from "csstype"; diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts index bcf3b9beea..d7962f8883 100644 --- a/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts @@ -1,5 +1,5 @@ import { ApiManager, ApiResponse } from "./ApiManager"; -import { Prompts } from "~/pages/PageEnigma/models"; +import { Prompts } from "~/pages/PageScene/models"; export class PromptsApi extends ApiManager { public GetPromptsByToken({ diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts index b15a8afaa3..e7c0d4ee79 100644 --- a/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts @@ -1,5 +1,5 @@ import { ApiManager, ApiResponse } from "./ApiManager"; -import { GenerateTtsAudioErrorType } from "~/pages/PageEnigma/enums"; +import { GenerateTtsAudioErrorType } from "~/pages/PageScene/enums"; import { Visibility } from "~/enums"; export interface GenerateTtsAudioRequest { diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts index 3e5e25e391..6aba3877d7 100644 --- a/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts @@ -4,7 +4,7 @@ import { UserBookmarkBatch, UserBookmarkByEntity, UserBookmarkByUser, -} from "~/pages/PageEnigma/models"; +} from "~/pages/PageScene/models"; import { authentication } from "~/signals"; export enum ScopedEntityTypes { diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/VoiceDesignerApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/VoiceDesignerApi.ts index 4256c9848f..6b1efe251f 100644 --- a/frontend/apps/artcraft/app/src/Classes/ApiManager/VoiceDesignerApi.ts +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/VoiceDesignerApi.ts @@ -1,5 +1,5 @@ import { ApiManager, ApiResponse } from "./ApiManager"; -import { Pagination } from "~/pages/PageEnigma/models"; +import { Pagination } from "~/pages/PageScene/models"; import { authentication } from "~/signals"; import { ZsDataset } from "~/models/Dataset"; diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts index 010d7965c0..4f64fd93da 100644 --- a/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts @@ -1,7 +1,7 @@ import { ApiManager, ApiResponse } from "./ApiManager"; import { authentication } from "~/signals"; import { Weight } from "~/models"; -import { Pagination, PaginationInfinite } from "~/pages/PageEnigma/models"; +import { Pagination, PaginationInfinite } from "~/pages/PageScene/models"; import { FilterEngineCategories, FilterMediaClasses, diff --git a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx index 2a0cc0c9c2..44c419bce9 100644 --- a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx +++ b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx @@ -25,11 +25,11 @@ import { import { toast } from "react-hot-toast"; import { v4 as uuidv4 } from "uuid"; import { useTabStore } from "../../pages/Stores/TabState"; -import { addObject } from "../../pages/PageEnigma/actions"; -import { getActiveEditor } from "../../pages/PageEnigma/contexts/EngineContext"; -import { set3DPageMounted } from "../../pages/PageEnigma/Editor/editor"; +import { addObject } from "../../pages/PageScene/actions"; +import { getActiveEditor } from "../../pages/PageScene/contexts/EngineContext"; +import { set3DPageMounted } from "../../pages/PageScene/Editor/editor"; import { AssetType } from "~/enums"; -import type { MediaItem } from "../../pages/PageEnigma/models"; +import type { MediaItem } from "../../pages/PageScene/models"; import { SPLAT_MODELS } from "@storyteller/model-list"; import { ClassyModelSelector, diff --git a/frontend/apps/artcraft/app/src/components/signaled/ErrorDialog.tsx b/frontend/apps/artcraft/app/src/components/signaled/ErrorDialog.tsx index ac546b4a5b..ac26e5cdca 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/ErrorDialog.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/ErrorDialog.tsx @@ -3,11 +3,11 @@ import { useShallow } from "zustand/shallow"; import { Modal } from "@storyteller/ui-modal"; import { Button } from "@storyteller/ui-button"; -import { usePageEnigmaStore } from "~/pages/PageEnigma/PageEnigmaStore"; +import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore"; export function ErrorDialog() { const { showErrorDialog, errorDialogTitle, errorDialogMessage, setShowErrorDialog } = - usePageEnigmaStore( + usePageSceneStore( useShallow((s) => ({ showErrorDialog: s.showErrorDialog, errorDialogTitle: s.errorDialogTitle, diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/MyMoviesButton.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/MyMoviesButton.tsx index c813744cd7..95e01e8276 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/MyMoviesButton.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/MyMoviesButton.tsx @@ -3,7 +3,7 @@ import { twMerge } from "tailwind-merge"; import { faFilm } from "@fortawesome/pro-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Button } from "@storyteller/ui-button"; -import { usePageEnigmaStore } from "~/pages/PageEnigma/PageEnigmaStore"; +import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore"; import { activeWorkflowJobs } from "~/signals"; @@ -18,7 +18,7 @@ export const MyMoviesButton = () => { +); diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/findDropTarget.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/findDropTarget.ts new file mode 100644 index 0000000000..227edd3778 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/findDropTarget.ts @@ -0,0 +1,79 @@ +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; +import { worldPointFromClient } from "./htmlStagePointer"; + +// Returns the id of the top-level node under the given client-space point, or +// null if the point is over bare canvas. Skips the dragged node, groups, and +// any nodes inside groups/cards. +// +// Used by node drag handlers on pointer-up to decide whether the release +// should either (a) form a new card with that target + the dragged node, or +// (b) append the dragged node to an existing card. +export const findDropTargetAt = ( + clientX: number, + clientY: number, + draggedId: string, +): string | null => { + const container = document.querySelector( + "[data-moodboard-html-stage]", + ); + if (!container) return null; + const { nodes, rootOrder, viewport } = useMoodboardStore.getState(); + const p = worldPointFromClient(container, clientX, clientY, viewport); + if (!p) return null; + // Iterate in reverse z-order so the topmost overlap wins. + for (let i = rootOrder.length - 1; i >= 0; i--) { + const id = rootOrder[i]; + if (id === draggedId) continue; + const n = nodes[id]; + if (!n) continue; + if (n.kind === "group") continue; + if ( + p.x >= n.x && + p.x <= n.x + n.width && + p.y >= n.y && + p.y <= n.y + n.height + ) { + return id; + } + } + return null; +}; + +// Applies the drop result: forms a new card (target is a regular node) or +// appends (target is already a card). Returns true if any mutation happened. +export const applyDropOnto = ( + draggedId: string, + targetId: string, +): boolean => { + const state = useMoodboardStore.getState(); + const target = state.nodes[targetId]; + if (!target) return false; + if (target.kind === "card") { + state.appendToCard(targetId, draggedId); + return true; + } + if (target.kind === "image" || target.kind === "video" || target.kind === "text") { + state.createCardFromDrop(draggedId, targetId); + return true; + } + return false; +}; + +// Recomputes the hover target under the pointer and publishes the result to +// the store's transient `dragPreview` so overlay UI can react. Safe to call +// on every pointermove — the setter is a shallow equality in practice since +// we always emit a fresh object, but targets change rarely during a drag. +export const updateDragPreview = ( + clientX: number, + clientY: number, + draggedId: string, +): void => { + const targetId = findDropTargetAt(clientX, clientY, draggedId); + useMoodboardStore + .getState() + .setDragPreview({ draggedId, targetId }); +}; + +export const clearDragPreview = (): void => { + useMoodboardStore.getState().setDragPreview(null); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/CardNodeHtml.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/CardNodeHtml.tsx new file mode 100644 index 0000000000..2bd971b9ae --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/CardNodeHtml.tsx @@ -0,0 +1,192 @@ +import { memo, useRef } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { + CardNode as CardNodeData, + MoodboardNode, +} from "../../PageMoodboard/types"; +import { useMoodboardStore } from "../../PageMoodboard/MoodboardStore"; + +interface Props { + node: CardNodeData; + draggable: boolean; + onSelect: (id: string, additive: boolean) => void; +} + +// Cards render their children in a vertical flex column. Children fill the +// card's inner width; their height follows the child's natural aspect ratio +// (images/videos) or own height (text). The card itself absorbs clicks — +// children inside a card aren't independently selectable, same pattern as +// GroupNodeHtml. +const CardNodeHtmlInner = ({ node, draggable, onSelect }: Props) => { + const updateNode = useMoodboardStore((s) => s.updateNode); + const pushHistory = useMoodboardStore((s) => s.pushHistory); + const children = useMoodboardStore( + useShallow((s) => + node.childIds + .map((id) => s.nodes[id]) + .filter((n): n is MoodboardNode => Boolean(n)), + ), + ); + const dragStateRef = useRef<{ + active: boolean; + startClient: { x: number; y: number }; + startNode: { x: number; y: number }; + moved: boolean; + } | null>(null); + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + if (!draggable) return; + e.stopPropagation(); + const additive = e.shiftKey; + onSelect(node.id, additive); + dragStateRef.current = { + active: true, + startClient: { x: e.clientX, y: e.clientY }, + startNode: { x: node.x, y: node.y }, + moved: false, + }; + const onMove = (ev: PointerEvent) => { + const st = dragStateRef.current; + if (!st || !st.active) return; + const { zoom } = useMoodboardStore.getState().viewport; + const dx = (ev.clientX - st.startClient.x) / zoom; + const dy = (ev.clientY - st.startClient.y) / zoom; + if (!st.moved && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) { + st.moved = true; + pushHistory(); + } + if (st.moved) { + updateNode(node.id, { + x: st.startNode.x + dx, + y: st.startNode.y + dy, + }); + } + }; + const onUp = () => { + dragStateRef.current = null; + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + const innerW = Math.max(node.width - node.padding * 2, 1); + + return ( +
+ {children.map((child) => ( + + ))} +
+ ); +}; + +interface SlotProps { + child: MoodboardNode; + innerWidth: number; +} + +// Each card slot renders a single child at full card-inner-width. We don't +// reuse ImageNodeHtml/VideoNodeHtml here because those position via absolute +// x/y — inside a card we want flex-driven layout instead. +export const CardChildSlot = ({ child, innerWidth }: SlotProps) => { + if (child.kind === "image") { + const ratio = + child.naturalH > 0 && child.naturalW > 0 + ? child.naturalH / child.naturalW + : 1; + return ( + + ); + } + if (child.kind === "video") { + const ratio = + child.naturalH > 0 && child.naturalW > 0 + ? child.naturalH / child.naturalW + : 1; + return ( +