Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zustand: migrate source selector #4693

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 66 additions & 46 deletions app/components-react/editor/elements/SourceSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Tooltip, Tree } from 'antd';
import { DataNode } from 'rc-tree/lib/interface';
import { TreeProps } from 'rc-tree/lib/Tree';
import cx from 'classnames';
import { inject, injectState, injectWatch, mutation, useModule } from 'slap';
import { SourcesService } from 'services/sources';
import { ScenesService, ISceneItem, TSceneNode, isItem } from 'services/scenes';
import { SelectionService } from 'services/selection';
Expand All @@ -22,6 +21,9 @@ import HelpTip from 'components-react/shared/HelpTip';
import Translate from 'components-react/shared/Translate';
import { WidgetsService } from '../../../app-services';
import { GuestCamService } from 'app-services';
import { Services } from 'components-react/service-provider';
import { initStore, useController } from '../../hooks/zustand';
import { useVuex } from '../../hooks';

interface ISourceMetadata {
id: string;
Expand All @@ -37,22 +39,22 @@ interface ISourceMetadata {
parentId?: string;
}

class SourceSelectorModule {
private scenesService = inject(ScenesService);
private sourcesService = inject(SourcesService);
private widgetsService = inject(WidgetsService);
private selectionService = inject(SelectionService);
private editorCommandsService = inject(EditorCommandsService);
private streamingService = inject(StreamingService);
private audioService = inject(AudioService);
private guestCamService = inject(GuestCamService);
class SourceSelectorController {
private scenesService = Services.ScenesService;
private sourcesService = Services.SourcesService;
private widgetsService = Services.WidgetsService;
private selectionService = Services.SelectionService;
private editorCommandsService = Services.EditorCommandsService;
private streamingService = Services.StreamingService;
private audioService = Services.AudioService;
private guestCamService = Services.GuestCamService;

sourcesTooltip = $t('The building blocks of your scene. Also contains widgets.');
addSourceTooltip = $t('Add a new Source to your Scene. Includes widgets.');
openSourcePropertiesTooltip = $t('Open the Source Properties.');
addGroupTooltip = $t('Add a Group so you can move multiple Sources at the same time.');

state = injectState({
store = initStore({
expandedFoldersIds: [] as string[],
showTreeMask: true,
});
Expand All @@ -61,6 +63,13 @@ class SourceSelectorModule {

callCameFromInsideTheHouse = false;

constructor() {
this.selectionService.store.watch(
s => s.lastSelectedId,
() => this.expandSelectedFolders(),
);
}

getTreeData(nodeData: ISourceMetadata[]) {
// recursive function for transforming SceneNode[] to a Tree format of Antd.Tree
const getTreeNodes = (sceneNodes: ISourceMetadata[]): DataNode[] => {
Expand Down Expand Up @@ -149,7 +158,7 @@ class SourceSelectorModule {

determineIcon(isLeaf: boolean, sourceId: string) {
if (!isLeaf) {
return this.state.expandedFoldersIds.includes(sourceId)
return this.store.expandedFoldersIds.includes(sourceId)
? 'fas fa-folder-open'
: 'fa fa-folder';
}
Expand Down Expand Up @@ -295,31 +304,31 @@ class SourceSelectorModule {
this.selectionService.views.globalSelection.select(ids);
}

@mutation()
toggleFolder(nodeId: string) {
if (this.state.expandedFoldersIds.includes(nodeId)) {
this.state.expandedFoldersIds.splice(this.state.expandedFoldersIds.indexOf(nodeId), 1);
} else {
this.state.expandedFoldersIds.push(nodeId);
}
this.store.setState(s => {
if (s.expandedFoldersIds.includes(nodeId)) {
s.expandedFoldersIds.splice(s.expandedFoldersIds.indexOf(nodeId), 1);
} else {
s.expandedFoldersIds.push(nodeId);
}
});
}

get lastSelectedId() {
return this.selectionService.state.lastSelectedId;
}

watchSelected = injectWatch(() => this.lastSelectedId, this.expandSelectedFolders);

async expandSelectedFolders() {
if (this.callCameFromInsideTheHouse) {
this.callCameFromInsideTheHouse = false;
return;
}
const node = this.scene.getNode(this.lastSelectedId);
if (!node || this.selectionService.state.selectedIds.length > 1) return;
this.state.setExpandedFoldersIds(
this.state.expandedFoldersIds.concat(node.getPath().slice(0, -1)),
);

this.store.setState(s => {
s.expandedFoldersIds.concat(node.getPath().slice(0, -1));
});

this.nodeRefs[this.lastSelectedId].current.scrollIntoView({ behavior: 'smooth' });
}
Expand Down Expand Up @@ -404,7 +413,7 @@ class SourceSelectorModule {
}

function SourceSelector() {
const { nodeData } = useModule(SourceSelectorModule);
const { nodeData } = useController(SourceSelectorCtx);
return (
<>
<StudioControls />
Expand Down Expand Up @@ -438,7 +447,7 @@ function StudioControls() {
addSource,
addFolder,
toggleSelectiveRecording,
} = useModule(SourceSelectorModule);
} = useController(SourceSelectorCtx);

return (
<div className={styles.topContainer} data-name="sourcesControls">
Expand Down Expand Up @@ -468,19 +477,26 @@ function StudioControls() {
}

function ItemsTree() {
const {
nodeData,
getTreeData,
activeItemIds,
expandedFoldersIds,
selectiveRecordingEnabled,
showContextMenu,
makeActive,
toggleFolder,
handleSort,
showTreeMask,
setShowTreeMask,
} = useModule(SourceSelectorModule);
const controller = useController(SourceSelectorCtx);
const { getTreeData, showContextMenu, makeActive, toggleFolder, handleSort, store } = controller;
const { showTreeMask, expandedFoldersIds } = store.useState(s => ({
showTreeMask: s.showTreeMask,
expandedFoldersIds: s.expandedFoldersIds,
}));
const { nodeData, activeItemIds, selectiveRecordingEnabled } = useVuex(() => {
return {
nodeData: controller.nodeData,
activeItemIds: controller.activeItemIds,
selectiveRecordingEnabled: controller.selectiveRecordingEnabled,
};
});
const setShowTreeMask = useCallback(
(isShown: boolean) =>
store.setState(s => {
s.showTreeMask = isShown;
}),
[],
);

// Force a rerender when the state of selective recording changes
const [selectiveRecordingToggled, setSelectiveRecordingToggled] = useState(false);
Expand Down Expand Up @@ -608,22 +624,26 @@ const TreeNode = React.forwardRef(
);
},
);
export const SourceSelectorCtx = React.createContext<SourceSelectorController | null>(null);

export default function SourceSelectorElement() {
const containerRef = useRef<HTMLDivElement>(null);
const controller = useMemo(() => new SourceSelectorController(), []);
const { renderElement } = useBaseElement(
<SourceSelector />,
{ x: 200, y: 120 },
containerRef.current,
);

return (
<div
ref={containerRef}
data-name="SourceSelector"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
{renderElement()}
</div>
<SourceSelectorCtx.Provider value={controller}>
<div
ref={containerRef}
data-name="SourceSelector"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
{renderElement()}
</div>
</SourceSelectorCtx.Provider>
);
}
87 changes: 87 additions & 0 deletions app/components-react/hooks/zustand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { StoreApi, useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { immer } from 'zustand/middleware/immer';
import React, { Context, useContext, useMemo } from 'react';

/**
* Initializes a Zustand store with the provided initial state, utilizing immer middleware.
*
*/
export function initStore<TState extends any>(initialStateDraft: TState) {
const initialState: TState = { ...(initialStateDraft as any) };
const store = createStore<TState, [['zustand/immer', never]]>(immer(set => initialState));

// Define shortcut getters for each property in initialState
for (const key in initialState) {
if ((initialState as any).hasOwnProperty(key)) {
Object.defineProperty(store, key, {
get() {
return store.getState()[key];
},
});
}
}

// Create a reactive hook for React components
const useState = createBoundedUseStore(store);
(store as any).useState = useState;

// ensure we have correct types
return store as typeof store & { useState: typeof useState } & Readonly<typeof initialStateDraft>;
}

/**
* Creates a custom useStore hook that is bound to a specific Zustand store instance.
*
* @template S The store API type.
* @param store The Zustand store instance to bind the hook to.
* @returns A custom hook bound to the provided store instance.
*/
const createBoundedUseStore = (store => (selector, equals) =>
useStore(store, selector as never, equals)) as <S extends StoreApi<unknown>>(
store: S,
) => {
(): ExtractState<S>;
<T>(selector: (state: ExtractState<S>) => T, equals?: (a: T, b: T) => boolean): T;
};

/**
* Extracts the state type from a given Zustand store API.
*
* @template S The store API type.
*/
type ExtractState<S> = S extends { getState: () => infer X } ? X : never;

/**
* A wrapper around React.useContext for controllers.
* This hook ensures that the controller's actions are bound to the controller instance.
* @param ControllerCtx
*/
export function useController<T>(ControllerCtx: Context<T>): NonNullable<T> {
// eslint-disable-next-line no-undef
const controller = useContext(ControllerCtx);
if (!controller) {
throw new Error(
'No controller found in context. Did you forget to wrap your component in a controller provider?',
);
}

// Bind the controller's actions to the controller instance.
useMemo(() => {
// Fetch the action names from the prototype of the service instance.
const actionNames = Object.getOwnPropertyNames(Object.getPrototypeOf(controller));
const actions: Record<string, any> = {};

// Loop through the action names and bind them to the service instance.
for (const actionName of actionNames) {
// Skip the constructor and any non-function properties.
if (actionName === 'constructor') continue;
if (!(controller as any)[actionName].bind) continue;
actions[actionName] = (controller as any)[actionName].bind(controller);
}

Object.assign(controller, actions);
}, [controller]);

return controller as NonNullable<T>;
}
16 changes: 9 additions & 7 deletions app/components-react/windows/source-showcase/SourceTag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ export default function SourceTag(p: {
essential?: boolean;
excludeWrap?: boolean;
}) {
const {
inspectSource,
selectInspectedSource,
inspectedSource,
inspectedAppId,
inspectedAppSourceId,
} = useSourceShowcaseSettings();
const { inspectSource, selectInspectedSource, store } = useSourceShowcaseSettings();

const { inspectedSource, inspectedAppId, inspectedAppSourceId } = store.useState(s => {
return {
inspectedSource: s.inspectedSource,
inspectedAppId: s.inspectedAppId,
inspectedAppSourceId: s.inspectedAppSourceId,
};
});
const { UserService } = Services;
const { platform } = useVuex(() => ({ platform: UserService.views.platform?.type }));

Expand Down
27 changes: 20 additions & 7 deletions app/components-react/windows/source-showcase/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,31 @@ import { SourceDisplayData } from 'services/sources';
import { WidgetDisplayData, WidgetType } from 'services/widgets';
import { $i } from 'services/utils';
import { $t } from 'services/i18n';
import { useSourceShowcaseSettings } from './useSourceShowcase';
import {
SourceShowcaseController,
SourceShowcaseControllerCtx,
useSourceShowcaseSettings,
} from './useSourceShowcase';
import styles from './SourceShowcase.m.less';
import SourceGrid from './SourceGrid';
import Scrollable from 'components-react/shared/Scrollable';
import pick from 'lodash/pick';

const { Content, Sider } = Layout;

export default function SourcesShowcase() {
const {
selectInspectedSource,
availableAppSources,
inspectedSource,
} = useSourceShowcaseSettings();
const controller = useMemo(() => new SourceShowcaseController(), []);
return (
<SourceShowcaseControllerCtx.Provider value={controller}>
<SourcesShowcaseModal />
</SourceShowcaseControllerCtx.Provider>
);
}

function SourcesShowcaseModal() {
const { selectInspectedSource, availableAppSources, store } = useSourceShowcaseSettings();

const inspectedSource = store.useState(s => s.inspectedSource);

const [activeTab, setActiveTab] = useState('all');

Expand Down Expand Up @@ -52,7 +64,8 @@ export default function SourcesShowcase() {

function SideBar() {
const { UserService, CustomizationService, PlatformAppsService } = Services;
const { inspectedSource, inspectedAppId, inspectedAppSourceId } = useSourceShowcaseSettings();
const { store } = useSourceShowcaseSettings();
const { inspectedSource, inspectedAppId, inspectedAppSourceId } = store.useState();

const { demoMode, platform } = useVuex(() => ({
demoMode: CustomizationService.views.isDarkTheme ? 'night' : 'day',
Expand Down
Loading
Loading