Create idb-backed image prop (#366)
Co-authored-by: Clement Roche <rchclement@gmail.com>
This commit is contained in:
parent
95b329b02d
commit
8d8e2348dd
21 changed files with 838 additions and 31 deletions
67
packages/playground/src/shared/image/index.tsx
Normal file
67
packages/playground/src/shared/image/index.tsx
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* A super basic Turtle geometry renderer hooked up to Theatre, so the parameters
|
||||||
|
* can be tweaked and animated.
|
||||||
|
*/
|
||||||
|
import {getProject, types} from '@theatre/core'
|
||||||
|
import studio from '@theatre/studio'
|
||||||
|
import React, {useEffect, useState} from 'react'
|
||||||
|
import {render} from 'react-dom'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
studio.initialize()
|
||||||
|
const project = getProject('Image type playground', {
|
||||||
|
assets: {
|
||||||
|
baseUrl: 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const sheet = project.sheet('Image type')
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ImageTypeExample: React.FC<{}> = (props) => {
|
||||||
|
const [imageUrl, setImageUrl] = useState<string>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const object = sheet.object('image', {
|
||||||
|
image: types.image('', {
|
||||||
|
label: 'texture',
|
||||||
|
}),
|
||||||
|
image2: types.image('', {
|
||||||
|
label: 'another texture',
|
||||||
|
}),
|
||||||
|
something: 'asdf',
|
||||||
|
})
|
||||||
|
object.onValuesChange(({image}) => {
|
||||||
|
setImageUrl(project.getAssetUrl(image))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sheet.detachObject('canvas')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
onClick={() => {
|
||||||
|
if (sheet.sequence.position === 0) {
|
||||||
|
sheet.sequence.position = 0
|
||||||
|
sheet.sequence.play()
|
||||||
|
} else {
|
||||||
|
sheet.sequence.position = 0
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={imageUrl} />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
project.ready.then(() => {
|
||||||
|
render(<ImageTypeExample />, document.getElementById('root'))
|
||||||
|
})
|
191
theatre/core/src/projects/DefaultAssetStorage.ts
Normal file
191
theatre/core/src/projects/DefaultAssetStorage.ts
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
import {createStore} from './IDBStorage'
|
||||||
|
import type Project from './Project'
|
||||||
|
import type {IAssetStorageConfig} from './Project'
|
||||||
|
// @ts-ignore
|
||||||
|
import blobCompare from 'blob-compare'
|
||||||
|
import {notify} from '@theatre/core/coreExports'
|
||||||
|
import {getAllPossibleAssetIDs} from '@theatre/shared/utils/assets'
|
||||||
|
|
||||||
|
export const createDefaultAssetStorageConfig = ({
|
||||||
|
project,
|
||||||
|
baseUrl = '',
|
||||||
|
}: {
|
||||||
|
project: Project
|
||||||
|
baseUrl?: string
|
||||||
|
}): IAssetStorageConfig => {
|
||||||
|
return {
|
||||||
|
coreAssetStorage: {
|
||||||
|
getAssetUrl: (assetId: string) => `${baseUrl}/${assetId}`,
|
||||||
|
},
|
||||||
|
createStudioAssetStorage: async () => {
|
||||||
|
// in SSR we bail out and return a dummy asset manager
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {
|
||||||
|
getAssetUrl: () => '',
|
||||||
|
createAsset: () => Promise.resolve(null),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for support.
|
||||||
|
if (!('indexedDB' in window)) {
|
||||||
|
console.log("This browser doesn't support IndexedDB.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAssetUrl: (assetId: string) => {
|
||||||
|
throw new Error(
|
||||||
|
`IndexedDB is required by the default asset manager, but it's not supported by this browser. To use assets, please provide your own asset manager to the project config.`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
createAsset: (asset: Blob) => {
|
||||||
|
throw new Error(
|
||||||
|
`IndexedDB is required by the default asset manager, but it's not supported by this browser. To use assets, please provide your own asset manager to the project config.`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idb = createStore(`${project.address.projectId}-assets`)
|
||||||
|
|
||||||
|
// get all possible asset ids referenced by either static props or keyframes
|
||||||
|
const possibleAssetIDs = getAllPossibleAssetIDs(project)
|
||||||
|
|
||||||
|
// Clean up assets not referenced by the project. We can only do this at the start because otherwise
|
||||||
|
// we'd break undo/redo.
|
||||||
|
const idbKeys = await idb.keys<string>()
|
||||||
|
await Promise.all(
|
||||||
|
idbKeys.map(async (key) => {
|
||||||
|
if (!possibleAssetIDs.includes(key)) {
|
||||||
|
await idb.del(key)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clean up idb entries exported to disk
|
||||||
|
await Promise.all(
|
||||||
|
idbKeys.map(async (key) => {
|
||||||
|
const assetUrl = `${baseUrl}/${key}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(assetUrl, {method: 'HEAD'})
|
||||||
|
if (response.ok) {
|
||||||
|
await idb.del(key)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(
|
||||||
|
'Failed to access assets',
|
||||||
|
`Failed to access assets at ${
|
||||||
|
project.config.assets?.baseUrl ?? '/'
|
||||||
|
}. This is likely due to a CORS issue.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// A map for caching the assets outside of the db. We also need this to be able to retrieve idb asset urls synchronously.
|
||||||
|
const assetsMap = new Map(await idb.entries<string, Blob>())
|
||||||
|
|
||||||
|
// A map for caching the object urls created from idb assets.
|
||||||
|
const urlCache = new Map<Blob, string>()
|
||||||
|
|
||||||
|
/** Gets idb aset url from asset blob */
|
||||||
|
const getUrlForAsset = (asset: Blob) => {
|
||||||
|
if (urlCache.has(asset)) {
|
||||||
|
return urlCache.get(asset)!
|
||||||
|
} else {
|
||||||
|
const url = URL.createObjectURL(asset)
|
||||||
|
urlCache.set(asset, url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets idb asset url from id */
|
||||||
|
const getUrlForId = (assetId: string) => {
|
||||||
|
const asset = assetsMap.get(assetId)
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error(`Asset with id ${assetId} not found`)
|
||||||
|
}
|
||||||
|
return getUrlForAsset(asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAssetUrl: (assetId: string) => {
|
||||||
|
return assetsMap.has(assetId)
|
||||||
|
? getUrlForId(assetId)
|
||||||
|
: `${baseUrl}/${assetId}`
|
||||||
|
},
|
||||||
|
createAsset: async (asset: File) => {
|
||||||
|
const existingIDs = getAllPossibleAssetIDs(project)
|
||||||
|
|
||||||
|
let sameSame = false
|
||||||
|
|
||||||
|
if (existingIDs.includes(asset.name)) {
|
||||||
|
let existingAsset: Blob | undefined
|
||||||
|
try {
|
||||||
|
existingAsset =
|
||||||
|
assetsMap.get(asset.name) ??
|
||||||
|
(await fetch(`${baseUrl}/${asset.name}`).then((r) =>
|
||||||
|
r.ok ? r.blob() : undefined,
|
||||||
|
))
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(
|
||||||
|
'Failed to access assets',
|
||||||
|
`Failed to access assets at ${
|
||||||
|
project.config.assets?.baseUrl ?? '/'
|
||||||
|
}. This is likely due to a CORS issue.`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingAsset) {
|
||||||
|
// @ts-ignore
|
||||||
|
sameSame = await blobCompare.isEqual(asset, existingAsset)
|
||||||
|
|
||||||
|
// if same same, we do nothing
|
||||||
|
if (sameSame) {
|
||||||
|
return asset.name
|
||||||
|
// if different, we ask the user to pls rename
|
||||||
|
} else {
|
||||||
|
/** Initiates rename using a dialog. Returns a boolean indicating if the rename was succesful. */
|
||||||
|
const renameAsset = (text: string): boolean => {
|
||||||
|
const newAssetName = prompt(text, asset.name)
|
||||||
|
|
||||||
|
if (newAssetName === null) {
|
||||||
|
// asset creation canceled
|
||||||
|
return false
|
||||||
|
} else if (newAssetName === '') {
|
||||||
|
return renameAsset(
|
||||||
|
'Asset name cannot be empty. Please choose a different file name for this asset.',
|
||||||
|
)
|
||||||
|
} else if (existingIDs.includes(newAssetName)) {
|
||||||
|
console.log(existingIDs)
|
||||||
|
return renameAsset(
|
||||||
|
'An asset with this name already exists. Please choose a different file name for this asset.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rename asset
|
||||||
|
asset = new File([asset], newAssetName, {type: asset.type})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rename asset returns false if the user cancels the rename
|
||||||
|
const success = renameAsset(
|
||||||
|
'An asset with this name already exists. Please choose a different file name for this asset.',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsMap.set(asset.name, asset)
|
||||||
|
await idb.set(asset.name, asset)
|
||||||
|
return asset.name
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
22
theatre/core/src/projects/IDBStorage.ts
Normal file
22
theatre/core/src/projects/IDBStorage.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import * as idb from 'idb-keyval'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom IDB keyval storage creator. Right now this exists solely as a more convenient way to use idb-keyval with a custom db name.
|
||||||
|
* It also automatically prefixes the provided name with `theatrejs-` to avoid conflicts with other libraries.
|
||||||
|
*
|
||||||
|
* @param name - The name of the database
|
||||||
|
* @returns An object with the same methods as idb-keyval, but with a custom database name
|
||||||
|
*/
|
||||||
|
export const createStore = (name: string) => {
|
||||||
|
const customStore = idb.createStore(`theatrejs-${name}`, 'default-store')
|
||||||
|
|
||||||
|
return {
|
||||||
|
set: (key: string, value: any) => idb.set(key, value, customStore),
|
||||||
|
get: <T = any>(key: string) => idb.get<T>(key, customStore),
|
||||||
|
del: (key: string) => idb.del(key, customStore),
|
||||||
|
keys: <T extends IDBValidKey>() => idb.keys<T>(customStore),
|
||||||
|
entries: <KeyType extends IDBValidKey, ValueType = any>() =>
|
||||||
|
idb.entries<KeyType, ValueType>(customStore),
|
||||||
|
values: <T = any>() => idb.values<T>(customStore),
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,9 +24,35 @@ import type {
|
||||||
ITheatreLoggingConfig,
|
ITheatreLoggingConfig,
|
||||||
} from '@theatre/shared/logger'
|
} from '@theatre/shared/logger'
|
||||||
import {_coreLogger} from '@theatre/core/_coreLogger'
|
import {_coreLogger} from '@theatre/core/_coreLogger'
|
||||||
|
import {createDefaultAssetStorageConfig} from './DefaultAssetStorage'
|
||||||
|
|
||||||
|
type ICoreAssetStorage = {
|
||||||
|
/** Returns a URL for the provided asset ID */
|
||||||
|
getAssetUrl: (assetId: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IStudioAssetStorage extends ICoreAssetStorage {
|
||||||
|
/** Creates an asset from the provided blob and returns a promise to its ID */
|
||||||
|
createAsset: (asset: File) => Promise<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IAssetStorageConfig = {
|
||||||
|
/**
|
||||||
|
* An object containing the core asset storage methods.
|
||||||
|
*/
|
||||||
|
coreAssetStorage: ICoreAssetStorage
|
||||||
|
/** A function that returns a promise to an object containing asset storage methods to be used by studio. */
|
||||||
|
createStudioAssetStorage: () => Promise<IStudioAssetStorage>
|
||||||
|
}
|
||||||
|
|
||||||
|
type IAssetConf = {
|
||||||
|
/** The base URL for assets. */
|
||||||
|
baseUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Conf = Partial<{
|
export type Conf = Partial<{
|
||||||
state: OnDiskState
|
state: OnDiskState
|
||||||
|
assets: IAssetConf
|
||||||
experiments: ExperimentsConf
|
experiments: ExperimentsConf
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
@ -50,13 +76,17 @@ export default class Project {
|
||||||
|
|
||||||
readonly address: ProjectAddress
|
readonly address: ProjectAddress
|
||||||
|
|
||||||
private readonly _readyDeferred: Deferred<undefined>
|
private readonly _studioReadyDeferred: Deferred<undefined>
|
||||||
|
private readonly _defaultAssetStorageReadyDeferred: Deferred<undefined>
|
||||||
|
private readonly _readyPromise: Promise<void>
|
||||||
|
|
||||||
private _sheetTemplates = new Atom<{
|
private _sheetTemplates = new Atom<{
|
||||||
[sheetId: string]: SheetTemplate | undefined
|
[sheetId: string]: SheetTemplate | undefined
|
||||||
}>({})
|
}>({})
|
||||||
sheetTemplatesP = this._sheetTemplates.pointer
|
sheetTemplatesP = this._sheetTemplates.pointer
|
||||||
private _studio: Studio | undefined
|
private _studio: Studio | undefined
|
||||||
|
private _defaultAssetStorageConfig: IAssetStorageConfig
|
||||||
|
assetStorage: IStudioAssetStorage
|
||||||
|
|
||||||
type: 'Theatre_Project' = 'Theatre_Project'
|
type: 'Theatre_Project' = 'Theatre_Project'
|
||||||
readonly _logger: ILogger
|
readonly _logger: ILogger
|
||||||
|
@ -87,6 +117,20 @@ export default class Project {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this._defaultAssetStorageConfig = createDefaultAssetStorageConfig({
|
||||||
|
project: this,
|
||||||
|
baseUrl: config.assets?.baseUrl,
|
||||||
|
})
|
||||||
|
this._defaultAssetStorageReadyDeferred = defer()
|
||||||
|
this.assetStorage = {
|
||||||
|
...this._defaultAssetStorageConfig.coreAssetStorage,
|
||||||
|
|
||||||
|
// Until the asset storage is ready, we'll throw an error when the user tries to use it
|
||||||
|
createAsset: () => {
|
||||||
|
throw new Error(`Please wait for Project.ready to use assets.`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
this._pointerProxies = {
|
this._pointerProxies = {
|
||||||
historic: new PointerProxy(onDiskStateAtom.pointer.historic),
|
historic: new PointerProxy(onDiskStateAtom.pointer.historic),
|
||||||
ahistoric: new PointerProxy(onDiskStateAtom.pointer.ahistoric),
|
ahistoric: new PointerProxy(onDiskStateAtom.pointer.ahistoric),
|
||||||
|
@ -101,14 +145,21 @@ export default class Project {
|
||||||
|
|
||||||
projectsSingleton.add(id, this)
|
projectsSingleton.add(id, this)
|
||||||
|
|
||||||
this._readyDeferred = defer()
|
this._studioReadyDeferred = defer()
|
||||||
|
|
||||||
|
this._readyPromise = Promise.all([
|
||||||
|
this._studioReadyDeferred.promise,
|
||||||
|
this._defaultAssetStorageReadyDeferred.promise,
|
||||||
|
// hide the array from the user, i.e. make it Promise<void> instead of Promise<[undefined, undefined]>
|
||||||
|
]).then(() => {})
|
||||||
|
|
||||||
if (config.state) {
|
if (config.state) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// The user has provided config.state but in case @theatre/studio is loaded,
|
// The user has provided config.state but in case @theatre/studio is loaded,
|
||||||
// let's give it one tick to attach itself
|
// let's give it one tick to attach itself
|
||||||
if (!this._studio) {
|
if (!this._studio) {
|
||||||
this._readyDeferred.resolve(undefined)
|
this._studioReadyDeferred.resolve(undefined)
|
||||||
|
this._defaultAssetStorageReadyDeferred.resolve(undefined)
|
||||||
this._logger._trace('ready deferred resolved with no state')
|
this._logger._trace('ready deferred resolved with no state')
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
|
@ -166,7 +217,15 @@ export default class Project {
|
||||||
studio.atomP.ephemeral.coreByProject[this.address.projectId],
|
studio.atomP.ephemeral.coreByProject[this.address.projectId],
|
||||||
)
|
)
|
||||||
|
|
||||||
this._readyDeferred.resolve(undefined)
|
// asset storage has to be initialized after the pointers are set
|
||||||
|
this._defaultAssetStorageConfig
|
||||||
|
.createStudioAssetStorage()
|
||||||
|
.then((assetStorage) => {
|
||||||
|
this.assetStorage = assetStorage
|
||||||
|
this._defaultAssetStorageReadyDeferred.resolve(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
this._studioReadyDeferred.resolve(undefined)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -175,11 +234,14 @@ export default class Project {
|
||||||
}
|
}
|
||||||
|
|
||||||
get ready() {
|
get ready() {
|
||||||
return this._readyDeferred.promise
|
return this._readyPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
isReady() {
|
isReady() {
|
||||||
return this._readyDeferred.status === 'resolved'
|
return (
|
||||||
|
this._studioReadyDeferred.status === 'resolved' &&
|
||||||
|
this._defaultAssetStorageReadyDeferred.status === 'resolved'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrCreateSheet(
|
getOrCreateSheet(
|
||||||
|
|
|
@ -3,6 +3,7 @@ import Project from '@theatre/core/projects/Project'
|
||||||
import type {ISheet} from '@theatre/core/sheets/TheatreSheet'
|
import type {ISheet} from '@theatre/core/sheets/TheatreSheet'
|
||||||
|
|
||||||
import type {ProjectAddress} from '@theatre/shared/utils/addresses'
|
import type {ProjectAddress} from '@theatre/shared/utils/addresses'
|
||||||
|
import type {Asset} from '@theatre/shared/utils/assets'
|
||||||
import type {
|
import type {
|
||||||
ProjectId,
|
ProjectId,
|
||||||
SheetId,
|
SheetId,
|
||||||
|
@ -20,7 +21,9 @@ export type IProjectConfig = {
|
||||||
* The state of the project, as [exported](https://www.theatrejs.com/docs/latest/manual/projects#state) by the studio.
|
* The state of the project, as [exported](https://www.theatrejs.com/docs/latest/manual/projects#state) by the studio.
|
||||||
*/
|
*/
|
||||||
state?: $IntentionalAny
|
state?: $IntentionalAny
|
||||||
// experiments?: IProjectConfigExperiments
|
assets?: {
|
||||||
|
baseUrl?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// export type IProjectConfigExperiments = {
|
// export type IProjectConfigExperiments = {
|
||||||
|
@ -70,6 +73,14 @@ export interface IProject {
|
||||||
* **Docs: https://www.theatrejs.com/docs/latest/manual/sheets**
|
* **Docs: https://www.theatrejs.com/docs/latest/manual/sheets**
|
||||||
*/
|
*/
|
||||||
sheet(sheetId: string, instanceId?: string): ISheet
|
sheet(sheetId: string, instanceId?: string): ISheet
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the URL for an asset.
|
||||||
|
*
|
||||||
|
* @param asset - The asset to get the URL for
|
||||||
|
* @returns The URL for the asset, or `undefined` if the asset is not found
|
||||||
|
*/
|
||||||
|
getAssetUrl(asset: Asset): string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class TheatreProject implements IProject {
|
export default class TheatreProject implements IProject {
|
||||||
|
@ -95,6 +106,12 @@ export default class TheatreProject implements IProject {
|
||||||
return {...privateAPI(this).address}
|
return {...privateAPI(this).address}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAssetUrl(asset: Asset): string | undefined {
|
||||||
|
return asset.id
|
||||||
|
? privateAPI(this).assetStorage.getAssetUrl(asset.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
sheet(sheetId: string, instanceId: string = 'default'): ISheet {
|
sheet(sheetId: string, instanceId: string = 'default'): ISheet {
|
||||||
const sanitizedPath = validateAndSanitiseSlashedPathOrThrow(
|
const sanitizedPath = validateAndSanitiseSlashedPathOrThrow(
|
||||||
sheetId,
|
sheetId,
|
||||||
|
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
import {propTypeSymbol, sanitizeCompoundProps} from './internals'
|
import {propTypeSymbol, sanitizeCompoundProps} from './internals'
|
||||||
// eslint-disable-next-line unused-imports/no-unused-imports
|
// eslint-disable-next-line unused-imports/no-unused-imports
|
||||||
import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
|
import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
|
||||||
|
import type {Asset} from '@theatre/shared/utils/assets'
|
||||||
|
|
||||||
// Notes on naming:
|
// Notes on naming:
|
||||||
// As of now, prop types are either `simple` or `composite`.
|
// As of now, prop types are either `simple` or `composite`.
|
||||||
|
@ -138,6 +139,74 @@ export const compound = <Props extends UnknownShorthandCompoundProps>(
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An image prop type
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
*
|
||||||
|
* // with a label:
|
||||||
|
* const obj = sheet.object('key', {
|
||||||
|
* url: t.image('My image.png', {
|
||||||
|
* label: 'texture'
|
||||||
|
* })
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param opts - Options (See usage examples)
|
||||||
|
*/
|
||||||
|
export const image = (
|
||||||
|
// The defaultValue parameter is a string for convenience, but it will be converted to an Asset object
|
||||||
|
defaultValue: Asset['id'],
|
||||||
|
opts: {
|
||||||
|
label?: string
|
||||||
|
interpolate?: Interpolator<Asset['id']>
|
||||||
|
} = {},
|
||||||
|
): PropTypeConfig_Image => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
validateCommonOpts('t.image(defaultValue, opts)', opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const interpolate: Interpolator<Asset> = (left, right, progression) => {
|
||||||
|
const stringInterpolate = opts.interpolate ?? leftInterpolate
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
id: stringInterpolate(left.id, right.id, progression),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
default: {type: 'image', id: defaultValue},
|
||||||
|
valueType: null as $IntentionalAny,
|
||||||
|
[propTypeSymbol]: 'TheatrePropType',
|
||||||
|
label: opts.label,
|
||||||
|
interpolate,
|
||||||
|
deserializeAndSanitize: _ensureImage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _ensureImage = (val: unknown): Asset | undefined => {
|
||||||
|
if (!val) return undefined
|
||||||
|
|
||||||
|
let valid = true
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof (val as $IntentionalAny).id !== 'string' &&
|
||||||
|
![null, undefined].includes((val as $IntentionalAny).id)
|
||||||
|
) {
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((val as $IntentionalAny).type !== 'image') valid = false
|
||||||
|
|
||||||
|
if (!valid) return undefined
|
||||||
|
|
||||||
|
return val as Asset
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A number prop type.
|
* A number prop type.
|
||||||
*
|
*
|
||||||
|
@ -690,6 +759,8 @@ export interface PropTypeConfig_StringLiteral<T extends string>
|
||||||
|
|
||||||
export interface PropTypeConfig_Rgba extends ISimplePropType<'rgba', Rgba> {}
|
export interface PropTypeConfig_Rgba extends ISimplePropType<'rgba', Rgba> {}
|
||||||
|
|
||||||
|
export interface PropTypeConfig_Image extends ISimplePropType<'image', Asset> {}
|
||||||
|
|
||||||
type DeepPartialCompound<Props extends UnknownValidCompoundProps> = {
|
type DeepPartialCompound<Props extends UnknownValidCompoundProps> = {
|
||||||
[K in keyof Props]?: DeepPartial<Props[K]>
|
[K in keyof Props]?: DeepPartial<Props[K]>
|
||||||
}
|
}
|
||||||
|
@ -722,6 +793,7 @@ export type PropTypeConfig_AllSimples =
|
||||||
| PropTypeConfig_String
|
| PropTypeConfig_String
|
||||||
| PropTypeConfig_StringLiteral<$IntentionalAny>
|
| PropTypeConfig_StringLiteral<$IntentionalAny>
|
||||||
| PropTypeConfig_Rgba
|
| PropTypeConfig_Rgba
|
||||||
|
| PropTypeConfig_Image
|
||||||
|
|
||||||
export type PropTypeConfig =
|
export type PropTypeConfig =
|
||||||
| PropTypeConfig_AllSimples
|
| PropTypeConfig_AllSimples
|
||||||
|
|
|
@ -48,6 +48,7 @@
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.2",
|
||||||
"babel-polyfill": "^6.26.0",
|
"babel-polyfill": "^6.26.0",
|
||||||
"babel-register": "^6.26.0",
|
"babel-register": "^6.26.0",
|
||||||
|
"blob-compare": "1.1.0",
|
||||||
"circular-dependency-plugin": "^5.2.2",
|
"circular-dependency-plugin": "^5.2.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"esbuild": "^0.12.15",
|
"esbuild": "^0.12.15",
|
||||||
|
@ -57,10 +58,12 @@
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"fs-extra": "^10.0.0",
|
"fs-extra": "^10.0.0",
|
||||||
"html-loader": "^2.1.2",
|
"html-loader": "^2.1.2",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"immer": "^9.0.6",
|
"immer": "^9.0.6",
|
||||||
"jiff": "^0.7.3",
|
"jiff": "^0.7.3",
|
||||||
"json-touch-patch": "^0.11.2",
|
"json-touch-patch": "^0.11.2",
|
||||||
|
"jszip": "3.10.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"marked": "^4.1.1",
|
"marked": "^4.1.1",
|
||||||
|
|
37
theatre/shared/src/utils/assets.ts
Normal file
37
theatre/shared/src/utils/assets.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import type Project from '@theatre/core/projects/Project'
|
||||||
|
import {val} from '@theatre/dataverse'
|
||||||
|
|
||||||
|
export function getAllPossibleAssetIDs(project: Project, type?: string) {
|
||||||
|
// Apparently the value returned by val() can be undefined. Should fix TS.
|
||||||
|
const sheets = Object.values(val(project.pointers.historic.sheetsById) ?? {})
|
||||||
|
const staticValues = sheets
|
||||||
|
.flatMap((sheet) => Object.values(sheet?.staticOverrides.byObject ?? {}))
|
||||||
|
.flatMap((overrides) => Object.values(overrides ?? {}))
|
||||||
|
const keyframeValues = sheets
|
||||||
|
.flatMap((sheet) => Object.values(sheet?.sequence?.tracksByObject ?? {}))
|
||||||
|
.flatMap((tracks) => Object.values(tracks?.trackData ?? {}))
|
||||||
|
.flatMap((track) => track?.keyframes)
|
||||||
|
.map((keyframe) => keyframe?.value)
|
||||||
|
|
||||||
|
const allAssets = [...staticValues, ...keyframeValues]
|
||||||
|
// value is Asset of the type provided
|
||||||
|
.filter((value) => {
|
||||||
|
return (
|
||||||
|
(value as Asset | undefined)?.type &&
|
||||||
|
(type
|
||||||
|
? (value as Asset | undefined)?.type == type
|
||||||
|
: typeof (value as Asset | undefined)?.type === 'string')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
// map assets to their ids
|
||||||
|
.map((value) => (value as Asset).id)
|
||||||
|
// ensure ids are unique and not null and not empty
|
||||||
|
.filter(
|
||||||
|
(id, index, self) =>
|
||||||
|
id !== null && id !== '' && self.indexOf(id) === index,
|
||||||
|
) as string[]
|
||||||
|
|
||||||
|
return allAssets
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Asset = {type: 'image'; id: string | undefined}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type {Asset} from './assets'
|
||||||
|
|
||||||
export type GenericAction = {type: string; payload: unknown}
|
export type GenericAction = {type: string; payload: unknown}
|
||||||
|
|
||||||
export type ReduxReducer<State extends {}> = (
|
export type ReduxReducer<State extends {}> = (
|
||||||
|
@ -48,6 +50,7 @@ export type SerializablePrimitive =
|
||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
| {r: number; g: number; b: number; a: number}
|
| {r: number; g: number; b: number; a: number}
|
||||||
|
| Asset
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This type represents all values that can be safely serialized.
|
* This type represents all values that can be safely serialized.
|
||||||
|
|
|
@ -6,6 +6,9 @@ import React, {useCallback, useState} from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import DetailPanelButton from '@theatre/studio/uiComponents/DetailPanelButton'
|
import DetailPanelButton from '@theatre/studio/uiComponents/DetailPanelButton'
|
||||||
import StateConflictRow from './ProjectDetails/StateConflictRow'
|
import StateConflictRow from './ProjectDetails/StateConflictRow'
|
||||||
|
import JSZip from 'jszip'
|
||||||
|
import {notify} from '@theatre/studio/notify'
|
||||||
|
import {getAllPossibleAssetIDs} from '@theatre/shared/utils/assets'
|
||||||
|
|
||||||
const Container = styled.div``
|
const Container = styled.div``
|
||||||
|
|
||||||
|
@ -17,10 +20,35 @@ const TheExportRow = styled.div`
|
||||||
`
|
`
|
||||||
|
|
||||||
const ExportTooltip = styled(BasicPopover)`
|
const ExportTooltip = styled(BasicPopover)`
|
||||||
|
display flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1em;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates a file download for the provided data with the provided file name
|
||||||
|
*
|
||||||
|
* @param content - The content to save
|
||||||
|
* @param fileName - The name of the file to save
|
||||||
|
*/
|
||||||
|
function saveFile(content: string | Blob, fileName: string) {
|
||||||
|
const file = new File([content], fileName)
|
||||||
|
const objUrl = URL.createObjectURL(file)
|
||||||
|
const a = Object.assign(document.createElement('a'), {
|
||||||
|
href: objUrl,
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener',
|
||||||
|
})
|
||||||
|
a.setAttribute('download', fileName)
|
||||||
|
a.click()
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(objUrl)
|
||||||
|
}, 40000)
|
||||||
|
}
|
||||||
|
|
||||||
const ProjectDetails: React.FC<{
|
const ProjectDetails: React.FC<{
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
}> = ({projects}) => {
|
}> = ({projects}) => {
|
||||||
|
@ -34,41 +62,74 @@ const ProjectDetails: React.FC<{
|
||||||
|
|
||||||
const [downloaded, setDownloaded] = useState(false)
|
const [downloaded, setDownloaded] = useState(false)
|
||||||
|
|
||||||
const exportProject = useCallback(() => {
|
const exportProject = useCallback(async () => {
|
||||||
|
// get all possible asset ids referenced by either static props or keyframes
|
||||||
|
const allValues = getAllPossibleAssetIDs(project)
|
||||||
|
|
||||||
|
const blobs = new Map<string, Blob>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// only export assets that are referenced by the project
|
||||||
|
await Promise.all(
|
||||||
|
allValues.map(async (value) => {
|
||||||
|
const assetUrl = project.assetStorage.getAssetUrl(value)
|
||||||
|
|
||||||
|
const response = await fetch(assetUrl)
|
||||||
|
if (response.ok) {
|
||||||
|
blobs.set(value, await response.blob())
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(
|
||||||
|
`Failed to access assets`,
|
||||||
|
`Export aborted. Failed to access assets at ${
|
||||||
|
project.config.assets?.baseUrl ?? '/'
|
||||||
|
}. This is likely due to a CORS issue.`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// abort the export
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blobs.size > 0) {
|
||||||
|
const zip = new JSZip()
|
||||||
|
|
||||||
|
for (const [assetID, blob] of blobs) {
|
||||||
|
zip.file(assetID, blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetsFile = await zip.generateAsync({type: 'blob'})
|
||||||
|
saveFile(assetsFile, `${slugifiedProjectId}.assets.zip`)
|
||||||
|
}
|
||||||
|
|
||||||
const str = JSON.stringify(
|
const str = JSON.stringify(
|
||||||
getStudio().createContentOfSaveFile(project.address.projectId),
|
getStudio().createContentOfSaveFile(project.address.projectId),
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
const file = new File([str], suggestedFileName, {
|
|
||||||
type: 'application/json',
|
saveFile(str, suggestedFileName)
|
||||||
})
|
|
||||||
const objUrl = URL.createObjectURL(file)
|
|
||||||
const a = Object.assign(document.createElement('a'), {
|
|
||||||
href: objUrl,
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener',
|
|
||||||
})
|
|
||||||
a.setAttribute('download', suggestedFileName)
|
|
||||||
a.click()
|
|
||||||
|
|
||||||
setDownloaded(true)
|
setDownloaded(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDownloaded(false)
|
setDownloaded(false)
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
URL.revokeObjectURL(objUrl)
|
|
||||||
}, 40000)
|
|
||||||
}, [project, suggestedFileName])
|
}, [project, suggestedFileName])
|
||||||
|
|
||||||
const exportTooltip = usePopover(
|
const exportTooltip = usePopover(
|
||||||
{debugName: 'ProjectDetails', pointerDistanceThreshold: 50},
|
{debugName: 'ProjectDetails', pointerDistanceThreshold: 50},
|
||||||
() => (
|
() => (
|
||||||
<ExportTooltip>
|
<ExportTooltip>
|
||||||
|
<p>
|
||||||
This will create a JSON file with the state of your project. You can
|
This will create a JSON file with the state of your project. You can
|
||||||
commit this file to your git repo and include it in your production
|
commit this file to your git repo and include it in your production
|
||||||
bundle.
|
bundle.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If your project uses assets, this will also create a zip file with all
|
||||||
|
the assets that you can unpack in your public folder.
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
href="https://www.theatrejs.com/docs/latest/manual/projects#state"
|
href="https://www.theatrejs.com/docs/latest/manual/projects#state"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {valueInProp} from '@theatre/shared/propTypes/utils'
|
||||||
const SingleKeyframePropEditorContainer = styled.div`
|
const SingleKeyframePropEditorContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
select {
|
select {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
@ -153,5 +154,5 @@ function useEditingToolsForKeyframeEditorPopover(
|
||||||
keyframes: [newKeyframe],
|
keyframes: [newKeyframe],
|
||||||
snappingFunction: obj.sheet.getSequence().closestGridPosition,
|
snappingFunction: obj.sheet.getSequence().closestGridPosition,
|
||||||
})
|
})
|
||||||
})
|
}, obj)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@ import type {
|
||||||
} from '@theatre/studio/StudioStore/StudioStore'
|
} from '@theatre/studio/StudioStore/StudioStore'
|
||||||
import type {IEditingTools} from '@theatre/studio/propEditors/utils/IEditingTools'
|
import type {IEditingTools} from '@theatre/studio/propEditors/utils/IEditingTools'
|
||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
|
import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
|
||||||
|
import type {Asset} from '@theatre/shared/utils/assets'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function takes a function `writeTx` that sets a value in the private Studio API and
|
* This function takes a function `writeTx` that sets a value in the private Studio API and
|
||||||
|
@ -21,12 +23,14 @@ import {useMemo} from 'react'
|
||||||
*/
|
*/
|
||||||
export function useTempTransactionEditingTools<T extends SerializableValue>(
|
export function useTempTransactionEditingTools<T extends SerializableValue>(
|
||||||
writeTx: (api: ITransactionPrivateApi, value: T) => void,
|
writeTx: (api: ITransactionPrivateApi, value: T) => void,
|
||||||
|
obj: SheetObject,
|
||||||
): IEditingTools<T> {
|
): IEditingTools<T> {
|
||||||
return useMemo(() => createTempTransactionEditingTools<T>(writeTx), [])
|
return useMemo(() => createTempTransactionEditingTools<T>(writeTx, obj), [])
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTempTransactionEditingTools<T>(
|
function createTempTransactionEditingTools<T>(
|
||||||
writeTx: (api: ITransactionPrivateApi, value: T) => void,
|
writeTx: (api: ITransactionPrivateApi, value: T) => void,
|
||||||
|
obj: SheetObject,
|
||||||
) {
|
) {
|
||||||
let currentTransaction: CommitOrDiscard | null = null
|
let currentTransaction: CommitOrDiscard | null = null
|
||||||
const createTempTx = (value: T) =>
|
const createTempTx = (value: T) =>
|
||||||
|
@ -37,6 +41,14 @@ function createTempTransactionEditingTools<T>(
|
||||||
currentTransaction = null
|
currentTransaction = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editAssets = {
|
||||||
|
createAsset: obj.sheet.project.assetStorage.createAsset,
|
||||||
|
getAssetUrl: (asset: Asset) =>
|
||||||
|
asset.id
|
||||||
|
? obj.sheet.project.assetStorage.getAssetUrl(asset.id)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
temporarilySetValue(value: T): void {
|
temporarilySetValue(value: T): void {
|
||||||
discardTemporaryValue()
|
discardTemporaryValue()
|
||||||
|
@ -47,5 +59,6 @@ function createTempTransactionEditingTools<T>(
|
||||||
discardTemporaryValue()
|
discardTemporaryValue()
|
||||||
createTempTx(value).commit()
|
createTempTx(value).commit()
|
||||||
},
|
},
|
||||||
|
...editAssets,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
132
theatre/studio/src/propEditors/simpleEditors/ImagePropEditor.tsx
Normal file
132
theatre/studio/src/propEditors/simpleEditors/ImagePropEditor.tsx
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import type {PropTypeConfig_Image} from '@theatre/core/propTypes'
|
||||||
|
import {Trash} from '@theatre/studio/uiComponents/icons'
|
||||||
|
import React, {useCallback, useEffect} from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import type {ISimplePropEditorReactProps} from './ISimplePropEditorReactProps'
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 2px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Group = styled.div<{empty: boolean}>`
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
${({empty}) =>
|
||||||
|
empty
|
||||||
|
? `border: 1px dashed rgba(255, 255, 255, 0.2)`
|
||||||
|
: `border: 1px solid rgba(255, 255, 255, 0.05)`}
|
||||||
|
`
|
||||||
|
|
||||||
|
const InputLabel = styled.label`
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #919191;
|
||||||
|
`
|
||||||
|
|
||||||
|
// file input
|
||||||
|
const Input = styled.input.attrs({type: 'file', accept: 'image/*'})`
|
||||||
|
display: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Preview = styled.img`
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
`
|
||||||
|
|
||||||
|
const DeleteButton = styled.button`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
color: #a8a8a9;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function ImagePropEditor({
|
||||||
|
propConfig,
|
||||||
|
editingTools,
|
||||||
|
value,
|
||||||
|
autoFocus,
|
||||||
|
}: ISimplePropEditorReactProps<PropTypeConfig_Image>) {
|
||||||
|
const [previewUrl, setPreviewUrl] = React.useState<string>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
setPreviewUrl(editingTools.getAssetUrl(value))
|
||||||
|
} else {
|
||||||
|
setPreviewUrl(undefined)
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const onChange = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
editingTools.permanentlySetValue({type: 'image', id: undefined})
|
||||||
|
const imageId = await editingTools.createAsset(file)
|
||||||
|
|
||||||
|
if (!imageId) {
|
||||||
|
editingTools.permanentlySetValue(value)
|
||||||
|
} else {
|
||||||
|
editingTools.permanentlySetValue({
|
||||||
|
type: 'image',
|
||||||
|
id: imageId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
event.target.value = null
|
||||||
|
},
|
||||||
|
[editingTools, value],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Group empty={!value}>
|
||||||
|
<InputLabel>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
onChange={onChange}
|
||||||
|
accept="image/*"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
{previewUrl ? <Preview src={previewUrl} /> : <span>Add image</span>}
|
||||||
|
</InputLabel>
|
||||||
|
{value && (
|
||||||
|
<DeleteButton
|
||||||
|
onClick={() => {
|
||||||
|
editingTools.permanentlySetValue({type: 'image', id: undefined})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash />
|
||||||
|
</DeleteButton>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImagePropEditor
|
|
@ -89,7 +89,6 @@ function RgbaPropEditor({
|
||||||
editingTools.temporarilySetValue(rgba)
|
editingTools.temporarilySetValue(rgba)
|
||||||
}}
|
}}
|
||||||
permanentlySetValue={(color) => {
|
permanentlySetValue={(color) => {
|
||||||
// console.log('perm')
|
|
||||||
const rgba = decorateRgba(color)
|
const rgba = decorateRgba(color)
|
||||||
editingTools.permanentlySetValue(rgba)
|
editingTools.permanentlySetValue(rgba)
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import StringPropEditor from './StringPropEditor'
|
||||||
import RgbaPropEditor from './RgbaPropEditor'
|
import RgbaPropEditor from './RgbaPropEditor'
|
||||||
import type {ISimplePropEditorReactProps} from './ISimplePropEditorReactProps'
|
import type {ISimplePropEditorReactProps} from './ISimplePropEditorReactProps'
|
||||||
import type {PropConfigForType} from '@theatre/studio/propEditors/utils/PropConfigForType'
|
import type {PropConfigForType} from '@theatre/studio/propEditors/utils/PropConfigForType'
|
||||||
|
import ImagePropEditor from './ImagePropEditor'
|
||||||
|
|
||||||
export const simplePropEditorByPropType: ISimplePropEditorByPropType = {
|
export const simplePropEditorByPropType: ISimplePropEditorByPropType = {
|
||||||
number: NumberPropEditor,
|
number: NumberPropEditor,
|
||||||
|
@ -14,6 +15,7 @@ export const simplePropEditorByPropType: ISimplePropEditorByPropType = {
|
||||||
boolean: BooleanPropEditor,
|
boolean: BooleanPropEditor,
|
||||||
stringLiteral: StringLiteralPropEditor,
|
stringLiteral: StringLiteralPropEditor,
|
||||||
rgba: RgbaPropEditor,
|
rgba: RgbaPropEditor,
|
||||||
|
image: ImagePropEditor,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ISimplePropEditorByPropType = {
|
type ISimplePropEditorByPropType = {
|
||||||
|
|
|
@ -20,6 +20,7 @@ import type {NearbyKeyframes} from './getNearbyKeyframesOfTrack'
|
||||||
import {getNearbyKeyframesOfTrack} from './getNearbyKeyframesOfTrack'
|
import {getNearbyKeyframesOfTrack} from './getNearbyKeyframesOfTrack'
|
||||||
import type {NearbyKeyframesControls} from './NextPrevKeyframeCursors'
|
import type {NearbyKeyframesControls} from './NextPrevKeyframeCursors'
|
||||||
import NextPrevKeyframeCursors from './NextPrevKeyframeCursors'
|
import NextPrevKeyframeCursors from './NextPrevKeyframeCursors'
|
||||||
|
import type {Asset} from '@theatre/shared/utils/assets'
|
||||||
|
|
||||||
interface EditingToolsCommon<T> {
|
interface EditingToolsCommon<T> {
|
||||||
value: T
|
value: T
|
||||||
|
@ -31,6 +32,9 @@ interface EditingToolsCommon<T> {
|
||||||
temporarilySetValue(v: T): void
|
temporarilySetValue(v: T): void
|
||||||
discardTemporaryValue(): void
|
discardTemporaryValue(): void
|
||||||
permanentlySetValue(v: T): void
|
permanentlySetValue(v: T): void
|
||||||
|
|
||||||
|
getAssetUrl: (asset: Asset) => string | undefined
|
||||||
|
createAsset(asset: Blob): Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditingToolsDefault<T> extends EditingToolsCommon<T> {
|
interface EditingToolsDefault<T> extends EditingToolsCommon<T> {
|
||||||
|
@ -109,6 +113,14 @@ function createDerivation<T extends SerializablePrimitive>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const editAssets = {
|
||||||
|
createAsset: obj.sheet.project.assetStorage.createAsset,
|
||||||
|
getAssetUrl: (asset: Asset) =>
|
||||||
|
asset.id
|
||||||
|
? obj.sheet.project.assetStorage.getAssetUrl(asset.id)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
const beingScrubbed =
|
const beingScrubbed =
|
||||||
val(
|
val(
|
||||||
get(
|
get(
|
||||||
|
@ -125,6 +137,7 @@ function createDerivation<T extends SerializablePrimitive>(
|
||||||
|
|
||||||
const common: EditingToolsCommon<T> = {
|
const common: EditingToolsCommon<T> = {
|
||||||
...editPropValue,
|
...editPropValue,
|
||||||
|
...editAssets,
|
||||||
value: final,
|
value: final,
|
||||||
beingScrubbed,
|
beingScrubbed,
|
||||||
contextMenuItems,
|
contextMenuItems,
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
|
import type {Asset} from '@theatre/shared/utils/assets'
|
||||||
|
|
||||||
export interface IEditingTools<T> {
|
export interface IEditingTools<T> {
|
||||||
temporarilySetValue(v: T): void
|
temporarilySetValue(v: T): void
|
||||||
discardTemporaryValue(): void
|
discardTemporaryValue(): void
|
||||||
permanentlySetValue(v: T): void
|
permanentlySetValue(v: T): void
|
||||||
|
|
||||||
|
getAssetUrl(asset: Asset): string | undefined
|
||||||
|
createAsset(asset: Blob): Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
21
theatre/studio/src/uiComponents/icons/AddImage.tsx
Normal file
21
theatre/studio/src/uiComponents/icons/AddImage.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
function AddImage(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3.335 13.998c-.367 0-.68-.13-.942-.391a1.285 1.285 0 01-.391-.942v-9.33c0-.367.13-.68.391-.942.261-.26.575-.391.942-.391h5.998v3.332h1.333v1.333h3.332v5.998c0 .367-.13.68-.391.942-.261.26-.575.391-.942.391h-9.33zM4 11.332H12L9.499 8 7.5 10.666l-1.5-2-1.999 2.666zm7.331-5.331V4.668H10V3.335h1.333V2.002h1.333v1.333h1.333v1.333h-1.333V6h-1.333z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddImage
|
21
theatre/studio/src/uiComponents/icons/Trash.tsx
Normal file
21
theatre/studio/src/uiComponents/icons/Trash.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
function Trash(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.8 11.6a.6.6 0 00.6-.6V7.4a.6.6 0 00-1.2 0V11a.6.6 0 00.6.6zm6-7.2h-2.4v-.6A1.8 1.8 0 008.6 2H7.4a1.8 1.8 0 00-1.8 1.8v.6H3.2a.6.6 0 100 1.2h.6v6.6A1.8 1.8 0 005.6 14h4.8a1.8 1.8 0 001.8-1.8V5.6h.6a.6.6 0 100-1.2zm-6-.6a.6.6 0 01.6-.6h1.2a.6.6 0 01.6.6v.6H6.8v-.6zm4.2 8.4a.6.6 0 01-.6.6H5.6a.6.6 0 01-.6-.6V5.6h6v6.6zm-1.8-.6a.6.6 0 00.6-.6V7.4a.6.6 0 00-1.2 0V11a.6.6 0 00.6.6z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Trash
|
|
@ -15,3 +15,5 @@ export {default as GlobeSimple} from './GlobeSimple'
|
||||||
export {default as Resize} from './Resize'
|
export {default as Resize} from './Resize'
|
||||||
export {default as Package} from './Package'
|
export {default as Package} from './Package'
|
||||||
export {default as Bell} from './Bell'
|
export {default as Bell} from './Bell'
|
||||||
|
export {default as Trash} from './Trash'
|
||||||
|
export {default as AddImage} from './AddImage'
|
||||||
|
|
67
yarn.lock
67
yarn.lock
|
@ -10896,6 +10896,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"blob-compare@npm:1.1.0":
|
||||||
|
version: 1.1.0
|
||||||
|
resolution: "blob-compare@npm:1.1.0"
|
||||||
|
dependencies:
|
||||||
|
webworker-promise: ^0.4.2
|
||||||
|
checksum: 28cb1f5c6dc53195e67bb2765ec5a1aa7f1123460d9ccf64116aebf3ed36a3f4df5f065ac5ec4caf5da4fbbb5e944bca5c7296b960f693bea70dc4118953539d
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"blob-polyfill@npm:^5.0.20210201":
|
"blob-polyfill@npm:^5.0.20210201":
|
||||||
version: 5.0.20210201
|
version: 5.0.20210201
|
||||||
resolution: "blob-polyfill@npm:5.0.20210201"
|
resolution: "blob-polyfill@npm:5.0.20210201"
|
||||||
|
@ -18042,6 +18051,15 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"idb-keyval@npm:^6.2.0":
|
||||||
|
version: 6.2.0
|
||||||
|
resolution: "idb-keyval@npm:6.2.0"
|
||||||
|
dependencies:
|
||||||
|
safari-14-idb-fix: ^3.0.0
|
||||||
|
checksum: ddd58f95829ff734e3e83b29608709cdfba38e5795b1f6261ff1afc582ce48f475d0c827d7cf32aacbc5ea6b13d00c2d4a2f7240afccd5252565abeafbd7b990
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"idb@npm:^6.1.4":
|
"idb@npm:^6.1.4":
|
||||||
version: 6.1.5
|
version: 6.1.5
|
||||||
resolution: "idb@npm:6.1.5"
|
resolution: "idb@npm:6.1.5"
|
||||||
|
@ -18104,6 +18122,13 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"immediate@npm:~3.0.5":
|
||||||
|
version: 3.0.6
|
||||||
|
resolution: "immediate@npm:3.0.6"
|
||||||
|
checksum: f9b3486477555997657f70318cc8d3416159f208bec4cca3ff3442fd266bc23f50f0c9bd8547e1371a6b5e82b821ec9a7044a4f7b944798b25aa3cc6d5e63e62
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"immer@npm:8.0.1":
|
"immer@npm:8.0.1":
|
||||||
version: 8.0.1
|
version: 8.0.1
|
||||||
resolution: "immer@npm:8.0.1"
|
resolution: "immer@npm:8.0.1"
|
||||||
|
@ -20589,6 +20614,18 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"jszip@npm:3.10.1":
|
||||||
|
version: 3.10.1
|
||||||
|
resolution: "jszip@npm:3.10.1"
|
||||||
|
dependencies:
|
||||||
|
lie: ~3.3.0
|
||||||
|
pako: ~1.0.2
|
||||||
|
readable-stream: ~2.3.6
|
||||||
|
setimmediate: ^1.0.5
|
||||||
|
checksum: abc77bfbe33e691d4d1ac9c74c8851b5761fba6a6986630864f98d876f3fcc2d36817dfc183779f32c00157b5d53a016796677298272a714ae096dfe6b1c8b60
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"just-curry-it@npm:^3.1.0":
|
"just-curry-it@npm:^3.1.0":
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
resolution: "just-curry-it@npm:3.1.0"
|
resolution: "just-curry-it@npm:3.1.0"
|
||||||
|
@ -20762,6 +20799,15 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"lie@npm:~3.3.0":
|
||||||
|
version: 3.3.0
|
||||||
|
resolution: "lie@npm:3.3.0"
|
||||||
|
dependencies:
|
||||||
|
immediate: ~3.0.5
|
||||||
|
checksum: 33102302cf19766f97919a6a98d481e01393288b17a6aa1f030a3542031df42736edde8dab29ffdbf90bebeffc48c761eb1d064dc77592ca3ba3556f9fe6d2a8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"lilconfig@npm:2.0.5, lilconfig@npm:^2.0.3, lilconfig@npm:^2.0.5":
|
"lilconfig@npm:2.0.5, lilconfig@npm:^2.0.3, lilconfig@npm:^2.0.5":
|
||||||
version: 2.0.5
|
version: 2.0.5
|
||||||
resolution: "lilconfig@npm:2.0.5"
|
resolution: "lilconfig@npm:2.0.5"
|
||||||
|
@ -23102,7 +23148,7 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"pako@npm:~1.0.5":
|
"pako@npm:~1.0.2, pako@npm:~1.0.5":
|
||||||
version: 1.0.11
|
version: 1.0.11
|
||||||
resolution: "pako@npm:1.0.11"
|
resolution: "pako@npm:1.0.11"
|
||||||
checksum: 1be2bfa1f807608c7538afa15d6f25baa523c30ec870a3228a89579e474a4d992f4293859524e46d5d87fd30fa17c5edf34dbef0671251d9749820b488660b16
|
checksum: 1be2bfa1f807608c7538afa15d6f25baa523c30ec870a3228a89579e474a4d992f4293859524e46d5d87fd30fa17c5edf34dbef0671251d9749820b488660b16
|
||||||
|
@ -27504,6 +27550,13 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"safari-14-idb-fix@npm:^3.0.0":
|
||||||
|
version: 3.0.0
|
||||||
|
resolution: "safari-14-idb-fix@npm:3.0.0"
|
||||||
|
checksum: 30d5baf3d9a17b52842b2c3fd3f0001fde7095a36191b6140ac78c19e8e1ff4b2607b9beb3b066c6bec107ccb5399c96e29748a6f5fcd592425f806f5e36f759
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1":
|
"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1":
|
||||||
version: 5.1.2
|
version: 5.1.2
|
||||||
resolution: "safe-buffer@npm:5.1.2"
|
resolution: "safe-buffer@npm:5.1.2"
|
||||||
|
@ -27983,7 +28036,7 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"setimmediate@npm:^1.0.4":
|
"setimmediate@npm:^1.0.4, setimmediate@npm:^1.0.5":
|
||||||
version: 1.0.5
|
version: 1.0.5
|
||||||
resolution: "setimmediate@npm:1.0.5"
|
resolution: "setimmediate@npm:1.0.5"
|
||||||
checksum: c9a6f2c5b51a2dabdc0247db9c46460152ffc62ee139f3157440bd48e7c59425093f42719ac1d7931f054f153e2d26cf37dfeb8da17a794a58198a2705e527fd
|
checksum: c9a6f2c5b51a2dabdc0247db9c46460152ffc62ee139f3157440bd48e7c59425093f42719ac1d7931f054f153e2d26cf37dfeb8da17a794a58198a2705e527fd
|
||||||
|
@ -29695,6 +29748,7 @@ fsevents@^1.2.7:
|
||||||
babel-loader: ^8.2.2
|
babel-loader: ^8.2.2
|
||||||
babel-polyfill: ^6.26.0
|
babel-polyfill: ^6.26.0
|
||||||
babel-register: ^6.26.0
|
babel-register: ^6.26.0
|
||||||
|
blob-compare: 1.1.0
|
||||||
circular-dependency-plugin: ^5.2.2
|
circular-dependency-plugin: ^5.2.2
|
||||||
cross-env: ^7.0.3
|
cross-env: ^7.0.3
|
||||||
esbuild: ^0.12.15
|
esbuild: ^0.12.15
|
||||||
|
@ -29706,10 +29760,12 @@ fsevents@^1.2.7:
|
||||||
fs-extra: ^10.0.0
|
fs-extra: ^10.0.0
|
||||||
fuzzy: ^0.1.3
|
fuzzy: ^0.1.3
|
||||||
html-loader: ^2.1.2
|
html-loader: ^2.1.2
|
||||||
|
idb-keyval: ^6.2.0
|
||||||
identity-obj-proxy: ^3.0.0
|
identity-obj-proxy: ^3.0.0
|
||||||
immer: ^9.0.6
|
immer: ^9.0.6
|
||||||
jiff: ^0.7.3
|
jiff: ^0.7.3
|
||||||
json-touch-patch: ^0.11.2
|
json-touch-patch: ^0.11.2
|
||||||
|
jszip: 3.10.1
|
||||||
lodash: ^4.17.21
|
lodash: ^4.17.21
|
||||||
lodash-es: ^4.17.21
|
lodash-es: ^4.17.21
|
||||||
marked: ^4.1.1
|
marked: ^4.1.1
|
||||||
|
@ -31424,6 +31480,13 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"webworker-promise@npm:^0.4.2":
|
||||||
|
version: 0.4.4
|
||||||
|
resolution: "webworker-promise@npm:0.4.4"
|
||||||
|
checksum: 6edb84c235ff49e437c3cfd8c10b75a202911c78e92050dbfad79d051d4df3ea7abbd2e16e0056e46ce0eaf53441105902647bd2b7cca4bc7c0c4f8dfd6b6da7
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"whatwg-encoding@npm:^1.0.1, whatwg-encoding@npm:^1.0.5":
|
"whatwg-encoding@npm:^1.0.1, whatwg-encoding@npm:^1.0.5":
|
||||||
version: 1.0.5
|
version: 1.0.5
|
||||||
resolution: "whatwg-encoding@npm:1.0.5"
|
resolution: "whatwg-encoding@npm:1.0.5"
|
||||||
|
|
Loading…
Reference in a new issue