UNPKG

@caspingus/lt

Version:

A utility library of helpers and tools for working with Learnosity APIs.

375 lines (326 loc) 12.1 kB
import * as app from '../../../core/app'; import logger from '../../../../utils/logger'; import { max } from 'lodash-es'; /** * Extensions add specific functionality to Learnosity APIs. * They rely on modules within LT being available. * * -- * * Adds the ability for authors to add more native column tabs * than is currently possible today in the Layout area. * * <p><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/images/nativeTabs/tabs.gif" alt="" width="860"></p> * @module Extensions/Authoring/nativeTabs */ const state = { columns: { numTabsLeft: 2, numTabsRight: 2, }, dirty: false, logPrefix: 'LT Native Tabs: ', options: { maxTabs: 5, }, renderedCss: false, }; /** * Extension constructor. * @example * import { LT } from '@caspingus/lt/src/authoring/index'; * * LT.init(authorApp); // Set up LT with the Author API application instance variable * LT.extensions.nativeTabs.run(); * @since 2.26.0 * @param {object=} options Override for `maxTabs` (value between 2 and 10). Default is 5. */ export function run(options = {}) { state.renderedCss || injectCSS(); overrideOptions(options); // Reset state on item render app.appInstance().on('render:item', () => { state.columns.numTabsLeft = 2; state.columns.numTabsRight = 2; state.dirty = false; }); app.appInstance().on('navigate', checkForSetup); // app.appInstance().on('itemsettings:applied', saveTabsToItem); function checkForSetup() { setTimeout(() => { if (['items/:reference/settings/:tab', 'items/:reference/settings', undefined].includes(app.appInstance().getLocation().route)) { const lastElement = app.appInstance().getLocation().location.split('/').pop(); if (['layout', 'settings'].includes(lastElement)) { setup(); } } }, 100); } } /** * Injects an input field to choose number of tabs. * @since 2.26.0 * @ignore */ function setup() { const elTabsWrapper1 = document.querySelector('[data-authorapi-selector="lrn-author-tabs-col1"]'); const elTabsWrapper2 = document.querySelector('[data-authorapi-selector="lrn-author-tabs-col2"]'); const elNumTabs1 = document.getElementById('lt__nativeTabs-col1'); const elNumTabs2 = document.getElementById('lt__nativeTabs-col2'); const elSettingsApply = document.querySelector('[data-authorapi-selector="lrn-author-apply-settings"]'); const itemJson = app.appInstance().getItem(); const numCurrentTabs = countTabs(itemJson.item.definition); state.columns.numTabsLeft = max([numCurrentTabs[0], state.columns.numTabsLeft]); state.columns.numTabsRight = max([numCurrentTabs[1], state.columns.numTabsRight]); if (elSettingsApply) { if (!elNumTabs1 && elTabsWrapper1) { elTabsWrapper1.querySelector('.lrn-author-layout-content').insertAdjacentHTML('beforeend', getNumTabsUI('col1', state.columns.numTabsLeft)); } if (!elNumTabs2 && elTabsWrapper2) { elTabsWrapper2.querySelector('.lrn-author-layout-content').insertAdjacentHTML('beforeend', getNumTabsUI('col2', state.columns.numTabsRight)); } setNumColumnsState(); elSettingsApply.addEventListener('click', () => { saveTabsToItem(numCurrentTabs); }); } else { logger.warn(`${state.logPrefix}Settings apply button not found`); } function getNumTabsUI(idSuffix, numTabs) { return ` <label class="lrn-author-checkbox-label" for="lt__nativeTabs-${idSuffix}"> <span class="label-full">Number of tabs</span> <span class="label-short">Num tabs</span> </label> <div class="lrn-form-group"> <input id="lt__nativeTabs-${idSuffix}" class="lrn-author-form-control lt__nativeTabsInput" type="number" min="2" max="${state.options.maxTabs}" value="${numTabs}" class="lrn-form-control lt__width-sm"> </div> `; } } /** * If the user changes the number of tabs in the UI, * we need to make sure the Apply button is enabled. * This is a workaround for the fact that the Apply button * is not enabled when the user changes the number of tabs. * @since 2.26.0 * @ignore */ function checkApplyButtonEnabled() { if (state.dirty) { const elSettingsApply = document.querySelector('[data-authorapi-selector="lrn-author-apply-settings"]'); if (elSettingsApply && elSettingsApply.disabled) { const elTabsEnabledLeft = document.querySelector('[data-authorapi-selector="itemLayoutTabLabel - Left"]'); const elTabsEnabledRight = document.querySelector('[data-authorapi-selector="itemLayoutTabLabel - Right"]'); // Toggle the checkbox to activate the Apply button if (elTabsEnabledLeft.checked) { elTabsEnabledLeft.click(); elTabsEnabledLeft.click(); return; } if (elTabsEnabledRight.checked) { elTabsEnabledRight.click(); elTabsEnabledRight.click(); } } } } /** * If the user unchecks the tabs enabled checkbox, * we need to (re)set the number of tabs to 2. * @since 2.26.0 * @ignore */ function checkTabsEnabled() { const elTabsEnabledLeft = document.querySelector('[data-authorapi-selector="itemLayoutTabLabel - Left"]'); const elTabsEnabledRight = document.querySelector('[data-authorapi-selector="itemLayoutTabLabel - Right"]'); if (!elTabsEnabledLeft.checked) { state.columns.numTabsLeft = 2; } if (!elTabsEnabledRight.checked) { state.columns.numTabsRight = 2; } return { leftEnabled: elTabsEnabledLeft.checked, rightEnabled: elTabsEnabledRight.checked, }; } /** * Returns the number of tabs on the item. Returns an array * of 2 elements, one for each column. * @param {object} root Item definition * @returns {array} * @since 2.26.0 * @ignore */ function countTabs(root) { const result = [0, 0]; // Default to two columns, both with 0 tabs // Helper to count tabs in a nested region tree function countTabs(region) { let count = 0; function traverse(node) { if (Array.isArray(node)) { node.forEach(traverse); } else if (typeof node === 'object' && node !== null) { if (node.type === 'tab') { count++; } for (const key in node) { if (node.hasOwnProperty(key)) { traverse(node[key]); } } } } traverse(region); return count; } // Traverse first two columns only const columns = root.regions?.filter(r => r.type === 'column') || []; for (let i = 0; i < 2; i++) { if (columns[i]) { result[i] = countTabs(columns[i]); } } return result; } /** * Saves tab layout to item JSON * @since 2.26.0 * @ignore */ function saveTabsToItem(numCurrentTabs) { const tabsEnabled = checkTabsEnabled(); setTimeout(() => { const itemJson = app.appInstance().getItem(); const currentDefinition = itemJson.item.definition; adjustTabs(0, state.columns.numTabsLeft, tabsEnabled.leftEnabled, numCurrentTabs[0], currentDefinition); adjustTabs(1, state.columns.numTabsRight, tabsEnabled.rightEnabled, numCurrentTabs[1], currentDefinition); itemJson.item.definition = currentDefinition; app.appInstance().setItemJson(itemJson); }, 100); function adjustTabs(columnIndex, tabCount, isEnabled, currentCount, currentDefinition) { if (tabCount >= 2 && isEnabled) { const tabRegions = currentDefinition.regions[columnIndex].regions[0].regions; if (tabCount < tabRegions.length) { const tabsToRemove = tabRegions.length - tabCount; // Before removing tabs, check if the tabs are empty. // If they are not, we need to move the content to another tab for (let i = tabRegions.length; i > tabRegions.length - tabsToRemove; i--) { if (tabRegions[i - 1].hasOwnProperty('widgets')) { if (tabRegions[0].hasOwnProperty('widgets')) { tabRegions[0].widgets.push(tabRegions[i - 1].widgets); } else { tabRegions[0].widgets = tabRegions[i - 1].widgets; } } } tabRegions.splice(-tabsToRemove); } else { const startIndex = (currentCount || 2) + 1; for (let index = startIndex; index <= tabCount; index++) { tabRegions.push({ label: `Tab ${index}`, type: 'tab', }); } } } } } /** * Saves the UI number of tabs to internal state * so we can use it once the Apply button is clicked. * @since 2.26.0 * @ignore */ function setNumColumnsState() { const elNumTabs1 = document.getElementById('lt__nativeTabs-col1'); const elNumTabs2 = document.getElementById('lt__nativeTabs-col2'); elNumTabs1.addEventListener('change', () => { state.columns.numTabsLeft = validateNumTabs(+elNumTabs1.value); state.dirty = true; checkApplyButtonEnabled(); }); elNumTabs2.addEventListener('change', () => { state.columns.numTabsRight = validateNumTabs(+elNumTabs2.value); state.dirty = true; checkApplyButtonEnabled(); }); } /** * Validates the number of tabs entered by the user. * If the number is less than 2, it returns 2. * If the number is greater than the maxTabs option, it returns maxTabs. * Otherwise, it returns the number entered by the user. * @param {number} num * @returns {number} * @since 2.26.0 * @ignore */ function validateNumTabs(num) { if (num < 2) { return 2; } else if (num > state.options.maxTabs) { return state.options.maxTabs; } return num; } /** * Calling page can override certain options. * @since 2.26.0 * @ignore * @param {object} options */ function overrideOptions(options) { ['maxTabs'].forEach(prop => { if (typeof options?.[prop] === 'number' && options?.[prop] >= 2 && options?.[prop] <= 10) { state.options[prop] = options[prop]; } }); } /** * Injects the necessary CSS to the header * @since 2.26.0 * @ignore */ function injectCSS() { const elStyle = document.createElement('style'); const css = ` /* Learnosity native tab styles */ .lrn .lrn-author-ui, .lrn.lrn-author { .lrn-author-api-react-container .lrn-author-item-settings .lrn-author-layout-settings .lrn-author-layout-tab .lrn-author-layout-content, .lrn-author-api-react-container .lrn-author-activity-labels .lrn-author-layout-settings .lrn-author-layout-tab .lrn-author-layout-content { padding: 9px; } .lrn-form-control.lt__width-sm, .lrn-author-form-control.lt__nativeTabsInput { width: 80px; } .lrn-author-checkbox-label { padding-bottom: .5714285714em; } [data-authorapi-selector="lrn-author-tabs-col1"], [data-authorapi-selector="lrn-author-tabs-col2"] { .lrn-author-layout-content { container-type: inline-size; .label-short { display: none; } @container (max-width: 120px) { .label-full { display: none; } .label-short { display: inline; } } } } } `; elStyle.textContent = css; document.head.append(elStyle); state.renderedCss = true; }