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>
This will create a JSON file with the state of your project. You can <p>
commit this file to your git repo and include it in your production This will create a JSON file with the state of your project. You can
bundle. commit this file to your git repo and include it in your production
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"