Initial OSS commit

This commit is contained in:
Aria Minaei 2021-06-18 13:05:06 +02:00
commit 4a7303f40a
391 changed files with 245738 additions and 0 deletions

203
theatre/core/LICENSE Normal file
View file

@ -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.

19
theatre/core/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "@theatre/core",
"main": "dist/index.js",
"license": "Apache-2.0",
"files": [
"dist/index.js",
"dist/index.d.ts",
"index.js.map"
],
"scripts": {
"prepack": "cd .. && yarn run prepare"
},
"dependencies": {
"@theatre/dataverse": "workspace:*"
},
"devDependencies": {
"typescript": "^4.3.2"
}
}

View file

@ -0,0 +1,12 @@
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: `ImportDeclaration[importKind!='type'][source.value=/@theatre\\u002Fstudio/]`,
message:
'@theatre/core may not import @theatre/studio modules except via type imports.',
},
],
},
}

View file

@ -0,0 +1,31 @@
import type Studio from '@theatre/studio/Studio'
import projectsSingleton from './projects/projectsSingleton'
export type CoreBits = {
projectsP: typeof projectsSingleton.atom.pointer.projects
}
export default class CoreBundle {
private _studio: Studio | undefined = undefined
constructor() {}
get type(): 'Theatre_CoreBundle' {
return 'Theatre_CoreBundle'
}
get version() {
return $env.version
}
getBitsForStudio(studio: Studio, callback: (bits: CoreBits) => void) {
if (this._studio) {
throw new Error(`@theatre/core is already attached to @theatre/studio`)
}
this._studio = studio
const bits: CoreBits = {
projectsP: projectsSingleton.atom.pointer.projects,
}
callback(bits)
}
}

View file

@ -0,0 +1,80 @@
import projectsSingleton from '@theatre/core/projects/projectsSingleton'
import type {OnDiskState} from '@theatre/core/projects/store/storeTypes'
import type {
IProject,
IProjectConfig,
} from '@theatre/core/projects/TheatreProject'
import TheatreProject from '@theatre/core/projects/TheatreProject'
import * as types from '@theatre/shared/src/propTypes'
import {InvalidArgumentError} from '@theatre/shared/utils/errors'
import {validateName} from '@theatre/shared/utils/sanitizers'
import userReadableTypeOfValue from '@theatre/shared/utils/userReadableTypeOfValue'
export {types}
export function getProject(id: string, config: IProjectConfig = {}): IProject {
const {...restOfConfig} = config
if (projectsSingleton.has(id)) {
return projectsSingleton.get(id)!.publicApi
}
if ($env.NODE_ENV === 'development') {
validateName(id, 'projectName in Theatre.getProject(projectName)', true)
validateProjectIdOrThrow(id)
}
if (config.state) {
if ($env.NODE_ENV === 'development') {
shallowValidateOnDiskState(id, config.state)
} else {
deepValidateOnDiskState(id, config.state)
}
}
return new TheatreProject(id, restOfConfig)
}
/**
* Lightweight validator that only makes sure the state's definitionVersion is correct.
* Does not do a thorough validation of the state.
*/
const shallowValidateOnDiskState = (projectId: string, s: OnDiskState) => {
if (
Array.isArray(s) ||
s == null ||
s.definitionVersion !== $env.currentProjectStateDefinitionVersion
) {
throw new InvalidArgumentError(
`Error validating conf.state in Theatre.getProject(${JSON.stringify(
projectId,
)}, conf). The state seems to be formatted in a way that is unreadable to Theatre.js. Read more at https://docs.theatrejs.com`,
)
}
}
const deepValidateOnDiskState = (projectId: string, s: OnDiskState) => {
shallowValidateOnDiskState(projectId, s)
// @TODO do a deep validation here
}
const validateProjectIdOrThrow = (value: string) => {
if (typeof value !== 'string') {
throw new InvalidArgumentError(
`Argument 'name' in \`Theatre.getProject(name, ...)\` must be a string. Instead, it was ${userReadableTypeOfValue(
value,
)}.`,
)
}
const idTrimmed = value.trim()
if (idTrimmed.length !== value.length) {
throw new InvalidArgumentError(
`Argument 'name' in \`Theatre.getProject("${value}", ...)\` should not have surrounding whitespace.`,
)
}
if (idTrimmed.length < 3) {
throw new InvalidArgumentError(
`Argument 'name' in \`Theatre.getProject("${value}", ...)\` should be at least 3 characters long.`,
)
}
}

View file

@ -0,0 +1,14 @@
import {Ticker} from '@theatre/dataverse'
const coreTicker = new Ticker()
export default coreTicker
/**
* @todo users should also be able to define their own ticker.
*/
const onAnimationFrame = (t: number) => {
coreTicker.tick(t)
window.requestAnimationFrame(onAnimationFrame)
}
window.requestAnimationFrame(onAnimationFrame)

56
theatre/core/src/index.ts Normal file
View file

@ -0,0 +1,56 @@
export * from './coreExports'
export type {
IProject,
IProjectConfig,
} from '@theatre/core/projects/TheatreProject'
export type {ISequence} from '@theatre/core/sequences/TheatreSequence'
export type {ISheetObject} from '@theatre/core/sheetObjects/TheatreSheetObject'
export type {ISheet} from '@theatre/core/sheets/TheatreSheet'
import * as globalVariableNames from '@theatre/shared/globalVariableNames'
import type StudioBundle from '@theatre/studio/StudioBundle'
import CoreBundle from './CoreBundle'
registerCoreBundle()
function registerCoreBundle() {
if (typeof window == 'undefined') return
// @ts-ignore ignore
const existingBundle = window[globalVariableNames.coreBundle]
if (typeof existingBundle !== 'undefined') {
if (
typeof existingBundle === 'object' &&
existingBundle &&
typeof existingBundle.version === 'string'
) {
throw new Error(
`It seems that the module '@theatre/core' is loaded more than once. This could have two possible causes:\n` +
`1. You might have two separate versions of theatre in node_modules.\n` +
`2. Or this might be a bundling misconfiguration, in case you're using a bundler like Webpack/ESBuild/Rollup.\n\n` +
`Note that it **is okay** to import '@theatre/core' multiple times. But those imports should point to the same module.`,
)
} else {
throw new Error(
`The variable window.${globalVariableNames.coreBundle} seems to be already set by a module other than @theatre/core.`,
)
}
}
const coreBundle = new CoreBundle()
// @ts-ignore ignore
window[globalVariableNames.coreBundle] = coreBundle
const possibleExistingStudioBundle: undefined | StudioBundle =
// @ts-ignore ignore
window[globalVariableNames.studioBundle]
if (
possibleExistingStudioBundle &&
possibleExistingStudioBundle !== null &&
possibleExistingStudioBundle.type === 'Theatre_StudioBundle'
) {
possibleExistingStudioBundle.registerCoreBundle(coreBundle)
}
}

View file

