mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-02 08:28:18 +00:00
pagescene: add view-only mode + anon signup CTA
- view-only mode (driven by ownerToken !== currentUserToken): gates engine mutators (add/delete/toggle/transform/color), gizmo attach, delete/undo/redo keybinds, drag-to-add; hides editor chrome (OnboardingHelper, Controls3D, ControlsTopButtons, PromptBox3D, ControlPanelSceneObject, Outliner row toggles, model/cost buttons). - new optional adapter.promptSignup; webapp adapter wires to existing useSignupCta modal. Save / Save-as-copy / Generate / Upload buttons call it when currentUserToken is undefined, so anon visitors hit a signup modal instead of silent failures. - PromptBox3D gains onBeforeSubmit?: () => boolean hook; Stage3DBody uses it to route anon Generate clicks through promptSignup, since the Generate flow bypasses adapter.enqueueGeneration today.
This commit is contained in:
1 parent
fcb0ddec56
commit
e120d009ae
21 files changed
+184
-66
No files matched your search
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Stage3D, usePageSceneStore } from "@storyteller/ui-pagescene";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { useSidebar } from "../../components/ui/sidebar";
|
||||
import { useSignupCta } from "../../components/signup-cta-modal";
|
||||
import { useWebAppPageSceneAdapter } from "./web-adapter";
|
||||
|
||||
// Stage3D fills its parent box; this wrapper is that box. It clamps
|
||||
@@ -27,6 +28,7 @@ export default function PageScene() {
|
||||
const { user } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const { setOpen, isMobile } = useSidebar();
|
||||
const { openSignupCta } = useSignupCta();
|
||||
|
||||
const didAutoCollapseRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -78,6 +80,7 @@ export default function PageScene() {
|
||||
initialSceneToken: sceneToken,
|
||||
navigateToImageTo3D,
|
||||
getViewportSize,
|
||||
promptSignup: openSignupCta,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -102,16 +102,27 @@ export interface WebAppPageSceneAdapterOptions {
|
||||
// Wrapper size — kept in a ref so the closure sees live values without
|
||||
// rebuilding the adapter on every resize.
|
||||
getViewportSize: () => { width: number; height: number };
|
||||
// Open the host's signup CTA modal. Called from inside the lib when an
|
||||
// anonymous visitor clicks Save / Generate / Upload / My Library.
|
||||
promptSignup: (reason?: string) => void;
|
||||
}
|
||||
|
||||
export const useWebAppPageSceneAdapter = (
|
||||
options: WebAppPageSceneAdapterOptions,
|
||||
): PageSceneAdapter => {
|
||||
const { userToken, initialSceneToken, navigateToImageTo3D, getViewportSize } = options;
|
||||
const { userToken, initialSceneToken, navigateToImageTo3D, getViewportSize, promptSignup } = options;
|
||||
|
||||
return useMemo<PageSceneAdapter>(
|
||||
() => ({
|
||||
enqueueGeneration: async () => ({ status: "fail" }),
|
||||
enqueueGeneration: async () => {
|
||||
if (!userToken) {
|
||||
promptSignup("generate");
|
||||
return { status: "fail" };
|
||||
}
|
||||
// Generation through the webapp adapter isn't wired up yet —
|
||||
// signed-in users still hit this path during the editor port.
|
||||
return { status: "fail" };
|
||||
},
|
||||
|
||||
saveScene: ({ saveJson, sceneTitle, sceneToken, sceneThumbnail }) =>
|
||||
saveSceneViaApi(saveJson, sceneTitle, sceneToken, sceneThumbnail),
|
||||
@@ -124,8 +135,17 @@ export const useWebAppPageSceneAdapter = (
|
||||
getApiSchemeAndHost: apiHost,
|
||||
getCurrentUserToken: () => userToken,
|
||||
|
||||
getCdnUrl: (bucketPath, _width, _quality) =>
|
||||
`${GetCdnOrigin()}${bucketPath}`,
|
||||
getCdnUrl: (bucketPath, width, quality) => {
|
||||
const base = GetCdnOrigin();
|
||||
const path = bucketPath?.startsWith("/")
|
||||
? bucketPath
|
||||
: `/${bucketPath ?? ""}`;
|
||||
const params: string[] = [];
|
||||
if (width) params.push(`width=${width}`);
|
||||
if (quality) params.push(`quality=${quality}`);
|
||||
if (params.length === 0) return `${base}${path}`;
|
||||
return `${base}/cdn-cgi/image/${params.join(",")}${path}`;
|
||||
},
|
||||
|
||||
// The wrapper around <Stage3D> creates a fixed-positioning containing
|
||||
// block sized via the webapp's SidebarInset flex layout. Return its
|
||||
@@ -247,6 +267,8 @@ export const useWebAppPageSceneAdapter = (
|
||||
|
||||
navigateToImageTo3D,
|
||||
|
||||
promptSignup,
|
||||
|
||||
performLogout: async () => {
|
||||
try {
|
||||
await new UsersApi().Logout();
|
||||
@@ -259,6 +281,6 @@ export const useWebAppPageSceneAdapter = (
|
||||
|
||||
initialSceneToken,
|
||||
}),
|
||||
[userToken, initialSceneToken, navigateToImageTo3D, getViewportSize],
|
||||
[userToken, initialSceneToken, navigateToImageTo3D, getViewportSize, promptSignup],
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,7 @@ export type {
|
||||
} from "./lib/adapter";
|
||||
|
||||
// Store
|
||||
export { usePageSceneStore } from "./lib/PageSceneStore";
|
||||
export { usePageSceneStore, useIsViewOnly, getIsViewOnly } from "./lib/PageSceneStore";
|
||||
export type {
|
||||
Camera,
|
||||
FocalLengthDragging,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { MediaItem } from "../models";
|
||||
import { usePageSceneStore } from "../PageSceneStore";
|
||||
import { usePageSceneStore, getIsViewOnly } from "../PageSceneStore";
|
||||
import { AssetType } from "../enums";
|
||||
import type Editor from "../engine/editor";
|
||||
import { pickDropPosition } from "../engine/pickDropPosition";
|
||||
@@ -37,6 +37,7 @@ class DndAsset {
|
||||
item: MediaItem,
|
||||
editor: Editor | null,
|
||||
) {
|
||||
if (getIsViewOnly()) return;
|
||||
if (event.button === 0) {
|
||||
this.editor = editor;
|
||||
const store = usePageSceneStore.getState();
|
||||
|
||||
@@ -490,3 +490,16 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
|
||||
setEditorCanvasEl: (el) => set({ editorCanvasEl: el }),
|
||||
setCamViewCanvasEl: (el) => set({ camViewCanvasEl: el }),
|
||||
}));
|
||||
|
||||
// View-only mode: visitor is looking at someone else's scene (owner is
|
||||
// known and isn't them). A blank scene has no owner so this returns
|
||||
// false; the scene owner viewing their own scene returns false too.
|
||||
const computeIsViewOnly = (s: PageSceneState): boolean =>
|
||||
s.sceneMeta.ownerToken !== undefined &&
|
||||
s.sceneMeta.ownerToken !== s.currentUserToken;
|
||||
|
||||
export const useIsViewOnly = (): boolean =>
|
||||
usePageSceneStore(computeIsViewOnly);
|
||||
|
||||
export const getIsViewOnly = (): boolean =>
|
||||
computeIsViewOnly(usePageSceneStore.getState());
|
||||
@@ -52,7 +52,7 @@ import { useViewportSize } from "./hooks/useViewportSize";
|
||||
import { GridVisibleChangedEvent } from "./engine/events/EngineEvent";
|
||||
import { pickDropPosition } from "./engine/pickDropPosition";
|
||||
import { AssetType, CameraAspectRatio } from "./enums";
|
||||
import { usePageSceneStore } from "./PageSceneStore";
|
||||
import { usePageSceneStore, useIsViewOnly } from "./PageSceneStore";
|
||||
import type { MediaItem } from "./models/assets";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.Stage3D;
|
||||
@@ -74,6 +74,7 @@ export const Stage3DBody = () => {
|
||||
(s) => s.setIsPromptBoxFocused,
|
||||
);
|
||||
const gridVisible = usePageSceneStore((s) => s.gridVisible);
|
||||
const isViewOnly = useIsViewOnly();
|
||||
const addCamera = usePageSceneStore((s) => s.addCamera);
|
||||
const updateCamera = usePageSceneStore((s) => s.updateCamera);
|
||||
const deleteCamera = usePageSceneStore((s) => s.deleteCamera);
|
||||
@@ -320,7 +321,7 @@ export const Stage3DBody = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<OnboardingHelper />
|
||||
{!isViewOnly && <OnboardingHelper />}
|
||||
|
||||
<div className="relative flex h-full w-full">
|
||||
<div
|
||||
@@ -333,17 +334,19 @@ export const Stage3DBody = () => {
|
||||
</SceneContainer>
|
||||
|
||||
<FocalLengthDisplay />
|
||||
<PoseModeSelector />
|
||||
{!isViewOnly && <PoseModeSelector />}
|
||||
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ControlsTopButtons />
|
||||
<Controls3D />
|
||||
{!isViewOnly && (
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ControlsTopButtons />
|
||||
<Controls3D />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-0"
|
||||
@@ -358,10 +361,13 @@ export const Stage3DBody = () => {
|
||||
<PreviewEngineCamera />
|
||||
</div>
|
||||
|
||||
<ControlPanelSceneObject />
|
||||
{!isViewOnly && <ControlPanelSceneObject />}
|
||||
</div>
|
||||
|
||||
<PromptBox3D
|
||||
{/* Future: <PreviewBox /> slot when isViewOnly — shows the
|
||||
author's generated still next to the camera. */}
|
||||
|
||||
{!isViewOnly && <PromptBox3D
|
||||
cameras={cameras}
|
||||
cameraAspectRatio={camAspect}
|
||||
disableHotkeyInput={disableHotkeyInput}
|
||||
@@ -395,7 +401,16 @@ export const Stage3DBody = () => {
|
||||
editor.positive_prompt = prompt;
|
||||
}}
|
||||
snapshotCurrentFrame={editor?.snapShotOfCurrentFrame.bind(editor)}
|
||||
/>
|
||||
onBeforeSubmit={() => {
|
||||
const currentUserToken =
|
||||
usePageSceneStore.getState().currentUserToken;
|
||||
if (!currentUserToken && editor?.adapter.promptSignup) {
|
||||
editor.adapter.promptSignup("generate");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
/>}
|
||||
|
||||
<LoadingDots
|
||||
className="absolute left-0 top-0 z-50"
|
||||
@@ -404,19 +419,21 @@ export const Stage3DBody = () => {
|
||||
message={editorLoader.message}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
|
||||
<ClassyModelSelector
|
||||
items={STAGE_3D_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
showIconsInList
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
{!isViewOnly && (
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
|
||||
<ClassyModelSelector
|
||||
items={STAGE_3D_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
showIconsInList
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={PAGE_ID} />
|
||||
{!isViewOnly && <CostCalculatorButton modelPage={PAGE_ID} />}
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,14 @@ import type Editor from "../engine/editor";
|
||||
import { MediaItem } from "../models";
|
||||
import { CreateAction } from "../engine/editor/actions/CreateAction";
|
||||
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
|
||||
import { getIsViewOnly } from "../PageSceneStore";
|
||||
|
||||
export async function addCharacter(
|
||||
editor: Editor,
|
||||
item: MediaItem,
|
||||
position?: THREE.Vector3,
|
||||
): Promise<string | undefined> {
|
||||
if (getIsViewOnly()) return undefined;
|
||||
const obj = await editor.sceneManager?.create(
|
||||
item.media_id,
|
||||
item.name ?? "character",
|
||||
|
||||
@@ -3,12 +3,14 @@ import type Editor from "../engine/editor";
|
||||
import { MediaItem } from "../models";
|
||||
import { CreateAction } from "../engine/editor/actions/CreateAction";
|
||||
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
|
||||
import { getIsViewOnly } from "../PageSceneStore";
|
||||
|
||||
export async function addObject(
|
||||
editor: Editor,
|
||||
item: MediaItem,
|
||||
position?: THREE.Vector3,
|
||||
): Promise<string | undefined> {
|
||||
if (getIsViewOnly()) return undefined;
|
||||
const obj = await editor.sceneManager?.create(
|
||||
item.media_id,
|
||||
item.name ?? "object",
|
||||
|
||||
@@ -3,12 +3,14 @@ import type Editor from "../engine/editor";
|
||||
import { MediaItem } from "../models";
|
||||
import { CreateAction } from "../engine/editor/actions/CreateAction";
|
||||
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
|
||||
import { getIsViewOnly } from "../PageSceneStore";
|
||||
|
||||
export async function addShape(
|
||||
editor: Editor,
|
||||
item: MediaItem,
|
||||
position?: THREE.Vector3,
|
||||
): Promise<string | undefined> {
|
||||
if (getIsViewOnly()) return undefined;
|
||||
const obj = await editor.sceneManager?.create(
|
||||
"Parim",
|
||||
item.media_id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { ColorAction } from "../engine/editor/actions/ColorAction";
|
||||
import { getIsViewOnly } from "../PageSceneStore";
|
||||
|
||||
// One-shot apply: use when the change is atomic (e.g. a button click
|
||||
// or programmatic setter). Records a single ColorAction.
|
||||
@@ -14,6 +15,7 @@ export function setObjectColor(
|
||||
uuid: string,
|
||||
color: string,
|
||||
): void {
|
||||
if (getIsViewOnly()) return;
|
||||
const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid);
|
||||
if (!obj) return;
|
||||
const before = (obj.userData.color as string) ?? "#ffffff";
|
||||
@@ -36,6 +38,9 @@ export function beginColorSession(
|
||||
editor: Editor,
|
||||
uuid: string,
|
||||
): ColorSession {
|
||||
if (getIsViewOnly()) {
|
||||
return { apply() {}, commit() {} };
|
||||
}
|
||||
const startObj = editor.activeScene.scene.getObjectByProperty(
|
||||
"uuid",
|
||||
uuid,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { LockAction } from "../engine/editor/actions/LockAction";
|
||||
import { OutlinerItemLockToggledEvent } from "../engine/events/EngineEvent";
|
||||
import { getIsViewOnly } from "../PageSceneStore";
|
||||
|
||||
// Toggle the locked state of an object: flips userData.locked, runs
|
||||
// the gizmo attach/detach side effect, syncs the outliner row icon in
|
||||
@@ -9,6 +10,7 @@ import { OutlinerItemLockToggledEvent } from "../engine/events/EngineEvent";
|
||||
// View callers (Outliner) only need to provide editor + uuid; the
|
||||
// action handles the engine + store + history orchestration.
|
||||
export function toggleObjectLock(editor: Editor, uuid: string): void {
|
||||
if (getIsViewOnly()) return;
|
||||
const before = editor.selection.isObjectLocked(uuid);
|
||||
editor.bus.emit(new OutlinerItemLockToggledEvent(uuid));
|
||||
editor.selection.lockUnlockObject(uuid);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { VisibilityAction } from "../engine/editor/actions/VisibilityAction";
|
||||
import { OutlinerItemVisibilityToggledEvent } from "../engine/events/EngineEvent";
|
||||
import { getIsViewOnly } from "../PageSceneStore";
|
||||
|
||||
// Toggle the visibility of an object: flips obj.visible +
|
||||
// userData.visible, syncs the outliner row icon in the Zustand store,
|
||||
@@ -10,6 +11,7 @@ import { OutlinerItemVisibilityToggledEvent } from "../engine/events/EngineEvent
|
||||
// helper reads the before-state from the live engine object so the
|
||||
// view doesn't have to plumb it.
|
||||
export function toggleObjectVisibility(editor: Editor, uuid: string): void {
|
||||
if (getIsViewOnly()) return;
|
||||
const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid);
|
||||
if (!obj) return;
|
||||
const before = obj.visible;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { TransformAction } from "../engine/editor/actions/TransformAction";
|
||||
import { getIsViewOnly } from "../PageSceneStore";
|
||||
|
||||
// A transform "session" — opened on the first edit of a multi-input
|
||||
// editing flow (e.g. Object Panel position/rotation/scale text fields),
|
||||
@@ -24,6 +25,9 @@ export function beginTransformSession(
|
||||
editor: Editor,
|
||||
uuid: string,
|
||||
): TransformSession {
|
||||
if (getIsViewOnly()) {
|
||||
return { commit() {} };
|
||||
}
|
||||
const action = new TransformAction(editor, uuid);
|
||||
return {
|
||||
commit() {
|
||||
|
||||
@@ -203,6 +203,12 @@ export interface PageSceneAdapter {
|
||||
// callback. Tauri host: setLogoutStates. Web host: web auth flow.
|
||||
performLogout(): void;
|
||||
|
||||
// Open the host's signup/login modal. Called when an anonymous
|
||||
// visitor clicks a feature that requires an account (Save, Generate,
|
||||
// Upload, "My Library"). Tauri host leaves it undefined — its user
|
||||
// is always signed in. Webapp host wires this to its auth modal.
|
||||
promptSignup?(reason?: string): void;
|
||||
|
||||
// Optional event hooks — telemetry, host-side modals, tab title sync.
|
||||
onSelectionChange?(
|
||||
sel: { uuid: string; assetType: string } | null,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const Controls3D = () => {
|
||||
setAssetModalVisibleDuringDrag,
|
||||
selectedMode,
|
||||
transformSpace,
|
||||
currentUserToken,
|
||||
} = usePageSceneStore(
|
||||
useShallow((s) => ({
|
||||
assetModalVisible: s.assetModalVisible,
|
||||
@@ -47,6 +48,7 @@ export const Controls3D = () => {
|
||||
setAssetModalVisibleDuringDrag: s.setAssetModalVisibleDuringDrag,
|
||||
selectedMode: s.selectedMode,
|
||||
transformSpace: s.transformSpace,
|
||||
currentUserToken: s.currentUserToken,
|
||||
})),
|
||||
);
|
||||
const [showEmptySceneTooltip, setShowEmptySceneTooltip] = useState(false);
|
||||
@@ -102,6 +104,15 @@ export const Controls3D = () => {
|
||||
};
|
||||
|
||||
const handleAddAssetAction = (action: string) => {
|
||||
const requiresAuth =
|
||||
action === "library" ||
|
||||
action === "upload-3d" ||
|
||||
action === "upload-image" ||
|
||||
action === "upload-splat";
|
||||
if (requiresAuth && !currentUserToken && editor?.adapter.promptSignup) {
|
||||
editor.adapter.promptSignup(action);
|
||||
return;
|
||||
}
|
||||
switch (action) {
|
||||
case "presets":
|
||||
handleOpenModal();
|
||||
|
||||
+8
@@ -59,6 +59,10 @@ export const ControlsTopButtons = () => {
|
||||
// No-op without an editor; the toast is informational only.
|
||||
return;
|
||||
}
|
||||
if (!currentUserToken && editor.adapter.promptSignup) {
|
||||
editor.adapter.promptSignup("save");
|
||||
return;
|
||||
}
|
||||
const sceneGenerationMetadata = getSceneGenerationMetaData(editor);
|
||||
|
||||
const retSceneMediaToken = await editor.saveScene({
|
||||
@@ -89,6 +93,10 @@ export const ControlsTopButtons = () => {
|
||||
|
||||
const handleButtonSaveAsCopy = useCallback(async () => {
|
||||
if (!editor) return;
|
||||
if (!currentUserToken && editor.adapter.promptSignup) {
|
||||
editor.adapter.promptSignup("save");
|
||||
return;
|
||||
}
|
||||
const sceneGenerationMetadata = getSceneGenerationMetaData(editor);
|
||||
const retSceneMediaToken = await editor.saveScene({
|
||||
sceneTitle: sceneTitleInput,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Transition } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { EngineContext } from "../../contexts/EngineContext";
|
||||
import { OutlinerItem, usePageSceneStore } from "../../PageSceneStore";
|
||||
import { OutlinerItem, usePageSceneStore, useIsViewOnly } from "../../PageSceneStore";
|
||||
import { useViewportSize } from "../../hooks/useViewportSize";
|
||||
import { CameraAspectRatio } from "../../enums";
|
||||
import { toggleObjectLock } from "../../actions/toggleObjectLock";
|
||||
@@ -26,6 +26,7 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
|
||||
const isSelected = usePageSceneStore(
|
||||
(s) => s.outlinerSelectedItem?.id === item.id,
|
||||
);
|
||||
const isViewOnly = useIsViewOnly();
|
||||
|
||||
const editorEngine = useContext(EngineContext);
|
||||
|
||||
@@ -79,37 +80,39 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
|
||||
</div>
|
||||
{item.name}
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleToggleLock}
|
||||
style={{
|
||||
opacity: hovered || item.locked ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="w-3">
|
||||
<FontAwesomeIcon
|
||||
icon={item.locked ? faLock : faLockOpen}
|
||||
className="opacity-80 transition-opacity duration-100 hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleVisibility}
|
||||
style={{
|
||||
opacity: hovered || !item.visible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="w-4">
|
||||
<FontAwesomeIcon
|
||||
icon={item.visible ? faEye : faEyeSlash}
|
||||
className={twMerge(
|
||||
"opacity-80 transition-opacity duration-100 hover:opacity-100",
|
||||
item.locked && "text-white/90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{!isViewOnly && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleToggleLock}
|
||||
style={{
|
||||
opacity: hovered || item.locked ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="w-3">
|
||||
<FontAwesomeIcon
|
||||
icon={item.locked ? faLock : faLockOpen}
|
||||
className="opacity-80 transition-opacity duration-100 hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleVisibility}
|
||||
style={{
|
||||
opacity: hovered || !item.visible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="w-4">
|
||||
<FontAwesomeIcon
|
||||
icon={item.visible ? faEye : faEyeSlash}
|
||||
className={twMerge(
|
||||
"opacity-80 transition-opacity duration-100 hover:opacity-100",
|
||||
item.locked && "text-white/90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import { TransformAction } from "./editor/actions/TransformAction";
|
||||
|
||||
import Stats from "three/examples/jsm/libs/stats.module.js";
|
||||
import { SparkRenderer } from "@sparkjsdev/spark";
|
||||
import { usePageSceneStore } from "../PageSceneStore";
|
||||
import { usePageSceneStore, getIsViewOnly } from "../PageSceneStore";
|
||||
import { ensureAmmoLoaded } from "./ammoLoader";
|
||||
import { EngineEventBus } from "./events/EngineEventBus";
|
||||
import { EngineStoreBridge } from "./EngineStoreBridge";
|
||||
@@ -780,6 +780,7 @@ class Editor {
|
||||
}
|
||||
|
||||
deleteObject(uuid: string) {
|
||||
if (getIsViewOnly()) return;
|
||||
const obj = this.activeScene.scene.getObjectByProperty("uuid", uuid);
|
||||
if (obj) this.history.record(new DeleteAction(this, obj));
|
||||
this.mouse_controls?.clearFKVisuals();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type * as THREE from "three";
|
||||
import { TransformControls } from "../TransformControls.js";
|
||||
import { getIsViewOnly } from "../../PageSceneStore";
|
||||
|
||||
export type TransformMode = "translate" | "rotate" | "scale";
|
||||
export type TransformSpace = "world" | "local";
|
||||
@@ -90,6 +91,7 @@ export class GizmoController {
|
||||
}
|
||||
|
||||
attach(object: THREE.Object3D) {
|
||||
if (getIsViewOnly()) return;
|
||||
this.control?.attach(object);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
TransformSpaceChangedEvent,
|
||||
} from "./events/EngineEvent";
|
||||
import type { PoseMode, TransformSpace } from "../PageSceneStore";
|
||||
import { getIsViewOnly } from "../PageSceneStore";
|
||||
|
||||
const EDITABLE_INPUT_TYPES = new Set([
|
||||
"text",
|
||||
@@ -344,6 +345,7 @@ export class MouseControls {
|
||||
this.focus();
|
||||
return;
|
||||
} else if (event.key === "Backspace" || event.key === "Delete") {
|
||||
if (getIsViewOnly()) return;
|
||||
if (this.selected) {
|
||||
this.selected.forEach((selected) => {
|
||||
this.deleteObject(selected.uuid);
|
||||
@@ -384,15 +386,18 @@ export class MouseControls {
|
||||
this.deps.bus.emit(new SelectedModeChangedEvent("scale"));
|
||||
return;
|
||||
} else if (event.key === "k") {
|
||||
if (getIsViewOnly()) return;
|
||||
this.toggleFKMode();
|
||||
return;
|
||||
} else if (event.key === "b") {
|
||||
if (getIsViewOnly()) return;
|
||||
// Open asset modal
|
||||
this.deps.bus.emit(new AssetModalVisibilityChangedEvent(true, true));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !this.isProcessing) {
|
||||
if (getIsViewOnly()) return;
|
||||
const keyLower = event.key.toLowerCase();
|
||||
if (keyLower === "z" && !event.shiftKey) {
|
||||
// undo
|
||||
|
||||
@@ -74,6 +74,9 @@ interface PromptBox3DProps {
|
||||
} | null)
|
||||
| undefined;
|
||||
credits?: number | null;
|
||||
// Optional pre-submit gate. Returns false to abort the generation
|
||||
// (e.g. host wants to open a signup modal for anon visitors).
|
||||
onBeforeSubmit?: () => boolean;
|
||||
}
|
||||
|
||||
export const PromptBox3D = ({
|
||||
@@ -100,6 +103,7 @@ export const PromptBox3D = ({
|
||||
selectedProvider,
|
||||
snapshotCurrentFrame,
|
||||
credits,
|
||||
onBeforeSubmit,
|
||||
}: PromptBox3DProps) => {
|
||||
//const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -275,6 +279,9 @@ export const PromptBox3D = ({
|
||||
const maxLen = selectedImageModel?.maxPromptLength ?? 1000;
|
||||
|
||||
const handleEnqueue = async () => {
|
||||
if (onBeforeSubmit && !onBeforeSubmit()) {
|
||||
return;
|
||||
}
|
||||
if (isFinite(maxLen) && prompt.length > maxLen) {
|
||||
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user