diff --git a/packages/playground/src/shared/image/index.tsx b/packages/playground/src/shared/image/index.tsx new file mode 100644 index 0000000..561ff64 --- /dev/null +++ b/packages/playground/src/shared/image/index.tsx @@ -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() + + 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 ( + { + if (sheet.sequence.position === 0) { + sheet.sequence.position = 0 + sheet.sequence.play() + } else { + sheet.sequence.position = 0 + } + }} + > + + + ) +} + +project.ready.then(() => { + render(, document.getElementById('root')) +}) diff --git a/theatre/core/src/projects/DefaultAssetStorage.ts b/theatre/core/src/projects/DefaultAssetStorage.ts new file mode 100644 index 0000000..a145e17 --- /dev/null +++ b/theatre/core/src/projects/DefaultAssetStorage.ts @@ -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() + 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()) + + // A map for caching the object urls created from idb assets. + const urlCache = new Map() + + /** 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 + }, + } + }, + } +} diff --git a/theatre/core/src/projects/IDBStorage.ts b/theatre/core/src/projects/IDBStorage.ts new file mode 100644 index 0000000..6f0cab2 --- /dev/null +++ b/theatre/core/src/projects/IDBStorage.ts @@ -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: (key: string) => idb.get(key, customStore), + del: (key: string) => idb.del(key, customStore), + keys: () => idb.keys(customStore), + entries: () => + idb.entries(customStore), + values: () => idb.values(customStore), + } +} diff --git a/theatre/core/src/projects/Project.ts b/theatre/core/src/projects/Project.ts index 910fb5b..a133922 100644 --- a/theatre/core/src/projects/Project.ts +++ b/theatre/core/src/projects/Project.ts @@ -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 +} + +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 +} + +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 + private readonly _studioReadyDeferred: Deferred + private readonly _defaultAssetStorageReadyDeferred: Deferred + private readonly _readyPromise: Promise 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 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( diff --git a/theatre/core/src/projects/TheatreProject.ts b/theatre/core/src/projects/TheatreProject.ts index d3576d9..62af80a 100644 --- a/theatre/core/src/projects/TheatreProject.ts +++ b/theatre/core/src/projects/TheatreProject.ts @@ -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, diff --git a/theatre/core/src/propTypes/index.ts b/theatre/core/src/propTypes/index.ts index 9a8c3dd..e88cc23 100644 --- a/theatre/core/src/propTypes/index.ts +++ b/theatre/core/src/propTypes/index.ts @@ -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 = ( 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 + } = {}, +): PropTypeConfig_Image => { + if (process.env.NODE_ENV !== 'production') { + validateCommonOpts('t.image(defaultValue, opts)', opts) + } + + const interpolate: Interpolator = (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 export interface PropTypeConfig_Rgba extends ISimplePropType<'rgba', Rgba> {} +export interface PropTypeConfig_Image extends ISimplePropType<'image', Asset> {} + type DeepPartialCompound = { [K in keyof Props]?: DeepPartial } @@ -722,6 +793,7 @@ export type PropTypeConfig_AllSimples = | PropTypeConfig_String | PropTypeConfig_StringLiteral<$IntentionalAny> | PropTypeConfig_Rgba + | PropTypeConfig_Image export type PropTypeConfig = | PropTypeConfig_AllSimples diff --git a/theatre/package.json b/theatre/package.json index 5090bc1..8126ad3 100644 --- a/theatre/package.json +++ b/theatre/package.json @@ -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", diff --git a/theatre/shared/src/utils/assets.ts b/theatre/shared/src/utils/assets.ts new file mode 100644 index 0000000..9a253f7 --- /dev/null +++ b/theatre/shared/src/utils/assets.ts @@ -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} diff --git a/theatre/shared/src/utils/types.ts b/theatre/shared/src/utils/types.ts index 59e4498..afdcc49 100644 --- a/theatre/shared/src/utils/types.ts +++ b/theatre/shared/src/utils/types.ts @@ -1,3 +1,5 @@ +import type {Asset} from './assets' + export type GenericAction = {type: string; payload: unknown} export type ReduxReducer = ( @@ -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. diff --git a/theatre/studio/src/panels/DetailPanel/ProjectDetails.tsx b/theatre/studio/src/panels/DetailPanel/ProjectDetails.tsx index a5f8960..9648bce 100644 --- a/theatre/studio/src/panels/DetailPanel/ProjectDetails.tsx +++ b/theatre/studio/src/panels/DetailPanel/ProjectDetails.tsx @@ -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() + + 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}, () => ( - 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. +

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

+

+ If your project uses assets, this will also create a zip file with all + the assets that you can unpack in your public folder. +