@ -0,0 +1,161 @@
import type {OnDiskState} from '@theatre/core/projects/store/storeTypes'
import type TheatreProject from '@theatre/core/projects/TheatreProject'
import type Sheet from '@theatre/core/sheets/Sheet'
import SheetTemplate from '@theatre/core/sheets/SheetTemplate'
import type Studio from '@theatre/studio/Studio'
import type {ProjectAddress} from '@theatre/shared/utils/addresses'
import type {Pointer} from '@theatre/dataverse'
import {PointerProxy} from '@theatre/dataverse'
import {Atom} from '@theatre/dataverse'
import initialiseProjectState from './initialiseProjectState'
import projectsSingleton from './projectsSingleton'
import type {ProjectState} from './store/storeTypes'
import type {Deferred} from '@theatre/shared/utils/defer'
import {defer} from '@theatre/shared/utils/defer'
export type Conf = Partial<{
state: OnDiskState
}>
export default class Project {
readonly pointers: {
historic: Pointer<ProjectState['historic']>
ahistoric: Pointer<ProjectState['ahistoric']>
ephemeral: Pointer<ProjectState['ephemeral']>
}
private readonly _pointerProxies: {
historic: PointerProxy<ProjectState['historic']>
ahistoric: PointerProxy<ProjectState['ahistoric']>
ephemeral: PointerProxy<ProjectState['ephemeral']>
}
readonly address: ProjectAddress
private readonly _readyDeferred: Deferred<undefined>
private _sheetTemplates = new Atom<{
[sheetId: string]: SheetTemplate | undefined
}>({})
sheetTemplatesP = this._sheetTemplates.pointer
private _studio: Studio | undefined
type: 'Theatre_Project' = 'Theatre_Project'
constructor(
id: string,
readonly config: Conf = {},
readonly publicApi: TheatreProject,
) {
this.address = {projectId: id}
const onDiskStateAtom = new Atom<ProjectState>({
ahistoric: {
ahistoricStuff: '',
},
historic: config.state ?? {
sheetsById: {},
definitionVersion: $env.currentProjectStateDefinitionVersion,
},
ephemeral: {
loadingState: {
type: 'loaded',
},
lastExportedObject: null,
},
})
this._pointerProxies = {
historic: new PointerProxy(onDiskStateAtom.pointer.historic),
ahistoric: new PointerProxy(onDiskStateAtom.pointer.ahistoric),
ephemeral: new PointerProxy(onDiskStateAtom.pointer.ephemeral),
}
this.pointers = {
historic: this._pointerProxies.historic.pointer,
ahistoric: this._pointerProxies.ahistoric.pointer,
ephemeral: this._pointerProxies.ephemeral.pointer,
}
projectsSingleton.add(id, this)
this._readyDeferred = defer()
if (config.state) {
setTimeout(() => {
// The user has provided config.state but in case @theatre/studio is loaded,
// let's give it one tick to attach itself
if (!this._studio) {
this._readyDeferred.resolve(undefined)
}
}, 0)
} else {
setTimeout(() => {
if (!this._studio) {
throw new Error(
`Argument config.state in Theatre.getProject("${id}", config) is empty. This is fine ` +
`while you are using @theatre/core along with @theatre/sutdio. But since @theatre/studio ` +
`is not loaded, the state of project "${id}" will be empty.\n\n` +
`To fix this, you need to add @theatre/studio into the bundle and export ` +
`the projet's state. Learn how to do that at https://docs.theatrejs.com/export.html`,
)
}
}, 1000)
}
}
attachToStudio(studio: Studio) {
if (this._studio) {
if (this._studio !== studio) {
throw new Error(
`Project ${this.address.projectId} is already attached to studio ${this._studio.address.studioId}`,
)
} else {
console.warn(
`Project ${this.address.projectId} is already attached to studio ${this._studio.address.studioId}`,
)
return
}
}
this._studio = studio
studio.initialized.then(async () => {
await initialiseProjectState(studio, this, this.config.state)
this._pointerProxies.historic.setPointer(
studio.atomP.historic.coreByProject[this.address.projectId],
)
this._pointerProxies.ahistoric.setPointer(
studio.atomP.ahistoric.coreByProject[this.address.projectId],
)
this._pointerProxies.ephemeral.setPointer(
studio.atomP.ephemeral.coreByProject[this.address.projectId],
)
this._readyDeferred.resolve(undefined)
})
}
get isAttachedToStudio() {
return !!this._studio
}
get ready() {
return this._readyDeferred.promise
}
isReady() {
return this._readyDeferred.status === 'resolved'
}
getOrCreateSheet(sheetId: string, instanceId: string = 'default'): Sheet {
let template = this._sheetTemplates.getState()[sheetId]
if (!template) {
template = new SheetTemplate(this, sheetId)
this._sheetTemplates.setIn([sheetId], template)
}
return template.getInstance(instanceId)
}
}

View file

@ -0,0 +1,66 @@
import {privateAPI, setPrivateAPI} from '@theatre/shared/privateAPIs'
import Project from '@theatre/core/projects/Project'
import type {ISheet} from '@theatre/core/sheets/TheatreSheet'
import type {ProjectAddress} from '@theatre/shared/utils/addresses'
import {validateName} from '@theatre/shared/utils/sanitizers'
import {validateAndSanitiseSlashedPathOrThrow} from '@theatre/shared/utils/slashedPaths'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
export type IProjectConfig = Partial<{
state: $IntentionalAny
}>
export interface IProject {
readonly type: 'Theatre_Project_PublicAPI'
readonly ready: Promise<void>
/**
* Shows whether the project is ready to be used.
* Better to use IProject.ready, which is a promise that will
* resolve when the project is ready.
*/
readonly isReady: boolean
readonly address: ProjectAddress
sheet(sheetId: string, instanceId?: string): ISheet
}
export default class TheatreProject implements IProject {
get type(): 'Theatre_Project_PublicAPI' {
return 'Theatre_Project_PublicAPI'
}
/**
* @internal
*/
constructor(id: string, config: IProjectConfig = {}) {
setPrivateAPI(this, new Project(id, config, this))
}
get ready(): Promise<void> {
return privateAPI(this).ready
}
get isReady(): boolean {
return privateAPI(this).isReady()
}
get address(): ProjectAddress {
return {...privateAPI(this).address}
}
sheet(sheetId: string, instanceId: string = 'default'): ISheet {
const sanitizedPath = validateAndSanitiseSlashedPathOrThrow(
sheetId,
'project.sheet',
)
if (!$env.isCore) {
validateName(
instanceId,
'instanceId in project.sheet(sheetId, instanceId)',
true,
)
}
return privateAPI(this).getOrCreateSheet(sanitizedPath, instanceId)
.publicApi
}
}

View file

@ -0,0 +1,95 @@
import type Studio from '@theatre/studio/Studio'
import delay from '@theatre/shared/utils/delay'
import {original} from 'immer'
import type Project from './Project'
import type {OnDiskState} from './store/storeTypes'
/**
* @todo this could be turned into a simple derivation, like:
* editor.isReady: IDerivation<{isReady: true} | {isReady: false, readon: 'conflictBetweenDiskStateAndBrowserState'}>
*/
export default async function initialiseProjectState(
studio: Studio,
project: Project,
onDiskState: OnDiskState | undefined,
) {
/*
* If in the future we move to IndexedDB to store the state, we'll have
* to deal with it being async (as opposed to localStorage that is synchronous.)
* so here we're artifically delaying the loading of the state to make sure users
* don't count on the state always being already loaded synchronously
*/
await delay(0)
studio.transaction(({drafts}) => {
const projectId = project.address.projectId
drafts.ephemeral.coreByProject[projectId] = {
lastExportedObject: null,
loadingState: {type: 'loading'},
}
drafts.ahistoric.coreByProject[projectId] = {
ahistoricStuff: '',
}
function useInitialState() {
drafts.ephemeral.coreByProject[projectId].loadingState = {
type: 'loaded',
}
drafts.historic.coreByProject[projectId] = {
sheetsById: {},
definitionVersion: $env.currentProjectStateDefinitionVersion,
}
}
function useOnDiskState(state: OnDiskState) {
drafts.ephemeral.coreByProject[projectId].loadingState = {
type: 'loaded',
}
drafts.historic.coreByProject[projectId] = state
}
function useBrowserState() {
drafts.ephemeral.coreByProject[projectId].loadingState = {
type: 'loaded',
}
}
function browserStateIsNotBasedOnDiskState(onDiskState: OnDiskState) {
drafts.ephemeral.coreByProject[projectId].loadingState = {
type: 'browserStateIsNotBasedOnDiskState',
onDiskState,
}
}
const browserState = original(drafts.historic)?.coreByProject[
project.address.projectId
]
if (!browserState) {
if (!onDiskState) {
useInitialState()
} else {
useOnDiskState(onDiskState)
}
} else {
if (!onDiskState) {
useBrowserState()
} else {
if (
!browserState.exportBookkeeping ||
browserState.exportBookkeeping.basedOnRevisions.indexOf(
onDiskState.exportBookkeeping.revision,
) == -1
) {
browserStateIsNotBasedOnDiskState(onDiskState)
} else {
useBrowserState()
}
}
}
})
}

View file

@ -0,0 +1,30 @@
import {Atom} from '@theatre/dataverse'
import type Project from './Project'
interface State {
projects: Record<string, Project>
}
class ProjectsSingleton {
readonly atom = new Atom({projects: {}} as State)
constructor() {}
/**
* We're trusting here that each project id is unique
*/
add(id: string, project: Project) {
this.atom.reduceState(['projects', id], () => project)
}
get(id: string): Project | undefined {
return this.atom.getState().projects[id]
}
has(id: string) {
return !!this.get(id)
}
}
const singleton = new ProjectsSingleton()
export default singleton

View file

@ -0,0 +1,49 @@
import type {StrictRecord} from '@theatre/shared/utils/types'
import type {SheetState_Historic} from './types/SheetState_Historic'
export interface ProjectLoadedState {
type: 'loaded'
}
type ProjectLoadingState =
| {type: 'loading'}
| ProjectLoadedState
| {
type: 'browserStateIsNotBasedOnDiskState'
onDiskState: OnDiskState
}
/**
* Ahistoric state is persisted, but its changes
* are not undoable.
*/
export interface ProjectAhistoricState {
ahistoricStuff: string
}
/**
* Ephemeral state is neither persisted nor undoable
*/
export interface ProjectEphemeralState {
loadingState: ProjectLoadingState
lastExportedObject: null | OnDiskState
}
/**
* Historic state is both persisted and is undoable
*/
export interface ProjectState_Historic {
sheetsById: StrictRecord<string, SheetState_Historic>
exportBookkeeping?: {revision: string; basedOnRevisions: string[]}
definitionVersion: string
}
export interface ProjectState {
historic: ProjectState_Historic
ahistoric: ProjectAhistoricState
ephemeral: ProjectEphemeralState
}
export interface OnDiskState extends ProjectState_Historic {
exportBookkeeping: {revision: string; basedOnRevisions: string[]}
}

View file

