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,
} from '@theatre/shared/logger'
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<{
state: OnDiskState
assets: IAssetConf
experiments: ExperimentsConf
}>
@ -50,13 +76,17 @@ export default class Project {
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<{
[sheetId: string]: SheetTemplate | undefined
}>({})
sheetTemplatesP = this._sheetTemplates.pointer
private _studio: Studio | undefined
private _defaultAssetStorageConfig: IAssetStorageConfig
assetStorage: IStudioAssetStorage
type: 'Theatre_Project' = 'Theatre_Project'
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 = {
historic: new PointerProxy(onDiskStateAtom.pointer.historic),
ahistoric: new PointerProxy(onDiskStateAtom.pointer.ahistoric),
@ -101,14 +145,21 @@ export default class Project {
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) {
setTimeout(() => {
// The user has provided config.state but in case @theatre/studio is loaded,
// let's give it one tick to attach itself
if (!this._studio) {
this._readyDeferred.resolve(undefined)
this._studioReadyDeferred.resolve(undefined)
this._defaultAssetStorageReadyDeferred.resolve(undefined)
this._logger._trace('ready deferred resolved with no state')
}
}, 0)
@ -166,7 +217,15 @@ export default class Project {
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() {
return this._readyDeferred.promise
return this._readyPromise
}
isReady() {
return this._readyDeferred.status === 'resolved'
return (
this._studioReadyDeferred.status === 'resolved' &&
this._defaultAssetStorageReadyDeferred.status === 'resolved'
)
}
getOrCreateSheet(

View file

@ -3,6 +3,7 @@ import Project from '@theatre/core/projects/Project'
import type {ISheet} from '@theatre/core/sheets/TheatreSheet'
import type {ProjectAddress} from '@theatre/shared/utils/addresses'
import type {Asset} from '@theatre/shared/utils/assets'
import type {
ProjectId,
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.
*/
state?: $IntentionalAny
// experiments?: IProjectConfigExperiments
assets?: {
baseUrl?: string
}
}
// export type IProjectConfigExperiments = {
@ -70,6 +73,14 @@ export interface IProject {
* **Docs: https://www.theatrejs.com/docs/latest/manual/sheets**
*/
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 {
@ -95,6 +106,12 @@ export default class TheatreProject implements IProject {
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 {
const sanitizedPath = validateAndSanitiseSlashedPathOrThrow(
sheetId,

View file

@ -17,6 +17,7 @@ import type {
import {propTypeSymbol, sanitizeCompoundProps} from './internals'
// eslint-disable-next-line unused-imports/no-unused-imports
import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
import type {Asset} from '@theatre/shared/utils/assets'
// Notes on naming:
// As of now, prop types are either `simple` or `composite`.
@ -138,6 +139,74 @@ export const compound = <Props extends UnknownShorthandCompoundProps>(
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.
*
@ -690,6 +759,8 @@ export interface PropTypeConfig_StringLiteral<T extends string>
export interface PropTypeConfig_Rgba extends ISimplePropType<'rgba', Rgba> {}
export interface PropTypeConfig_Image extends ISimplePropType<'image', Asset> {}
type DeepPartialCompound<Props extends UnknownValidCompoundProps> = {
[K in keyof Props]?: DeepPartial<Props[K]>
}
@ -722,6 +793,7 @@ export type PropTypeConfig_AllSimples =
| PropTypeConfig_String
| PropTypeConfig_StringLiteral<$IntentionalAny>
| PropTypeConfig_Rgba
| PropTypeConfig_Image
export type PropTypeConfig =
| PropTypeConfig_AllSimples

View file

@ -48,6 +48,7 @@
"babel-loader": "^8.2.2",
"babel-polyfill": "^6.26.0",
"babel-register": "^6.26.0",
"blob-compare": "1.1.0",
"circular-dependency-plugin": "^5.2.2",
"cross-env": "^7.0.3",
"esbuild": "^0.12.15",
@ -57,10 +58,12 @@
"file-loader": "^6.2.0",
"fs-extra": "^10.0.0",
"html-loader": "^2.1.2",
"idb-keyval": "^6.2.0",
"identity-obj-proxy": "^3.0.0",
"immer": "^9.0.6",
"jiff": "^0.7.3",
"json-touch-patch": "^0.11.2",
"jszip": "3.10.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"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 ReduxReducer<State extends {}> = (
@ -48,6 +50,7 @@ export type SerializablePrimitive =
| number
| boolean
| {r: number; g: number; b: number; a: number}
| Asset
/**
* 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 DetailPanelButton from '@theatre/studio/uiComponents/DetailPanelButton'
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``
@ -17,10 +20,35 @@ const TheExportRow = styled.div`
`
const ExportTooltip = styled(BasicPopover)`
display flex;
flex-direction: column;
gap: 1em;
width: 280px;
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<{
projects: Project[]
}> = ({projects}) => {
@ -34,41 +62,74 @@ const ProjectDetails: React.FC<{
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(
getStudio().createContentOfSaveFile(project.address.projectId),
null,
2,
)
const file = new File([str], suggestedFileName, {
type: 'application/json',
})
const objUrl = URL.createObjectURL(file)
const a = Object.assign(document.createElement('a'), {
href: objUrl,
target: '_blank',
rel: 'noopener',
})
a.setAttribute('download', suggestedFileName)
a.click()
saveFile(str, suggestedFileName)
setDownloaded(true)
setTimeout(() => {
setDownloaded(false)
}, 2000)
setTimeout(() => {
URL.revokeObjectURL(objUrl)
}, 40000)
}, [project, suggestedFileName])
const exportTooltip = usePopover(
{debugName: 'ProjectDetails', pointerDistanceThreshold: 50},
() => (
<ExportTooltip>
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
bundle.
<p>
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
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
href="https://www.theatrejs.com/docs/latest/manual/projects#state"
target="_blank"

View file

@ -15,6 +15,7 @@ import {valueInProp} from '@theatre/shared/propTypes/utils'
const SingleKeyframePropEditorContainer = styled.div`
display: flex;
align-items: stretch;
min-width: 200px;
select {
min-width: 100px;
@ -153,5 +154,5 @@ function useEditingToolsForKeyframeEditorPopover(
keyframes: [newKeyframe],
snappingFunction: obj.sheet.getSequence().closestGridPosition,
})
})
}, obj)
}

View file

@ -6,6 +6,8 @@ import type {
} from '@theatre/studio/StudioStore/StudioStore'
import type {IEditingTools} from '@theatre/studio/propEditors/utils/IEditingTools'
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
@ -21,12 +23,14 @@ import {useMemo} from 'react'
*/
export function useTempTransactionEditingTools<T extends SerializableValue>(
writeTx: (api: ITransactionPrivateApi, value: T) => void,
obj: SheetObject,
): IEditingTools<T> {
return useMemo(() => createTempTransactionEditingTools<T>(writeTx), [])
return useMemo(() => createTempTransactionEditingTools<T>(writeTx, obj), [])
}
function createTempTransactionEditingTools<T>(
writeTx: (api: ITransactionPrivateApi, value: T) => void,
obj: SheetObject,
) {
let currentTransaction: CommitOrDiscard | null = null
const createTempTx = (value: T) =>
@ -37,6 +41,14 @@ function createTempTransactionEditingTools<T>(
currentTransaction = null
}
const editAssets = {
createAsset: obj.sheet.project.assetStorage.createAsset,
getAssetUrl: (asset: Asset) =>
asset.id
? obj.sheet.project.assetStorage.getAssetUrl(asset.id)
: undefined,
}
return {
temporarilySetValue(value: T): void {
discardTemporaryValue()
@ -47,5 +59,6 @@ function createTempTransactionEditingTools<T>(
discardTemporaryValue()
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)
}}
permanentlySetValue={(color) => {
// console.log('perm')
const rgba = decorateRgba(color)
editingTools.permanentlySetValue(rgba)
}}

View file

@ -7,6 +7,7 @@ import StringPropEditor from './StringPropEditor'
import RgbaPropEditor from './RgbaPropEditor'
import type {ISimplePropEditorReactProps} from './ISimplePropEditorReactProps'
import type {PropConfigForType} from '@theatre/studio/propEditors/utils/PropConfigForType'
import ImagePropEditor from './ImagePropEditor'
export const simplePropEditorByPropType: ISimplePropEditorByPropType = {
number: NumberPropEditor,
@ -14,6 +15,7 @@ export const simplePropEditorByPropType: ISimplePropEditorByPropType = {
boolean: BooleanPropEditor,
stringLiteral: StringLiteralPropEditor,
rgba: RgbaPropEditor,
image: ImagePropEditor,
}
type ISimplePropEditorByPropType = {

View file

@ -20,6 +20,7 @@ import type {NearbyKeyframes} from './getNearbyKeyframesOfTrack'
import {getNearbyKeyframesOfTrack} from './getNearbyKeyframesOfTrack'
import type {NearbyKeyframesControls} from './NextPrevKeyframeCursors'
import NextPrevKeyframeCursors from './NextPrevKeyframeCursors'
import type {Asset} from '@theatre/shared/utils/assets'
interface EditingToolsCommon<T> {
value: T
@ -31,6 +32,9 @@ interface EditingToolsCommon<T> {
temporarilySetValue(v: T): void
discardTemporaryValue(): void
permanentlySetValue(v: T): void
getAssetUrl: (asset: Asset) => string | undefined
createAsset(asset: Blob): Promise<string | null>
}
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 =
val(
get(
@ -125,6 +137,7 @@ function createDerivation<T extends SerializablePrimitive>(
const common: EditingToolsCommon<T> = {
...editPropValue,
...editAssets,
value: final,
beingScrubbed,
contextMenuItems,

View file

@ -1,5 +1,10 @@
import type {Asset} from '@theatre/shared/utils/assets'
export interface IEditingTools<T> {
temporarilySetValue(v: T): void
discardTemporaryValue(): 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 Package} from './Package'
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
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":
version: 5.0.20210201
resolution: "blob-polyfill@npm:5.0.20210201"
@ -18042,6 +18051,15 @@ fsevents@^1.2.7:
languageName: node
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":
version: 6.1.5
resolution: "idb@npm:6.1.5"
@ -18104,6 +18122,13 @@ fsevents@^1.2.7:
languageName: node
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":
version: 8.0.1
resolution: "immer@npm:8.0.1"
@ -20589,6 +20614,18 @@ fsevents@^1.2.7:
languageName: node
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":
version: 3.1.0
resolution: "just-curry-it@npm:3.1.0"
@ -20762,6 +20799,15 @@ fsevents@^1.2.7:
languageName: node
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":
version: 2.0.5
resolution: "lilconfig@npm:2.0.5"
@ -23102,7 +23148,7 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"pako@npm:~1.0.5":
"pako@npm:~1.0.2, pako@npm:~1.0.5":
version: 1.0.11
resolution: "pako@npm:1.0.11"
checksum: 1be2bfa1f807608c7538afa15d6f25baa523c30ec870a3228a89579e474a4d992f4293859524e46d5d87fd30fa17c5edf34dbef0671251d9749820b488660b16
@ -27504,6 +27550,13 @@ fsevents@^1.2.7:
languageName: node
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":
version: 5.1.2
resolution: "safe-buffer@npm:5.1.2"
@ -27983,7 +28036,7 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"setimmediate@npm:^1.0.4":
"setimmediate@npm:^1.0.4, setimmediate@npm:^1.0.5":
version: 1.0.5
resolution: "setimmediate@npm:1.0.5"
checksum: c9a6f2c5b51a2dabdc0247db9c46460152ffc62ee139f3157440bd48e7c59425093f42719ac1d7931f054f153e2d26cf37dfeb8da17a794a58198a2705e527fd
@ -29695,6 +29748,7 @@ fsevents@^1.2.7:
babel-loader: ^8.2.2
babel-polyfill: ^6.26.0
babel-register: ^6.26.0
blob-compare: 1.1.0
circular-dependency-plugin: ^5.2.2
cross-env: ^7.0.3
esbuild: ^0.12.15
@ -29706,10 +29760,12 @@ fsevents@^1.2.7:
fs-extra: ^10.0.0
fuzzy: ^0.1.3
html-loader: ^2.1.2
idb-keyval: ^6.2.0
identity-obj-proxy: ^3.0.0
immer: ^9.0.6
jiff: ^0.7.3
json-touch-patch: ^0.11.2
jszip: 3.10.1
lodash: ^4.17.21
lodash-es: ^4.17.21
marked: ^4.1.1
@ -31424,6 +31480,13 @@ fsevents@^1.2.7:
languageName: node
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":
version: 1.0.5
resolution: "whatwg-encoding@npm:1.0.5"