UX Improvements

* The cam state of SnapshotEditor cameras now persist
* Title bar for the prop editor
* Cleaned up the toolbar
This commit is contained in:
Aria Minaei 2021-07-30 10:59:59 +02:00
parent d621794280
commit 215cd880f0
13 changed files with 354 additions and 170 deletions

View file

@ -1,5 +1,5 @@
import {editable as e, bindToCanvas} from '@theatre/plugin-r3f'
import {OrbitControls, Stars} from '@react-three/drei'
import {Stars} from '@react-three/drei'
import {getProject} from '@theatre/core'
import React, {Suspense} from 'react'
import {Canvas} from '@react-three/fiber'
@ -86,12 +86,12 @@ function App() {
<Suspense fallback={null}>
<Model url={sceneGLB} />
</Suspense>
<OrbitControls
{/* <OrbitControls
enablePan={false}
enableZoom={true}
maxPolarAngle={Math.PI / 2}
minPolarAngle={Math.PI / 2}
/>
/> */}
<Stars radius={500} depth={50} count={1000} factor={10} />
</Canvas>
</div>

View file

@ -0,0 +1,11 @@
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: `ImportDeclaration[source.value=/@theatre\\u002F(studio|core)\\u002F/]`,
message: `Importing Theatre's submodules would not work in the production build.`,
},
],
},
}

View file