@ -0,0 +1,51 @@
import type {KeyframeId, SequenceTrackId} from '@theatre/shared/utils/ids'
import type {SerializableMap, StrictRecord} from '@theatre/shared/utils/types'
export interface SheetState_Historic {
/**
* @remarks
* Notes for when we implement FSMs:
*
* Each FSM state will have overrides of its own. Since a state could be a descendant
* of another state, it will be able to inherit the overrides from ancestor states.
*/
staticOverrides: {
byObject: StrictRecord<string, SerializableMap>
}
sequence?: Sequence
}
type Sequence = PositionalSequence
type PositionalSequence = {
type: 'PositionalSequence'
length: number
/**
* If set to, say, 30, then the keyframe editor will try to snap all keyframes
* to a 30fps grid
*/
subUnitsPerUnit: number
tracksByObject: StrictRecord<
string,
{
trackIdByPropPath: StrictRecord<string, SequenceTrackId>
trackData: StrictRecord<SequenceTrackId, TrackData>
}
>
}
export type TrackData = BasicKeyframedTrack
export type Keyframe = {
id: KeyframeId
value: number
position: number
handles: [leftX: number, leftY: number, rightX: number, rightY: number]
connectedRight: boolean
}
export type BasicKeyframedTrack = {
type: 'BasicKeyframedTrack'
keyframes: Keyframe[]
}

View file

@ -0,0 +1,389 @@
import type Project from '@theatre/core/projects/Project'
import coreTicker from '@theatre/core/coreTicker'
import type Sheet from '@theatre/core/sheets/Sheet'
import type {SequenceAddress} from '@theatre/shared/utils/addresses'
import didYouMean from '@theatre/shared/utils/didYouMean'
import {InvalidArgumentError} from '@theatre/shared/utils/errors'
import type {IRange} from '@theatre/shared/utils/types'
import type {IBox, IDerivation, Pointer} from '@theatre/dataverse'
import {Box, prism, val, valueDerivation} from '@theatre/dataverse'
import {padStart} from 'lodash-es'
import type {
IPlaybackController,
IPlaybackState,
} from './playbackControllers/DefaultPlaybackController'
import DefaultPlaybackController from './playbackControllers/DefaultPlaybackController'
import TheatreSequence from './TheatreSequence'
import logger from '@theatre/shared/logger'
export type IPlaybackRange = IRange
export type IPlaybackDirection =
| 'normal'
| 'reverse'
| 'alternate'
| 'alternateReverse'
const possibleDirections = [
'normal',
'reverse',
'alternate',
'alternateReverse',
]
export default class Sequence {
public readonly address: SequenceAddress
publicApi: TheatreSequence
private _playbackControllerBox: IBox<IPlaybackController>
private _statePointerDerivation: IDerivation<Pointer<IPlaybackState>>
private _positionD: IDerivation<number>
private _positionFormatterD: IDerivation<ISequencePositionFormatter>
_playableRangeD: undefined | IDerivation<{start: number; end: number}>
constructor(
readonly _project: Project,
readonly _sheet: Sheet,
readonly _lengthD: IDerivation<number>,
readonly _subUnitsPerUnitD: IDerivation<number>,
playbackController?: IPlaybackController,
) {
this.address = {...this._sheet.address, sequenceName: 'default'}
this.publicApi = new TheatreSequence(this)
this._playbackControllerBox = new Box(
playbackController ?? new DefaultPlaybackController(coreTicker),
)
this._statePointerDerivation = this._playbackControllerBox.derivation.map(
(playbackController) => playbackController.statePointer,
)
this._positionD = this._statePointerDerivation.flatMap((statePointer) =>
valueDerivation(statePointer.position),
)
this._positionFormatterD = this._subUnitsPerUnitD.map(
(subUnitsPerUnit) => new TimeBasedPositionFormatter(subUnitsPerUnit),
)
}
get positionFormatter(): ISequencePositionFormatter {
return this._positionFormatterD.getValue()
}
get derivationToStatePointer() {
return this._statePointerDerivation
}
get length() {
return this._lengthD.getValue()
}
get positionDerivation() {
return this._positionD
}
get position() {
return this._playbackControllerBox.get().getCurrentPosition()
}
get subUnitsPerUnit(): number {
return this._subUnitsPerUnitD.getValue()
}
get positionSnappedToGrid(): number {
return this.closestGridPosition(this.position)
}
closestGridPosition(posInUnitSpace: number): number {
const subUnitsPerUnit = this.subUnitsPerUnit
const gridLength = 1 / subUnitsPerUnit
return Math.round(posInUnitSpace / gridLength) * gridLength
}
set position(requestedPosition: number) {
let position = requestedPosition
this.pause()
if (!$env.isCore) {
if (typeof position !== 'number') {
logger.error(
`value t in sequence.position = t must be a number. ${typeof position} given`,
)
position = 0
}
if (position < 0) {
logger.error(
`sequence.position must be a positive number. ${position} given`,
)
position = 0
}
}
if (position > this.length) {
position = this.length
}
const dur = this.length
this._playbackControllerBox
.get()
.gotoPosition(position > dur ? dur : position)
}
getDurationCold() {
return this._lengthD.getValue()
}
get playing() {
return this._playbackControllerBox.get().playing
}
_makeRangeFromSequenceTemplate(): IDerivation<IPlaybackRange> {
return prism(() => {
return {
start: 0,
end: val(this._lengthD),
}
})
}
async play(
conf?: Partial<{
iterationCount: number
range: IPlaybackRange
rate: number
direction: IPlaybackDirection
}>,
): Promise<boolean> {
const sequenceDuration = this.length
const range =
conf && conf.range
? conf.range
: {
start: 0,
end: sequenceDuration,
}
if (!$env.isCore) {
if (typeof range.start !== 'number' || range.start < 0) {
throw new InvalidArgumentError(
`Argument conf.range.start in sequence.play(conf) must be a positive number. ${JSON.stringify(
range.start,
)} given.`,
)
}
if (range.start >= sequenceDuration) {
throw new InvalidArgumentError(
`Argument conf.range.start in sequence.play(conf) cannot be longer than the duration of the sequence, which is ${sequenceDuration}ms. ${JSON.stringify(
range.start,
)} given.`,
)
}
if (typeof range.end !== 'number' || range.end <= 0) {
throw new InvalidArgumentError(
`Argument conf.range.end in sequence.play(conf) must be a number larger than zero. ${JSON.stringify(
range.end,
)} given.`,
)
}
if (range.end > sequenceDuration) {
logger.warn(
`Argument conf.range.end in sequence.play(conf) cannot be longer than the duration of the sequence, which is ${sequenceDuration}ms. ${JSON.stringify(
range.end,
)} given.`,
)
range.end = sequenceDuration
}
if (range.end <= range.start) {
throw new InvalidArgumentError(
`Argument conf.range.end in sequence.play(conf) must be larger than conf.range.start. ${JSON.stringify(
range,
)} given.`,
)
}
}
const iterationCount =
conf && typeof conf.iterationCount === 'number' ? conf.iterationCount : 1
if (!$env.isCore) {
if (
!(Number.isInteger(iterationCount) && iterationCount > 0) &&
iterationCount !== Infinity
) {
throw new InvalidArgumentError(
`Argument conf.iterationCount in sequence.play(conf) must be an integer larger than 0. ${JSON.stringify(
iterationCount,
)} given.`,
)
}
}
const rate = conf && typeof conf.rate !== 'undefined' ? conf.rate : 1
if (!$env.isCore) {
if (typeof rate !== 'number' || rate === 0) {
throw new InvalidArgumentError(
`Argument conf.rate in sequence.play(conf) must be a number larger than 0. ${JSON.stringify(
rate,
)} given.`,
)
}
if (rate < 0) {
throw new InvalidArgumentError(
`Argument conf.rate in sequence.play(conf) must be a number larger than 0. ${JSON.stringify(
rate,
)} given. If you want the animation to play backwards, try setting conf.direction to 'reverse' or 'alternateReverse'.`,
)
}
}
const direction = conf && conf.direction ? conf.direction : 'normal'
if (!$env.isCore) {
if (possibleDirections.indexOf(direction) === -1) {
throw new InvalidArgumentError(
`Argument conf.direction in sequence.play(conf) must be one of ${JSON.stringify(
possibleDirections,
)}. ${JSON.stringify(direction)} given. ${didYouMean(
direction,
possibleDirections,
)}`,
)
}
}
return await this._play(
iterationCount,
{start: range.start, end: range.end},
rate,
direction,
)
}
protected _play(
iterationCount: number,
range: IPlaybackRange,
rate: number,
direction: IPlaybackDirection,
): Promise<boolean> {
return this._playbackControllerBox
.get()
.play(iterationCount, range, rate, direction)
}
pause() {
this._playbackControllerBox.get().pause()
}
replacePlaybackController(playbackController: IPlaybackController) {
this.pause()
const oldController = this._playbackControllerBox.get()
this._playbackControllerBox.set(playbackController)
const time = oldController.getCurrentPosition()
oldController.destroy()
playbackController.gotoPosition(time)
}
}
export interface ISequencePositionFormatter {
formatSubUnitForGrid(posInUnitSpace: number): string
formatFullUnitForGrid(posInUnitSpace: number): string
formatForPlayhead(posInUnitSpace: number): string
}
class TimeBasedPositionFormatter implements ISequencePositionFormatter {
constructor(private readonly _fps: number) {}
formatSubUnitForGrid(posInUnitSpace: number): string {
const subSecondPos = posInUnitSpace % 1
const frame = 1 / this._fps
const frames = Math.round(subSecondPos / frame)
return frames + 'f'
}
formatFullUnitForGrid(posInUnitSpace: number): string {
let p = posInUnitSpace
let s = ''
if (p >= hour) {
const hours = Math.floor(p / hour)
s += hours + 'h'
p = p % hour
}
if (p >= minute) {
const minutes = Math.floor(p / minute)
s += minutes + 'm'
p = p % minute
}
if (p >= second) {
const seconds = Math.floor(p / second)
s += seconds + 's'
p = p % second
}
const frame = 1 / this._fps
if (p >= frame) {
const frames = Math.floor(p / frame)
s += frames + 'f'
p = p % frame
}
return s.length === 0 ? '0s' : s
}
formatForPlayhead(posInUnitSpace: number): string {
let p = posInUnitSpace
let s = ''
if (p >= hour) {
const hours = Math.floor(p / hour)
s += padStart(hours.toString(), 2, '0') + 'h'
p = p % hour
}
if (p >= minute) {
const minutes = Math.floor(p / minute)
s += padStart(minutes.toString(), 2, '0') + 'm'
p = p % minute
} else if (s.length > 0) {
s += '00m'
}
if (p >= second) {
const seconds = Math.floor(p / second)
s += padStart(seconds.toString(), 2, '0') + 's'
p = p % second
} else {
s += '00s'
}
const frameLength = 1 / this._fps
if (p >= frameLength) {
const frames = Math.round(p / frameLength)
s += padStart(frames.toString(), 2, '0') + 'f'
p = p % frameLength
} else if (p / frameLength > 0.98) {
const frames = 1
s += padStart(frames.toString(), 2, '0') + 'f'
p = p % frameLength
} else {
s += '00f'
}
return s.length === 0 ? '00s00f' : s
}
}
const second = 1
const minute = second * 60
const hour = minute * 60

