diff --git a/src/@types/app.d.ts b/src/@types/app.d.ts index f6540a1d..0e4f9cd0 100644 --- a/src/@types/app.d.ts +++ b/src/@types/app.d.ts @@ -1,5 +1,7 @@ import type { TI18nLang } from './core/i18n'; import type { TFeatureFlagMenu } from './components/menu'; +import type { TComponentDefinitionElementsPainter } from './components/painter'; +import type { TComponentDefinitionElementsSinger } from './components/singer'; /** Type definition of an app configuration preset's component configuration. */ export type TAppComponentConfig = @@ -12,11 +14,11 @@ export type TAppComponentConfig = } | { id: 'painter'; - elements?: string[] | true; + elements?: TComponentDefinitionElementsPainter[] | true; } | { id: 'singer'; - elements?: string[] | true; + elements?: TComponentDefinitionElementsSinger[] | true; }; /** Type defintion of an app configuration preset. */ diff --git a/src/@types/components/index.d.ts b/src/@types/components/index.d.ts index c7972657..9d906017 100644 --- a/src/@types/components/index.d.ts +++ b/src/@types/components/index.d.ts @@ -36,9 +36,7 @@ export interface IComponentDefinition { /** Assets used. */ assets?: string[]; /** Syntax elements exposed. */ - elements?: { - [identifier: string]: IElementSpecification; - }; + elements?: Record; } import { IElementSpecification } from '@sugarlabs/musicblocks-v4-lib'; diff --git a/src/@types/components/painter.d.ts b/src/@types/components/painter.d.ts new file mode 100644 index 00000000..2f1fa96b --- /dev/null +++ b/src/@types/components/painter.d.ts @@ -0,0 +1,28 @@ +import type { IComponentDefinition } from '.'; +import type { TAsset } from '../core/assets'; +import type { IElementSpecification } from '@sugarlabs/musicblocks-v4-lib'; + +export type TComponentDefinitionElementsPainter = + | 'move-forward' + | 'move-backward' + | 'turn-left' + | 'turn-right' + | 'set-xy' + | 'set-heading' + | 'draw-arc' + | 'set-color' + | 'set-thickness' + | 'pen-up' + | 'pen-down' + | 'set-background' + | 'clear'; + +export interface IComponentDefinitionPainter extends IComponentDefinition { + elements: Record; +} + +export type TInjectedPainter = { + flags: undefined; + i18n: undefined; + assets: Record<'image.icon.mouse', TAsset>; +}; diff --git a/src/@types/components/painter.ts b/src/@types/components/painter.ts deleted file mode 100644 index 3f9bb8c8..00000000 --- a/src/@types/components/painter.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { TAsset } from '../core/assets'; - -export type TInjectedPainter = { - flags: undefined; - i18n: undefined; - assets: Record<'image.icon.mouse', TAsset>; -}; diff --git a/src/@types/components/singer.d.ts b/src/@types/components/singer.d.ts new file mode 100644 index 00000000..62172f8c --- /dev/null +++ b/src/@types/components/singer.d.ts @@ -0,0 +1,18 @@ +import type { IComponentDefinition } from '.'; +import type { IElementSpecification } from '@sugarlabs/musicblocks-v4-lib'; + +export type TComponentDefinitionElementsSinger = + | 'test-synth' + | 'play-note' + | 'reset-notes-played' + | 'play-generic'; + +export interface IComponentDefinitionSinger extends IComponentDefinition { + elements: Record; +} + +export type TInjectedSinger = { + flags: undefined; + i18n: undefined; + assets: undefined; +}; diff --git a/src/@types/components/singer.ts b/src/@types/components/singer.ts deleted file mode 100644 index 73fbd8f9..00000000 --- a/src/@types/components/singer.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type TInjectedSinger = { - flags: undefined; - i18n: undefined; - assets: undefined; -}; diff --git a/src/app/index.ts b/src/app/index.ts index e4850e45..05eda806 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -60,12 +60,51 @@ function updateImportMap( return loadMap; } -// ================================================================================================= +async function _importComponents(config?: IAppConfig): Promise<{ + components: Partial>; + componentDefinitions: Partial>; +}> { + /** Map of component identifier and corresponding component module. */ + let components: Partial>; + /** List of 2-tuples of component identifier and component definition. */ + let componentDefinitions: Partial>; -(async () => { - // load configuration preset file - const config = await loadConfig(import.meta.env.VITE_CONFIG_PRESET); + { + const componentIds = ( + config !== undefined + ? Object.entries(componentMap) + .filter(([id]) => + config.components.map(({ id }) => id).includes(id as TComponentId), + ) + .map(([id]) => id) + : Object.keys(componentMap) + ) as TComponentId[]; + + const callback = + config !== undefined + ? (componentId: TComponentId) => + updateImportMap('import', 'components', componentId) + : () => 1; + + components = await importComponents(componentIds, callback); + + componentDefinitions = Object.fromEntries( + (Object.entries(components) as [TComponentId, IComponent][]).map(([id, component]) => [ + id, + component.definition, + ]) as [TComponentId, IComponentDefinition][], + ); + } + + return { + components, + componentDefinitions, + }; +} +// ------------------------------------------------------------------------------------------------- + +async function init(config: IAppConfig) { /* * Import and load i18n strings for the configured language asynchronously. */ @@ -85,20 +124,9 @@ function updateImportMap( */ { - components = await importComponents( - (import.meta.env.PROD - ? Object.entries(componentMap) - .filter(([id]) => - config.components.map(({ id }) => id).includes(id as TComponentId), - ) - .map(([id]) => id) - : Object.keys(componentMap)) as TComponentId[], - (componentId: TComponentId) => updateImportMap('import', 'components', componentId), - ); - - componentDefinitionEntries = ( - Object.entries(components) as [TComponentId, IComponent][] - ).map(([id, component]) => [id, component.definition]) as [ + const _components = await _importComponents(config); + components = _components.components; + componentDefinitionEntries = Object.entries(_components.componentDefinitions) as [ TComponentId, IComponentDefinition, ][]; @@ -150,18 +178,11 @@ function updateImportMap( // Inject feature flags. componentDefinitionEntries.forEach( - ([componentId, { flags }]) => - (components[componentId]!.injected.flags = import.meta.env.PROD - ? // @ts-ignore - config.components.find(({ id }) => id === componentId)?.flags - : Object.keys(flags).length !== 0 - ? Object.fromEntries( - Object.keys( - componentDefinitionEntries.find(([id]) => id === componentId)![1] - .flags, - ).map((flag) => [flag, false]), - ) - : undefined), + ([componentId]) => + (components[componentId]!.injected.flags = config.components.find( + ({ id }) => id === componentId, + // @ts-ignore + )?.flags), ); } @@ -199,10 +220,8 @@ function updateImportMap( componentsOrdered.map((componentId) => { return { id: componentId, - filter: import.meta.env.PROD - ? // @ts-ignore - config.components.find(({ id }) => id === componentId)?.elements - : true, + // @ts-ignore + filter: config.components.find(({ id }) => id === componentId)?.elements, }; }), ); @@ -221,4 +240,62 @@ function updateImportMap( if (import.meta.env.PROD) { loadServiceWorker(); } +} + +// ================================================================================================= + +(async function () { + // load configuration preset file + const config = await loadConfig(import.meta.env.VITE_CONFIG_PRESET); + + /** + * if PRODUCTION mode, proceed initializing with configuration preset. + */ + + if (import.meta.env.PROD) { + await init(config); + return; + } + + /** + * if DEVELOPMENT mode, and configuration in session storage, + * proceed initializing with configuration from session storage. + */ + + { + const config = window.sessionStorage.getItem('appConfig'); + + if (config !== null) { + await init(JSON.parse(config) as IAppConfig); + return; + } + } + + /** + * if DEVELOPMENT mode, and configuration not in session storage, + * open configurator page. + * @todo currently needs refresh to go to main app page + */ + + { + const { mountConfigPage, updateConfigPage } = await import('@/core/view'); + + requestAnimationFrame(() => { + (async function () { + window.sessionStorage.setItem('appConfig', JSON.stringify(config)); + + await mountConfigPage( + { ...config }, + ( + await _importComponents() + ).componentDefinitions, + (config: IAppConfig) => + requestAnimationFrame(() => { + window.sessionStorage.setItem('appConfig', JSON.stringify(config)); + updateConfigPage(config); + }), + ); + })(); + }); + } })(); diff --git a/src/components/painter/index.ts b/src/components/painter/index.ts index 41565ff7..4ee514eb 100644 --- a/src/components/painter/index.ts +++ b/src/components/painter/index.ts @@ -1,5 +1,4 @@ -import type { IComponentDefinition } from '@/@types/components'; -import type { TInjectedPainter } from '@/@types/components/painter'; +import type { IComponentDefinitionPainter, TInjectedPainter } from '@/@types/components/painter'; import type { IComponentMenu } from '@/@types/components/menu'; import { getComponent } from '@/core/config'; @@ -28,7 +27,7 @@ import { exportDrawing, startRecording, stopRecording } from './core/sketchP5'; // == definition =================================================================================== -export const definition: IComponentDefinition = { +export const definition: IComponentDefinitionPainter = { dependencies: { optional: ['menu'], required: [], diff --git a/src/components/singer/index.ts b/src/components/singer/index.ts index 0c3c7e26..537e235d 100644 --- a/src/components/singer/index.ts +++ b/src/components/singer/index.ts @@ -1,5 +1,4 @@ -import type { IComponentDefinition } from '@/@types/components'; -import type { TInjectedSinger } from '@/@types/components/singer'; +import type { IComponentDefinitionSinger, TInjectedSinger } from '@/@types/components/singer'; import { setup as setupComponent } from './singer'; import { @@ -11,7 +10,7 @@ import { // == definition =================================================================================== -export const definition: IComponentDefinition = { +export const definition: IComponentDefinitionSinger = { dependencies: { optional: ['menu'], required: [], diff --git a/src/core/view/components/config/Config.tsx b/src/core/view/components/config/Config.tsx new file mode 100644 index 00000000..571648de --- /dev/null +++ b/src/core/view/components/config/Config.tsx @@ -0,0 +1,331 @@ +import type { IAppConfig } from '@/@types/app'; +import type { IComponentDefinition, TComponentId } from '@/@types/components'; + +import { getConfigCache, updateConfigCache } from '.'; +import { default as componentMap } from '@/components'; + +// -- ui items ------------------------------------------------------------------------------------- + +import { WToggleSwitch } from '@/common/components'; + +import './index.scss'; + +// -- component definition ------------------------------------------------------------------------- + +/** + * React component definition for the feature configurator component. + */ +export default function (props: { + /** App configurations. */ + config: IAppConfig; + /** Map of component definitions. */ + definitions: Partial>; + /** Callback for when configurations are updated. */ + handlerUpdate: (config: IAppConfig) => unknown; +}): JSX.Element { + // --------------------------------------------------------------------------- + + function _isActive(componentId: TComponentId): boolean { + return props.config.components.findIndex(({ id }) => id === componentId) !== -1; + } + + function _getElements(componentId: TComponentId): Record | null { + const componentConfig = props.config.components.find(({ id }) => id === componentId); + + if (componentConfig === undefined) return null; + + if (!('elements' in componentConfig) || componentConfig['elements'] === undefined) return null; + + const componentDefintion = props.definitions[componentId]!; + const elements = Object.keys(componentDefintion.elements!); + + return componentConfig.elements === true + ? Object.fromEntries(elements.map((elementName) => [elementName, true])) + : Object.fromEntries( + elements.map((elementName) => [ + elementName, + (componentConfig.elements! as string[]).includes(elementName), + ]), + ); + } + + function _getFlags(componentId: TComponentId): Record | null { + const componentConfig = props.config.components.find(({ id }) => id === componentId); + + if (componentConfig === undefined) return null; + + return 'flags' in componentConfig ? componentConfig.flags : null; + } + + const modules = Object.fromEntries( + Object.entries(componentMap) + .map( + ([componentId, { name, desc }]) => + ({ + id: componentId, + name, + desc, + } as { + id: TComponentId; + name: string; + desc: string; + }), + ) + .map( + (data) => + ({ + ...data, + active: _isActive(data.id), + } as { + id: TComponentId; + name: string; + desc: string; + active: boolean; + }), + ) + .map( + (data) => + ({ + ...data, + elements: _getElements(data.id), + } as { + id: TComponentId; + name: string; + desc: string; + active: boolean; + elements: Record | null; + }), + ) + .map( + (data) => + ({ + ...data, + flags: _getFlags(data.id), + } as { + id: TComponentId; + name: string; + desc: string; + active: boolean; + elements: Record | null; + flags: Record | null; + }), + ) + .map((data) => [data.id, data]), + ); + + function _update(callback: (config: IAppConfig) => IAppConfig) { + props.handlerUpdate(callback({ ...props.config })); + } + + // --------------------------------------------------------------------------- + + function toggleModule(componentId: TComponentId) { + _update((config: IAppConfig) => { + const configCache = getConfigCache(); + + if (!modules[componentId].active) { + const newComponentConfig: { + id: TComponentId; + elements?: string[]; + flags?: Record; + } = { + id: componentId, + }; + + const configCacheComponentIndex = configCache.components.findIndex( + ({ id }) => id === componentId, + ); + + if ('elements' in props.definitions[componentId]!) { + newComponentConfig['elements'] = + configCacheComponentIndex !== -1 + ? // @ts-ignore + configCache.components[configCacheComponentIndex].elements + : Object.keys(props.definitions[componentId]!.elements!); + } + + if ('flags' in props.definitions[componentId]!) { + newComponentConfig['flags'] = + configCacheComponentIndex !== -1 + ? // @ts-ignore + configCache.components[configCacheComponentIndex].flags + : Object.fromEntries( + Object.keys(props.definitions[componentId]!.flags!).map((flag) => [flag, false]), + ); + } + + // @ts-ignore + config = { ...config, components: [...config.components, newComponentConfig] }; + } /** if (modules[componentId].active) */ else { + let configCacheComponentIndex = configCache!.components.findIndex( + ({ id }) => id === componentId, + ); + + const configComponentIndex = config.components.findIndex(({ id }) => id === componentId); + + if (configCacheComponentIndex === -1) { + updateConfigCache((configCache) => { + const newConfigCache = { ...configCache }; + // @ts-ignore + newConfigCache.components.push({ id: componentId }); + configCacheComponentIndex = newConfigCache.components!.length - 1; + return newConfigCache; + }); + } + + if ('flags' in config.components[configComponentIndex]) { + updateConfigCache((configCache) => { + const newConfigCache = { ...configCache }; + // @ts-ignore + newConfigCache.components[configCacheComponentIndex].flags = + //@ts-ignore + config.components[configComponentIndex].flags; + return newConfigCache; + }); + } + + if ('elements' in config.components[configComponentIndex]) { + updateConfigCache((configCache) => { + const newConfigCache = { ...configCache }; + // @ts-ignore + newConfigCache.components[configCacheComponentIndex].elements = + //@ts-ignore + config.components[configComponentIndex].elements; + return newConfigCache; + }); + } + + config.components.splice(configComponentIndex, 1); + } + + return { ...config }; + }); + } + + function toggleModuleElement(componentId: TComponentId, elementName: string) { + _update((config: IAppConfig) => { + const componentIndex = config.components.findIndex(({ id }) => id === componentId); + + // @ts-ignore + if (config.components[componentIndex].elements === true) { + // @ts-ignore + config.components[componentIndex].elements = Object.entries(_getElements(componentId)) + .filter(([_, active]) => active) + .map(([elementName]) => elementName); + } + + // @ts-ignore + if (config.components[componentIndex].elements.includes(elementName)) { + // @ts-ignore + const elementIndex = (config.components[componentIndex].elements as string[]).findIndex( + (_elementName) => _elementName === elementName, + ); + // @ts-ignore + config.components[componentIndex].elements.splice(elementIndex, 1); + } else { + // @ts-ignore + config.components[componentIndex].elements.push(elementName); + } + + return config; + }); + } + + function toggleModuleFlag(componentId: TComponentId, flag: string) { + _update((config: IAppConfig) => { + const componentIndex = config.components.findIndex(({ id }) => id === componentId); + // @ts-ignore + config.components[componentIndex].flags[flag] = + // @ts-ignore + !config.components[componentIndex].flags[flag]; + + return config; + }); + } + + // --------------------------------------------------------------------------- + + return ( +
+

