From 246e75ccb5c1472966af274f8f25843392aa8629 Mon Sep 17 00:00:00 2001 From: Andrew Prifer <2991360+AndrewPrifer@users.noreply.github.com> Date: Sun, 22 Jan 2023 18:01:31 +0100 Subject: [PATCH] Theatric - a leva-like Theatre.js API for React (#375) Co-authored-by: Aria Minaei --- .../playground/src/shared/theatric/index.tsx | 80 +++++ .../playground/src/shared/theatric/state.json | 19 ++ packages/playground/tsconfig.json | 3 +- packages/theatric/.gitignore | 1 + packages/theatric/LICENSE | 203 ++++++++++++ packages/theatric/devEnv/api-extractor.json | 36 +++ .../devEnv/api-extractor.tsconfig.json | 7 + packages/theatric/devEnv/build.ts | 55 ++++ packages/theatric/devEnv/tsconfig.json | 3 + packages/theatric/package.json | 51 +++ packages/theatric/src/index.ts | 292 ++++++++++++++++++ packages/theatric/tsconfig.json | 14 + theatre/core/src/coreExports.ts | 2 + .../src/sheetObjects/SheetObjectTemplate.ts | 7 +- theatre/core/src/sheets/TheatreSheet.ts | 5 +- tsconfig.base.json | 3 +- yarn.lock | 23 ++ 17 files changed, 794 insertions(+), 10 deletions(-) create mode 100644 packages/playground/src/shared/theatric/index.tsx create mode 100644 packages/playground/src/shared/theatric/state.json create mode 100644 packages/theatric/.gitignore create mode 100644 packages/theatric/LICENSE create mode 100644 packages/theatric/devEnv/api-extractor.json create mode 100644 packages/theatric/devEnv/api-extractor.tsconfig.json create mode 100644 packages/theatric/devEnv/build.ts create mode 100644 packages/theatric/devEnv/tsconfig.json create mode 100644 packages/theatric/package.json create mode 100644 packages/theatric/src/index.ts create mode 100644 packages/theatric/tsconfig.json diff --git a/packages/playground/src/shared/theatric/index.tsx b/packages/playground/src/shared/theatric/index.tsx new file mode 100644 index 0000000..27865ab --- /dev/null +++ b/packages/playground/src/shared/theatric/index.tsx @@ -0,0 +1,80 @@ +import {button, initialize, useControls} from 'theatric' +import {render} from 'react-dom' +import React, {useState} from 'react' +import state from './state.json' + +initialize(state) + +function SomeComponent({id}: {id: string}) { + const {foo, $get, $set} = useControls( + { + foo: 0, + bar: 0, + bez: button(() => { + $set((p) => p.foo, 2) + $set((p) => p.bar, 3) + console.log($get((p) => p.foo)) + }), + }, + {folder: id}, + ) + + return ( +
+ {id}: {foo} +
+ ) +} + +function App() { + const {bar, $set, $get} = useControls({ + bar: {foo: 'bar'}, + baz: button(() => console.log($get((p) => p.bar))), + }) + + const {another, panel, yo} = useControls( + { + another: '', + panel: '', + yo: 0, + }, + {panel: 'My panel'}, + ) + + const {} = useControls({}) + + const [showComponent, setShowComponent] = useState(false) + + return ( +
+
{JSON.stringify(bar)}
+ + + + + {showComponent && } + {yo} +
+ ) +} + +render(, document.getElementById('root')) diff --git a/packages/playground/src/shared/theatric/state.json b/packages/playground/src/shared/theatric/state.json new file mode 100644 index 0000000..187f5d0 --- /dev/null +++ b/packages/playground/src/shared/theatric/state.json @@ -0,0 +1,19 @@ +{ + "sheetsById": { + "Panels": { + "staticOverrides": { + "byObject": { + "Default panel": { + "first": { + "foo": 73 + } + } + } + } + } + }, + "definitionVersion": "0.4.0", + "revisionHistory": [ + "cfRereBjMbBNLzC3" + ] +} \ No newline at end of file diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json index 39d0b70..8f093b8 100644 --- a/packages/playground/tsconfig.json +++ b/packages/playground/tsconfig.json @@ -12,7 +12,8 @@ "references": [ {"path": "../../theatre"}, {"path": "../dataverse"}, - {"path": "../r3f"} + {"path": "../r3f"}, + {"path": "../theatric"} ], "include": ["./src/**/*", "./src/**/*.json", "./devEnv/**/*"] } diff --git a/packages/theatric/.gitignore b/packages/theatric/.gitignore new file mode 100644 index 0000000..3e22129 --- /dev/null +++ b/packages/theatric/.gitignore @@ -0,0 +1 @@ +/dist \ No newline at end of file diff --git a/packages/theatric/LICENSE b/packages/theatric/LICENSE new file mode 100644 index 0000000..6b0b127 --- /dev/null +++ b/packages/theatric/LICENSE @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/packages/theatric/devEnv/api-extractor.json b/packages/theatric/devEnv/api-extractor.json new file mode 100644 index 0000000..f801507 --- /dev/null +++ b/packages/theatric/devEnv/api-extractor.json @@ -0,0 +1,36 @@ +/** + * Config file for API Extractor. For more info, please visit: https://api-extractor.com + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + /** + * Optionally specifies another JSON config file that this file extends from. This provides a way for + * standard settings to be shared across multiple projects. + * + * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains + * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be + * resolved using NodeJS require(). + * + * SUPPORTED TOKENS: none + * DEFAULT VALUE: "" + */ + "extends": "../../../devEnv/api-extractor-base.json", + // "extends": "my-package/include/api-extractor-base.json" + + /** + * Determines the "" token that can be used with other config file settings. The project folder + * typically contains the tsconfig.json and package.json config files, but the path is user-defined. + * + * The path is resolved relative to the folder of the config file that contains the setting. + * + * The default value for "projectFolder" is the token "", which means the folder is determined by traversing + * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder + * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error + * will be reported. + * + * SUPPORTED TOKENS: + * DEFAULT VALUE: "" + */ + "projectFolder": ".." +} diff --git a/packages/theatric/devEnv/api-extractor.tsconfig.json b/packages/theatric/devEnv/api-extractor.tsconfig.json new file mode 100644 index 0000000..bcc091f --- /dev/null +++ b/packages/theatric/devEnv/api-extractor.tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "compilerOptions": { + "paths": {} + } +} diff --git a/packages/theatric/devEnv/build.ts b/packages/theatric/devEnv/build.ts new file mode 100644 index 0000000..599d720 --- /dev/null +++ b/packages/theatric/devEnv/build.ts @@ -0,0 +1,55 @@ +import * as path from 'path' +import {build} from 'esbuild' +import type {Plugin} from 'esbuild' + +const externalPlugin = (patterns: RegExp[]): Plugin => { + return { + name: `external`, + + setup(build) { + build.onResolve({filter: /.*/}, (args) => { + const external = patterns.some((p) => { + return p.test(args.path) + }) + + if (external) { + return {path: args.path, external} + } + }) + }, + } +} + +const definedGlobals = { + global: 'window', +} + +function createBundles(watch: boolean) { + const pathToPackage = path.join(__dirname, '../') + const esbuildConfig: Parameters[0] = { + entryPoints: [path.join(pathToPackage, 'src/index.ts')], + bundle: true, + sourcemap: true, + define: definedGlobals, + watch, + platform: 'neutral', + mainFields: ['browser', 'module', 'main'], + target: ['firefox57', 'chrome58'], + conditions: ['browser', 'node'], + plugins: [externalPlugin([/^[\@a-zA-Z]+/])], + } + + build({ + ...esbuildConfig, + outfile: path.join(pathToPackage, 'dist/index.js'), + format: 'cjs', + }) + + // build({ + // ...esbuildConfig, + // outfile: path.join(pathToPackage, 'dist/index.mjs'), + // format: 'esm', + // }) +} + +createBundles(false) diff --git a/packages/theatric/devEnv/tsconfig.json b/packages/theatric/devEnv/tsconfig.json new file mode 100644 index 0000000..077404a --- /dev/null +++ b/packages/theatric/devEnv/tsconfig.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/packages/theatric/package.json b/packages/theatric/package.json new file mode 100644 index 0000000..0201bab --- /dev/null +++ b/packages/theatric/package.json @@ -0,0 +1,51 @@ +{ + "name": "theatric", + "version": "0.6.0-dev.3", + "license": "Apache-2.0", + "author": { + "name": "Andrew Prifer", + "email": "andrew.prifer@gmail.com", + "url": "https://github.com/AndrewPrifer" + }, + "repository": { + "type": "git", + "url": "https://github.com/theatre-js/theatre", + "directory": "packages/theatric" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "prepack": "node ../../devEnv/ensurePublishing.js", + "typecheck": "yarn run build", + "build": "run-s build:ts build:js build:api-json", + "build:ts": "tsc --build ./tsconfig.json", + "build:js": "node -r esbuild-register ./devEnv/build.ts", + "build:api-json": "api-extractor run --local --config devEnv/api-extractor.json", + "prepublish": "node ../../devEnv/ensurePublishing.js", + "clean": "rm -rf ./dist && rm -f tsconfig.tsbuildinfo" + }, + "devDependencies": { + "@microsoft/api-extractor": "^7.18.11", + "@types/jest": "^26.0.23", + "@types/node": "^15.6.2", + "@types/react": "^17.0.9", + "@types/react-dom": "^17.0.6", + "esbuild": "^0.12.15", + "esbuild-register": "^2.5.0", + "npm-run-all": "^4.1.5", + "typescript": "^4.4.2" + }, + "dependencies": { + "@theatre/core": "workspace:*", + "@theatre/react": "workspace:*", + "@theatre/studio": "workspace:*", + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } +} diff --git a/packages/theatric/src/index.ts b/packages/theatric/src/index.ts new file mode 100644 index 0000000..e4f74ed --- /dev/null +++ b/packages/theatric/src/index.ts @@ -0,0 +1,292 @@ +import type { + IProjectConfig, + ISheetObject, + SheetObjectActionsConfig, + UnknownShorthandCompoundProps, +} from '@theatre/core' +import {val} from '@theatre/core' +import {getProject} from '@theatre/core' +import type {Pointer} from '@theatre/dataverse' +import {isPointer} from '@theatre/dataverse' +import type {IStudio} from '@theatre/studio' +import studio from '@theatre/studio' +import isEqual from 'lodash-es/isEqual' +import {useEffect, useMemo, useState, useRef} from 'react' + +type KeysMatching = { + [K in keyof T]-?: T[K] extends V ? K : never +}[keyof T] + +type OmitMatching = Omit> + +// Because treeshaking studio relies on static checks like the following, we can't make including studio configurable at runtime. +// What we can do, if there arises a need to use studio in production with theatric, is to let users provide their own studio instance. +// That way we can treeshake our own, and the user can give us theirs, if they want to. + +if (process.env.NODE_ENV === 'development') { + studio.initialize() +} + +// Just to be able to treeshake studio out of the bundle +const maybeTransaction = + process.env.NODE_ENV === 'development' + ? studio.transaction.bind(studio) + : () => {} + +let _state: IProjectConfig['state'] | undefined = undefined + +export function initialize(state: IProjectConfig['state']) { + if (_state !== undefined) { + console.warn( + 'Theatric has already been initialized, either through another initialize call, or by calling useControls() before calling initialize().', + ) + return + } + _state = state +} + +const allProps: Record = {} +const allActions: Record = {} + +type Button = { + type: 'button' + onClick: () => void +} +type Buttons = { + [key: string]: Button +} + +type ControlsAndButtons = { + [key: string]: {type: 'button'} | UnknownShorthandCompoundProps[string] +} + +/** + * The type of the `$set()` function returned by `useControls()`. + */ +type Setter = ( + pointer: (p: ISheetObject['props']) => Pointer, + value: S, +) => void + +/** + * The type of the `$get()` function returned by `useControls()`. + */ +type Getter = ( + pointer: (p: ISheetObject['props']) => Pointer, +) => S + +export function useControls( + config: Config, + options: {panel?: string; folder?: string} = {}, +): ISheetObject>['value'] & { + $set: Setter> + $get: Getter> +} { + // initialize state to null, if it hasn't been initialized yet + if (_state === undefined) { + _state = null + } + + /* + * This is a performance hack just to avoid a bunch of unnecessary calculations and effect runs whenever the hook is called, + * since the config object is very likely not memoized by the user. + * Since the config object can include functions, we can't rely for correctness on just deep comparing the config object, + * we also have to perform a deep comparison on the theatre object values in onValuesChange before calling setState in order + * to truly make sure we avoid infinite loops in this case, since then the config object will always be reported to be different by isEqual. + * + * Note: normally object.onValuesChange wouldn't be called twice with the same values, but when the object is reconfigured (which it is), + * this doesn't seem to be the case. + * + * Also note: normally it'd be illegal to set refs during render (since renders might not be committed), but it is fine here + * because we are only using it for memoization, _config is never going to be stale. + */ + const configRef = useRef(config) + const _config = useMemo(() => { + if (isEqual(config, configRef.current)) { + return configRef.current + } else { + configRef.current = config + return config + } + }, [config]) + + const {folder} = options + + const controlsWithoutButtons = useMemo( + () => + Object.fromEntries( + Object.entries(_config).filter( + ([key, value]) => (value as any).type !== 'button', + ), + ) as UnknownShorthandCompoundProps, + [_config], + ) + + const buttons = useMemo( + () => + Object.fromEntries( + Object.entries(_config).filter( + ([key, value]) => (value as any).type === 'button', + ), + ) as unknown as Buttons, + [_config], + ) + + const props = useMemo( + () => + folder ? {[folder]: controlsWithoutButtons} : controlsWithoutButtons, + [folder, controlsWithoutButtons], + ) + + const actions = useMemo( + () => + Object.fromEntries( + Object.entries(buttons).map(([key, value]) => [ + `${folder ? `${folder}: ` : ''}${key}`, + (object: ISheetObject, studio: IStudio) => { + value + .onClick + // (path, value) => { + // // this is not ideal because it will create a separate undo level for each set call, + // // but this is the only thing that theatre's public API allows us to do. + // // Wrapping the whole thing in a transaction wouldn't work either because side effects + // // would be run twice. + // maybeTransaction((api) => { + // api.set( + // get(folder ? object.props[folder] : object.props, path), + // value, + // ) + // }) + // }, + // (path) => get(folder ? object.value[folder] : object.value, path), + () + }, + ]), + ), + [buttons, folder], + ) + + const sheet = useMemo( + () => getProject('Theatric', {state: _state}).sheet('Panels'), + [], + ) + const panel = options.panel ?? 'Default panel' + const allPanelProps = allProps[panel] ?? (allProps[panel] = []) + const allPanelActions = allActions[panel] ?? (allActions[panel] = []) + + // have to do this to make sure the values are immediately available + const object = useMemo( + () => + sheet.object(panel, Object.assign({}, ...allProps[panel], props), { + reconfigure: true, + actions: Object.assign({}, ...allActions[panel], actions), + }), + [panel, props, actions], + ) + + useEffect(() => { + allPanelProps.push(props) + allPanelActions.push(actions) + // cleanup runs after render, so we have to reconfigure with the new props here too, doing it during render just makes sure that + // the very first values returned are not undefined + sheet.object(panel, Object.assign({}, ...allPanelProps), { + reconfigure: true, + actions: Object.assign({}, ...allPanelActions), + }) + + return () => { + allPanelProps.splice(allPanelProps.indexOf(props), 1) + allActions[panel].splice(allPanelActions.indexOf(actions), 1) + sheet.object(panel, Object.assign({}, ...allPanelProps), { + reconfigure: true, + actions: Object.assign({}, ...allPanelActions), + }) + } + }, [props, actions, allPanelActions, allPanelProps, sheet, panel]) + + const [values, setValues] = useState( + (folder ? object.value[folder] : object.value) as ISheetObject< + OmitMatching + >['value'], + ) + + const valuesRef = useRef(object.value) + + useEffect(() => { + const unsub = object.onValuesChange((newValues) => { + if (folder) newValues = newValues[folder] + + // Normally object.onValuesChange wouldn't be called twice with the same values, but when the object is reconfigured (like we do above), + // this doesn't seem to be the case, so we need to explicitly do this here to avoid infinite loops. + if (isEqual(newValues, valuesRef.current)) return + + valuesRef.current = newValues + setValues(newValues as any) + }) + + return unsub + }, [object]) + + const $setAndGet = useMemo(() => { + const rootPointer = folder + ? (object as ISheetObject).props[folder] + : object.props + + const $set: Setter> = ( + getPointer, + value, + ) => { + if (typeof getPointer !== 'function') { + throw new Error( + `The first argument to $set must be a function that returns a pointer. Instead, it was ${typeof getPointer}`, + ) + } + + const pointer = getPointer(rootPointer as any) + if (!isPointer(pointer)) { + throw new Error( + `The function passed to $set must return a pointer. Instead, it returned ${pointer}`, + ) + } + // this is not ideal because it will create a separate undo level for each set call, + // but this is the only thing that theatre's public API allows us to do. + // Wrapping the whole thing in a transaction wouldn't work either because side effects + // would be run twice. + maybeTransaction((api) => { + api.set(pointer, value) + }) + } + + const $get: Getter> = ( + getPointer, + ) => { + if (typeof getPointer !== 'function') { + throw new Error( + `The first argument to $get must be a function that returns a pointer. Instead, it was ${typeof getPointer}`, + ) + } + + const pointer = getPointer(rootPointer as any) + if (!isPointer(pointer)) { + throw new Error( + `The function passed to $get must return a pointer. Instead, it returned ${pointer}`, + ) + } + + return val(pointer) + } + + return {$set, $get} + }, [folder, object]) + + return {...values, ...$setAndGet} +} + +export {types} from '@theatre/core' + +export const button = (onClick: Button['onClick']) => { + return { + type: 'button' as const, + onClick, + } +} diff --git a/packages/theatric/tsconfig.json b/packages/theatric/tsconfig.json new file mode 100644 index 0000000..a03e340 --- /dev/null +++ b/packages/theatric/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["ESNext", "DOM"], + "rootDir": "src", + "types": ["jest", "node"], + "emitDeclarationOnly": true, + "target": "es6", + "composite": true + }, + "include": ["./src/**/*"], + "references": [{"path": "../../theatre"}] +} diff --git a/theatre/core/src/coreExports.ts b/theatre/core/src/coreExports.ts index f5804fc..344ccde 100644 --- a/theatre/core/src/coreExports.ts +++ b/theatre/core/src/coreExports.ts @@ -206,3 +206,5 @@ export function val(pointer: PointerType): T { throw new Error(`Called val(p) where p is not a pointer.`) } } + +export type {SheetObjectActionsConfig} from './sheets/TheatreSheet' diff --git a/theatre/core/src/sheetObjects/SheetObjectTemplate.ts b/theatre/core/src/sheetObjects/SheetObjectTemplate.ts index 5cad18a..f19e6cf 100644 --- a/theatre/core/src/sheetObjects/SheetObjectTemplate.ts +++ b/theatre/core/src/sheetObjects/SheetObjectTemplate.ts @@ -2,7 +2,6 @@ import type Project from '@theatre/core/projects/Project' import type Sheet from '@theatre/core/sheets/Sheet' import type SheetTemplate from '@theatre/core/sheets/SheetTemplate' import type { - SheetObjectAction, SheetObjectActionsConfig, SheetObjectPropTypeConfig, } from '@theatre/core/sheets/TheatreSheet' @@ -74,7 +73,7 @@ export default class SheetObjectTemplate { return this._config.pointer } - get staticActions() { + get actions() { return this._actions.get() } @@ -108,8 +107,8 @@ export default class SheetObjectTemplate { this._config.set(config) } - registerAction(name: string, action: SheetObjectAction) { - this._actions.setByPointer((p) => p[name], action) + setActions(actions: SheetObjectActionsConfig) { + this._actions.set(actions) } /** diff --git a/theatre/core/src/sheets/TheatreSheet.ts b/theatre/core/src/sheets/TheatreSheet.ts index 5ab7b56..631e683 100644 --- a/theatre/core/src/sheets/TheatreSheet.ts +++ b/theatre/core/src/sheets/TheatreSheet.ts @@ -180,10 +180,7 @@ export default class TheatreSheet implements ISheet { } if (opts?.actions) { - Object.entries(opts.actions).forEach(([key, action]) => { - existingObject.template.registerAction(key, action) - }) - console.log('registered actions', opts.actions) + existingObject.template.setActions(opts.actions) } return existingObject.publicApi as $IntentionalAny diff --git a/tsconfig.base.json b/tsconfig.base.json index ab99a31..d595248 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,7 +28,8 @@ "@theatre/r3f/dist/extension": ["./packages/r3f/src/extension/index.ts"], "@theatre/dataverse-experiments": [ "./packages/dataverse-experiments/src/index.ts" - ] + ], + "theatric": ["./packages/theatric/src/index.ts"] }, "forceConsistentCasingInFileNames": true }, diff --git a/yarn.lock b/yarn.lock index 63086eb..99bddb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31351,6 +31351,29 @@ fsevents@^1.2.7: languageName: unknown linkType: soft +"theatric@workspace:packages/theatric": + version: 0.0.0-use.local + resolution: "theatric@workspace:packages/theatric" + dependencies: + "@microsoft/api-extractor": ^7.18.11 + "@theatre/core": "workspace:*" + "@theatre/react": "workspace:*" + "@theatre/studio": "workspace:*" + "@types/jest": ^26.0.23 + "@types/node": ^15.6.2 + "@types/react": ^17.0.9 + "@types/react-dom": ^17.0.6 + esbuild: ^0.12.15 + esbuild-register: ^2.5.0 + lodash-es: ^4.17.21 + npm-run-all: ^4.1.5 + typescript: ^4.4.2 + peerDependencies: + react: "*" + react-dom: "*" + languageName: unknown + linkType: soft + "three-mesh-bvh@npm:^0.4.1": version: 0.4.1 resolution: "three-mesh-bvh@npm:0.4.1"