View file

@ -0,0 +1,76 @@
import logger from '@theatre/shared/logger'
import {privateAPI, setPrivateAPI} from '@theatre/shared/privateAPIs'
import {defer} from '@theatre/shared/utils/defer'
import type Sequence from './Sequence'
import type {IPlaybackDirection, IPlaybackRange} from './Sequence'
export interface ISequence {
/**
* Starts playback of a sequence.
* Returns a promise that either resolves to true when the playback completes,
* or resolves to false if playback gets interrupted (for example by calling sequence.pause())
*/
play(
conf?: Partial<{
iterationCount: number
range: IPlaybackRange
rate: number
direction: IPlaybackDirection
}>,
): Promise<boolean>
pause(): void
time: number
}
export default class TheatreSequence {
/**
* @internal
*/
constructor(sheet: Sequence) {
setPrivateAPI(this, sheet)
}
play(
conf?: Partial<{
iterationCount: number
range: IPlaybackRange
rate: number
direction: IPlaybackDirection
}>,
): Promise<boolean> {
if (privateAPI(this)._project.isReady()) {
return privateAPI(this).play(conf)
} else {
if (!$env.isCore) {
logger.warn(
`You seem to have called sequence.play() before the project has finished loading.\n` +
`This would **not** a problem in production when using '@theatre/core', since Theatre loads instantly in core mode. ` +
`However, when using '@theatre/studio', it takes a few milliseconds for it to load your project's state, ` +
`before which your sequences cannot start playing.\n` +
`\n` +
`To fix this, simply defer calling sequence.play() until after the project is loaded, like this:\n` +
`project.ready.then(() => {\n` +
` sequence.play()\n` +
`})`,
)
}
const d = defer<boolean>()
d.resolve(true)
return d.promise
}
}
pause() {
privateAPI(this).pause()
}
get time() {
return privateAPI(this).position
}
set time(t: number) {
privateAPI(this).position = t
}
}

View file

@ -0,0 +1,195 @@
import type {
IPlaybackDirection,
IPlaybackRange,
} from '@theatre/core/sequences/Sequence'
import {defer} from '@theatre/shared/utils/defer'
import {InvalidArgumentError} from '@theatre/shared/utils/errors'
import noop from '@theatre/shared/utils/noop'
import type {Pointer, Ticker} from '@theatre/dataverse'
import {Atom} from '@theatre/dataverse'
import type {
IPlaybackController,
IPlaybackState,
} from './DefaultPlaybackController'
export default class AudioPlaybackController implements IPlaybackController {
_mainGain: GainNode
private _state: Atom<IPlaybackState> = new Atom({position: 0})
readonly statePointer: Pointer<IPlaybackState>
_stopPlayCallback: () => void = noop
playing: boolean = false
constructor(
private readonly _ticker: Ticker,
private readonly _decodedBuffer: AudioBuffer,
private readonly _audioContext: AudioContext,
private readonly _nodeDestination: AudioDestinationNode,
) {
this.statePointer = this._state.pointer
this._mainGain = this._audioContext.createGain()
this._mainGain.connect(this._nodeDestination)
}
destroy() {}
pause() {
this._stopPlayCallback()
this.playing = false
this._stopPlayCallback = noop
}
gotoPosition(time: number) {
this._updatePositionInState(time)
}
private _updatePositionInState(time: number) {
this._state.reduceState(['position'], () => time)
}
getCurrentPosition() {
return this._state.getState().position
}
play(
iterationCount: number,
range: IPlaybackRange,
rate: number,
direction: IPlaybackDirection,
): Promise<boolean> {
if (this.playing) {
this.pause()
}
this.playing = true
const ticker = this._ticker
let lastTickerTime = ticker.time
const dur = range.end - range.start
const prevTime = this.getCurrentPosition()
if (rate !== 1.0) {
throw new InvalidArgumentError(
`Audio-controlled sequences can only have a playbackRate of 1.0. ${rate} given.`,
)
}
if (direction !== 'normal') {
throw new InvalidArgumentError(
`Audio-controlled sequences can only be played in the "normal" direction. ` +
`'${direction}' given.`,
)
}
if (prevTime < range.start || prevTime > range.end) {
// if we're currently out of the range
this._updatePositionInState(range.start)
} else if (prevTime === range.end) {
// if we're currently at the very end of the range
this._updatePositionInState(range.start)
}
let countSoFar = 1
const deferred = defer<boolean>()
const currentSource = this._audioContext.createBufferSource()
currentSource.buffer = this._decodedBuffer
currentSource.connect(this._mainGain)
const audioStartTimeInSeconds = this._audioContext.currentTime
const wait = 0
const timeToRangeEnd = range.end - prevTime
if (iterationCount > 1) {
currentSource.loop = true
currentSource.loopStart = range.start
currentSource.loopEnd = range.end
}
currentSource.start(
audioStartTimeInSeconds + wait,
prevTime - wait,
iterationCount === 1 ? wait + timeToRangeEnd : undefined,
)
const tick = (tickerTime: number) => {
const lastTime = this.getCurrentPosition()
const timeDiff = (tickerTime - lastTickerTime) * rate
lastTickerTime = tickerTime
/*
* I don't know why exactly this happens, but every 10 times or so, the first sequence.play({iterationCount: 1}),
* the first call of tick() will have a timeDiff < 0.
* This might be because of Spectre mitigation (they randomize performance.now() a bit), or it could be that
* I'm using performance.now() the wrong way.
* Anyway, this seems like a working fix for it:
*/
if (timeDiff < 0) {
requestNextTick()
return
}
const newTime = lastTime + timeDiff
if (newTime < range.start) {
if (countSoFar === iterationCount) {
this._updatePositionInState(range.start)
this.playing = false
cleanup()
deferred.resolve(true)
return
} else {
countSoFar++
const diff = (range.start - newTime) % dur
this._updatePositionInState(range.start + diff)
requestNextTick()
return
}
} else if (newTime === range.end) {
this._updatePositionInState(range.end)
if (countSoFar === iterationCount) {
this.playing = false
cleanup()
deferred.resolve(true)
return
}
requestNextTick()
return
} else if (newTime > range.end) {
if (countSoFar === iterationCount) {
this._updatePositionInState(range.end)
this.playing = false
cleanup()
deferred.resolve(true)
return
} else {
countSoFar++
const diff = (newTime - range.end) % dur
this._updatePositionInState(range.start + diff)
requestNextTick()
return
}
} else {
this._updatePositionInState(newTime)
requestNextTick()
return
}
}
const cleanup = () => {
currentSource.stop()
currentSource.disconnect()
}
this._stopPlayCallback = () => {
cleanup()
ticker.offThisOrNextTick(tick)
ticker.offNextTick(tick)
if (this.playing) deferred.resolve(false)
}
const requestNextTick = () => ticker.onNextTick(tick)
ticker.onThisOrNextTick(tick)
return deferred.promise
}
}

View file