refresh webpage to go to main application page

+ +
    + {Object.entries(modules).map(([id, { name, desc, active, elements, flags }], i) => ( +
  • +
    +
    +

    {name}

    +

    {desc}

    +
    +
    + toggleModule(id as TComponentId)} + /> +
    +
    + + {((elements && Object.keys(elements).length > 0) || + (flags && Object.keys(flags).length > 0)) && ( +
    + {elements && Object.keys(elements).length > 0 && ( +
    +

    + Elements +

    + +
      + {Object.keys(elements).map((element, i) => ( +
    • +

      + {element} +

      + toggleModuleElement(id as TComponentId, element)} + /> +
    • + ))} +
    +
    + )} + + {flags && Object.keys(flags).length > 0 && ( +
    +

    + Feature Flags +

    + +
      + {Object.keys(flags).map((flag, i) => ( +
    • +

      + {flag} +

      + toggleModuleFlag(id as TComponentId, flag)} + /> +
    • + ))} +
    +
    + )} +
    + )} +
  • + ))} +
+
+ ); +} diff --git a/src/core/view/components/config/ConfigPage.tsx b/src/core/view/components/config/ConfigPage.tsx new file mode 100644 index 00000000..5b85c8be --- /dev/null +++ b/src/core/view/components/config/ConfigPage.tsx @@ -0,0 +1,33 @@ +import type { IAppConfig } from '@/@types/app'; +import type { IComponentDefinition, TComponentId } from '@/@types/components'; + +import Config from './Config'; + +// -- stylesheet ----------------------------------------------------------------------------------- + +import './index.scss'; + +// -- component definition ------------------------------------------------------------------------- + +export default function (props: { + /** App configurations. */ + config: IAppConfig; + /** Map of component definitions. */ + definitions: Partial>; + /** Callback for when configurations are updated. */ + handlerUpdate: (config: IAppConfig) => unknown; +}): JSX.Element { + // --------------------------------------------------------------------------- + + return ( +
+
+ +
+
+ ); +} diff --git a/src/core/view/components/config/index.scss b/src/core/view/components/config/index.scss new file mode 100644 index 00000000..7cee5665 --- /dev/null +++ b/src/core/view/components/config/index.scss @@ -0,0 +1,174 @@ +@import '@/common/scss/colors.scss'; +@import '@/common/scss/sizes.scss'; + +#config-page { + width: 100%; + height: 100%; + min-height: 100vh; + padding: 30px 12px; + background-color: $c-bg-white; + overflow-y: auto; + + #config-page-content-wrapper { + width: 100%; + margin: 0 auto; + max-width: 720px; + height: max-content; + + #config-wrapper { + border-radius: $s-border-radius; + } + } +} + +#config-wrapper { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + height: 100%; + height: max-content; + padding: 12px; + background-color: $mb-accent; + + #config-disclaimer { + width: 100%; + margin: 0; + padding: 8px 0; + border-radius: $s-border-radius; + font-size: 0.9rem; + font-weight: bold; + text-align: center; + text-transform: uppercase; + color: $mb-accent; + background-color: $c-bg-white; + } + + #config-module-list { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + margin: 0; + padding: 0; + list-style: none; + + .config-module-list-item { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; + padding: 12px; + border-radius: 4px; + background-color: $mb-accent-dark; + + > * { + width: 100%; + } + + .config-module-list-item-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: stretch; + padding: 0 12px; + + .config-module-list-item-header-left, + .config-module-list-item-header-right { + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + } + + .config-module-list-item-header-left { + align-items: flex-start; + gap: 4px; + + .config-module-list-item-name { + margin: 0; + font-size: 1.2rem; + color: $text-light; + } + + .config-module-list-item-desc { + margin: 0; + font-size: 0.9rem; + color: $mb-accent-light; + } + } + + .config-module-list-item-header-right { + padding-top: 2px; + align-items: flex-end; + } + } + + .config-module-list-item-body { + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + + &.config-module-list-item-body-inactive { + opacity: 50%; + + &::before { + position: absolute; + z-index: 100; + display: table; + content: ''; + width: 100%; + height: 100%; + } + } + + .config-module-list-item-subitems { + border: 1px solid $mb-accent; + border-radius: 4px; + + .config-module-list-item-subitems-label { + margin: 0; + padding: 8px 0 8px 12px; + font-size: 0.9rem; + color: $text-light; + background-color: $mb-accent; + } + + .config-module-list-item-subitem-list { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; + + .config-module-list-item-subitem { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 4px 12px; + border-bottom: 1px solid $mb-accent; + background-color: $mb-accent-light; + + &:first-child { + border-top: 1px solid $mb-accent-light; + } + + &:last-child { + border-bottom: unset; + } + + .config-module-list-item-subitem-name { + margin: 0; + font-size: 0.9rem; + font-weight: bold; + color: $mb-accent-dark; + } + } + } + } + } + } + } +} diff --git a/src/core/view/components/config/index.tsx b/src/core/view/components/config/index.tsx new file mode 100644 index 00000000..699bb943 --- /dev/null +++ b/src/core/view/components/config/index.tsx @@ -0,0 +1,101 @@ +import type { IAppConfig } from '@/@types/app'; +import type { IComponentDefinition, TComponentId } from '@/@types/components'; + +import ReactDOM from 'react-dom'; + +import { setView } from '..'; + +// -- ui items ------------------------------------------------------------------------------------- + +import Config from './Config'; +import ConfigPage from './ConfigPage'; + +// -- private variables ---------------------------------------------------------------------------- + +let _data: { + definitions: Partial>; + config: IAppConfig; + handlerUpdate: (config: IAppConfig) => unknown; +}; + +let _configCache: IAppConfig; + +// -- private functions ---------------------------------------------------------------------------- + +async function _mount( + container: HTMLElement, + component: 'config' | 'config-page', + data: { + definitions: Partial>; + config: IAppConfig; + handlerUpdate: (config: IAppConfig) => unknown; + }, +): Promise { + return new Promise((resolve) => { + ReactDOM.render( + component === 'config' ? ( + + ) : ( + + ), + container, + ); + + requestAnimationFrame(() => resolve()); + }); +} + +async function _setup(component: 'config' | 'config-page'): Promise { + return new Promise((resolve) => { + setView('setup', async (container: HTMLElement) => { + await _mount(container, component, _data); + }); + + requestAnimationFrame(() => resolve()); + }); +} + +// -- public functions ----------------------------------------------------------------------------- + +export function getConfigCache(): IAppConfig { + return { ..._configCache }; +} + +export function updateConfigCache(callback: (newConfig: IAppConfig) => IAppConfig): void { + _configCache = JSON.parse(JSON.stringify(callback(_configCache))); +} + +// ------------------------------------------------------------------------------------------------- + +/** + * Mounts the configurator page in the view. + * @param config app configurations + * @param definitions map of component definitions + * @param handlerUpdate callback for when configurations are updated + */ +export async function mountConfigPage( + config: IAppConfig, + definitions: Partial>, + handlerUpdate: (config: IAppConfig) => unknown, +) { + _data = { definitions, config, handlerUpdate }; + _configCache = JSON.parse(JSON.stringify({ ...config })); + await _setup('config-page'); +} + +/** + * Updates the mounted configurator page. + * @param config app configurations + */ +export async function updateConfigPage(config: IAppConfig) { + _data = { ..._data, config }; + await _setup('config-page'); +} diff --git a/src/core/view/index.ts b/src/core/view/index.ts index 385038fd..cc6091a4 100644 --- a/src/core/view/index.ts +++ b/src/core/view/index.ts @@ -52,4 +52,5 @@ export function createItem( } export { setView } from './components'; +export { mountConfigPage, updateConfigPage } from './components/config'; export { setToolbarExtended, unsetToolbarExtended } from './components/toolbar';