From bcfb91fbb766724857a44b3a3ece8cd88018a93b Mon Sep 17 00:00:00 2001 From: Colin Duffy Date: Wed, 5 Jul 2023 10:56:17 -0700 Subject: [PATCH] feat(remote): multiple-window support --- .../playground/src/shared/remote/Box3D.tsx | 85 ++++++++++ .../playground/src/shared/remote/Remote.ts | 152 ++++++++++++++++++ .../src/shared/remote/RemoteController.ts | 119 ++++++++++++++ .../playground/src/shared/remote/Scene.tsx | 55 +++++++ .../playground/src/shared/remote/index.tsx | 12 ++ 5 files changed, 423 insertions(+) create mode 100644 packages/playground/src/shared/remote/Box3D.tsx create mode 100644 packages/playground/src/shared/remote/Remote.ts create mode 100644 packages/playground/src/shared/remote/RemoteController.ts create mode 100644 packages/playground/src/shared/remote/Scene.tsx create mode 100644 packages/playground/src/shared/remote/index.tsx diff --git a/packages/playground/src/shared/remote/Box3D.tsx b/packages/playground/src/shared/remote/Box3D.tsx new file mode 100644 index 0000000..bd8dbb4 --- /dev/null +++ b/packages/playground/src/shared/remote/Box3D.tsx @@ -0,0 +1,85 @@ +import React, {useEffect, useRef} from 'react' +import type {CSSProperties} from 'react' +import {types} from '@theatre/core' +import type {ISheet} from '@theatre/core' +import {remote} from './Remote' + +// Box element +export const BoxSize = 100 + +const Box3DCSS: CSSProperties = { + border: '1px solid #999', + position: 'absolute', + width: `${BoxSize}px`, + height: `${BoxSize}px`, +} + +const Box3DTextCSS: CSSProperties = { + margin: '0', + padding: '0', + position: 'absolute', + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + textAlign: 'center', + width: '100%', +} + +export const Box3D: React.FC<{ + sheet: ISheet + name: string + x: number + y: number +}> = ({sheet, name, x, y}) => { + const elementRef = useRef(null) + + // Animation + useEffect(() => { + const element = elementRef.current! + const id = `Box - ${name}` + const sheetObj = remote.sheetObject( + sheet.address.sheetId, + id, + { + background: types.rgba({r: 16 / 255, g: 16 / 255, b: 16 / 255, a: 1}), + opacity: types.number(1, {range: [0, 1]}), + position: { + x: x, + y: y, + z: 0, + }, + rotation: { + x: types.number(0, {range: [-360, 360]}), + y: types.number(0, {range: [-360, 360]}), + z: types.number(0, {range: [-360, 360]}), + }, + scale: { + x: 1, + y: 1, + z: 1, + }, + }, + (values: any) => { + const {background, opacity, position, rotation, scale} = values + element.style.backgroundColor = `rgba(${background.r * 255}, ${ + background.g * 255 + }, ${background.b * 255}, 1)` + element.style.opacity = opacity + const translate3D = `translate3d(${position.x}px, ${position.y}px, ${position.z}px)` + const rotate3D = `rotateX(${rotation.x}deg) rotateY(${rotation.y}deg) rotateZ(${rotation.z}deg)` + const scale3D = `scaleX(${scale.x}) scaleY(${scale.y}) scaleZ(${scale.z})` + const transform = `${scale3D} ${translate3D} ${rotate3D}` + element.style.transform = transform + }, + ) + return () => { + if (sheetObj !== undefined) remote.unsubscribe(sheetObj) + } + }, []) + + return ( +
+ {name} +
+ ) +} diff --git a/packages/playground/src/shared/remote/Remote.ts b/packages/playground/src/shared/remote/Remote.ts new file mode 100644 index 0000000..3d55fef --- /dev/null +++ b/packages/playground/src/shared/remote/Remote.ts @@ -0,0 +1,152 @@ +import type {IProject, ISheet, ISheetObject} from '@theatre/core' +import type {VoidFn} from '@theatre/dataverse/src/types' + +export type TheatreUpdateCallback = (data: any) => void + +export type BroadcastDataEvent = + | 'setSheet' + | 'setSheetObject' + | 'updateSheetObject' + | 'updateTimeline' + +export interface BroadcastData { + event: BroadcastDataEvent + data: any +} + +export type BroadcastCallback = (data: BroadcastData) => void + +// Default SheetObject.onValuesChange callback +const noop: TheatreUpdateCallback = (values: any) => {} + +function isColor(obj: any) { + return ( + obj.r !== undefined && + obj.g !== undefined && + obj.b !== undefined && + obj.a !== undefined + ) +} + +// Which hashtag to add to the URL +const hashtag = 'editor' + +class RemoteSingleton { + // Remote + mode = 'listener' + channel: BroadcastChannel + + // Theatre + project!: IProject + sheets: Map = new Map() + sheetObjects: Map = new Map() + sheetObjectCBs: Map = new Map() + sheetObjectUnsubscribe: Map = new Map() + + constructor() { + this.channel = new BroadcastChannel('theatre') + this.showTheatre = document.location.hash.search(hashtag) > -1 + } + + // Remote + + send(data: BroadcastData) { + if (this.mode === 'theatre') { + this.channel.postMessage(data) + } + } + + listen(callback: BroadcastCallback) { + if (this.mode === 'listener') { + this.channel.onmessage = (event: MessageEvent) => { + callback(event.data) + } + } + } + + // Theatre + + sheet(name: string): ISheet { + let sheet: any = this.sheets.get(name) + if (sheet !== undefined) return sheet + + sheet = this.project.sheet(name) + this.sheets.set(name, sheet) + return sheet + } + + sheetObject( + sheetName: string, + key: string, + props: any, + onUpdate?: TheatreUpdateCallback, + ): ISheetObject | undefined { + const sheet = this.sheets.get(sheetName) + if (sheet === undefined) return undefined + + const objName = `${sheetName}_${key}` + let obj = this.sheetObjects.get(objName) + if (obj !== undefined) { + obj = sheet.object(key, {...props, ...obj.value}, {reconfigure: true}) + return obj + } + + obj = sheet.object(key, props) + this.sheetObjects.set(objName, obj) + this.sheetObjectCBs.set(objName, onUpdate !== undefined ? onUpdate : noop) + + const unsubscribe = obj.onValuesChange((values: any) => { + if (this.showTheatre) { + for (let i in values) { + const value = values[i] + if (typeof value === 'object') { + if (isColor(value)) { + values[i] = { + r: value.r, + g: value.g, + b: value.b, + a: value.a, + } + } + } + } + this.send({ + event: 'updateSheetObject', + data: { + sheetObject: objName, + values: values, + }, + }) + } else { + const callback = this.sheetObjectCBs.get(objName) + if (callback !== undefined) callback(values) + } + }) + this.sheetObjectUnsubscribe.set(objName, unsubscribe) + + return obj + } + + unsubscribe(sheet: ISheetObject) { + const id = `${sheet.address.sheetId}_${sheet.address.objectKey}` + const unsubscribe = this.sheetObjectUnsubscribe.get(id) + if (unsubscribe !== undefined) { + unsubscribe() + } + } + + // Getters / Setters + + get showTheatre(): boolean { + return this.mode === 'theatre' + } + + set showTheatre(value: boolean) { + if (value) { + this.mode = 'theatre' + document.title += ' - Editor' + } + } +} + +export const remote = new RemoteSingleton() diff --git a/packages/playground/src/shared/remote/RemoteController.ts b/packages/playground/src/shared/remote/RemoteController.ts new file mode 100644 index 0000000..bd32585 --- /dev/null +++ b/packages/playground/src/shared/remote/RemoteController.ts @@ -0,0 +1,119 @@ +import type {IProject, ISheet} from '@theatre/core' +import studio from '@theatre/studio' +import {remote} from './Remote' +import type {BroadcastData, BroadcastDataEvent} from './Remote' + +/** + * Handles the communication between windows + */ +export default function RemoteController(project: IProject) { + let activeSheet: ISheet | undefined = undefined + remote.project = project + + /** + * Editor is hidden, this window receives updates + */ + const receiveRemote = () => { + studio.ui.hide() + + remote.listen((msg: BroadcastData) => { + switch (msg.event) { + case 'setSheet': + const sheet = remote.sheets.get(msg.data.sheet) + if (sheet !== undefined) { + activeSheet = sheet + studio.setSelection([sheet]) + } + break + + case 'setSheetObject': + const sheetObj = remote.sheetObjects.get( + `${msg.data.sheet}_${msg.data.key}`, + ) + if (sheetObj !== undefined) { + studio.setSelection([sheetObj]) + } + break + + case 'updateSheetObject': + const sheetObjCB = remote.sheetObjectCBs.get(msg.data.sheetObject) + if (sheetObjCB !== undefined) sheetObjCB(msg.data.values) + break + + case 'updateTimeline': + activeSheet = remote.sheets.get(msg.data.sheet) + if (activeSheet !== undefined) { + activeSheet.sequence.position = msg.data.position + } + break + } + }) + } + + /** + * Editor is visible, this window sends updates + */ + const sendRemote = () => { + studio.ui.restore() + + studio.onSelectionChange((value: any[]) => { + if (value.length < 1) return + + value.forEach((obj: any) => { + let id = obj.address.sheetId + let type: BroadcastDataEvent = 'setSheet' + let data = {} + switch (obj.type) { + case 'Theatre_Sheet_PublicAPI': + type = 'setSheet' + data = { + sheet: obj.address.sheetId, + } + activeSheet = remote.sheets.get(obj.address.sheetId) + break + + case 'Theatre_SheetObject_PublicAPI': + type = 'setSheetObject' + id += `_${obj.address.objectKey}` + data = { + sheet: obj.address.sheetId, + key: obj.address.objectKey, + } + break + } + remote.send({event: type, data: data}) + }) + }) + + // Timeline + let position = 0 + const onRafUpdate = () => { + if ( + activeSheet !== undefined && + position !== activeSheet.sequence.position + ) { + position = activeSheet.sequence.position + const t = activeSheet as ISheet + remote.send({ + event: 'updateTimeline', + data: { + position: position, + sheet: t.address.sheetId, + }, + }) + } + } + const onRaf = () => { + onRafUpdate() + requestAnimationFrame(onRaf) + } + onRafUpdate() // Initial position + onRaf() + } + + if (remote.showTheatre) { + sendRemote() + } else { + receiveRemote() + } +} diff --git a/packages/playground/src/shared/remote/Scene.tsx b/packages/playground/src/shared/remote/Scene.tsx new file mode 100644 index 0000000..65e5bc0 --- /dev/null +++ b/packages/playground/src/shared/remote/Scene.tsx @@ -0,0 +1,55 @@ +import React, {useEffect, useRef} from 'react' +import type {CSSProperties} from 'react' +import {types} from '@theatre/core' +import {Box3D} from './Box3D' +import {remote} from './Remote' + +// Scene + +const SceneCSS: CSSProperties = { + overflow: 'hidden', + position: 'absolute', + left: '0', + right: '0', + top: '0', + bottom: '0', +} + +export const Scene: React.FC<{}> = ({}) => { + const containerRef = useRef(null!) + const sheet = remote.sheet('DOM') + + useEffect(() => { + const container = containerRef.current! + const sheetObj = remote.sheetObject( + 'DOM', + 'Container', + { + perspective: types.number( + Math.max(window.innerWidth, window.innerHeight), + {range: [0, 2000]}, + ), + originX: types.number(50, {range: [0, 100]}), + originY: types.number(50, {range: [0, 100]}), + }, + (values: any) => { + container.style.perspective = `${values.perspective}px` + container.style.perspectiveOrigin = `${values.originX}% ${values.originY}%` + }, + ) + return () => { + if (sheetObj !== undefined) remote.unsubscribe(sheetObj) + } + }, []) + + if (remote.showTheatre) { + SceneCSS.display = 'none' + SceneCSS.visibility = 'hidden' + } + + return ( +
+ +
+ ) +} diff --git a/packages/playground/src/shared/remote/index.tsx b/packages/playground/src/shared/remote/index.tsx new file mode 100644 index 0000000..0a2f05f --- /dev/null +++ b/packages/playground/src/shared/remote/index.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import studio from '@theatre/studio' +import {getProject} from '@theatre/core' +import {Scene} from './Scene' +import RemoteController from './RemoteController' + +const project = getProject('Sample project') +studio.initialize() +RemoteController(project) + +ReactDOM.render(, document.getElementById('root'))