diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js index fea8c01e3..cd8bf279f 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.js @@ -10,7 +10,7 @@ const isProd = process.argv[2] === 'production'; // Gather our input files const entryPoints = { - app: path.join(__dirname, '../../resources/js/app.js'), + app: path.join(__dirname, '../../resources/js/app.ts'), code: path.join(__dirname, '../../resources/js/code/index.mjs'), 'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'), markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'), diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index 5f4902f86..000000000 --- a/resources/js/app.js +++ /dev/null @@ -1,33 +0,0 @@ -import {EventManager} from './services/events.ts'; -import {HttpManager} from './services/http.ts'; -import {Translator} from './services/translations.ts'; -import * as componentMap from './components'; -import {ComponentStore} from './services/components.ts'; - -// eslint-disable-next-line no-underscore-dangle -window.__DEV__ = false; - -// Url retrieval function -window.baseUrl = function baseUrl(path) { - let targetPath = path; - let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content'); - if (basePath[basePath.length - 1] === '/') basePath = basePath.slice(0, basePath.length - 1); - if (targetPath[0] === '/') targetPath = targetPath.slice(1); - return `${basePath}/${targetPath}`; -}; - -window.importVersioned = function importVersioned(moduleName) { - const version = document.querySelector('link[href*="/dist/styles.css?version="]').href.split('?version=').pop(); - const importPath = window.baseUrl(`dist/${moduleName}.js?version=${version}`); - return import(importPath); -}; - -// Set events, http & translation services on window -window.$http = new HttpManager(); -window.$events = new EventManager(); -window.$trans = new Translator(); - -// Load & initialise components -window.$components = new ComponentStore(); -window.$components.register(componentMap); -window.$components.init(); diff --git a/resources/js/app.ts b/resources/js/app.ts new file mode 100644 index 000000000..141a50ef5 --- /dev/null +++ b/resources/js/app.ts @@ -0,0 +1,23 @@ +import {EventManager} from './services/events'; +import {HttpManager} from './services/http'; +import {Translator} from './services/translations'; +import * as componentMap from './components/index'; +import {ComponentStore} from './services/components'; +import {baseUrl, importVersioned} from "./services/util"; + +// eslint-disable-next-line no-underscore-dangle +window.__DEV__ = false; + +// Make common important util functions global +window.baseUrl = baseUrl; +window.importVersioned = importVersioned; + +// Setup events, http & translation services +window.$http = new HttpManager(); +window.$events = new EventManager(); +window.$trans = new Translator(); + +// Load & initialise components +window.$components = new ComponentStore(); +window.$components.register(componentMap); +window.$components.init(); diff --git a/resources/js/components/add-remove-rows.js b/resources/js/components/add-remove-rows.js index 3213c4835..e7de15ae5 100644 --- a/resources/js/components/add-remove-rows.js +++ b/resources/js/components/add-remove-rows.js @@ -1,5 +1,5 @@ -import {onChildEvent} from '../services/dom'; -import {uniqueId} from '../services/util'; +import {onChildEvent} from '../services/dom.ts'; +import {uniqueId} from '../services/util.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/ajax-delete-row.js b/resources/js/components/ajax-delete-row.js index aa2801f19..6ed3deedf 100644 --- a/resources/js/components/ajax-delete-row.js +++ b/resources/js/components/ajax-delete-row.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; export class AjaxDeleteRow extends Component { diff --git a/resources/js/components/ajax-form.js b/resources/js/components/ajax-form.js index 583dde572..de1a6db43 100644 --- a/resources/js/components/ajax-form.js +++ b/resources/js/components/ajax-form.js @@ -1,4 +1,4 @@ -import {onEnterPress, onSelect} from '../services/dom'; +import {onEnterPress, onSelect} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js index f45b25e36..2dc7313a8 100644 --- a/resources/js/components/attachments.js +++ b/resources/js/components/attachments.js @@ -1,4 +1,4 @@ -import {showLoading} from '../services/dom'; +import {showLoading} from '../services/dom.ts'; import {Component} from './component'; export class Attachments extends Component { diff --git a/resources/js/components/auto-suggest.js b/resources/js/components/auto-suggest.js index 2eede241c..0b828e71b 100644 --- a/resources/js/components/auto-suggest.js +++ b/resources/js/components/auto-suggest.js @@ -1,7 +1,7 @@ -import {escapeHtml} from '../services/util'; -import {onChildEvent} from '../services/dom'; +import {escapeHtml} from '../services/util.ts'; +import {onChildEvent} from '../services/dom.ts'; import {Component} from './component'; -import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; +import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts'; const ajaxCache = {}; diff --git a/resources/js/components/book-sort.js b/resources/js/components/book-sort.js index 2ba7d5d36..48557141f 100644 --- a/resources/js/components/book-sort.js +++ b/resources/js/components/book-sort.js @@ -1,6 +1,6 @@ import Sortable, {MultiDrag} from 'sortablejs'; import {Component} from './component'; -import {htmlToDom} from '../services/dom'; +import {htmlToDom} from '../services/dom.ts'; // Auto sort control const sortOperations = { diff --git a/resources/js/components/chapter-contents.js b/resources/js/components/chapter-contents.js index 7c6480a1a..6b0707bdd 100644 --- a/resources/js/components/chapter-contents.js +++ b/resources/js/components/chapter-contents.js @@ -1,4 +1,4 @@ -import {slideUp, slideDown} from '../services/animations'; +import {slideUp, slideDown} from '../services/animations.ts'; import {Component} from './component'; export class ChapterContents extends Component { diff --git a/resources/js/components/code-editor.js b/resources/js/components/code-editor.js index 091c3483f..12937d472 100644 --- a/resources/js/components/code-editor.js +++ b/resources/js/components/code-editor.js @@ -1,4 +1,4 @@ -import {onChildEvent, onEnterPress, onSelect} from '../services/dom'; +import {onChildEvent, onEnterPress, onSelect} from '../services/dom.ts'; import {Component} from './component'; export class CodeEditor extends Component { diff --git a/resources/js/components/collapsible.js b/resources/js/components/collapsible.js index 6f740ed71..7b6fa79fb 100644 --- a/resources/js/components/collapsible.js +++ b/resources/js/components/collapsible.js @@ -1,4 +1,4 @@ -import {slideDown, slideUp} from '../services/animations'; +import {slideDown, slideUp} from '../services/animations.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/confirm-dialog.js b/resources/js/components/confirm-dialog.js index 184618fcc..00f3cfed2 100644 --- a/resources/js/components/confirm-dialog.js +++ b/resources/js/components/confirm-dialog.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/dropdown-search.js b/resources/js/components/dropdown-search.js index 2344619f5..fcbabc022 100644 --- a/resources/js/components/dropdown-search.js +++ b/resources/js/components/dropdown-search.js @@ -1,5 +1,5 @@ -import {debounce} from '../services/util'; -import {transitionHeight} from '../services/animations'; +import {debounce} from '../services/util.ts'; +import {transitionHeight} from '../services/animations.ts'; import {Component} from './component'; export class DropdownSearch extends Component { diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index 4efd428ac..5dd5dd93b 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -1,5 +1,5 @@ -import {onSelect} from '../services/dom'; -import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; +import {onSelect} from '../services/dom.ts'; +import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js index 920fe875f..598e0d8d4 100644 --- a/resources/js/components/dropzone.js +++ b/resources/js/components/dropzone.js @@ -2,7 +2,7 @@ import {Component} from './component'; import {Clipboard} from '../services/clipboard.ts'; import { elem, getLoading, onSelect, removeLoading, -} from '../services/dom'; +} from '../services/dom.ts'; export class Dropzone extends Component { diff --git a/resources/js/components/entity-permissions.js b/resources/js/components/entity-permissions.js index 7ab99a2a7..b020c5d85 100644 --- a/resources/js/components/entity-permissions.js +++ b/resources/js/components/entity-permissions.js @@ -1,4 +1,4 @@ -import {htmlToDom} from '../services/dom'; +import {htmlToDom} from '../services/dom.ts'; import {Component} from './component'; export class EntityPermissions extends Component { diff --git a/resources/js/components/entity-search.js b/resources/js/components/entity-search.js index 7a5044470..9d4513326 100644 --- a/resources/js/components/entity-search.js +++ b/resources/js/components/entity-search.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; export class EntitySearch extends Component { diff --git a/resources/js/components/entity-selector.js b/resources/js/components/entity-selector.js index 561370d7a..7491119a1 100644 --- a/resources/js/components/entity-selector.js +++ b/resources/js/components/entity-selector.js @@ -1,4 +1,4 @@ -import {onChildEvent} from '../services/dom'; +import {onChildEvent} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/event-emit-select.js b/resources/js/components/event-emit-select.js index 2097c0528..f722a25e7 100644 --- a/resources/js/components/event-emit-select.js +++ b/resources/js/components/event-emit-select.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/expand-toggle.js b/resources/js/components/expand-toggle.js index 0d2018b9d..29173a058 100644 --- a/resources/js/components/expand-toggle.js +++ b/resources/js/components/expand-toggle.js @@ -1,4 +1,4 @@ -import {slideUp, slideDown} from '../services/animations'; +import {slideUp, slideDown} from '../services/animations.ts'; import {Component} from './component'; export class ExpandToggle extends Component { diff --git a/resources/js/components/global-search.js b/resources/js/components/global-search.js index 798bd7aac..2cdaf591a 100644 --- a/resources/js/components/global-search.js +++ b/resources/js/components/global-search.js @@ -1,6 +1,6 @@ -import {htmlToDom} from '../services/dom'; -import {debounce} from '../services/util'; -import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; +import {htmlToDom} from '../services/dom.ts'; +import {debounce} from '../services/util.ts'; +import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js index 47231477b..c8108ab28 100644 --- a/resources/js/components/image-manager.js +++ b/resources/js/components/image-manager.js @@ -1,6 +1,6 @@ import { onChildEvent, onSelect, removeLoading, showLoading, -} from '../services/dom'; +} from '../services/dom.ts'; import {Component} from './component'; export class ImageManager extends Component { diff --git a/resources/js/components/index.js b/resources/js/components/index.ts similarity index 100% rename from resources/js/components/index.js rename to resources/js/components/index.ts diff --git a/resources/js/components/optional-input.js b/resources/js/components/optional-input.js index 64cee12cd..1b133047d 100644 --- a/resources/js/components/optional-input.js +++ b/resources/js/components/optional-input.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; export class OptionalInput extends Component { diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js index fd8ad1f2e..8c0a8b33e 100644 --- a/resources/js/components/page-comment.js +++ b/resources/js/components/page-comment.js @@ -1,5 +1,5 @@ import {Component} from './component'; -import {getLoading, htmlToDom} from '../services/dom'; +import {getLoading, htmlToDom} from '../services/dom.ts'; import {buildForInput} from '../wysiwyg-tinymce/config'; export class PageComment extends Component { diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index 1d6abfe20..3d7e1365f 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -1,5 +1,5 @@ import {Component} from './component'; -import {getLoading, htmlToDom} from '../services/dom'; +import {getLoading, htmlToDom} from '../services/dom.ts'; import {buildForInput} from '../wysiwyg-tinymce/config'; export class PageComments extends Component { diff --git a/resources/js/components/page-display.js b/resources/js/components/page-display.js index 1e13ae388..d3ac78a4a 100644 --- a/resources/js/components/page-display.js +++ b/resources/js/components/page-display.js @@ -1,5 +1,5 @@ -import * as DOM from '../services/dom'; -import {scrollAndHighlightElement} from '../services/util'; +import * as DOM from '../services/dom.ts'; +import {scrollAndHighlightElement} from '../services/util.ts'; import {Component} from './component'; function toggleAnchorHighlighting(elementId, shouldHighlight) { diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index 216067552..7ffceb0f9 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -1,5 +1,5 @@ -import {onSelect} from '../services/dom'; -import {debounce} from '../services/util'; +import {onSelect} from '../services/dom.ts'; +import {debounce} from '../services/util.ts'; import {Component} from './component'; import {utcTimeStampToLocalTime} from '../services/dates.ts'; diff --git a/resources/js/components/pointer.js b/resources/js/components/pointer.js index 607576cb9..292b923e5 100644 --- a/resources/js/components/pointer.js +++ b/resources/js/components/pointer.js @@ -1,4 +1,4 @@ -import * as DOM from '../services/dom'; +import * as DOM from '../services/dom.ts'; import {Component} from './component'; import {copyTextToClipboard} from '../services/clipboard.ts'; diff --git a/resources/js/components/popup.js b/resources/js/components/popup.js index 662736548..6bd8f9c72 100644 --- a/resources/js/components/popup.js +++ b/resources/js/components/popup.js @@ -1,5 +1,5 @@ -import {fadeIn, fadeOut} from '../services/animations'; -import {onSelect} from '../services/dom'; +import {fadeIn, fadeOut} from '../services/animations.ts'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/template-manager.js b/resources/js/components/template-manager.js index 56ec876d4..cf81990ab 100644 --- a/resources/js/components/template-manager.js +++ b/resources/js/components/template-manager.js @@ -1,4 +1,4 @@ -import * as DOM from '../services/dom'; +import * as DOM from '../services/dom.ts'; import {Component} from './component'; export class TemplateManager extends Component { diff --git a/resources/js/components/user-select.js b/resources/js/components/user-select.js index e6adc3c23..f9ec03ed3 100644 --- a/resources/js/components/user-select.js +++ b/resources/js/components/user-select.js @@ -1,4 +1,4 @@ -import {onChildEvent} from '../services/dom'; +import {onChildEvent} from '../services/dom.ts'; import {Component} from './component'; export class UserSelect extends Component { diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index e505c96e0..b637c97c1 100644 --- a/resources/js/global.d.ts +++ b/resources/js/global.d.ts @@ -7,10 +7,12 @@ declare global { const __DEV__: boolean; interface Window { + __DEV__: boolean; $components: ComponentStore; $events: EventManager; $trans: Translator; $http: HttpManager; baseUrl: (path: string) => string; + importVersioned: (module: string) => Promise; } } \ No newline at end of file diff --git a/resources/js/markdown/codemirror.js b/resources/js/markdown/codemirror.js index a6332cbb8..664767605 100644 --- a/resources/js/markdown/codemirror.js +++ b/resources/js/markdown/codemirror.js @@ -1,5 +1,5 @@ import {provideKeyBindings} from './shortcuts'; -import {debounce} from '../services/util'; +import {debounce} from '../services/util.ts'; import {Clipboard} from '../services/clipboard.ts'; /** diff --git a/resources/js/services/animations.js b/resources/js/services/animations.ts similarity index 63% rename from resources/js/services/animations.js rename to resources/js/services/animations.ts index bc983c807..adf4cb3c9 100644 --- a/resources/js/services/animations.js +++ b/resources/js/services/animations.ts @@ -1,30 +1,30 @@ /** * Used in the function below to store references of clean-up functions. * Used to ensure only one transitionend function exists at any time. - * @type {WeakMap} */ -const animateStylesCleanupMap = new WeakMap(); +const animateStylesCleanupMap: WeakMap = new WeakMap(); /** * Animate the css styles of an element using FLIP animation techniques. - * Styles must be an object where the keys are style properties, camelcase, and the values + * Styles must be an object where the keys are style rule names and the values * are an array of two items in the format [initialValue, finalValue] - * @param {Element} element - * @param {Object} styles - * @param {Number} animTime - * @param {Function} onComplete */ -function animateStyles(element, styles, animTime = 400, onComplete = null) { +function animateStyles( + element: HTMLElement, + styles: Record, + animTime: number = 400, + onComplete: Function | null = null +): void { const styleNames = Object.keys(styles); for (const style of styleNames) { - element.style[style] = styles[style][0]; + element.style.setProperty(style, styles[style][0]); } const cleanup = () => { for (const style of styleNames) { - element.style[style] = null; + element.style.removeProperty(style); } - element.style.transition = null; + element.style.removeProperty('transition'); element.removeEventListener('transitionend', cleanup); animateStylesCleanupMap.delete(element); if (onComplete) onComplete(); @@ -33,7 +33,7 @@ function animateStyles(element, styles, animTime = 400, onComplete = null) { setTimeout(() => { element.style.transition = `all ease-in-out ${animTime}ms`; for (const style of styleNames) { - element.style[style] = styles[style][1]; + element.style.setProperty(style, styles[style][1]); } element.addEventListener('transitionend', cleanup); @@ -43,9 +43,8 @@ function animateStyles(element, styles, animTime = 400, onComplete = null) { /** * Run the active cleanup action for the given element. - * @param {Element} element */ -function cleanupExistingElementAnimation(element) { +function cleanupExistingElementAnimation(element: Element) { if (animateStylesCleanupMap.has(element)) { const oldCleanup = animateStylesCleanupMap.get(element); oldCleanup(); @@ -54,15 +53,12 @@ function cleanupExistingElementAnimation(element) { /** * Fade in the given element. - * @param {Element} element - * @param {Number} animTime - * @param {Function|null} onComplete */ -export function fadeIn(element, animTime = 400, onComplete = null) { +export function fadeIn(element: HTMLElement, animTime: number = 400, onComplete: Function | null = null): void { cleanupExistingElementAnimation(element); element.style.display = 'block'; animateStyles(element, { - opacity: ['0', '1'], + 'opacity': ['0', '1'], }, animTime, () => { if (onComplete) onComplete(); }); @@ -70,14 +66,11 @@ export function fadeIn(element, animTime = 400, onComplete = null) { /** * Fade out the given element. - * @param {Element} element - * @param {Number} animTime - * @param {Function|null} onComplete */ -export function fadeOut(element, animTime = 400, onComplete = null) { +export function fadeOut(element: HTMLElement, animTime: number = 400, onComplete: Function | null = null): void { cleanupExistingElementAnimation(element); animateStyles(element, { - opacity: ['1', '0'], + 'opacity': ['1', '0'], }, animTime, () => { element.style.display = 'none'; if (onComplete) onComplete(); @@ -86,20 +79,18 @@ export function fadeOut(element, animTime = 400, onComplete = null) { /** * Hide the element by sliding the contents upwards. - * @param {Element} element - * @param {Number} animTime */ -export function slideUp(element, animTime = 400) { +export function slideUp(element: HTMLElement, animTime: number = 400) { cleanupExistingElementAnimation(element); const currentHeight = element.getBoundingClientRect().height; const computedStyles = getComputedStyle(element); const currentPaddingTop = computedStyles.getPropertyValue('padding-top'); const currentPaddingBottom = computedStyles.getPropertyValue('padding-bottom'); const animStyles = { - maxHeight: [`${currentHeight}px`, '0px'], - overflow: ['hidden', 'hidden'], - paddingTop: [currentPaddingTop, '0px'], - paddingBottom: [currentPaddingBottom, '0px'], + 'max-height': [`${currentHeight}px`, '0px'], + 'overflow': ['hidden', 'hidden'], + 'padding-top': [currentPaddingTop, '0px'], + 'padding-bottom': [currentPaddingBottom, '0px'], }; animateStyles(element, animStyles, animTime, () => { @@ -109,10 +100,8 @@ export function slideUp(element, animTime = 400) { /** * Show the given element by expanding the contents. - * @param {Element} element - Element to animate - * @param {Number} animTime - Animation time in ms */ -export function slideDown(element, animTime = 400) { +export function slideDown(element: HTMLElement, animTime: number = 400) { cleanupExistingElementAnimation(element); element.style.display = 'block'; const targetHeight = element.getBoundingClientRect().height; @@ -120,10 +109,10 @@ export function slideDown(element, animTime = 400) { const targetPaddingTop = computedStyles.getPropertyValue('padding-top'); const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom'); const animStyles = { - maxHeight: ['0px', `${targetHeight}px`], - overflow: ['hidden', 'hidden'], - paddingTop: ['0px', targetPaddingTop], - paddingBottom: ['0px', targetPaddingBottom], + 'max-height': ['0px', `${targetHeight}px`], + 'overflow': ['hidden', 'hidden'], + 'padding-top': ['0px', targetPaddingTop], + 'padding-bottom': ['0px', targetPaddingBottom], }; animateStyles(element, animStyles, animTime); @@ -134,11 +123,8 @@ export function slideDown(element, animTime = 400) { * Call with first state, and you'll receive a function in return. * Call the returned function in the second state to animate between those two states. * If animating to/from 0-height use the slide-up/slide down as easier alternatives. - * @param {Element} element - Element to animate - * @param {Number} animTime - Animation time in ms - * @returns {function} - Function to run in second state to trigger animation. */ -export function transitionHeight(element, animTime = 400) { +export function transitionHeight(element: HTMLElement, animTime: number = 400): () => void { const startHeight = element.getBoundingClientRect().height; const initialComputedStyles = getComputedStyle(element); const startPaddingTop = initialComputedStyles.getPropertyValue('padding-top'); @@ -151,10 +137,10 @@ export function transitionHeight(element, animTime = 400) { const targetPaddingTop = computedStyles.getPropertyValue('padding-top'); const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom'); const animStyles = { - height: [`${startHeight}px`, `${targetHeight}px`], - overflow: ['hidden', 'hidden'], - paddingTop: [startPaddingTop, targetPaddingTop], - paddingBottom: [startPaddingBottom, targetPaddingBottom], + 'height': [`${startHeight}px`, `${targetHeight}px`], + 'overflow': ['hidden', 'hidden'], + 'padding-top': [startPaddingTop, targetPaddingTop], + 'padding-bottom': [startPaddingBottom, targetPaddingBottom], }; animateStyles(element, animStyles, animTime); diff --git a/resources/js/services/dom.js b/resources/js/services/dom.ts similarity index 63% rename from resources/js/services/dom.js rename to resources/js/services/dom.ts index bcfd0b565..c88827bac 100644 --- a/resources/js/services/dom.js +++ b/resources/js/services/dom.ts @@ -1,12 +1,15 @@ +/** + * Check if the given param is a HTMLElement + */ +export function isHTMLElement(el: any): el is HTMLElement { + return el instanceof HTMLElement; +} + /** * Create a new element with the given attrs and children. * Children can be a string for text nodes or other elements. - * @param {String} tagName - * @param {Object} attrs - * @param {Element[]|String[]}children - * @return {*} */ -export function elem(tagName, attrs = {}, children = []) { +export function elem(tagName: string, attrs: Record = {}, children: Element[]|string[] = []): HTMLElement { const el = document.createElement(tagName); for (const [key, val] of Object.entries(attrs)) { @@ -30,10 +33,8 @@ export function elem(tagName, attrs = {}, children = []) { /** * Run the given callback against each element that matches the given selector. - * @param {String} selector - * @param {Function} callback */ -export function forEach(selector, callback) { +export function forEach(selector: string, callback: (el: Element) => any) { const elements = document.querySelectorAll(selector); for (const element of elements) { callback(element); @@ -42,11 +43,8 @@ export function forEach(selector, callback) { /** * Helper to listen to multiple DOM events - * @param {Element} listenerElement - * @param {Array} events - * @param {Function} callback */ -export function onEvents(listenerElement, events, callback) { +export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void { for (const eventName of events) { listenerElement.addEventListener(eventName, callback); } @@ -55,10 +53,8 @@ export function onEvents(listenerElement, events, callback) { /** * Helper to run an action when an element is selected. * A "select" is made to be accessible, So can be a click, space-press or enter-press. - * @param {HTMLElement|Array} elements - * @param {function} callback */ -export function onSelect(elements, callback) { +export function onSelect(elements: HTMLElement|HTMLElement[], callback: (e: Event) => any): void { if (!Array.isArray(elements)) { elements = [elements]; } @@ -76,16 +72,13 @@ export function onSelect(elements, callback) { /** * Listen to key press on the given element(s). - * @param {String} key - * @param {HTMLElement|Array} elements - * @param {function} callback */ -function onKeyPress(key, elements, callback) { +function onKeyPress(key: string, elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void { if (!Array.isArray(elements)) { elements = [elements]; } - const listener = event => { + const listener = (event: KeyboardEvent) => { if (event.key === key) { callback(event); } @@ -96,19 +89,15 @@ function onKeyPress(key, elements, callback) { /** * Listen to enter press on the given element(s). - * @param {HTMLElement|Array} elements - * @param {function} callback */ -export function onEnterPress(elements, callback) { +export function onEnterPress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void { onKeyPress('Enter', elements, callback); } /** * Listen to escape press on the given element(s). - * @param {HTMLElement|Array} elements - * @param {function} callback */ -export function onEscapePress(elements, callback) { +export function onEscapePress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void { onKeyPress('Escape', elements, callback); } @@ -116,14 +105,15 @@ export function onEscapePress(elements, callback) { * Set a listener on an element for an event emitted by a child * matching the given childSelector param. * Used in a similar fashion to jQuery's $('listener').on('eventName', 'childSelector', callback) - * @param {Element} listenerElement - * @param {String} childSelector - * @param {String} eventName - * @param {Function} callback */ -export function onChildEvent(listenerElement, childSelector, eventName, callback) { - listenerElement.addEventListener(eventName, event => { - const matchingChild = event.target.closest(childSelector); +export function onChildEvent( + listenerElement: HTMLElement, + childSelector: string, + eventName: string, + callback: (this: HTMLElement, e: Event, child: HTMLElement) => any +): void { + listenerElement.addEventListener(eventName, (event: Event) => { + const matchingChild = (event.target as HTMLElement|null)?.closest(childSelector) as HTMLElement; if (matchingChild) { callback.call(matchingChild, event, matchingChild); } @@ -132,16 +122,13 @@ export function onChildEvent(listenerElement, childSelector, eventName, callback /** * Look for elements that match the given selector and contain the given text. - * Is case insensitive and returns the first result or null if nothing is found. - * @param {String} selector - * @param {String} text - * @returns {Element} + * Is case-insensitive and returns the first result or null if nothing is found. */ -export function findText(selector, text) { +export function findText(selector: string, text: string): Element|null { const elements = document.querySelectorAll(selector); text = text.toLowerCase(); for (const element of elements) { - if (element.textContent.toLowerCase().includes(text)) { + if ((element.textContent || '').toLowerCase().includes(text) && isHTMLElement(element)) { return element; } } @@ -151,17 +138,15 @@ export function findText(selector, text) { /** * Show a loading indicator in the given element. * This will effectively clear the element. - * @param {Element} element */ -export function showLoading(element) { +export function showLoading(element: HTMLElement): void { element.innerHTML = '
'; } /** * Get a loading element indicator element. - * @returns {Element} */ -export function getLoading() { +export function getLoading(): HTMLElement { const wrap = document.createElement('div'); wrap.classList.add('loading-container'); wrap.innerHTML = '
'; @@ -170,9 +155,8 @@ export function getLoading() { /** * Remove any loading indicators within the given element. - * @param {Element} element */ -export function removeLoading(element) { +export function removeLoading(element: HTMLElement): void { const loadingEls = element.querySelectorAll('.loading-container'); for (const el of loadingEls) { el.remove(); @@ -182,12 +166,15 @@ export function removeLoading(element) { /** * Convert the given html data into a live DOM element. * Initiates any components defined in the data. - * @param {String} html - * @returns {Element} */ -export function htmlToDom(html) { +export function htmlToDom(html: string): HTMLElement { const wrap = document.createElement('div'); wrap.innerHTML = html; window.$components.init(wrap); - return wrap.children[0]; + const firstChild = wrap.children[0]; + if (!isHTMLElement(firstChild)) { + throw new Error('Could not find child HTMLElement when creating DOM element from HTML'); + } + + return firstChild; } diff --git a/resources/js/services/keyboard-navigation.js b/resources/js/services/keyboard-navigation.ts similarity index 66% rename from resources/js/services/keyboard-navigation.js rename to resources/js/services/keyboard-navigation.ts index 34111bb2d..13fbdfecc 100644 --- a/resources/js/services/keyboard-navigation.js +++ b/resources/js/services/keyboard-navigation.ts @@ -1,14 +1,17 @@ +import {isHTMLElement} from "./dom"; + +type OptionalKeyEventHandler = ((e: KeyboardEvent) => any)|null; + /** * Handle common keyboard navigation events within a given container. */ export class KeyboardNavigationHandler { - /** - * @param {Element} container - * @param {Function|null} onEscape - * @param {Function|null} onEnter - */ - constructor(container, onEscape = null, onEnter = null) { + protected containers: HTMLElement[]; + protected onEscape: OptionalKeyEventHandler; + protected onEnter: OptionalKeyEventHandler; + + constructor(container: HTMLElement, onEscape: OptionalKeyEventHandler = null, onEnter: OptionalKeyEventHandler = null) { this.containers = [container]; this.onEscape = onEscape; this.onEnter = onEnter; @@ -18,9 +21,8 @@ export class KeyboardNavigationHandler { /** * Also share the keyboard event handling to the given element. * Only elements within the original container are considered focusable though. - * @param {Element} element */ - shareHandlingToEl(element) { + shareHandlingToEl(element: HTMLElement) { this.containers.push(element); element.addEventListener('keydown', this.#keydownHandler.bind(this)); } @@ -30,7 +32,8 @@ export class KeyboardNavigationHandler { */ focusNext() { const focusable = this.#getFocusable(); - const currentIndex = focusable.indexOf(document.activeElement); + const activeEl = document.activeElement; + const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1; let newIndex = currentIndex + 1; if (newIndex >= focusable.length) { newIndex = 0; @@ -44,7 +47,8 @@ export class KeyboardNavigationHandler { */ focusPrevious() { const focusable = this.#getFocusable(); - const currentIndex = focusable.indexOf(document.activeElement); + const activeEl = document.activeElement; + const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1; let newIndex = currentIndex - 1; if (newIndex < 0) { newIndex = focusable.length - 1; @@ -53,12 +57,9 @@ export class KeyboardNavigationHandler { focusable[newIndex].focus(); } - /** - * @param {KeyboardEvent} event - */ - #keydownHandler(event) { + #keydownHandler(event: KeyboardEvent) { // Ignore certain key events in inputs to allow text editing. - if (event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) { + if (isHTMLElement(event.target) && event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) { return; } @@ -71,7 +72,7 @@ export class KeyboardNavigationHandler { } else if (event.key === 'Escape') { if (this.onEscape) { this.onEscape(event); - } else if (document.activeElement) { + } else if (isHTMLElement(document.activeElement)) { document.activeElement.blur(); } } else if (event.key === 'Enter' && this.onEnter) { @@ -81,14 +82,15 @@ export class KeyboardNavigationHandler { /** * Get an array of focusable elements within the current containers. - * @returns {Element[]} */ - #getFocusable() { - const focusable = []; + #getFocusable(): HTMLElement[] { + const focusable: HTMLElement[] = []; const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])'; for (const container of this.containers) { - focusable.push(...container.querySelectorAll(selector)); + const toAdd = [...container.querySelectorAll(selector)].filter(e => isHTMLElement(e)); + focusable.push(...toAdd); } + return focusable; } diff --git a/resources/js/services/util.js b/resources/js/services/util.js deleted file mode 100644 index 1264d1058..000000000 --- a/resources/js/services/util.js +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Returns a function, that, as long as it continues to be invoked, will not - * be triggered. The function will be called after it stops being called for - * N milliseconds. If `immediate` is passed, trigger the function on the - * leading edge, instead of the trailing. - * @attribution https://davidwalsh.name/javascript-debounce-function - * @param {Function} func - * @param {Number} waitMs - * @param {Boolean} immediate - * @returns {Function} - */ -export function debounce(func, waitMs, immediate) { - let timeout; - return function debouncedWrapper(...args) { - const context = this; - const later = function debouncedTimeout() { - timeout = null; - if (!immediate) func.apply(context, args); - }; - const callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, waitMs); - if (callNow) func.apply(context, args); - }; -} - -/** - * Scroll and highlight an element. - * @param {HTMLElement} element - */ -export function scrollAndHighlightElement(element) { - if (!element) return; - - let parent = element; - while (parent.parentElement) { - parent = parent.parentElement; - if (parent.nodeName === 'DETAILS' && !parent.open) { - parent.open = true; - } - } - - element.scrollIntoView({behavior: 'smooth'}); - - const highlight = getComputedStyle(document.body).getPropertyValue('--color-link'); - element.style.outline = `2px dashed ${highlight}`; - element.style.outlineOffset = '5px'; - element.style.transition = null; - setTimeout(() => { - element.style.transition = 'outline linear 3s'; - element.style.outline = '2px dashed rgba(0, 0, 0, 0)'; - const listener = () => { - element.removeEventListener('transitionend', listener); - element.style.transition = null; - element.style.outline = null; - element.style.outlineOffset = null; - }; - element.addEventListener('transitionend', listener); - }, 1000); -} - -/** - * Escape any HTML in the given 'unsafe' string. - * Take from https://stackoverflow.com/a/6234804. - * @param {String} unsafe - * @returns {string} - */ -export function escapeHtml(unsafe) { - return unsafe - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -/** - * Generate a random unique ID. - * - * @returns {string} - */ -export function uniqueId() { - // eslint-disable-next-line no-bitwise - const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); - return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`); -} - -/** - * Generate a random smaller unique ID. - * - * @returns {string} - */ -export function uniqueIdSmall() { - // eslint-disable-next-line no-bitwise - const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); - return S4(); -} - -/** - * Create a promise that resolves after the given time. - * @param {int} timeMs - * @returns {Promise} - */ -export function wait(timeMs) { - return new Promise(res => { - setTimeout(res, timeMs); - }); -} diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts new file mode 100644 index 000000000..c5a5d2db8 --- /dev/null +++ b/resources/js/services/util.ts @@ -0,0 +1,147 @@ +/** + * Returns a function, that, as long as it continues to be invoked, will not + * be triggered. The function will be called after it stops being called for + * N milliseconds. If `immediate` is passed, trigger the function on the + * leading edge, instead of the trailing. + * @attribution https://davidwalsh.name/javascript-debounce-function + */ +export function debounce(func: Function, waitMs: number, immediate: boolean): Function { + let timeout: number|null = null; + return function debouncedWrapper(this: any, ...args: any[]) { + const context: any = this; + const later = function debouncedTimeout() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + const callNow = immediate && !timeout; + if (timeout) { + clearTimeout(timeout); + } + timeout = window.setTimeout(later, waitMs); + if (callNow) func.apply(context, args); + }; +} + +function isDetailsElement(element: HTMLElement): element is HTMLDetailsElement { + return element.nodeName === 'DETAILS'; +} + +/** + * Scroll-to and highlight an element. + */ +export function scrollAndHighlightElement(element: HTMLElement): void { + if (!element) return; + + // Open up parent
elements if within + let parent = element; + while (parent.parentElement) { + parent = parent.parentElement; + if (isDetailsElement(parent) && !parent.open) { + parent.open = true; + } + } + + element.scrollIntoView({behavior: 'smooth'}); + + const highlight = getComputedStyle(document.body).getPropertyValue('--color-link'); + element.style.outline = `2px dashed ${highlight}`; + element.style.outlineOffset = '5px'; + element.style.removeProperty('transition'); + setTimeout(() => { + element.style.transition = 'outline linear 3s'; + element.style.outline = '2px dashed rgba(0, 0, 0, 0)'; + const listener = () => { + element.removeEventListener('transitionend', listener); + element.style.removeProperty('transition'); + element.style.removeProperty('outline'); + element.style.removeProperty('outlineOffset'); + }; + element.addEventListener('transitionend', listener); + }, 1000); +} + +/** + * Escape any HTML in the given 'unsafe' string. + * Take from https://stackoverflow.com/a/6234804. + */ +export function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Generate a random unique ID. + */ +export function uniqueId(): string { + // eslint-disable-next-line no-bitwise + const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`); +} + +/** + * Generate a random smaller unique ID. + */ +export function uniqueIdSmall(): string { + // eslint-disable-next-line no-bitwise + const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + return S4(); +} + +/** + * Create a promise that resolves after the given time. + */ +export function wait(timeMs: number): Promise { + return new Promise(res => { + setTimeout(res, timeMs); + }); +} + +/** + * Generate a full URL from the given relative URL, using a base + * URL defined in the head of the page. + */ +export function baseUrl(path: string): string { + let targetPath = path; + const baseUrlMeta = document.querySelector('meta[name="base-url"]'); + if (!baseUrlMeta) { + throw new Error('Could not find expected base-url meta tag in document'); + } + + let basePath = baseUrlMeta.getAttribute('content') || ''; + if (basePath[basePath.length - 1] === '/') { + basePath = basePath.slice(0, basePath.length - 1); + } + + if (targetPath[0] === '/') { + targetPath = targetPath.slice(1); + } + + return `${basePath}/${targetPath}`; +} + +/** + * Get the current version of BookStack in use. + * Grabs this from the version query used on app assets. + */ +function getVersion(): string { + const styleLink = document.querySelector('link[href*="/dist/styles.css?version="]'); + if (!styleLink) { + throw new Error('Could not find expected style link in document for version use'); + } + + const href = (styleLink.getAttribute('href') || ''); + return href.split('?version=').pop() || ''; +} + +/** + * Perform a module import, Ensuring the import is fetched with the current + * app version as a cache-breaker. + */ +export function importVersioned(moduleName: string): Promise { + const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`); + return import(importPath); +} \ No newline at end of file diff --git a/resources/js/wysiwyg-tinymce/plugin-drawio.js b/resources/js/wysiwyg-tinymce/plugin-drawio.js index 342cac0af..197c50b0e 100644 --- a/resources/js/wysiwyg-tinymce/plugin-drawio.js +++ b/resources/js/wysiwyg-tinymce/plugin-drawio.js @@ -1,5 +1,5 @@ import * as DrawIO from '../services/drawio.ts'; -import {wait} from '../services/util'; +import {wait} from '../services/util.ts'; let pageEditor = null; let currentNode = null;