pagescene: drop view-only mutation gates + rename selector

Visitors can now fully interact with someone else's scene — move objects,
add shapes, edit colors, etc. The previous strict view-only mode is gone.
Save remains the only auth/ownership-gated action; its rewiring lands in
a follow-up.

Renames computeIsViewOnly to computeIsVisitingOthersScene to reflect the
remaining role (it only drives the PreviewBox today; Save-copy label and
Reset button consume it next). Drops getIsViewOnly()/useIsViewOnly()
early-returns from engine actions, drag-drop, gizmo attach, keybinds,
and the Outliner row controls. Strips !isViewOnly wrappers from chrome
in Stage3DBody so the prompt box, controls, panel, and model selector
show for everyone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tanish Manku
2026-05-13 15:09:05 -07:00
parent 5465aac4bc
commit 55e470d2ca
17 changed files with 85 additions and 109 deletions
@@ -15,7 +15,11 @@ export type {
} from "./lib/adapter";
// Store
export { usePageSceneStore, useIsViewOnly, getIsViewOnly } from "./lib/PageSceneStore";
export {
usePageSceneStore,
useIsVisitingOthersScene,
getIsVisitingOthersScene,
} from "./lib/PageSceneStore";
export type {
Camera,
FocalLengthDragging,
@@ -1,6 +1,6 @@
import React from "react";
import { MediaItem } from "../models";
import { usePageSceneStore, getIsViewOnly } from "../PageSceneStore";
import { usePageSceneStore } from "../PageSceneStore";
import { AssetType } from "../enums";
import type Editor from "../engine/editor";
import { pickDropPosition } from "../engine/pickDropPosition";
@@ -37,7 +37,6 @@ class DndAsset {
item: MediaItem,
editor: Editor | null,
) {
if (getIsViewOnly()) return;
if (event.button === 0) {
this.editor = editor;
const store = usePageSceneStore.getState();
@@ -495,15 +495,21 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
setCamViewCanvasEl: (el) => set({ camViewCanvasEl: el }),
}));
// View-only mode: visitor is looking at someone else's scene (owner is
// True when the 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 =>
//
// This no longer gates mutations — visitors can fully interact with
// the scene. It only drives ownership-aware UI: the primary Save
// button switches to "Save copy" (forks the scene to the visitor's
// account on confirm) and the PreviewBox shows the author's
// generation result.
const computeIsVisitingOthersScene = (s: PageSceneState): boolean =>
s.sceneMeta.ownerToken !== undefined &&
s.sceneMeta.ownerToken !== s.currentUserToken;
export const useIsViewOnly = (): boolean =>
usePageSceneStore(computeIsViewOnly);
export const useIsVisitingOthersScene = (): boolean =>
usePageSceneStore(computeIsVisitingOthersScene);
export const getIsViewOnly = (): boolean =>
computeIsViewOnly(usePageSceneStore.getState());
export const getIsVisitingOthersScene = (): boolean =>
computeIsVisitingOthersScene(usePageSceneStore.getState());
@@ -64,7 +64,7 @@ import { useViewportSize } from "./hooks/useViewportSize";
import { GridVisibleChangedEvent } from "./engine/events/EngineEvent";
import { pickDropPosition } from "./engine/pickDropPosition";
import { AssetType, CameraAspectRatio } from "./enums";
import { usePageSceneStore, useIsViewOnly } from "./PageSceneStore";
import { usePageSceneStore, useIsVisitingOthersScene } from "./PageSceneStore";
import type { MediaItem } from "./models/assets";
const PAGE_ID: ModelPage = ModelPage.Stage3D;
@@ -107,7 +107,7 @@ export const Stage3DBody = ({
);
const gridVisible = usePageSceneStore((s) => s.gridVisible);
const previewImageUrl = usePageSceneStore((s) => s.sceneMeta.previewImageUrl);
const isViewOnly = useIsViewOnly();
const isVisitingOthersScene = useIsVisitingOthersScene();
const addCamera = usePageSceneStore((s) => s.addCamera);
const updateCamera = usePageSceneStore((s) => s.updateCamera);
const deleteCamera = usePageSceneStore((s) => s.deleteCamera);
@@ -407,7 +407,7 @@ export const Stage3DBody = ({
return (
<div className="h-full w-full">
{!isViewOnly && <OnboardingHelper />}
<OnboardingHelper />
<div className="relative flex h-full w-full">
<div id="engine-n-panels-wrapper" className="flex h-full w-full">
@@ -417,22 +417,20 @@ export const Stage3DBody = ({
</SceneContainer>
<FocalLengthDisplay />
{!isViewOnly && <PoseModeSelector />}
<PoseModeSelector />
{!isViewOnly && (
<div
className="absolute left-0 top-0 w-full"
onClick={handleOverlayClick}
>
<div className="grid grid-cols-3 gap-4">
<ControlsTopButtons />
<Controls3D showImageTo3DButton={showImageTo3DButton} />
<div className="flex items-start justify-end gap-2 pr-2 pt-2">
<AnonHintChip />
</div>
<div
className="absolute left-0 top-0 w-full"
onClick={handleOverlayClick}
>
<div className="grid grid-cols-3 gap-4">
<ControlsTopButtons />
<Controls3D showImageTo3DButton={showImageTo3DButton} />
<div className="flex items-start justify-end gap-2 pr-2 pt-2">
<AnonHintChip />
</div>
</div>
)}
</div>
<div
className="absolute bottom-0 left-0 right-0"
@@ -446,12 +444,12 @@ export const Stage3DBody = ({
<PreviewEngineCamera />
</div>
{!isViewOnly && <ControlPanelSceneObject />}
<ControlPanelSceneObject />
</div>
{isViewOnly && <PreviewBox imageUrl={previewImageUrl} />}
{isVisitingOthersScene && <PreviewBox imageUrl={previewImageUrl} />}
{!isViewOnly && <PromptBox3D
<PromptBox3D
cameras={cameras}
cameraAspectRatio={camAspect}
disableHotkeyInput={disableHotkeyInput}
@@ -495,7 +493,7 @@ export const Stage3DBody = ({
}
return true;
}}
/>}
/>
<LoadingDots
className="absolute left-0 top-0 z-50"
@@ -504,7 +502,7 @@ export const Stage3DBody = ({
message={editorLoader.message}
/>
{!isViewOnly && modelSelectorPlacement === "bottom-left" && (
{modelSelectorPlacement === "bottom-left" && (
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
<ClassyModelSelector
items={STAGE_3D_PAGE_MODEL_LIST}
@@ -519,7 +517,7 @@ export const Stage3DBody = ({
)}
{(showCostCalculator || showHelpMenu) && (
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
{showCostCalculator && !isViewOnly && (
{showCostCalculator && (
<CostCalculatorButton modelPage={PAGE_ID} />
)}
{showHelpMenu && <HelpMenuButton />}
@@ -3,14 +3,12 @@ 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,14 +3,12 @@ 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,14 +3,12 @@ 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,6 +1,5 @@
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.
@@ -15,7 +14,6 @@ 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";
@@ -38,9 +36,6 @@ export function beginColorSession(
editor: Editor,
uuid: string,
): ColorSession {
if (getIsViewOnly()) {
return { apply() {}, commit() {} };
}
const startObj = editor.activeScene.scene.getObjectByProperty(
"uuid",
uuid,
@@ -1,7 +1,6 @@
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
@@ -10,7 +9,6 @@ import { getIsViewOnly } from "../PageSceneStore";
// 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,7 +1,6 @@
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,
@@ -11,7 +10,6 @@ import { getIsViewOnly } from "../PageSceneStore";
// 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,6 +1,5 @@
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),
@@ -25,9 +24,6 @@ export function beginTransformSession(
editor: Editor,
uuid: string,
): TransformSession {
if (getIsViewOnly()) {
return { commit() {} };
}
const action = new TransformAction(editor, uuid);
return {
commit() {
@@ -2,19 +2,17 @@ import { useContext } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRightToBracket } from "@fortawesome/pro-solid-svg-icons";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import { usePageSceneStore, useIsViewOnly } from "../../PageSceneStore";
import { usePageSceneStore } from "../../PageSceneStore";
// Quiet "you're signed out" affordance shown in the editor playground for
// anonymous visitors. Hidden in view-only mode (the visitor is consuming
// someone else's scene; no upsell pressure needed). The actual per-action
// signup CTAs (Save, Generate, Upload) still fire when the user clicks
// those buttons — this chip just sets expectations up front.
// Quiet "you're signed out" affordance shown in the editor for anonymous
// visitors — playground or someone else's scene alike. The actual
// per-action signup CTAs (Save, Generate, Upload) still fire when the
// user clicks those buttons; this chip just sets expectations up front.
export const AnonHintChip = () => {
const editor = useContext(EngineContext);
const currentUserToken = usePageSceneStore((s) => s.currentUserToken);
const isViewOnly = useIsViewOnly();
if (currentUserToken || isViewOnly) return null;
if (currentUserToken) return null;
const handleClick = () => {
editor?.adapter.promptSignup?.("hint");
@@ -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, useIsViewOnly } from "../../PageSceneStore";
import { OutlinerItem, usePageSceneStore } from "../../PageSceneStore";
import { useViewportSize } from "../../hooks/useViewportSize";
import { CameraAspectRatio } from "../../enums";
import { toggleObjectLock } from "../../actions/toggleObjectLock";
@@ -26,7 +26,6 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
const isSelected = usePageSceneStore(
(s) => s.outlinerSelectedItem?.id === item.id,
);
const isViewOnly = useIsViewOnly();
const editorEngine = useContext(EngineContext);
@@ -80,39 +79,37 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
</div>
{item.name}
</span>
{!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 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>
);
};
@@ -2,13 +2,14 @@ import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-solid-svg-icons";
// Small fixed-position card shown next to the camera in view-only mode.
// Renders the author's generation result so a visitor can see what the
// scene was made to produce. Today this is scaffolding only — the URL
// data path (likely a new field returned by adapter.loadScene's response
// payload, propagated into sceneMeta.previewImageUrl) is wired up in a
// follow-up PR. Renders null when no URL is supplied, so it's safe to
// mount unconditionally inside the isViewOnly branch.
// Small fixed-position card shown next to the camera when a visitor is
// looking at someone else's scene. Renders the author's generation
// result so a visitor can see what the scene was made to produce. Today
// this is scaffolding only — the URL data path (likely a new field
// returned by adapter.loadScene's response payload, propagated into
// sceneMeta.previewImageUrl) is wired up in a follow-up PR. Renders
// null when no URL is supplied, so it's safe to mount unconditionally
// inside the isVisitingOthersScene branch.
export interface PreviewBoxProps {
imageUrl?: string;
}
@@ -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, getIsViewOnly } from "../PageSceneStore";
import { usePageSceneStore } from "../PageSceneStore";
import { ensureAmmoLoaded } from "./ammoLoader";
import { EngineEventBus } from "./events/EngineEventBus";
import { EngineStoreBridge } from "./EngineStoreBridge";
@@ -787,7 +787,6 @@ 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,7 +9,6 @@
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";
@@ -91,7 +90,6 @@ export class GizmoController {
}
attach(object: THREE.Object3D) {
if (getIsViewOnly()) return;
this.control?.attach(object);
}
@@ -21,7 +21,6 @@ import {
TransformSpaceChangedEvent,
} from "./events/EngineEvent";
import type { PoseMode, TransformSpace } from "../PageSceneStore";
import { getIsViewOnly } from "../PageSceneStore";
const EDITABLE_INPUT_TYPES = new Set([
"text",
@@ -345,7 +344,6 @@ 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);
@@ -386,18 +384,15 @@ 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