@ -9,7 +9,7 @@ import React, {
import {useEditorStore} from '../store'
import {createPortal} from '@react-three/fiber'
import EditableProxy from './EditableProxy'
import type {OrbitControls} from '@react-three/drei'
import type {OrbitControls} from 'three-stdlib'
import TransformControls from './TransformControls'
import shallow from 'zustand/shallow'
import type {Material, Mesh, Object3D} from 'three'
@ -21,7 +21,7 @@ import {useSelected} from './useSelected'
import {useVal} from '@theatre/dataverse-react'
export interface ProxyManagerProps {
orbitControlsRef: React.MutableRefObject<typeof OrbitControls | undefined>
orbitControlsRef: React.MutableRefObject<OrbitControls | null>
}
type IEditableProxy = {

View file

@ -1,9 +1,7 @@
import {useState} from 'react'
import {useLayoutEffect} from 'react'
import React, {useEffect, useRef} from 'react'
import React from 'react'
import {Canvas} from '@react-three/fiber'
import {useEditorStore} from '../store'
import {OrbitControls} from '@react-three/drei'
import shallow from 'zustand/shallow'
import root from 'react-shadow/styled-components'
import ProxyManager from './ProxyManager'
@ -11,6 +9,9 @@ import studio, {ToolbarIconButton} from '@theatre/studio'
import {useVal} from '@theatre/dataverse-react'
import styled, {createGlobalStyle, StyleSheetManager} from 'styled-components'
import {IoCameraReverseOutline} from 'react-icons/all'
import type {ISheetObject, ISheet} from '@theatre/core'
import type {$FixMe} from '../types'
import useSnapshotEditorCamera from './useSnapshotEditorCamera'
const GlobalStyle = createGlobalStyle`
:host {
@ -31,31 +32,29 @@ const GlobalStyle = createGlobalStyle`
}
`
const EditorScene = () => {
const orbitControlsRef = useRef<typeof OrbitControls>()
const EditorScene: React.FC<{snapshotEditorSheet: ISheet; paneId: string}> = ({
snapshotEditorSheet,
paneId,
}) => {
const [editorCamera, orbitControlsRef] = useSnapshotEditorCamera(
snapshotEditorSheet,
paneId,
)
const [editorObject, helpersRoot, setOrbitControlsRef] = useEditorStore(
(state) => [
state.editorObject,
state.helpersRoot,
state.setOrbitControlsRef,
],
const [editorObject, helpersRoot] = useEditorStore(
(state) => [state.editorObject, state.helpersRoot],
shallow,
)
const showGrid = useVal(editorObject?.props.viewport.showGrid) ?? true
const showAxes = useVal(editorObject?.props.viewport.showAxes) ?? true
useEffect(() => {
setOrbitControlsRef(orbitControlsRef)
}, [setOrbitControlsRef])
return (
<>
{showGrid && <gridHelper args={[20, 20, '#6e6e6e', '#4a4b4b']} />}
{showAxes && <axesHelper args={[500]} />}
{/* @ts-ignore */}
<OrbitControls ref={orbitControlsRef} enableDamping={false} />
{editorCamera}
<primitive object={helpersRoot}></primitive>
<ProxyManager orbitControlsRef={orbitControlsRef} />
<color attach="background" args={[0.24, 0.24, 0.24]} />
@ -96,82 +95,83 @@ const Tools = styled.div`
pointer-events: auto;
`
const SnapshotEditor: React.FC<{}> = () => {
const [editorObject, sceneSnapshot, initialEditorCamera, createSnapshot] =
useEditorStore(
const SnapshotEditor: React.FC<{object: ISheetObject<$FixMe>; paneId: string}> =
(props) => {
const snapshotEditorSheet = props.object.sheet
const paneId = props.paneId
const [editorObject, sceneSnapshot, createSnapshot] = useEditorStore(
(state) => [
state.editorObject,
state.sceneSnapshot,
state.initialEditorCamera,
state.createSnapshot,
],
shallow,
)
const editorOpen = true
useLayoutEffect(() => {
let timeout: NodeJS.Timeout | undefined
if (editorOpen) {
// a hack to make sure all the scene's props are
// applied before we take a snapshot
timeout = setTimeout(createSnapshot, 100)
}
return () => {
if (timeout !== undefined) {
clearTimeout(timeout)
const editorOpen = true
useLayoutEffect(() => {
let timeout: NodeJS.Timeout | undefined
if (editorOpen) {
// a hack to make sure all the scene's props are
// applied before we take a snapshot
timeout = setTimeout(createSnapshot, 100)
}
}
}, [editorOpen])
return () => {
if (timeout !== undefined) {
clearTimeout(timeout)
}
}
}, [editorOpen])
const [overlay, setOverlay] = useState<HTMLDivElement | null>(null)
if (!editorObject) return <></>
if (!editorObject) return <></>
return (
<root.div>
<StyleSheetManager disableVendorPrefixes>
<>
<GlobalStyle />
<Wrapper>
<Overlay>
<Tools>
<ToolbarIconButton
icon={<IoCameraReverseOutline />}
label="Refresh Snapshot"
onClick={createSnapshot}
></ToolbarIconButton>
</Tools>
</Overlay>
return (
<root.div>
<StyleSheetManager disableVendorPrefixes>
<>
<GlobalStyle />
{/* <PortalContext.Provider value={overlay}> */}
<Wrapper>
<Overlay ref={setOverlay}>
<Tools>
<ToolbarIconButton
icon={<IoCameraReverseOutline />}
label="Refresh Snapshot"
onClick={createSnapshot}
></ToolbarIconButton>
</Tools>
</Overlay>
{sceneSnapshot ? (
<>
<CanvasWrapper>
<Canvas
// @ts-ignore
colorManagement
camera={initialEditorCamera}
onCreated={({gl}) => {
gl.setClearColor('white')
}}
shadowMap
dpr={[1, 2]}
fog={'red'}
onPointerMissed={() =>
studio.__experimental_setSelection([])
}
>
<EditorScene />
</Canvas>
</CanvasWrapper>
</>
) : null}
</Wrapper>
{/* </PortalContext.Provider> */}
</>
</StyleSheetManager>
</root.div>
)
}
{sceneSnapshot ? (
<>
<CanvasWrapper>
<Canvas
// @ts-ignore
colorManagement
onCreated={({gl}) => {
gl.setClearColor('white')
}}
shadowMap
dpr={[1, 2]}
fog={'red'}
onPointerMissed={() =>
studio.__experimental_setSelection([])
}
>
<EditorScene
snapshotEditorSheet={snapshotEditorSheet}
paneId={paneId}
/>
</Canvas>
</CanvasWrapper>
</>
) : null}
</Wrapper>
{/* </PortalContext.Provider> */}
</>
</StyleSheetManager>
</root.div>
)
}
export default SnapshotEditor

View file

@ -2,21 +2,13 @@ import type {VFC} from 'react'
import React from 'react'
import {useEditorStore} from '../../store'
import shallow from 'zustand/shallow'
import {GiPocketBow, IoCameraOutline, RiFocus3Line} from 'react-icons/all'
import {Vector3} from 'three'
import type {$FixMe} from '@theatre/shared/utils/types'
import {IoCameraOutline} from 'react-icons/all'
import studio, {ToolbarIconButton} from '@theatre/studio'
import {getSelected} from '../useSelected'
import {useVal} from '@theatre/dataverse-react'
import styled from 'styled-components'
import TransformControlsModeSelect from './TransformControlsModeSelect'
import ViewportShadingSelect from './ViewportShadingSelect'
import TransformControlsSpaceSelect from './TransformControlsSpaceSelect'
const ToolGroup = styled.div`
pointer-events: auto;
`
const Toolbar: VFC = () => {
const [editorObject] = useEditorStore(
(state) => [state.editorObject],
@ -66,7 +58,7 @@ const Toolbar: VFC = () => {
}}
/>
<ToolbarIconButton
{/* <ToolbarIconButton
label="Focus on selected"
icon={<RiFocus3Line />}
onClick={() => {
@ -88,9 +80,9 @@ const Toolbar: VFC = () => {
)
}
}}
/>
/> */}
<ToolbarIconButton
{/* <ToolbarIconButton
label="Align object to view"
icon={<GiPocketBow />}
onClick={() => {
@ -121,7 +113,7 @@ const Toolbar: VFC = () => {
}
}
}}
/>
/> */}
</>
)
}

View file

@ -3,7 +3,7 @@ import React, {forwardRef, useLayoutEffect, useEffect, useMemo} from 'react'
import type {ReactThreeFiber, Overwrite} from '@react-three/fiber'
import {useThree} from '@react-three/fiber'
import {TransformControls as TransformControlsImpl} from 'three/examples/jsm/controls/TransformControls'
import type {OrbitControls} from '@react-three/drei'
import type {OrbitControls} from 'three-stdlib'
type R3fTransformControls = Overwrite<
ReactThreeFiber.Object3DNode<
@ -15,7 +15,7 @@ type R3fTransformControls = Overwrite<
export interface TransformControlsProps extends R3fTransformControls {
object: Object3D
orbitControlsRef?: React.MutableRefObject<typeof OrbitControls | undefined>
orbitControlsRef?: React.MutableRefObject<OrbitControls | null>
onObjectChange?: (event: Event) => void
onDraggingChange?: (event: Event) => void
}

View file

@ -0,0 +1,37 @@
import type {MutableRefObject} from 'react'
import {useMemo, useState} from 'react'
/**
* Combines useRef() and useState().
*
* @example
* ```typescript
* const [ref, val] = useRefAndState<HTMLDivElement | null>(null)
*
* useEffect(() => {
* val.addEventListener(...)
* }, [val])
*
* return <div ref={ref}></div>
* ```
*/
export default function useRefAndState<T>(
initialValue: T,
): [ref: MutableRefObject<T>, state: T] {
const ref = useMemo(() => {
let current = initialValue
return {
get current() {
return current
},
set current(v: T) {
current = v
setState(v)
},
}
}, [])
const [state, setState] = useState<T>(() => initialValue)
return [ref, state]
}

View file

@ -0,0 +1,166 @@
import {OrbitControls, PerspectiveCamera} from '@react-three/drei'
import type {OrbitControls as OrbitControlsImpl} from 'three-stdlib'
import type {MutableRefObject} from 'react'
import {useLayoutEffect, useRef} from 'react'
import React from 'react'
import useRefAndState from './useRefAndState'
import {studio} from '@theatre/studio'
import type {PerspectiveCamera as PerspectiveCameraImpl} from 'three'
import type {ISheet} from '@theatre/core'
import {types} from '@theatre/core'
import type {ISheetObject} from '@theatre/core'
import {useThree} from '@react-three/fiber'
const camConf = types.compound({
transform: types.compound({
position: types.compound({
x: types.number(0),
y: types.number(10),
z: types.number(0),
}),
target: types.compound({
x: types.number(0),
y: types.number(10),
z: types.number(0),
}),
rotation: types.compound({
x: types.number(0),
y: types.number(0),
z: types.number(0),
}),
}),
})
export default function useSnapshotEditorCamera(
snapshotEditorSheet: ISheet,
paneId: string,
): [
node: React.ReactNode,
orbitControlsRef: MutableRefObject<OrbitControlsImpl | null>,
] {
// OrbitControls and Cam might change later on, so we use useRefAndState()
// instead of useRef() to catch those changes.
const [orbitControlsRef, orbitControls] =
useRefAndState<OrbitControlsImpl | null>(null)
const [camRef, cam] = useRefAndState<PerspectiveCameraImpl | undefined>(
undefined,
)
const objRef = useRef<ISheetObject<typeof camConf> | null>(null)
useLayoutEffect(() => {
if (!objRef.current) {
objRef.current = snapshotEditorSheet.object(
`Editor Camera ${paneId}`,
{},
camConf,
)
}
}, [paneId])
usePassValuesFromTheatreToCamera(cam, orbitControls, objRef)
usePassValuesFromOrbitControlsToTheatre(cam, orbitControls, objRef)
const node = (
<>
<PerspectiveCamera makeDefault ref={camRef} position={[0, 10, 0]} />
<OrbitControls
makeDefault
ref={orbitControlsRef}
camera={cam}
enableDamping={false}
/>
</>
)
return [node, orbitControlsRef]
}
function usePassValuesFromOrbitControlsToTheatre(
cam: PerspectiveCameraImpl | undefined,
orbitControls: OrbitControlsImpl | null,
objRef: MutableRefObject<ISheetObject<typeof camConf> | null>,
) {
useLayoutEffect(() => {
if (!cam || orbitControls == null) return
let currentScrub: undefined | ReturnType<typeof studio['debouncedScrub']>
let started = false
const onStart = () => {
started = true
if (!currentScrub) {
currentScrub = studio.debouncedScrub(600)
}
}
const onEnd = () => {
started = false
}
const onChange = () => {
if (!started) return
const p = cam!.position
const position = {x: p.x, y: p.y, z: p.z}
const r = cam!.rotation
const rotation = {x: r.x, y: r.y, z: r.z}
const t = orbitControls!.target
const target = {x: t.x, y: t.y, z: t.z}
const transform = {
position,
rotation,
target,
}
currentScrub!.capture(({set}) => {
set(objRef.current!.props.transform, transform)
})
}
orbitControls.addEventListener('start', onStart)
orbitControls.addEventListener('end', onEnd)
orbitControls.addEventListener('change', onChange)
return () => {
orbitControls.removeEventListener('start', onStart)
orbitControls.removeEventListener('end', onEnd)
orbitControls.removeEventListener('change', onChange)
}
}, [cam, orbitControls])
}
function usePassValuesFromTheatreToCamera(
cam: PerspectiveCameraImpl | undefined,
orbitControls: OrbitControlsImpl | null,
objRef: MutableRefObject<ISheetObject<typeof camConf> | null>,
) {
const invalidate = useThree(({invalidate}) => invalidate)
useLayoutEffect(() => {
if (!cam || orbitControls === null) return
const obj = objRef.current!
const setFromTheatre = ({
transform,
}: {
transform: typeof camConf['valueType']['transform']
}): void => {
const {position, rotation, target} = transform
cam.position.set(position.x, position.y, position.z)
cam.rotation.set(rotation.x, rotation.y, rotation.z)
orbitControls.target.set(target.x, target.y, target.z)
orbitControls.update()
invalidate()
}
const unsub = obj.onValuesChange(setFromTheatre)
setFromTheatre(obj.value)
return unsub
}, [cam, orbitControls, objRef, invalidate])
}

View file

@ -2,10 +2,6 @@ import type {StateCreator} from 'zustand'
import create from 'zustand'
import type {Object3D, Scene, WebGLRenderer} from 'three'
import {Group} from 'three'
import type {MutableRefObject} from 'react'
import type {OrbitControls} from '@react-three/drei'
// @ts-ignore TODO
import type {ContainerProps} from '@react-three/fiber'
import type {ISheet, ISheetObject} from '@theatre/core'
import {types, getProject} from '@theatre/core'
@ -138,27 +134,21 @@ export type EditorStore = {
scene: Scene | null
gl: WebGLRenderer | null
allowImplicitInstancing: boolean
orbitControlsRef: MutableRefObject<typeof OrbitControls | undefined> | null
helpersRoot: Group
editables: Record<string, Editable>
// this will come in handy when we start supporting multiple canvases
canvasName: string
sceneSnapshot: Scene | null
editablesSnapshot: Record<string, EditableSnapshot> | null
initialEditorCamera: ContainerProps['camera']
init: (
scene: Scene,
gl: WebGLRenderer,
allowImplicitInstancing: boolean,
editorCamera: ContainerProps['camera'],
sheet: ISheet,
editorObject: null | ISheetObject<typeof editorSheetObjectConfig>,
) => void
setOrbitControlsRef: (
orbitControlsRef: MutableRefObject<typeof OrbitControls | undefined>,
) => void
addEditable: <T extends EditableType>(type: T, uniqueName: string) => void
removeEditable: (uniqueName: string) => void
createSnapshot: () => void
@ -177,7 +167,6 @@ const config: StateCreator<EditorStore> = (set, get) => {
scene: null,
gl: null,
allowImplicitInstancing: false,
orbitControlsRef: null,
helpersRoot: new Group(),
editables: {},
canvasName: 'default',
@ -185,19 +174,11 @@ const config: StateCreator<EditorStore> = (set, get) => {
editablesSnapshot: null,
initialEditorCamera: {},
init: (
scene,
gl,
allowImplicitInstancing,
editorCamera,
sheet,
editorObject,
) => {
init: (scene, gl, allowImplicitInstancing, sheet, editorObject) => {
set({
scene,
gl,
allowImplicitInstancing,
initialEditorCamera: editorCamera,
sheet,
editorObject,
})
@ -238,9 +219,7 @@ const config: StateCreator<EditorStore> = (set, get) => {
},
}
}),
setOrbitControlsRef: (camera) => {
set({orbitControlsRef: camera})
},
removeEditable: (name) =>
set((state) => {
const {[name]: removed, ...rest} = state.editables
@ -284,7 +263,6 @@ export const useEditorStore = create<EditorStore>(config)
export type BindFunction = (options: {
allowImplicitInstancing?: boolean
editorCamera?: ContainerProps['camera']
sheet: ISheet
}) => (options: {gl: WebGLRenderer; scene: Scene}) => void
@ -309,23 +287,6 @@ const editorSheetObjectConfig = types.compound({
},
{as: 'menu', label: 'Shading'},
),
mode: types.stringLiteral(
'translate',
{
translate: 'Translate',
rotate: 'Rotate',
scale: 'Scale',
},
{as: 'switch', label: 'Mode'},
),
space: types.stringLiteral(
'world',
{
local: 'Local',
world: 'World',
},
{as: 'switch', label: 'Space'},
),
},
{label: 'Viewport Config'},
),
@ -355,7 +316,6 @@ const editorSheetObjectConfig = types.compound({
export const bindToCanvas: BindFunction = ({
allowImplicitInstancing = false,
editorCamera = {},
sheet,
}) => {
const uiSheet: null | ISheet =
@ -368,13 +328,6 @@ export const bindToCanvas: BindFunction = ({
return ({gl, scene}) => {
const init = useEditorStore.getState().init
init(
scene,
gl,
allowImplicitInstancing,
{...{position: [20, 20, 20]}, ...editorCamera},
sheet,
editorSheetObject,
)
init(scene, gl, allowImplicitInstancing, sheet, editorSheetObject)
}
}

View file

@ -3,7 +3,7 @@ import studioTicker from '@theatre/studio/studioTicker'
import type {IDerivation, Pointer} from '@theatre/dataverse'
import {prism} from '@theatre/dataverse'
import SimpleCache from '@theatre/shared/utils/SimpleCache'
import type {$FixMe, VoidFn} from '@theatre/shared/utils/types'
import type {$FixMe, $IntentionalAny, VoidFn} from '@theatre/shared/utils/types'
import type {IScrub} from '@theatre/studio/Scrub'
import type {Studio} from '@theatre/studio/Studio'
@ -16,6 +16,7 @@ import type {
PropTypeConfig_Boolean,
PropTypeConfig_Compound,
} from '@theatre/core/propTypes'
import {debounce} from 'lodash-es'
export interface ITransactionAPI {
set<V>(pointer: Pointer<V>, value: V): void
@ -28,7 +29,7 @@ export interface PaneClassDefinition<
class: string
dataType: DataType
component: React.ComponentType<{
id: string
paneId: string
object: ISheetObject<
PropTypeConfig_Compound<{
visible: PropTypeConfig_Boolean
@ -65,6 +66,7 @@ export interface IStudio {
transaction(fn: (api: ITransactionAPI) => void): void
scrub(): IScrub
debouncedScrub(threshhold: number): Pick<IScrub, 'capture'>
__experimental_setSelection(selection: Array<ISheetObject>): void
__experimental_onSelectionChange(
@ -158,6 +160,37 @@ export default class TheatreStudio implements IStudio {
return getStudio().scrub()
}
debouncedScrub(threshold: number = 1000): Pick<IScrub, 'capture'> {
let currentScrub: IScrub | undefined
const scheduleCommit = debounce(() => {
const s = currentScrub
if (!s) return
currentScrub = undefined
s.commit()
}, threshold)
const capture = (arg: $IntentionalAny) => {
if (!currentScrub) {
currentScrub = this.scrub()
}
let errored = true
try {
currentScrub.capture(arg)
errored = false
} finally {
if (errored) {
const s = currentScrub
currentScrub = undefined
s.discard()
} else {
scheduleCommit()
}
}
}
return {capture}
}
getPanesOfType<PaneClass extends string>(
paneClass: PaneClass,
): PaneInstance<PaneClass>[] {

View file

@ -132,7 +132,7 @@ const Content: React.FC<{paneInstance: PaneInstance<$FixMe>}> = ({
</PanelDragZone>
<F2>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Comp id={paneInstance.instanceId} object={paneInstance.object} />
<Comp paneId={paneInstance.instanceId} object={paneInstance.object} />
</ErrorBoundary>
</F2>
</Container>

View file

@ -54,6 +54,10 @@ const Title = styled.div`
font-weight: 500;
font-size: 10px;
user-select: none;
pointer-events: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`
const headerHeight = `32px`
@ -106,7 +110,9 @@ const ObjectEditorPanel: React.FC<{}> = (props) => {
<Container>
<Content>
<Header>
<Title>
<Title
title={`${obj.sheet.address.sheetId}: ${obj.sheet.address.sheetInstanceId} > ${obj.address.objectKey}`}
>
<TitleBar_Piece>{obj.sheet.address.sheetId} </TitleBar_Piece>
<TitleBar_Punctuation>{':'}&nbsp;</TitleBar_Punctuation>

View file

@ -1,7 +1,6 @@
import type {PropTypeConfig_Compound} from '@theatre/core/propTypes'
import {isPropConfigComposite} from '@theatre/shared/propTypes/utils'
import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
import {theme} from '@theatre/studio/css'
import {usePrism} from '@theatre/dataverse-react'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
import {getPointerParts} from '@theatre/dataverse'
@ -25,10 +24,8 @@ const Container = styled.div`
const Header = styled.div`
height: 30px;
/* padding-left: calc(var(--left-pad) + var(--depth) * var(--step)); */
display: flex;
align-items: stretch;
/* color: ${theme.panel.body.compoudThing.label.color}; */
position: relative;
${rowBg};
@ -40,17 +37,6 @@ const Padding = styled.div`
align-items: center;
`
const IconContainer = styled.div`
width: 12px;
margin-right: -12px;
/* margin-left: ${indentationFormula}; */
font-size: 9px;
text-align: center;
transform: rotateZ(90deg);
position: relative;
left: -18px;
`
const PropName = styled.div`
margin-left: 4px;
cursor: default;