( writeTx: (api: ITransactionPrivateApi, value: T) => void, + obj: SheetObject, ): IEditingTools { - return useMemo(() => createTempTransactionEditingTools(writeTx), []) + return useMemo(() => createTempTransactionEditingTools(writeTx, obj), []) } function createTempTransactionEditingTools( writeTx: (api: ITransactionPrivateApi, value: T) => void, + obj: SheetObject, ) { let currentTransaction: CommitOrDiscard | null = null const createTempTx = (value: T) => @@ -37,6 +41,14 @@ function createTempTransactionEditingTools( 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( discardTemporaryValue() createTempTx(value).commit() }, + ...editAssets, } } diff --git a/theatre/studio/src/propEditors/simpleEditors/ImagePropEditor.tsx b/theatre/studio/src/propEditors/simpleEditors/ImagePropEditor.tsx new file mode 100644 index 0000000..15cc414 --- /dev/null +++ b/theatre/studio/src/propEditors/simpleEditors/ImagePropEditor.tsx @@ -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) { + const [previewUrl, setPreviewUrl] = React.useState() + + 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 ( + + + + + {previewUrl ? : Add image} + + {value && ( + { + editingTools.permanentlySetValue({type: 'image', id: undefined}) + }} + > + + + )} + + + ) +} + +export default ImagePropEditor diff --git a/theatre/studio/src/propEditors/simpleEditors/RgbaPropEditor.tsx b/theatre/studio/src/propEditors/simpleEditors/RgbaPropEditor.tsx index b816b97..bd1f1b5 100644 --- a/theatre/studio/src/propEditors/simpleEditors/RgbaPropEditor.tsx +++ b/theatre/studio/src/propEditors/simpleEditors/RgbaPropEditor.tsx @@ -89,7 +89,6 @@ function RgbaPropEditor({ editingTools.temporarilySetValue(rgba) }} permanentlySetValue={(color) => { - // console.log('perm') const rgba = decorateRgba(color) editingTools.permanentlySetValue(rgba) }} diff --git a/theatre/studio/src/propEditors/simpleEditors/simplePropEditorByPropType.ts b/theatre/studio/src/propEditors/simpleEditors/simplePropEditorByPropType.ts index ca2ca48..5df8ba2 100644 --- a/theatre/studio/src/propEditors/simpleEditors/simplePropEditorByPropType.ts +++ b/theatre/studio/src/propEditors/simpleEditors/simplePropEditorByPropType.ts @@ -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 = { diff --git a/theatre/studio/src/propEditors/useEditingToolsForSimpleProp.tsx b/theatre/studio/src/propEditors/useEditingToolsForSimpleProp.tsx index 1d5ed87..65ff7a7 100644 --- a/theatre/studio/src/propEditors/useEditingToolsForSimpleProp.tsx +++ b/theatre/studio/src/propEditors/useEditingToolsForSimpleProp.tsx @@ -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 { value: T @@ -31,6 +32,9 @@ interface EditingToolsCommon { temporarilySetValue(v: T): void discardTemporaryValue(): void permanentlySetValue(v: T): void + + getAssetUrl: (asset: Asset) => string | undefined + createAsset(asset: Blob): Promise } interface EditingToolsDefault extends EditingToolsCommon { @@ -109,6 +113,14 @@ function createDerivation( [], ) + 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( const common: EditingToolsCommon = { ...editPropValue, + ...editAssets, value: final, beingScrubbed, contextMenuItems, diff --git a/theatre/studio/src/propEditors/utils/IEditingTools.tsx b/theatre/studio/src/propEditors/utils/IEditingTools.tsx index 4e68ab0..499752f 100644 --- a/theatre/studio/src/propEditors/utils/IEditingTools.tsx +++ b/theatre/studio/src/propEditors/utils/IEditingTools.tsx @@ -1,5 +1,10 @@ +import type {Asset} from '@theatre/shared/utils/assets' + export interface IEditingTools { temporarilySetValue(v: T): void discardTemporaryValue(): void permanentlySetValue(v: T): void + + getAssetUrl(asset: Asset): string | undefined + createAsset(asset: Blob): Promise } diff --git a/theatre/studio/src/uiComponents/icons/AddImage.tsx b/theatre/studio/src/uiComponents/icons/AddImage.tsx new file mode 100644 index 0000000..eebd3d3 --- /dev/null +++ b/theatre/studio/src/uiComponents/icons/AddImage.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' + +function AddImage(props: React.SVGProps) { + return ( + + + + ) +} + +export default AddImage diff --git a/theatre/studio/src/uiComponents/icons/Trash.tsx b/theatre/studio/src/uiComponents/icons/Trash.tsx new file mode 100644 index 0000000..002503a --- /dev/null +++ b/theatre/studio/src/uiComponents/icons/Trash.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' + +function Trash(props: React.SVGProps) { + return ( + + + + ) +} + +export default Trash diff --git a/theatre/studio/src/uiComponents/icons/index.ts b/theatre/studio/src/uiComponents/icons/index.ts index 0af97b5..eabc1ea 100644 --- a/theatre/studio/src/uiComponents/icons/index.ts +++ b/theatre/studio/src/uiComponents/icons/index.ts @@ -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' diff --git a/yarn.lock b/yarn.lock index 1a01a5b..ee19460 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"