@ -0,0 +1,178 @@
import type {
IPlaybackDirection,
IPlaybackRange,
} from '@theatre/core/sequences/Sequence'
import {defer} from '@theatre/shared/utils/defer'
import noop from '@theatre/shared/utils/noop'
import type {Pointer, Ticker} from '@theatre/dataverse'
import {Atom} from '@theatre/dataverse'
export interface IPlaybackState {
position: number
}
export interface IPlaybackController {
playing: boolean
getCurrentPosition(): number
gotoPosition(position: number): void
statePointer: Pointer<IPlaybackState>
destroy(): void
play(
iterationCount: number,
range: IPlaybackRange,
rate: number,
direction: IPlaybackDirection,
): Promise<boolean>
pause(): void
}
export default class DefaultPlaybackController implements IPlaybackController {
playing: boolean = false
_stopPlayCallback: () => void = noop
private _state: Atom<IPlaybackState> = new Atom({position: 0})
readonly statePointer: Pointer<IPlaybackState>
constructor(private readonly _ticker: Ticker) {
this.statePointer = this._state.pointer
}
destroy() {}
pause() {
this._stopPlayCallback()
this.playing = false
this._stopPlayCallback = noop
}
gotoPosition(time: number) {
this._updatePositionInState(time)
}
private _updatePositionInState(time: number) {
this._state.reduceState(['position'], () => time)
}
getCurrentPosition() {
return this._state.getState().position
}
play(
iterationCount: number,
range: IPlaybackRange,
rate: number,
direction: IPlaybackDirection,
): Promise<boolean> {
if (this.playing) {
this.pause()
}
this.playing = true
const ticker = this._ticker
let lastTickerTime = ticker.time
const dur = range.end - range.start
const prevTime = this.getCurrentPosition()
if (prevTime < range.start || prevTime > range.end) {
this._updatePositionInState(range.start)
} else if (
prevTime === range.end &&
(direction === 'normal' || direction === 'alternate')
) {
this._updatePositionInState(range.start)
} else if (
prevTime === range.start &&
(direction === 'reverse' || direction === 'alternateReverse')
) {
this._updatePositionInState(range.end)
}
let goingForward =
direction === 'alternateReverse' || direction === 'reverse' ? -1 : 1
let countSoFar = 1
const deferred = defer<boolean>()
const tick = (tickerTimeInMs: number) => {
const tickerTime = tickerTimeInMs / 1000
const lastTime = this.getCurrentPosition()
const timeDiff = (tickerTime - lastTickerTime) * (rate * goingForward)
lastTickerTime = tickerTime
/*
* I don't know why exactly this happens, but every 10 times or so, the first sequence.play({iterationCount: 1}),
* the first call of tick() will have a timeDiff < 0.
* This might be because of Spectre mitigation (they randomize performance.now() a bit), or it could be that
* I'm using performance.now() the wrong way.
* Anyway, this seems like a working fix for it:
*/
if (timeDiff < 0) {
requestNextTick()
return
}
const newTime = lastTime + timeDiff
if (newTime < range.start) {
if (countSoFar === iterationCount) {
this._updatePositionInState(range.start)
this.playing = false
deferred.resolve(true)
return
} else {
countSoFar++
const diff = (range.start - newTime) % dur
if (direction === 'reverse') {
this._updatePositionInState(range.end - diff)
} else {
goingForward = 1
this._updatePositionInState(range.start + diff)
}
requestNextTick()
return
}
} else if (newTime === range.end) {
this._updatePositionInState(range.end)
if (countSoFar === iterationCount) {
this.playing = false
deferred.resolve(true)
return
}
requestNextTick()
return
} else if (newTime > range.end) {
if (countSoFar === iterationCount) {
this._updatePositionInState(range.end)
this.playing = false
deferred.resolve(true)
return
} else {
countSoFar++
const diff = (newTime - range.end) % dur
if (direction === 'normal') {
this._updatePositionInState(range.start + diff)
} else {
goingForward = -1
this._updatePositionInState(range.end - diff)
}
requestNextTick()
return
}
} else {
this._updatePositionInState(newTime)
requestNextTick()
return
}
}
this._stopPlayCallback = () => {
ticker.offThisOrNextTick(tick)
ticker.offNextTick(tick)
if (this.playing) deferred.resolve(false)
}
const requestNextTick = () => ticker.onNextTick(tick)
ticker.onThisOrNextTick(tick)
return deferred.promise
}
}

View file

@ -0,0 +1,201 @@
import type {
BasicKeyframedTrack,
Keyframe,
TrackData,
} from '@theatre/core/projects/store/types/SheetState_Historic'
import type {IDerivation, Pointer} from '@theatre/dataverse'
import {ConstantDerivation, prism, val} from '@theatre/dataverse'
import logger from '@theatre/shared/logger'
import UnitBezier from 'timing-function/lib/UnitBezier'
export default function trackValueAtTime(
trackP: Pointer<TrackData | undefined>,
timeD: IDerivation<number>,
): IDerivation<unknown> {
return prism(() => {
const track = val(trackP)
const driverD = prism.memo(
'driver',
() => {
if (!track) {
return new ConstantDerivation(undefined)
} else if (track.type === 'BasicKeyframedTrack') {
return trackValueAtTime_basicKeyframedTrack(track, timeD)
} else {
logger.error(`Track type not yet supported.`)
return new ConstantDerivation(undefined)
}
},
[track],
)
return driverD.getValue()
})
}
type IStartedState = {
started: true
validFrom: number
validTo: number
der: IDerivation<unknown>
}
type IState = {started: false} | IStartedState
function trackValueAtTime_basicKeyframedTrack(
track: BasicKeyframedTrack,
timeD: IDerivation<number>,
): IDerivation<unknown> {
return prism(() => {
let stateRef = prism.ref<IState>('state', {started: false})
let state = stateRef.current
const time = timeD.getValue()
if (!state.started || time < state.validFrom || state.validTo <= time) {
stateRef.current = state = pp(timeD, track)
}
return state.der.getValue()
})
}
const undefinedConstD = new ConstantDerivation(undefined)
const pp = (
progressionD: IDerivation<number>,
track: BasicKeyframedTrack,
): IStartedState => {
const progression = progressionD.getValue()
if (track.keyframes.length === 0) {
return {
started: true,
validFrom: -Infinity,
validTo: Infinity,
der: undefinedConstD,
}
}
let currentKeyframeIndex = 0
while (true) {
const currentKeyframe = track.keyframes[currentKeyframeIndex]
if (!currentKeyframe) {
if ($env.NODE_ENV === 'development') {
logger.error(`Bug here`)
}
return states.error
}
const isLastKeyframe = currentKeyframeIndex === track.keyframes.length - 1
if (progression < currentKeyframe.position) {
if (currentKeyframeIndex === 0) {
return states.beforeFirstKeyframe(currentKeyframe)
} else {
if ($env.NODE_ENV === 'development') {
logger.error(`Bug here`)
}
return states.error
// note: uncomment these if we support starting with currentPointIndex != 0
// currentPointIndex--
// continue
}
} else if (currentKeyframe.position === progression) {
if (isLastKeyframe) {
return states.lastKeyframe(currentKeyframe)
} else {
return states.between(
currentKeyframe,
track.keyframes[currentKeyframeIndex + 1],
progressionD,
)
}
} else {
// last point
if (currentKeyframeIndex === track.keyframes.length - 1) {
return states.lastKeyframe(currentKeyframe)
} else {
const nextKeyframeIndex = currentKeyframeIndex + 1
if (track.keyframes[nextKeyframeIndex].position <= progression) {
currentKeyframeIndex = nextKeyframeIndex
continue
} else {
return states.between(
currentKeyframe,
track.keyframes[currentKeyframeIndex + 1],
progressionD,
)
}
}
}
}
}
const states = {
beforeFirstKeyframe(kf: Keyframe): IStartedState {
return {
started: true,
validFrom: -Infinity,
validTo: kf.position,
der: new ConstantDerivation(kf.value),
}
},
lastKeyframe(kf: Keyframe): IStartedState {
return {
started: true,
validFrom: kf.position,
validTo: Infinity,
der: new ConstantDerivation(kf.value),
}
},
between(
left: Keyframe,
right: Keyframe,
progressionD: IDerivation<number>,
): IStartedState {
if (!left.connectedRight) {
return {
started: true,
validFrom: left.position,
validTo: right.position,
der: new ConstantDerivation(left.value),
}
}
const solver = new UnitBezier(
left.handles[2],
left.handles[3],
right.handles[0],
right.handles[1],
)
const globalProgressionToLocalProgression = (
globalProgression: number,
): number => {
return (
(globalProgression - left.position) / (right.position - left.position)
)
}
const der = prism(() => {
const progression = globalProgressionToLocalProgression(
progressionD.getValue(),
)
const valueProgression = solver.solveSimple(progression)
return left.value + valueProgression * (right.value - left.value)
})
return {
started: true,
validFrom: left.position,
validTo: right.position,
der,
}
},
error: {
started: true,
validFrom: -Infinity,
validTo: Infinity,
der: undefinedConstD,
} as IStartedState,
}

View file

