Implement hot-reloading extension toolbars

This commit is contained in:
Aria Minaei 2023-08-03 10:54:54 +02:00
parent cd6f44d9dd
commit 4f00443ee1
8 changed files with 213 additions and 6 deletions

View file

@ -32,5 +32,31 @@ export function useExtensionButton(
}, [id]) }, [id])
} }
export function extensionButton(
title: string,
callback: () => void,
svgSource?: string,
) {
const id = 'useExtensionButton#' + idCounter++
studio.extend({
id: id,
toolbars: {
global(set) {
set([
{
type: 'Icon',
title,
onClick() {
callback()
},
svgSource: svgSource ?? stepForward,
},
])
return () => {}
},
},
})
}
// FontAwesome FaStepForward // FontAwesome FaStepForward
const stepForward = `<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 448 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M384 44v424c0 6.6-5.4 12-12 12h-48c-6.6 0-12-5.4-12-12V291.6l-195.5 181C95.9 489.7 64 475.4 64 448V64c0-27.4 31.9-41.7 52.5-24.6L312 219.3V44c0-6.6 5.4-12 12-12h48c6.6 0 12 5.4 12 12z"></path></svg>` const stepForward = `<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 448 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M384 44v424c0 6.6-5.4 12-12 12h-48c-6.6 0-12-5.4-12-12V291.6l-195.5 181C95.9 489.7 64 475.4 64 448V64c0-27.4 31.9-41.7 52.5-24.6L312 219.3V44c0-6.6 5.4-12 12-12h48c6.6 0 12 5.4 12 12z"></path></svg>`

View file

@ -0,0 +1,37 @@
import {editable as e, SheetProvider} from '@theatre/r3f'
import {Stars, TorusKnot} from '@react-three/drei'
import {getProject} from '@theatre/core'
import React from 'react'
import {Canvas} from '@react-three/fiber'
function App() {
return (
<div
onClick={() => {
// return setBgIndex((bgIndex) => (bgIndex + 1) % bgs.length)
}}
style={{
height: '100vh',
}}
>
<Canvas
dpr={[1.5, 2]}
linear
gl={{preserveDrawingBuffer: true}}
frameloop="demand"
>
<SheetProvider sheet={getProject('Space').sheet('Scene')}>
<ambientLight intensity={0.75} />
<e.group theatreKey="trefoil">
<TorusKnot scale={[1, 1, 1]} args={[1, 0.3, 128, 64]}>
<meshNormalMaterial />
</TorusKnot>
</e.group>
<Stars radius={500} depth={50} count={1000} factor={10} />
</SheetProvider>
</Canvas>
</div>
)
}
export default App

View file

@ -0,0 +1,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Theatre.js Playground</title>
<script src="./index.tsx" type="module"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -0,0 +1,86 @@
import type {IExtension} from '@theatre/studio'
import studio from '@theatre/studio'
import '@theatre/core'
import {extensionButton} from '../../shared/utils/useExtensionButton'
const ext1: IExtension = {
id: '@theatre/hello-world-extension',
toolbars: {
global(set, studio) {
console.log('mount 1')
set([
{
type: 'Icon',
title: 'Icon 1',
svgSource: '1',
onClick: () => {
console.log('Icon 1')
},
},
])
return () => {
console.log('unmount 1')
}
},
},
panes: [],
}
studio.initialize()
let currentStep = -1
extensionButton(
'Forward',
() => {
if (currentStep < steps.length - 1) {
currentStep++
steps[currentStep]()
}
},
'>',
)
const steps = [
function step1() {
studio.extend(ext1)
},
function step2() {
studio.extend(
{
...ext1,
toolbars: {
global(set, studio) {
console.log('mount 2')
set([
{
type: 'Icon',
title: 'Icon 2',
svgSource: '2',
onClick: () => {
console.log('Icon 2')
},
},
])
return () => {
console.log('unmount 2')
}
},
},
},
{__experimental_reconfigure: true},
)
},
function step3() {
studio.extend(
{
...ext1,
toolbars: {},
},
{__experimental_reconfigure: true},
)
},
]

