threepipe
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

TweakpaneUiPlugin.ts 8.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import * as TweakpaneImagePlugin from 'tweakpane-image-plugin'
  2. import {UiConfigRendererTweakpane} from 'uiconfig-tweakpane'
  3. import {
  4. Class,
  5. Color,
  6. createDiv,
  7. createStyles,
  8. CustomContextMenu,
  9. downloadBlob,
  10. getOrCall,
  11. htmlDialogWrapper,
  12. IEvent,
  13. IViewerPlugin,
  14. IViewerPluginSync,
  15. mobileAndTabletCheck,
  16. onChange,
  17. Texture,
  18. ThreeViewer,
  19. uiDropdown,
  20. uiFolderContainer,
  21. UiObjectConfig,
  22. UndoManagerPlugin,
  23. uploadFile,
  24. Vector2,
  25. Vector3,
  26. Vector4,
  27. WebGLCubeRenderTarget,
  28. WebGLMultipleRenderTargets,
  29. WebGLRenderTarget,
  30. windowDialogWrapper,
  31. } from 'threepipe'
  32. import styles from './tpTheme.css?inline'
  33. import {tpImageInputGenerator} from './tpImageInputGenerator'
  34. const themeColors = ['black', 'white', 'blue', 'light', 'dark'] as const
  35. type ThemeColors = typeof themeColors[number]
  36. @uiFolderContainer('Tweakpane UI')
  37. export class TweakpaneUiPlugin extends UiConfigRendererTweakpane implements IViewerPluginSync {
  38. declare ['constructor']: typeof TweakpaneUiPlugin
  39. static readonly PluginType = 'TweakpaneUi'
  40. enabled = true
  41. static CONTAINER_SLOT = 'uiconfigMainPanelSlot'
  42. @onChange(TweakpaneUiPlugin.prototype._colorModeChanged)
  43. @uiDropdown('Color Mode', themeColors.map(label=>({label})))
  44. colorMode: ThemeColors
  45. constructor(expanded = !mobileAndTabletCheck(), bigTheme = true, container?: HTMLElement, colorMode?: ThemeColors) {
  46. super(container ?? document.getElementById(TweakpaneUiPlugin.CONTAINER_SLOT) ?? document.getElementById('tweakpaneMainPanelSlot') ?? document.body, {
  47. expanded, autoPostFrame: false,
  48. }, false)
  49. this.THREE = {Color, Vector4, Vector3, Vector2} as any
  50. this._root!.registerPlugin(TweakpaneImagePlugin as any)
  51. if (bigTheme) createStyles(styles, container)
  52. this.colorMode = colorMode ?? getThemeColor()
  53. // @ts-expect-error required for tpTextureInputComponent so that it doesn't clone it. todo check others as well like object3d etc
  54. Texture.prototype._ui_isPrimitive = true
  55. // @ts-expect-error same
  56. WebGLRenderTarget.prototype._ui_isPrimitive = true
  57. // @ts-expect-error same
  58. WebGLCubeRenderTarget.prototype._ui_isPrimitive = true
  59. // @ts-expect-error same
  60. WebGLMultipleRenderTargets.prototype._ui_isPrimitive = true
  61. }
  62. protected _viewer?: ThreeViewer
  63. private _lastManager?: UndoManagerPlugin['undoManager']
  64. onAdded(viewer: ThreeViewer): void {
  65. this._viewer = viewer
  66. this.typeGenerators.image = tpImageInputGenerator(this._viewer)
  67. viewer.addEventListener('preRender', this._preRender)
  68. viewer.addEventListener('postRender', this._postRender)
  69. viewer.addEventListener('preFrame', this._preFrame)
  70. viewer.addEventListener('postFrame', this._postFrame)
  71. if (ThreeViewer.Dialog === windowDialogWrapper) ThreeViewer.Dialog = htmlDialogWrapper
  72. const undo = viewer.getOrAddPluginSync(UndoManagerPlugin) // yes, manual dependency
  73. if (undo?.undoManager) {
  74. this._lastManager?.dispose()
  75. this._lastManager = this.undoManager
  76. this.undoManager = undo.undoManager
  77. if (this._lastManager) Object.assign(this.undoManager.presets, this._lastManager.presets)
  78. }
  79. }
  80. onRemove(viewer: ThreeViewer): void {
  81. this._viewer = undefined
  82. viewer.removeEventListener('preRender', this._preRender)
  83. viewer.removeEventListener('postRender', this._postRender)
  84. viewer.removeEventListener('preFrame', this._preFrame)
  85. viewer.removeEventListener('postFrame', this._postFrame)
  86. this.undoManager = this._lastManager
  87. this._lastManager = undefined
  88. this.dispose()
  89. }
  90. private _plugins: IViewerPlugin[] = []
  91. setupPlugins(...plugins: Class<IViewerPlugin>[]): void {
  92. plugins.forEach(plugin => this.setupPluginUi(plugin))
  93. }
  94. setupPluginUi<T extends IViewerPlugin>(plugin: T|Class<T>, params?: Partial<UiObjectConfig>): UiObjectConfig | undefined {
  95. const p = (<Class<IViewerPlugin>>plugin).prototype ? this._viewer?.getPlugin<T>(<Class<T>>plugin) : <T>plugin
  96. if (!p) {
  97. console.warn('plugin not found:', plugin)
  98. return undefined
  99. }
  100. this._plugins.push(p)
  101. if (p.uiConfig && p.uiConfig.hidden === undefined) p.uiConfig.hidden = false // todo; this is a hack for now
  102. const ui = p.uiConfig
  103. this.appendChild(ui, params)
  104. this._setupPluginSerializationContext(ui, p)
  105. return ui
  106. }
  107. private _setupPluginSerializationContext(ui: any, p: IViewerPlugin) {
  108. // serialization
  109. if (!(ui?.uiRef && p.toJSON)) return;
  110. (p as any)._defaultState = typeof p.toJSON === 'function' ? p.toJSON() : null
  111. ;(p as any).resetDefaults = async() => {
  112. if (!(p as any)._defaultState) return
  113. await p.fromJSON?.((p as any)._defaultState)
  114. ui.uiRefresh?.(true, 'postFrame')
  115. }
  116. const topBtn = (ui.uiRef as any).controller_.view.element
  117. const opBtn = createDiv({
  118. innerHTML: '&#8942;',
  119. classList: ['pluginOptionsButton'],
  120. elementTag: 'button',
  121. })
  122. opBtn.onclick = (ev) => {
  123. const ops = {} as any
  124. if (typeof p.toJSON === 'function') {
  125. ops['download preset'] = async() => {
  126. if (!this._viewer) return
  127. const json = this._viewer.exportPluginConfig(p)
  128. await downloadBlob(new Blob([JSON.stringify(json, null, 2)], {type: 'application/json'}), 'preset.' + (p.constructor as any).PluginType + '.json')
  129. }
  130. }
  131. if (typeof p.fromJSON === 'function') {
  132. ops['upload preset'] = async() => {
  133. const files = await uploadFile(false, false)
  134. if (files.length === 0) return
  135. const file = files[0]
  136. const text = await file.text()
  137. const json = JSON.parse(text)
  138. await this._viewer?.importPluginConfig(json, p)
  139. ui.uiRefresh?.(true, 'postFrame')
  140. }
  141. if ((p as any)._defaultState) ops['reset defaults'] = () => (p as any).resetDefaults?.()
  142. }
  143. const menu = CustomContextMenu.Create(ops, topBtn.clientWidth - 120, 12)
  144. topBtn.append(menu)
  145. ev.preventDefault()
  146. }
  147. topBtn.appendChild(opBtn)
  148. }
  149. refreshPluginsEnabled() {
  150. this._plugins.forEach(p=>{
  151. const config = p.uiConfig
  152. if (config) {
  153. // const enabled = (p as any).enabled ?? true
  154. // safeSetProperty(config, 'hidden', !enabled, true)
  155. // if (config.expanded)
  156. // safeSetProperty(config, 'expanded', config.expanded && enabled, true)
  157. if (getOrCall(config.hidden) !== true)
  158. config.uiRefresh?.(true, 'postFrame')
  159. else if (config.uiRef) {
  160. config.uiRef.hidden = true
  161. }
  162. }
  163. })
  164. }
  165. private _preRender = () => this.refreshQueue('preRender')
  166. private _postRender = () => this.refreshQueue('postRender')
  167. private _postFrame = (e: IEvent<'postFrame'>) => {
  168. this.dispatchEvent(e)
  169. this.refreshQueue('postFrame')
  170. }
  171. private _preFrame = () => this.refreshQueue('preFrame')
  172. alert = async(message?: string): Promise<void> =>this._viewer ? this._viewer.dialog.alert(message) : window?.alert(message)
  173. confirm = async(message?: string): Promise<boolean> =>this._viewer ? this._viewer.dialog.confirm(message) : window?.confirm(message)
  174. prompt = async(message?: string, _default?: string, cancel = true): Promise<string | null> =>this._viewer ? this._viewer.dialog.prompt(message, _default, cancel) : window?.prompt(message, _default)
  175. protected _colorModeChanged() {
  176. setThemeColor(this.colorMode)
  177. }
  178. dispose() {
  179. this.undoManager?.dispose()
  180. this.unmount()
  181. }
  182. }
  183. function getThemeColor(): ThemeColors {
  184. const c = localStorage ? localStorage.getItem('tpTheme') as ThemeColors : undefined
  185. if (c && themeColors.includes(c)) return c
  186. return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
  187. }
  188. function setThemeColor(color: ThemeColors) {
  189. document.body.classList.remove(...themeColors.map(t=>'tpTheme-' + t))
  190. document.body.classList.add('tpTheme-' + color)
  191. if (!localStorage) return
  192. localStorage.setItem('tpTheme', color)
  193. }