@ -0,0 +1,131 @@
/**
* @jest-environment jsdom
*/
import {setupTestSheet} from '@theatre/shared/testUtils'
import {encodePathToProp} from '@theatre/shared/utils/addresses'
import {asKeyframeId, asSequenceTrackId} from '@theatre/shared/utils/ids'
import {iterateOver, prism, val} from '@theatre/dataverse'
describe(`SheetObject`, () => {
test('it should support setting/unsetting static props', async () => {
const {obj, studio} = await setupTestSheet({
staticOverrides: {
byObject: {
obj: {
position: {
x: 10,
},
},
},
},
})
const objValues = iterateOver(
prism(() => {
return val(val(obj.getValues()))
}),
)
expect(objValues.next().value).toMatchObject({
position: {x: 10, y: 1, z: 2},
})
// setting a static
studio.transaction(({set}) => {
set(obj.propsP.position.y, 5)
})
expect(objValues.next().value).toMatchObject({
position: {x: 10, y: 5, z: 2},
})
// unsetting a static
studio.transaction(({unset}) => {
unset(obj.propsP.position.y)
})
expect(objValues.next().value).toMatchObject({
position: {x: 10, y: 1, z: 2},
})
objValues.return()
})
test('it should support sequenced props', async () => {
const {obj, sheet} = await setupTestSheet({
staticOverrides: {
byObject: {},
},
sequence: {
type: 'PositionalSequence',
length: 20,
subUnitsPerUnit: 30,
tracksByObject: {
obj: {
trackIdByPropPath: {
[encodePathToProp(['position', 'y'])]: asSequenceTrackId('1'),
},
trackData: {
'1': {
type: 'BasicKeyframedTrack',
keyframes: [
{
id: asKeyframeId('0'),
position: 10,
connectedRight: true,
handles: [0.5, 0.5, 0.5, 0.5],
value: 3,
},
{
id: asKeyframeId('1'),
position: 20,
connectedRight: false,
handles: [0.5, 0.5, 0.5, 0.5],
value: 6,
},
],
},
},
},
},
},
})
const seq = sheet.publicApi.sequence()
const objValues = iterateOver(
prism(() => {
return val(val(obj.getValues()))
}),
)
expect(seq.time).toEqual(0)
expect(objValues.next().value).toMatchObject({
position: {x: 0, y: 3, z: 2},
})
seq.time = 5
expect(seq.time).toEqual(5)
expect(objValues.next().value).toMatchObject({
position: {x: 0, y: 3, z: 2},
})
seq.time = 11
expect(objValues.next().value).toMatchObject({
position: {x: 0, y: 3.29999747758308, z: 2},
})
seq.time = 15
expect(objValues.next().value).toMatchObject({
position: {x: 0, y: 4.5, z: 2},
})
seq.time = 22
expect(objValues.next().value).toMatchObject({
position: {x: 0, y: 6, z: 2},
})
objValues.return()
})
})

View file

@ -0,0 +1,203 @@
import trackValueAtTime from '@theatre/core/sequences/trackValueAtTime'
import type Sheet from '@theatre/core/sheets/Sheet'
import type {SheetObjectConfig} from '@theatre/core/sheets/TheatreSheet'
import type {SheetObjectAddress} from '@theatre/shared/utils/addresses'
import deepMergeWithCache from '@theatre/shared/utils/deepMergeWithCache'
import type {SequenceTrackId} from '@theatre/shared/utils/ids'
import pointerDeep from '@theatre/shared/utils/pointerDeep'
import SimpleCache from '@theatre/shared/utils/SimpleCache'
import type {
$FixMe,
$IntentionalAny,
SerializableMap,
SerializableValue,
} from '@theatre/shared/utils/types'
import {valToAtom} from '@theatre/shared/utils/valToAtom'
import type {IDerivation, Pointer} from '@theatre/dataverse'
import {Atom, getPointerParts, pointer, prism, val} from '@theatre/dataverse'
import type SheetObjectTemplate from './SheetObjectTemplate'
import TheatreSheetObject from './TheatreSheetObject'
// type Everything = {
// final: SerializableMap
// statics: SerializableMap
// defaults: SerializableMap
// sequenced: SerializableMap
// }
export default class SheetObject {
get type(): 'Theatre_SheetObject' {
return 'Theatre_SheetObject'
}
readonly address: SheetObjectAddress
readonly publicApi: TheatreSheetObject<$IntentionalAny>
private readonly _initialValue = new Atom<SerializableMap>({})
private readonly _cache = new SimpleCache()
constructor(
readonly sheet: Sheet,
readonly template: SheetObjectTemplate,
readonly nativeObject: unknown,
) {
this.address = {
...template.address,
sheetInstanceId: sheet.address.sheetInstanceId,
}
this.publicApi = new TheatreSheetObject(this)
}
overrideConfig(
nativeObject: unknown,
config: SheetObjectConfig<$IntentionalAny>,
) {
if (nativeObject !== this.nativeObject) {
// @todo
}
this.template.overrideConfig(nativeObject, config)
}
// getValues(): Record<string, unknown> {
// return {}
// }
getValues(): IDerivation<Pointer<SerializableMap>> {
return this._cache.get('getValues()', () =>
prism(() => {
const defaults = val(this.template.getDefaultValues())
const initial = val(this._initialValue.pointer)
const withInitialCache = prism.memo(
'withInitialCache',
() => new WeakMap(),
[],
)
const withInitial = deepMergeWithCache(
defaults,
initial,
withInitialCache,
)
const statics = val(this.template.getStaticValues())
const withStaticsCache = prism.memo(
'withStatics',
() => new WeakMap(),
[],
)
const withStatics = deepMergeWithCache(
withInitial,
statics,
withStaticsCache,
)
let final = withStatics
let sequenced
{
const pointerToSequencedValuesD = prism.memo(
'seq',
() => this.getSequencedValues(),
[],
)
const withSeqsCache = prism.memo(
'withSeqsCache',
() => new WeakMap(),
[],
)
sequenced = val(val(pointerToSequencedValuesD))
const withSeqs = deepMergeWithCache(final, sequenced, withSeqsCache)
final = withSeqs
}
const a = valToAtom<SerializableMap>('finalAtom', final)
return a.pointer
}),
)
}
getValueByPointer(pointer: SheetObject['propsP']): SerializableValue {
const allValuesP = val(this.getValues())
const {path} = getPointerParts(pointer)
return val(pointerDeep(allValuesP as $FixMe, path)) as SerializableMap
}
/**
* Returns values of props that are sequenced.
*/
getSequencedValues(): IDerivation<Pointer<SerializableMap>> {
return prism(() => {
const tracksToProcessD = prism.memo(
'tracksToProcess',
() => this.template.getArrayOfValidSequenceTracks(),
[],
)
const tracksToProcess = val(tracksToProcessD)
const valsAtom = new Atom<SerializableMap>({})
prism.effect(
'processTracks',
() => {
const untaps: Array<() => void> = []
for (const {trackId, pathToProp} of tracksToProcess) {
const derivation = this._trackIdToDerivation(trackId)
const updateSequenceValueFromItsDerivation = () => {
valsAtom.setIn(pathToProp, derivation.getValue())
}
const untap = derivation
.changesWithoutValues()
.tap(updateSequenceValueFromItsDerivation)
updateSequenceValueFromItsDerivation()
untaps.push(untap)
}
return () => {
for (const untap of untaps) {
untap()
}
}
},
tracksToProcess,
)
return valsAtom.pointer
})
}
protected _trackIdToDerivation(
trackId: SequenceTrackId,
): IDerivation<unknown> {
const trackP =
this.template.project.pointers.historic.sheetsById[this.address.sheetId]
.sequence.tracksByObject[this.address.objectKey].trackData[trackId]
const timeD = this.sheet.getSequence().positionDerivation
return trackValueAtTime(trackP, timeD)
}
get propsP(): Pointer<$FixMe> {
return this._cache.get('propsP', () =>
pointer<{props: {}}>({root: this, path: []}),
) as $FixMe
}
validateValue(pointer: Pointer<$FixMe>, value: unknown) {
// @todo
}
setInitialValue(val: SerializableMap) {
this.validateValue(this.propsP, val)
this._initialValue.setState(val)
}
}

View file

@ -0,0 +1,106 @@
/**
* @jest-environment jsdom
*/
import {setupTestSheet} from '@theatre/shared/testUtils'
import {encodePathToProp} from '@theatre/shared/utils/addresses'
import {asSequenceTrackId} from '@theatre/shared/utils/ids'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
import {iterateOver} from '@theatre/dataverse'
describe(`SheetObjectTemplate`, () => {
describe(`getArrayOfValidSequenceTracks()`, () => {
it('should only include valid tracks', async () => {
const {obj} = await setupTestSheet({
staticOverrides: {
byObject: {},
},
sequence: {
type: 'PositionalSequence',
subUnitsPerUnit: 30,
length: 10,
tracksByObject: {
obj: {
trackIdByPropPath: {
[encodePathToProp(['position', 'x'])]: asSequenceTrackId('x'),
[encodePathToProp(['position', 'invalid'])]: asSequenceTrackId(
'invalidTrack',
),
},
trackData: {
x: null as $IntentionalAny,
invalid: null as $IntentionalAny,
},
},
},
},
})
const iter = iterateOver(obj.template.getArrayOfValidSequenceTracks())
const validTracks = iter.next().value
expect(validTracks).toHaveLength(1)
expect(validTracks).toMatchObject([
{
pathToProp: ['position', 'x'],
trackId: 'x',
},
])
})
it('should return empty array when no tracks are set up', async () => {
const {obj} = await setupTestSheet({
staticOverrides: {
byObject: {},
},
sequence: {
type: 'PositionalSequence',
subUnitsPerUnit: 30,
length: 10,
tracksByObject: {},
},
})
const iter = iterateOver(obj.template.getArrayOfValidSequenceTracks())
expect(iter.next().value).toHaveLength(0)
})
})
describe(`getMapOfValidSequenceTracks_forStudio()`, () => {
it('should return valid sequences in map form', async () => {
const {obj} = await setupTestSheet({
staticOverrides: {
byObject: {},
},
sequence: {
type: 'PositionalSequence',
subUnitsPerUnit: 30,
length: 10,
tracksByObject: {
obj: {
trackIdByPropPath: {
[encodePathToProp(['position', 'x'])]: asSequenceTrackId('x'),
[encodePathToProp(['position', 'invalid'])]: asSequenceTrackId(
'invalidTrack',
),
},
trackData: {
x: null as $IntentionalAny,
invalid: null as $IntentionalAny,
},
},
},
},
})
const iter = iterateOver(
obj.template.getMapOfValidSequenceTracks_forStudio(),
)
const validTracks = iter.next().value
expect(validTracks).toMatchObject({
position: {
x: 'x',
},
})
})
})
})

