Create idb-backed image prop (#366)

Co-authored-by: Clement Roche <rchclement@gmail.com>
This commit is contained in:
Andrew Prifer 2022-12-28 16:15:54 +01:00 committed by Aria Minaei
parent 95b329b02d
commit 8d8e2348dd
21 changed files with 838 additions and 31 deletions

View 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'))
})

View 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
},
}
},
}
}

View 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),
}
}

View file

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

View file

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

View file

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

View file

@ -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",

View 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}

View file

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

View file

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

View file

@ -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)
} }

View file

@ -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,
} }
} }

View 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

View file

@ -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)
}} }}

View file

@ -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 = {

View file

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

View file

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

View 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

View 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

View file

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

View file

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