View file

@ -0,0 +1,25 @@
import {test, expect} from '@playwright/test'
test.describe('reconfigure-extension', () => {
test('works', async ({page}) => {
await page.goto('./tests/reconfigure-extension/')
const toolbar = page.locator(
'[data-test-id="theatre-extensionToolbar-global"]',
)
const forwardButton = toolbar.getByRole('button', {name: '>'})
await forwardButton.click()
const otherButton = toolbar.getByRole('button').nth(1)
expect(await otherButton.textContent()).toEqual('1')
await forwardButton.click()
expect(await otherButton.textContent()).toEqual('2')
await forwardButton.click()
// expect otherButton not to exist
await expect(otherButton).not.toBeAttached()
})
})

View file

@ -374,6 +374,21 @@ export interface IStudio {
* The extension's definition * The extension's definition
*/ */
extension: IExtension, extension: IExtension,
opts?: {
/**
* Whether to reconfigure the extension. This is useful if you're
* hot-reloading the extension.
*
* Mind you, that if the old version of the extension defines a pane,
* and the new version doesn't, all instances of that pane will disappear, as expected.
* _However_, if you again reconfigure the extension with the old version, the instances
* of the pane that pane will re-appear.
*
* We're not sure about whether this behavior makes sense or not. If not, let us know
* in the discord server or open an issue on github.
*/
__experimental_reconfigure?: boolean
},
): void ): void
/** /**
@ -504,8 +519,11 @@ export default class TheatreStudio implements IStudio {
return studio.initialize(opts) return studio.initialize(opts)
} }
extend(extension: IExtension): void { extend(
getStudio().extend(extension) extension: IExtension,
opts?: {__experimental_reconfigure?: boolean},
): void {
getStudio().extend(extension, opts)
} }
transaction(fn: (api: ITransactionAPI) => void): void { transaction(fn: (api: ITransactionAPI) => void): void {

View file

@ -31,14 +31,16 @@ const ExtensionToolsetRender: React.FC<{
}> = ({extension, toolbarId}) => { }> = ({extension, toolbarId}) => {
const toolsetConfigBox = useMemo(() => new Atom<ToolsetConfig>([]), []) const toolsetConfigBox = useMemo(() => new Atom<ToolsetConfig>([]), [])
const attachFn = extension.toolbars?.[toolbarId]
useLayoutEffect(() => { useLayoutEffect(() => {
const detach = extension.toolbars?.[toolbarId]?.( const detach = attachFn?.(
toolsetConfigBox.set.bind(toolsetConfigBox), toolsetConfigBox.set.bind(toolsetConfigBox),
getStudio()!.publicApi, getStudio()!.publicApi,
) )
if (typeof detach === 'function') return detach if (typeof detach === 'function') return detach
}, [extension, toolbarId]) }, [extension, toolbarId, attachFn])
const config = useVal(toolsetConfigBox.prism) const config = useVal(toolsetConfigBox.prism)
@ -69,7 +71,7 @@ export const ExtensionToolbar: React.FC<{
if (groups.length === 0) return null if (groups.length === 0) return null
return ( return (
<Container> <Container data-test-id={`theatre-extensionToolbar-${toolbarId}`}>
{showLeftDivider ? <GroupDivider></GroupDivider> : undefined} {showLeftDivider ? <GroupDivider></GroupDivider> : undefined}
{groups} {groups}
</Container> </Container>

View file

@ -15,10 +15,12 @@ const Container = styled(ToolbarIconButton)`
const IconButton: React.FC<{ const IconButton: React.FC<{
config: ToolConfigIcon config: ToolConfigIcon
}> = ({config}) => { testId?: string
}> = ({config, testId}) => {
return ( return (
<Container <Container
onClick={config.onClick} onClick={config.onClick}
data-testid={testId}
title={config.title} title={config.title}
dangerouslySetInnerHTML={{__html: config['svgSource'] ?? ''}} dangerouslySetInnerHTML={{__html: config['svgSource'] ?? ''}}
/> />