View file

@ -0,0 +1,209 @@
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 {SheetObjectConfig} from '@theatre/core/sheets/TheatreSheet'
import {emptyArray} from '@theatre/shared/utils'
import type {
PathToProp,
SheetObjectAddress,
WithoutSheetInstance,
} from '@theatre/shared/utils/addresses'
import getDeep from '@theatre/shared/utils/getDeep'
import type {SequenceTrackId} from '@theatre/shared/utils/ids'
import SimpleCache from '@theatre/shared/utils/SimpleCache'
import type {
$FixMe,
$IntentionalAny,
SerializableMap,
SerializableValue,
} from '@theatre/shared/utils/types'
import type {IDerivation, Pointer} from '@theatre/dataverse'
import {
Atom,
ConstantDerivation,
getPointerParts,
prism,
val,
} from '@theatre/dataverse'
import get from 'lodash-es/get'
import set from 'lodash-es/set'
import getPropDefaultsOfSheetObject from './getPropDefaultsOfSheetObject'
import SheetObject from './SheetObject'
import logger from '@theatre/shared/logger'
export type IPropPathToTrackIdTree = {
[key in string]?: SequenceTrackId | IPropPathToTrackIdTree
}
export default class SheetObjectTemplate {
readonly address: WithoutSheetInstance<SheetObjectAddress>
readonly type: 'Theatre_SheetObjectTemplate' = 'Theatre_SheetObjectTemplate'
protected _config: Atom<SheetObjectConfig<$IntentionalAny>>
readonly _cache = new SimpleCache()
readonly project: Project
get config() {
return this._config.getState()
}
constructor(
readonly sheetTemplate: SheetTemplate,
objectKey: string,
nativeObject: unknown,
config: SheetObjectConfig<$IntentionalAny>,
) {
this.address = {...sheetTemplate.address, objectKey}
this._config = new Atom(config)
this.project = sheetTemplate.project
}
createInstance(
sheet: Sheet,
nativeObject: unknown,
config: SheetObjectConfig<$IntentionalAny>,
): SheetObject {
this._config.setState(config)
return new SheetObject(sheet, this, nativeObject)
}
overrideConfig(
nativeObject: unknown,
config: SheetObjectConfig<$IntentionalAny>,
) {
this._config.setState(config)
}
/**
* Returns the default values (all defaults are read from the config)
*/
getDefaultValues(): IDerivation<SerializableMap> {
return this._cache.get('getDefaultValues()', () =>
prism(() => {
const config = val(this._config.pointer)
return getPropDefaultsOfSheetObject(config)
}),
)
}
/**
* Returns values that are set statically (ie, not sequenced, and not defaults)
*/
getStaticValues(): IDerivation<SerializableMap> {
return this._cache.get('getDerivationOfStatics', () =>
prism(() => {
const pointerToSheetState =
this.sheetTemplate.project.pointers.historic.sheetsById[
this.address.sheetId
]
return (
val(
pointerToSheetState.staticOverrides.byObject[
this.address.objectKey
],
) || {}
)
}),
)
}
/**
* Filters through the sequenced tracks those tracks who are valid
* according to the object's prop types.
*
* Returns an array.
*/
getArrayOfValidSequenceTracks(): IDerivation<
Array<{pathToProp: PathToProp; trackId: SequenceTrackId}>
> {
return this._cache.get('getArrayOfValidSequenceTracks', () =>
prism((): Array<{pathToProp: PathToProp; trackId: SequenceTrackId}> => {
const defaults = val(this.getDefaultValues())
const pointerToSheetState =
this.project.pointers.historic.sheetsById[this.address.sheetId]
const trackIdByPropPath = val(
pointerToSheetState.sequence.tracksByObject[this.address.objectKey]
.trackIdByPropPath,
)
const arrayOfIds: Array<{
pathToProp: PathToProp
trackId: SequenceTrackId
}> = []
if (trackIdByPropPath) {
for (const [pathToPropInString, trackId] of Object.entries(
trackIdByPropPath,
)) {
let pathToProp
try {
pathToProp = JSON.parse(pathToPropInString)
} catch (e) {
logger.warn(
`property ${JSON.stringify(
pathToPropInString,
)} cannot be parsed. Skipping.`,
)
continue
}
const defaultValue = get(defaults, pathToProp)
if (
typeof defaultValue === 'undefined' ||
typeof defaultValue === 'object'
) {
continue
}
arrayOfIds.push({pathToProp, trackId: trackId!})
}
} else {
return emptyArray as $IntentionalAny
}
if (arrayOfIds.length === 0) {
return emptyArray as $IntentionalAny
} else {
return arrayOfIds
}
}),
)
}
/**
* Filters through the sequenced tracks those tracks that are valid
* according to the object's prop types.
*
* Returns a map.
*
* Not available in core.
*/
getMapOfValidSequenceTracks_forStudio(): IDerivation<IPropPathToTrackIdTree> {
if (!$env.isCore) {
return this._cache.get('getMapOfValidSequenceTracks_forStudio', () =>
this.getArrayOfValidSequenceTracks().map((arr) => {
let map = {}
for (const {pathToProp, trackId} of arr) {
set(map, pathToProp, trackId)
}
return map
}),
)
} else {
return new ConstantDerivation({})
}
}
getDefaultsAtPointer(
pointer: Pointer<unknown>,
): SerializableValue | undefined {
const {path} = getPointerParts(pointer)
const defaults = this.getDefaultValues().getValue()
const defaultsAtPath = getDeep(defaults, path)
return defaultsAtPath as $FixMe
}
}

View file

@ -0,0 +1,90 @@
import {privateAPI, setPrivateAPI} from '@theatre/shared/privateAPIs'
import type {IProject} from '@theatre/core/projects/TheatreProject'
import coreTicker from '@theatre/core/coreTicker'
import type {ISheet} from '@theatre/core/sheets/TheatreSheet'
import type {SheetObjectAddress} from '@theatre/shared/utils/addresses'
import SimpleCache from '@theatre/shared/utils/SimpleCache'
import type {
$FixMe,
$IntentionalAny,
DeepPartialOfSerializableValue,
VoidFn,
} from '@theatre/shared/utils/types'
import type {IDerivation, Pointer} from '@theatre/dataverse'
import {prism, val} from '@theatre/dataverse'
import type {PropTypeConfig_Compound} from '@theatre/shared/src/propTypes'
import type SheetObject from './SheetObject'
export interface ISheetObject<
Props extends PropTypeConfig_Compound<$IntentionalAny> = PropTypeConfig_Compound<$IntentionalAny>,
> {
readonly type: 'Theatre_SheetObject_PublicAPI'
/**
* The type of the values of the SheetObject.
*/
readonly value: Props['valueType']
readonly props: Pointer<Props['valueType']>
readonly sheet: ISheet
readonly project: IProject
readonly address: SheetObjectAddress
onValuesChange(fn: (values: Props['valueType']) => void): VoidFn
// prettier-ignore
set initialValue(value: DeepPartialOfSerializableValue<Props['valueType']>)
}
export default class TheatreSheetObject<
Props extends PropTypeConfig_Compound<$IntentionalAny>,
> implements ISheetObject<Props>
{
get type(): 'Theatre_SheetObject_PublicAPI' {
return 'Theatre_SheetObject_PublicAPI'
}
private readonly _cache = new SimpleCache()
/**
* @internal
*/
constructor(internals: SheetObject) {
setPrivateAPI(this, internals)
}
get props(): Pointer<Props['valueType']> {
return privateAPI(this).propsP as $FixMe
}
get sheet(): ISheet {
return privateAPI(this).sheet.publicApi
}
get project(): IProject {
return privateAPI(this).sheet.project.publicApi
}
get address(): SheetObjectAddress {
return {...privateAPI(this).address}
}
private _valuesDerivation(): IDerivation<Props['valueType']> {
return this._cache.get('onValuesChangeDerivation', () => {
const sheetObject = privateAPI(this)
const d: IDerivation<Props> = prism(() => {
return val(sheetObject.getValues().getValue()) as $FixMe
})
return d
})
}
onValuesChange(fn: (values: Props['valueType']) => void): VoidFn {
return this._valuesDerivation().tapImmediate(coreTicker, fn)
}
get value(): Props['valueType'] {
return this._valuesDerivation().getValue()
}
set initialValue(val: DeepPartialOfSerializableValue<Props['valueType']>) {
privateAPI(this).setInitialValue(val)
}
}

View file

@ -0,0 +1,63 @@
import type {SheetObjectConfig} from '@theatre/core/sheets/TheatreSheet'
import type {
$FixMe,
$IntentionalAny,
SerializableMap,
SerializableValue,
} from '@theatre/shared/utils/types'
import type {
PropTypeConfig,
PropTypeConfig_Compound,
PropTypeConfig_Enum,
} from '@theatre/shared/src/propTypes'
const cachedDefaults = new WeakMap<PropTypeConfig, SerializableValue>()
/**
* Generates and caches a default value for the config of a SheetObject.
*/
export default function getPropDefaultsOfSheetObject(
config: SheetObjectConfig<$IntentionalAny>,
): SerializableMap {
return getDefaultsOfPropTypeConfig(config.props) as $IntentionalAny
}
function getDefaultsOfPropTypeConfig(
config: PropTypeConfig,
): SerializableValue {
if (cachedDefaults.has(config)) {
return cachedDefaults.get(config)!
}
const generated =
config.type === 'compound'
? generateDefaultsForCompound(config)
: config.type === 'enum'
? generateDefaultsForEnum(config)
: config.default
cachedDefaults.set(config, generated)
return generated
}
function generateDefaultsForEnum(config: PropTypeConfig_Enum) {
const defaults: SerializableMap = {
$case: config.defaultCase,
}
for (const [case_, caseConf] of Object.entries(config.cases)) {
defaults[case_] = getDefaultsOfPropTypeConfig(caseConf)
}
return defaults
}
function generateDefaultsForCompound(config: PropTypeConfig_Compound<$FixMe>) {
const defaults: SerializableMap = {}
for (const [key, propConf] of Object.entries(config.props)) {
defaults[key] = getDefaultsOfPropTypeConfig(propConf)
}
return defaults
}

View file

@ -0,0 +1,82 @@
import type Project from '@theatre/core/projects/Project'
import Sequence from '@theatre/core/sequences/Sequence'
import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
import type {SheetObjectConfig} from '@theatre/core/sheets/TheatreSheet'
import TheatreSheet from '@theatre/core/sheets/TheatreSheet'
import type {SheetAddress} from '@theatre/shared/utils/addresses'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
import {Atom, valueDerivation} from '@theatre/dataverse'
import type SheetTemplate from './SheetTemplate'
type IObjects = {[key: string]: SheetObject}
export default class Sheet {
private readonly _objects: Atom<IObjects> = new Atom<IObjects>({})
private _sequence: undefined | Sequence
readonly address: SheetAddress
readonly publicApi: TheatreSheet
readonly project: Project
readonly objectsP = this._objects.pointer
type: 'Theatre_Sheet' = 'Theatre_Sheet'
constructor(
readonly template: SheetTemplate,
public readonly instanceId: string,
) {
this.project = template.project
this.address = {
...template.address,
sheetInstanceId: this.instanceId,
}
this.publicApi = new TheatreSheet(this)
}
/**
* @remarks At some point, we have to reconcile the concept of "an object"
* with that of "an element."
*/
createObject(
key: string,
nativeObject: unknown,
config: SheetObjectConfig<$IntentionalAny>,
): SheetObject {
const objTemplate = this.template.getObjectTemplate(
key,
nativeObject,
config,
)
const object = objTemplate.createInstance(this, nativeObject, config)
this._objects.setIn([key], object)
return object
}
getObject(key: string): SheetObject | undefined {
return this._objects.getState()[key]
}
getSequence(): Sequence {
if (!this._sequence) {
const lengthD = valueDerivation(
this.project.pointers.historic.sheetsById[this.address.sheetId].sequence
.length,
).map((s) => (typeof s === 'number' ? s : 10))
const subUnitsPerUnitD = valueDerivation(
this.project.pointers.historic.sheetsById[this.address.sheetId].sequence
.subUnitsPerUnit,
).map((s) => (typeof s === 'number' ? s : 30))
this._sequence = new Sequence(
this.template.project,
this,
lengthD,
subUnitsPerUnitD,
)
}
return this._sequence
}
}

View file

@ -0,0 +1,52 @@
import type Project from '@theatre/core/projects/Project'
import SheetObjectTemplate from '@theatre/core/sheetObjects/SheetObjectTemplate'
import type {
SheetAddress,
WithoutSheetInstance,
} from '@theatre/shared/utils/addresses'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
import {Atom} from '@theatre/dataverse'
import Sheet from './Sheet'
import type {SheetObjectConfig} from './TheatreSheet'
export default class SheetTemplate {
readonly type: 'Theatre_SheetTemplate' = 'Theatre_SheetTemplate'
readonly address: WithoutSheetInstance<SheetAddress>
private _instances = new Atom<{[instanceId: string]: Sheet}>({})
readonly instancesP = this._instances.pointer
private _objectTemplates = new Atom<{
[objectKey: string]: SheetObjectTemplate
}>({})
readonly objectTemplatesP = this._objectTemplates.pointer
constructor(readonly project: Project, sheetId: string) {
this.address = {...project.address, sheetId}
}
getInstance(instanceId: string): Sheet {
let inst = this._instances.getState()[instanceId]
if (!inst) {
inst = new Sheet(this, instanceId)
this._instances.setIn([instanceId], inst)
}
return inst
}
getObjectTemplate(
key: string,
nativeObject: unknown,
config: SheetObjectConfig<$IntentionalAny>,
): SheetObjectTemplate {
let template = this._objectTemplates.getState()[key]
if (!template) {
template = new SheetObjectTemplate(this, key, nativeObject, config)
this._objectTemplates.setIn([key], template)
}
return template
}
}

View file

@ -0,0 +1,103 @@
import {privateAPI, setPrivateAPI} from '@theatre/shared/privateAPIs'
import type {IProject} from '@theatre/core/projects/TheatreProject'
import type TheatreSequence from '@theatre/core/sequences/TheatreSequence'
import type {ISequence} from '@theatre/core/sequences/TheatreSequence'
import type {PropTypeConfig_Compound} from '@theatre/shared/src/propTypes'
import type {ISheetObject} from '@theatre/core/sheetObjects/TheatreSheetObject'
import type Sheet from '@theatre/core/sheets/Sheet'
import type {SheetAddress} from '@theatre/shared/utils/addresses'
import {InvalidArgumentError} from '@theatre/shared/utils/errors'
import {validateAndSanitiseSlashedPathOrThrow} from '@theatre/shared/utils/slashedPaths'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
import userReadableTypeOfValue from '@theatre/shared/utils/userReadableTypeOfValue'
export type SheetObjectConfig<
Props extends PropTypeConfig_Compound<$IntentionalAny>,
> = {
props: Props
}
export interface ISheet {
readonly type: 'Theatre_Sheet_PublicAPI'
readonly project: IProject
readonly address: SheetAddress
object<Props extends PropTypeConfig_Compound<$IntentionalAny>>(
key: string,
nativeObject: unknown,
config: SheetObjectConfig<Props>,
): ISheetObject<Props>
sequence(): ISequence
}
export default class TheatreSheet implements ISheet {
get type(): 'Theatre_Sheet_PublicAPI' {
return 'Theatre_Sheet_PublicAPI'
}
/**
* @internal
*/
constructor(sheet: Sheet) {
setPrivateAPI(this, sheet)
}
object<Props extends PropTypeConfig_Compound<$IntentionalAny>>(
key: string,
nativeObject: unknown,
config: SheetObjectConfig<Props>,
): ISheetObject<Props> {
const internal = privateAPI(this)
const sanitizedPath = validateAndSanitiseSlashedPathOrThrow(
key,
`sheet.object("${key}", ...)`,
)
// @todo sanitize config
const existingObject = internal.getObject(sanitizedPath)
if (existingObject) {
existingObject.overrideConfig(nativeObject, config)
return existingObject.publicApi as $IntentionalAny
} else {
const object = internal.createObject(sanitizedPath, nativeObject, config)
return object.publicApi as $IntentionalAny
}
}
sequence(): TheatreSequence {
return privateAPI(this).getSequence().publicApi
}
get project(): IProject {
return privateAPI(this).project.publicApi
}
get address(): SheetAddress {
return {...privateAPI(this).address}
}
}
const validateSequenceNameOrThrow = (value: string) => {
if (typeof value !== 'string') {
throw new InvalidArgumentError(
`Argument 'name' in \`sheet.getSequence(name)\` must be a string. Instead, it was ${userReadableTypeOfValue(
value,
)}.`,
)
}
const idTrimmed = value.trim()
if (idTrimmed.length !== value.length) {
throw new InvalidArgumentError(
`Argument 'name' in \`sheet.getSequence("${value}")\` should not have surrounding whitespace.`,
)
}
if (idTrimmed.length < 3) {
throw new InvalidArgumentError(
`Argument 'name' in \`sheet.getSequence("${value}")\` should be at least 3 characters long.`,
)
}
}