From 09c6a3c2405742ea6003e9a8a30e7ad212ab6977 Mon Sep 17 00:00:00 2001 From: Dan Brown <ssddanbrown@googlemail.com> Date: Mon, 14 Nov 2022 23:19:02 +0000 Subject: [PATCH] Started refactor and alignment of component system - Updates old components to newer format, removes legacy component support. - Makes component registration easier and less duplicated. - Adds base component class to extend for better editor support. - Aligns global window exposure usage and aligns with other service names. --- resources/js/app.js | 7 +- resources/js/components/add-remove-rows.js | 10 +- resources/js/components/ajax-delete-row.js | 11 +- resources/js/components/ajax-form.js | 11 +- resources/js/components/attachments-list.js | 5 +- resources/js/components/attachments.js | 11 +- resources/js/components/auto-submit.js | 7 +- resources/js/components/auto-suggest.js | 8 +- resources/js/components/back-to-top.js | 19 +- resources/js/components/component.js | 58 ++++ resources/js/components/dropdown.js | 2 +- resources/js/components/image-manager.js | 4 +- resources/js/components/index.js | 341 ++++---------------- resources/js/components/page-comments.js | 4 +- resources/js/components/page-display.js | 2 +- resources/js/services/components.js | 153 +++++++++ resources/js/services/dom.js | 2 +- resources/js/wysiwyg/plugin-codeeditor.js | 2 +- resources/sass/_components.scss | 37 +++ resources/sass/styles.scss | 37 --- resources/views/layouts/base.blade.php | 2 +- 21 files changed, 355 insertions(+), 378 deletions(-) create mode 100644 resources/js/components/component.js create mode 100644 resources/js/services/components.js diff --git a/resources/js/app.js b/resources/js/app.js index 82748b75e..e49bf5e95 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -27,5 +27,8 @@ window.trans_choice = translator.getPlural.bind(translator); window.trans_plural = translator.parsePlural.bind(translator); // Load Components -import components from "./components" -components(); \ No newline at end of file +import * as components from "./services/components" +import * as componentMap from "./components"; +components.register(componentMap); +window.$components = components; +components.init(); diff --git a/resources/js/components/add-remove-rows.js b/resources/js/components/add-remove-rows.js index 9a5f019c5..19d2249fb 100644 --- a/resources/js/components/add-remove-rows.js +++ b/resources/js/components/add-remove-rows.js @@ -1,13 +1,13 @@ import {onChildEvent} from "../services/dom"; import {uniqueId} from "../services/util"; +import {Component} from "./component"; /** * AddRemoveRows * Allows easy row add/remove controls onto a table. * Needs a model row to use when adding a new row. - * @extends {Component} */ -class AddRemoveRows { +export class AddRemoveRows extends Component { setup() { this.modelRow = this.$refs.model; this.addButton = this.$refs.add; @@ -31,7 +31,7 @@ class AddRemoveRows { clone.classList.remove('hidden'); this.setClonedInputNames(clone); this.modelRow.parentNode.insertBefore(clone, this.modelRow); - window.components.init(clone); + window.$components.init(clone); } /** @@ -49,6 +49,4 @@ class AddRemoveRows { elem.name = elem.name.split('randrowid').join(rowId); } } -} - -export default AddRemoveRows; \ No newline at end of file +} \ No newline at end of file diff --git a/resources/js/components/ajax-delete-row.js b/resources/js/components/ajax-delete-row.js index 2feb3d5ac..f1af7f6cb 100644 --- a/resources/js/components/ajax-delete-row.js +++ b/resources/js/components/ajax-delete-row.js @@ -1,10 +1,7 @@ -/** - * AjaxDelete - * @extends {Component} - */ import {onSelect} from "../services/dom"; +import {Component} from "./component"; -class AjaxDeleteRow { +export class AjaxDeleteRow extends Component { setup() { this.row = this.$el; this.url = this.$opts.url; @@ -27,6 +24,4 @@ class AjaxDeleteRow { this.row.style.pointerEvents = null; }); } -} - -export default AjaxDeleteRow; \ No newline at end of file +} \ No newline at end of file diff --git a/resources/js/components/ajax-form.js b/resources/js/components/ajax-form.js index 91029d042..6f4e5af08 100644 --- a/resources/js/components/ajax-form.js +++ b/resources/js/components/ajax-form.js @@ -1,4 +1,5 @@ import {onEnterPress, onSelect} from "../services/dom"; +import {Component} from "./component"; /** * Ajax Form @@ -8,10 +9,8 @@ import {onEnterPress, onSelect} from "../services/dom"; * * Will handle a real form if that's what the component is added to * otherwise will act as a fake form element. - * - * @extends {Component} */ -class AjaxForm { +export class AjaxForm extends Component { setup() { this.container = this.$el; this.responseContainer = this.container; @@ -72,11 +71,9 @@ class AjaxForm { this.responseContainer.innerHTML = err.data; } - window.components.init(this.responseContainer); + window.$components.init(this.responseContainer); this.responseContainer.style.opacity = null; this.responseContainer.style.pointerEvents = null; } -} - -export default AjaxForm; \ No newline at end of file +} \ No newline at end of file diff --git a/resources/js/components/attachments-list.js b/resources/js/components/attachments-list.js index 34979c2e7..5004f9357 100644 --- a/resources/js/components/attachments-list.js +++ b/resources/js/components/attachments-list.js @@ -1,10 +1,11 @@ +import {Component} from "./component"; + /** * Attachments List * Adds '?open=true' query to file attachment links * when ctrl/cmd is pressed down. - * @extends {Component} */ -class AttachmentsList { +export class AttachmentsList extends Component { setup() { this.container = this.$el; diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js index 6dcfe9f12..a01147aa2 100644 --- a/resources/js/components/attachments.js +++ b/resources/js/components/attachments.js @@ -1,10 +1,7 @@ -/** - * Attachments - * @extends {Component} - */ import {showLoading} from "../services/dom"; +import {Component} from "./component"; -class Attachments { +export class Attachments extends Component { setup() { this.container = this.$el; @@ -49,7 +46,7 @@ class Attachments { this.mainTabs.components.tabs.show('items'); window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => { this.list.innerHTML = resp.data; - window.components.init(this.list); + window.$components.init(this.list); }); } @@ -66,7 +63,7 @@ class Attachments { showLoading(this.editContainer); const resp = await window.$http.get(`/attachments/edit/${id}`); this.editContainer.innerHTML = resp.data; - window.components.init(this.editContainer); + window.$components.init(this.editContainer); } stopEdit() { diff --git a/resources/js/components/auto-submit.js b/resources/js/components/auto-submit.js index 11494ae82..c8726ca7e 100644 --- a/resources/js/components/auto-submit.js +++ b/resources/js/components/auto-submit.js @@ -1,5 +1,6 @@ +import {Component} from "./component"; -class AutoSubmit { +export class AutoSubmit extends Component { setup() { this.form = this.$el; @@ -7,6 +8,4 @@ class AutoSubmit { this.form.submit(); } -} - -export default AutoSubmit; \ No newline at end of file +} \ No newline at end of file diff --git a/resources/js/components/auto-suggest.js b/resources/js/components/auto-suggest.js index 80857cbe5..b4e6c5957 100644 --- a/resources/js/components/auto-suggest.js +++ b/resources/js/components/auto-suggest.js @@ -1,13 +1,13 @@ import {escapeHtml} from "../services/util"; import {onChildEvent} from "../services/dom"; +import {Component} from "./component"; const ajaxCache = {}; /** * AutoSuggest - * @extends {Component} */ -class AutoSuggest { +export class AutoSuggest extends Component { setup() { this.parent = this.$el.parentElement; this.container = this.$el; @@ -148,6 +148,4 @@ class AutoSuggest { this.hideSuggestions(); } } -} - -export default AutoSuggest; \ No newline at end of file +} \ No newline at end of file diff --git a/resources/js/components/back-to-top.js b/resources/js/components/back-to-top.js index a1d87f22e..7a3719493 100644 --- a/resources/js/components/back-to-top.js +++ b/resources/js/components/back-to-top.js @@ -1,34 +1,35 @@ +import {Component} from "./component"; -class BackToTop { +export class BackToTop extends Component { - constructor(elem) { - this.elem = elem; + setup() { + this.button = this.$el; this.targetElem = document.getElementById('header'); this.showing = false; this.breakPoint = 1200; if (document.body.classList.contains('flexbox')) { - this.elem.style.display = 'none'; + this.button.style.display = 'none'; return; } - this.elem.addEventListener('click', this.scrollToTop.bind(this)); + this.button.addEventListener('click', this.scrollToTop.bind(this)); window.addEventListener('scroll', this.onPageScroll.bind(this)); } onPageScroll() { let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0; if (!this.showing && scrollTopPos > this.breakPoint) { - this.elem.style.display = 'block'; + this.button.style.display = 'block'; this.showing = true; setTimeout(() => { - this.elem.style.opacity = 0.4; + this.button.style.opacity = 0.4; }, 1); } else if (this.showing && scrollTopPos < this.breakPoint) { - this.elem.style.opacity = 0; + this.button.style.opacity = 0; this.showing = false; setTimeout(() => { - this.elem.style.display = 'none'; + this.button.style.display = 'none'; }, 500); } } diff --git a/resources/js/components/component.js b/resources/js/components/component.js new file mode 100644 index 000000000..292bbb624 --- /dev/null +++ b/resources/js/components/component.js @@ -0,0 +1,58 @@ +export class Component { + + /** + * The registered name of the component. + * @type {string} + */ + $name = ''; + + /** + * The element that the component is registered upon. + * @type {Element} + */ + $el = null; + + /** + * Mapping of referenced elements within the component. + * @type {Object<string, Element>} + */ + $refs = {}; + + /** + * Mapping of arrays of referenced elements within the component so multiple + * references, sharing the same name, can be fetched. + * @type {Object<string, Element[]>} + */ + $manyRefs = {}; + + /** + * Options passed into this component. + * @type {Object<String, String>} + */ + $opts = {}; + + /** + * Component-specific setup methods. + * Use this to assign local variables and run any initial setup or actions. + */ + setup() { + // + } + + /** + * Emit an event from this component. + * Will be bubbled up from the dom element this is registered on, as a custom event + * with the name `<elementName>-<eventName>`, with the provided data in the event detail. + * @param {String} eventName + * @param {Object} data + */ + $emit(eventName, data = {}) { + data.from = this; + const componentName = this.$name; + const event = new CustomEvent(`${componentName}-${eventName}`, { + bubbles: true, + detail: data + }); + this.$el.dispatchEvent(event); + } +} \ No newline at end of file diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index 781f90860..06a89d08c 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -74,7 +74,7 @@ class DropDown { } hideAll() { - for (let dropdown of window.components.dropdown) { + for (let dropdown of window.$components.get('dropdown')) { dropdown.hide(); } } diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js index 23a6c4cbb..1222096d4 100644 --- a/resources/js/components/image-manager.js +++ b/resources/js/components/image-manager.js @@ -132,7 +132,7 @@ class ImageManager { addReturnedHtmlElementsToList(html) { const el = document.createElement('div'); el.innerHTML = html; - window.components.init(el); + window.$components.init(el); for (const child of [...el.children]) { this.listContainer.appendChild(child); } @@ -207,7 +207,7 @@ class ImageManager { const params = requestDelete ? {delete: true} : {}; const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params); this.formContainer.innerHTML = formHtml; - window.components.init(this.formContainer); + window.$components.init(this.formContainer); } } diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 9f801668e..e3fa2cf78 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -1,282 +1,59 @@ -import addRemoveRows from "./add-remove-rows.js" -import ajaxDeleteRow from "./ajax-delete-row.js" -import ajaxForm from "./ajax-form.js" -import attachments from "./attachments.js" -import attachmentsList from "./attachments-list.js" -import autoSuggest from "./auto-suggest.js" -import autoSubmit from "./auto-submit.js"; -import backToTop from "./back-to-top.js" -import bookSort from "./book-sort.js" -import chapterContents from "./chapter-contents.js" -import codeEditor from "./code-editor.js" -import codeHighlighter from "./code-highlighter.js" -import codeTextarea from "./code-textarea.js" -import collapsible from "./collapsible.js" -import confirmDialog from "./confirm-dialog" -import customCheckbox from "./custom-checkbox.js" -import detailsHighlighter from "./details-highlighter.js" -import dropdown from "./dropdown.js" -import dropdownSearch from "./dropdown-search.js" -import dropzone from "./dropzone.js" -import editorToolbox from "./editor-toolbox.js" -import entityPermissions from "./entity-permissions"; -import entitySearch from "./entity-search.js" -import entitySelector from "./entity-selector.js" -import entitySelectorPopup from "./entity-selector-popup.js" -import eventEmitSelect from "./event-emit-select.js" -import expandToggle from "./expand-toggle.js" -import headerMobileToggle from "./header-mobile-toggle.js" -import homepageControl from "./homepage-control.js" -import imageManager from "./image-manager.js" -import imagePicker from "./image-picker.js" -import listSortControl from "./list-sort-control.js" -import markdownEditor from "./markdown-editor.js" -import newUserPassword from "./new-user-password.js" -import notification from "./notification.js" -import optionalInput from "./optional-input.js" -import pageComments from "./page-comments.js" -import pageDisplay from "./page-display.js" -import pageEditor from "./page-editor.js" -import pagePicker from "./page-picker.js" -import permissionsTable from "./permissions-table.js" -import pointer from "./pointer.js"; -import popup from "./popup.js" -import settingAppColorPicker from "./setting-app-color-picker.js" -import settingColorPicker from "./setting-color-picker.js" -import shelfSort from "./shelf-sort.js" -import shortcuts from "./shortcuts"; -import shortcutInput from "./shortcut-input"; -import sidebar from "./sidebar.js" -import sortableList from "./sortable-list.js" -import submitOnChange from "./submit-on-change.js" -import tabs from "./tabs.js" -import tagManager from "./tag-manager.js" -import templateManager from "./template-manager.js" -import toggleSwitch from "./toggle-switch.js" -import triLayout from "./tri-layout.js" -import userSelect from "./user-select.js" -import webhookEvents from "./webhook-events"; -import wysiwygEditor from "./wysiwyg-editor.js" - -const componentMapping = { - "add-remove-rows": addRemoveRows, - "ajax-delete-row": ajaxDeleteRow, - "ajax-form": ajaxForm, - "attachments": attachments, - "attachments-list": attachmentsList, - "auto-suggest": autoSuggest, - "auto-submit": autoSubmit, - "back-to-top": backToTop, - "book-sort": bookSort, - "chapter-contents": chapterContents, - "code-editor": codeEditor, - "code-highlighter": codeHighlighter, - "code-textarea": codeTextarea, - "collapsible": collapsible, - "confirm-dialog": confirmDialog, - "custom-checkbox": customCheckbox, - "details-highlighter": detailsHighlighter, - "dropdown": dropdown, - "dropdown-search": dropdownSearch, - "dropzone": dropzone, - "editor-toolbox": editorToolbox, - "entity-permissions": entityPermissions, - "entity-search": entitySearch, - "entity-selector": entitySelector, - "entity-selector-popup": entitySelectorPopup, - "event-emit-select": eventEmitSelect, - "expand-toggle": expandToggle, - "header-mobile-toggle": headerMobileToggle, - "homepage-control": homepageControl, - "image-manager": imageManager, - "image-picker": imagePicker, - "list-sort-control": listSortControl, - "markdown-editor": markdownEditor, - "new-user-password": newUserPassword, - "notification": notification, - "optional-input": optionalInput, - "page-comments": pageComments, - "page-display": pageDisplay, - "page-editor": pageEditor, - "page-picker": pagePicker, - "permissions-table": permissionsTable, - "pointer": pointer, - "popup": popup, - "setting-app-color-picker": settingAppColorPicker, - "setting-color-picker": settingColorPicker, - "shelf-sort": shelfSort, - "shortcuts": shortcuts, - "shortcut-input": shortcutInput, - "sidebar": sidebar, - "sortable-list": sortableList, - "submit-on-change": submitOnChange, - "tabs": tabs, - "tag-manager": tagManager, - "template-manager": templateManager, - "toggle-switch": toggleSwitch, - "tri-layout": triLayout, - "user-select": userSelect, - "webhook-events": webhookEvents, - "wysiwyg-editor": wysiwygEditor, -}; - -window.components = {}; - -/** - * Initialize components of the given name within the given element. - * @param {String} componentName - * @param {HTMLElement|Document} parentElement - */ -function searchForComponentInParent(componentName, parentElement) { - const elems = parentElement.querySelectorAll(`[${componentName}]`); - for (let j = 0, jLen = elems.length; j < jLen; j++) { - initComponent(componentName, elems[j]); - } -} - -/** - * Initialize a component instance on the given dom element. - * @param {String} name - * @param {Element} element - */ -function initComponent(name, element) { - const componentModel = componentMapping[name]; - if (componentModel === undefined) return; - - // Create our component instance - let instance; - try { - instance = new componentModel(element); - instance.$el = element; - const allRefs = parseRefs(name, element); - instance.$refs = allRefs.refs; - instance.$manyRefs = allRefs.manyRefs; - instance.$opts = parseOpts(name, element); - instance.$emit = (eventName, data = {}) => { - data.from = instance; - const event = new CustomEvent(`${name}-${eventName}`, { - bubbles: true, - detail: data - }); - instance.$el.dispatchEvent(event); - }; - if (typeof instance.setup === 'function') { - instance.setup(); - } - } catch (e) { - console.error('Failed to create component', e, name, element); - } - - - // Add to global listing - if (typeof window.components[name] === "undefined") { - window.components[name] = []; - } - window.components[name].push(instance); - - // Add to element listing - if (typeof element.components === 'undefined') { - element.components = {}; - } - element.components[name] = instance; -} - -/** - * Parse out the element references within the given element - * for the given component name. - * @param {String} name - * @param {Element} element - */ -function parseRefs(name, element) { - const refs = {}; - const manyRefs = {}; - - const prefix = `${name}@` - const selector = `[refs*="${prefix}"]`; - const refElems = [...element.querySelectorAll(selector)]; - if (element.matches(selector)) { - refElems.push(element); - } - - for (const el of refElems) { - const refNames = el.getAttribute('refs') - .split(' ') - .filter(str => str.startsWith(prefix)) - .map(str => str.replace(prefix, '')) - .map(kebabToCamel); - for (const ref of refNames) { - refs[ref] = el; - if (typeof manyRefs[ref] === 'undefined') { - manyRefs[ref] = []; - } - manyRefs[ref].push(el); - } - } - return {refs, manyRefs}; -} - -/** - * Parse out the element component options. - * @param {String} name - * @param {Element} element - * @return {Object<String, String>} - */ -function parseOpts(name, element) { - const opts = {}; - const prefix = `option:${name}:`; - for (const {name, value} of element.attributes) { - if (name.startsWith(prefix)) { - const optName = name.replace(prefix, ''); - opts[kebabToCamel(optName)] = value || ''; - } - } - return opts; -} - -/** - * Convert a kebab-case string to camelCase - * @param {String} kebab - * @returns {string} - */ -function kebabToCamel(kebab) { - const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1); - const words = kebab.split('-'); - return words[0] + words.slice(1).map(ucFirst).join(''); -} - -/** - * Initialize all components found within the given element. - * @param parentElement - */ -function initAll(parentElement) { - if (typeof parentElement === 'undefined') parentElement = document; - - // Old attribute system - for (const componentName of Object.keys(componentMapping)) { - searchForComponentInParent(componentName, parentElement); - } - - // New component system - const componentElems = parentElement.querySelectorAll(`[component],[components]`); - - for (const el of componentElems) { - const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean); - for (const name of componentNames) { - initComponent(name, el); - } - } -} - -window.components.init = initAll; -window.components.first = (name) => (window.components[name] || [null])[0]; - -export default initAll; - -/** - * @typedef Component - * @property {HTMLElement} $el - * @property {Object<String, HTMLElement>} $refs - * @property {Object<String, HTMLElement[]>} $manyRefs - * @property {Object<String, String>} $opts - * @property {function(string, Object)} $emit - */ \ No newline at end of file +export {AddRemoveRows} from "./add-remove-rows.js" +export {AjaxDeleteRow} from "./ajax-delete-row.js" +export {AjaxForm} from "./ajax-form.js" +export {Attachments} from "./attachments.js" +export {AttachmentsList} from "./attachments-list.js" +export {AutoSuggest} from "./auto-suggest.js" +export {AutoSubmit} from "./auto-submit.js"; +export {BackToTop} from "./back-to-top.js" +// export {BookSort} from "./book-sort.js" +// export {ChapterContents} from "./chapter-contents.js" +// export {CodeEditor} from "./code-editor.js" +// export {CodeHighlighter} from "./code-highlighter.js" +// export {CodeTextarea} from "./code-textarea.js" +// export {Collapsible} from "./collapsible.js" +// export {ConfirmDialog} from "./confirm-dialog" +// export {CustomCheckbox} from "./custom-checkbox.js" +// export {DetailsHighlighter} from "./details-highlighter.js" +// export {Dropdown} from "./dropdown.js" +// export {DropdownSearch} from "./dropdown-search.js" +// export {Dropzone} from "./dropzone.js" +// export {EditorToolbox} from "./editor-toolbox.js" +// export {EntityPermissions} from "./entity-permissions"; +// export {EntitySearch} from "./entity-search.js" +// export {EntitySelector} from "./entity-selector.js" +// export {EntitySelectorPopup} from "./entity-selector-popup.js" +// export {EventEmitSelect} from "./event-emit-select.js" +// export {ExpandToggle} from "./expand-toggle.js" +// export {HeaderMobileToggle} from "./header-mobile-toggle.js" +// export {HomepageControl} from "./homepage-control.js" +// export {ImageManager} from "./image-manager.js" +// export {ImagePicker} from "./image-picker.js" +// export {ListSortControl} from "./list-sort-control.js" +// export {MarkdownEditor} from "./markdown-editor.js" +// export {NewUserPassword} from "./new-user-password.js" +// export {Notification} from "./notification.js" +// export {OptionalInput} from "./optional-input.js" +// export {PageComments} from "./page-comments.js" +// export {PageDisplay} from "./page-display.js" +// export {PageEditor} from "./page-editor.js" +// export {PagePicker} from "./page-picker.js" +// export {PermissionsTable} from "./permissions-table.js" +// export {Pointer} from "./pointer.js"; +// export {Popup} from "./popup.js" +// export {SettingAppColorPicker} from "./setting-app-color-picker.js" +// export {SettingColorPicker} from "./setting-color-picker.js" +// export {ShelfSort} from "./shelf-sort.js" +// export {Shortcuts} from "./shortcuts"; +// export {ShortcutInput} from "./shortcut-input"; +// export {Sidebar} from "./sidebar.js" +// export {SortableList} from "./sortable-list.js" +// export {SubmitOnChange} from "./submit-on-change.js" +// export {Tabs} from "./tabs.js" +// export {TagManager} from "./tag-manager.js" +// export {TemplateManager} from "./template-manager.js" +// export {ToggleSwitch} from "./toggle-switch.js" +// export {TriLayout} from "./tri-layout.js" +// export {UserSelect} from "./user-select.js" +// export {WebhookEvents} from "./webhook-events"; +// export {WysiwygEditor} from "./wysiwyg-editor.js" \ No newline at end of file diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index c86eead1b..0264e24c6 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -90,7 +90,7 @@ class PageComments { newComment.innerHTML = resp.data; this.editingComment.innerHTML = newComment.children[0].innerHTML; window.$events.success(this.updatedText); - window.components.init(this.editingComment); + window.$components.init(this.editingComment); this.closeUpdateForm(); this.editingComment = null; }).catch(window.$events.showValidationErrors).then(() => { @@ -123,7 +123,7 @@ class PageComments { newComment.innerHTML = resp.data; let newElem = newComment.children[0]; this.container.appendChild(newElem); - window.components.init(newElem); + window.$components.init(newElem); window.$events.success(this.createdText); this.resetForm(); this.updateCount(); diff --git a/resources/js/components/page-display.js b/resources/js/components/page-display.js index b4f1cca4f..f8377130c 100644 --- a/resources/js/components/page-display.js +++ b/resources/js/components/page-display.js @@ -22,7 +22,7 @@ class PageDisplay { if (sidebarPageNav) { DOM.onChildEvent(sidebarPageNav, 'a', 'click', (event, child) => { event.preventDefault(); - window.components['tri-layout'][0].showContent(); + window.$components.first('tri-layout').showContent(); const contentId = child.getAttribute('href').substr(1); this.goToText(contentId); window.history.pushState(null, null, '#' + contentId); diff --git a/resources/js/services/components.js b/resources/js/services/components.js new file mode 100644 index 000000000..006f16e86 --- /dev/null +++ b/resources/js/services/components.js @@ -0,0 +1,153 @@ +const components = {}; +const componentMap = {}; + +/** + * Initialize a component instance on the given dom element. + * @param {String} name + * @param {Element} element + */ +function initComponent(name, element) { + /** @type {Function<Component>|undefined} **/ + const componentModel = componentMap[name]; + if (componentModel === undefined) return; + + // Create our component instance + /** @type {Component} **/ + let instance; + try { + instance = new componentModel(); + instance.$name = name; + instance.$el = element; + const allRefs = parseRefs(name, element); + instance.$refs = allRefs.refs; + instance.$manyRefs = allRefs.manyRefs; + instance.$opts = parseOpts(name, element); + instance.setup(); + } catch (e) { + console.error('Failed to create component', e, name, element); + } + + // Add to global listing + if (typeof components[name] === "undefined") { + components[name] = []; + } + components[name].push(instance); + + // Add to element listing + if (typeof element.components === 'undefined') { + element.components = {}; + } + element.components[name] = instance; +} + +/** + * Parse out the element references within the given element + * for the given component name. + * @param {String} name + * @param {Element} element + */ +function parseRefs(name, element) { + const refs = {}; + const manyRefs = {}; + + const prefix = `${name}@` + const selector = `[refs*="${prefix}"]`; + const refElems = [...element.querySelectorAll(selector)]; + if (element.matches(selector)) { + refElems.push(element); + } + + for (const el of refElems) { + const refNames = el.getAttribute('refs') + .split(' ') + .filter(str => str.startsWith(prefix)) + .map(str => str.replace(prefix, '')) + .map(kebabToCamel); + for (const ref of refNames) { + refs[ref] = el; + if (typeof manyRefs[ref] === 'undefined') { + manyRefs[ref] = []; + } + manyRefs[ref].push(el); + } + } + return {refs, manyRefs}; +} + +/** + * Parse out the element component options. + * @param {String} name + * @param {Element} element + * @return {Object<String, String>} + */ +function parseOpts(name, element) { + const opts = {}; + const prefix = `option:${name}:`; + for (const {name, value} of element.attributes) { + if (name.startsWith(prefix)) { + const optName = name.replace(prefix, ''); + opts[kebabToCamel(optName)] = value || ''; + } + } + return opts; +} + +/** + * Convert a kebab-case string to camelCase + * @param {String} kebab + * @returns {string} + */ +function kebabToCamel(kebab) { + const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1); + const words = kebab.split('-'); + return words[0] + words.slice(1).map(ucFirst).join(''); +} + +/** + * Initialize all components found within the given element. + * @param {Element|Document} parentElement + */ +export function init(parentElement = document) { + const componentElems = parentElement.querySelectorAll(`[component],[components]`); + + for (const el of componentElems) { + const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean); + for (const name of componentNames) { + initComponent(name, el); + } + } +} + +/** + * Register the given component mapping into the component system. + * @param {Object<String, ObjectConstructor<Component>>} mapping + */ +export function register(mapping) { + const keys = Object.keys(mapping); + for (const key of keys) { + componentMap[camelToKebab(key)] = mapping[key]; + } + console.log(componentMap); +} + +/** + * Get the first component of the given name. + * @param {String} name + * @returns {Component|null} + */ +export function first(name) { + return (components[name] || [null])[0]; +} + +/** + * Get all the components of the given name. + * @param {String} name + * @returns {Component[]} + */ +export function get(name = '') { + return components[name] || []; +} + +function camelToKebab(camelStr) { + return camelStr.replace(/[A-Z]/g, (str, offset) => (offset > 0 ? '-' : '') + str.toLowerCase()); +} \ No newline at end of file diff --git a/resources/js/services/dom.js b/resources/js/services/dom.js index eb5f6a853..882d5228d 100644 --- a/resources/js/services/dom.js +++ b/resources/js/services/dom.js @@ -128,6 +128,6 @@ export function removeLoading(element) { export function htmlToDom(html) { const wrap = document.createElement('div'); wrap.innerHTML = html; - window.components.init(wrap); + window.$components.init(wrap); return wrap.children[0]; } \ No newline at end of file diff --git a/resources/js/wysiwyg/plugin-codeeditor.js b/resources/js/wysiwyg/plugin-codeeditor.js index 66441c87e..cd0078b1d 100644 --- a/resources/js/wysiwyg/plugin-codeeditor.js +++ b/resources/js/wysiwyg/plugin-codeeditor.js @@ -9,7 +9,7 @@ function elemIsCodeBlock(elem) { * @param {function(string, string)} callback (Receives (code: string,language: string) */ function showPopup(editor, code, language, callback) { - window.components.first('code-editor').open(code, language, (newCode, newLang) => { + window.$components.first('code-editor').open(code, language, (newCode, newLang) => { callback(newCode, newLang) editor.focus() }); diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 66d76aaa2..db9248719 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1010,4 +1010,41 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border: 1px solid #b4b4b4; box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset; color: #333; +} + +// Back to top link +$btt-size: 40px; +.back-to-top { + background-color: var(--color-primary); + position: fixed; + bottom: $-m; + right: $-l; + padding: 5px 7px; + cursor: pointer; + color: #FFF; + fill: #FFF; + svg { + width: math.div($btt-size, 1.5); + height: math.div($btt-size, 1.5); + margin-inline-end: 4px; + } + width: $btt-size; + height: $btt-size; + border-radius: $btt-size; + transition: all ease-in-out 180ms; + opacity: 0; + z-index: 999; + overflow: hidden; + &:hover { + width: $btt-size*3.4; + opacity: 1 !important; + } + .inner { + width: $btt-size*3.4; + } + span { + position: relative; + vertical-align: top; + line-height: 2; + } } \ No newline at end of file diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 5e31dbdfb..23959d1f8 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -100,43 +100,6 @@ $loadingSize: 10px; } } -// Back to top link -$btt-size: 40px; -[back-to-top] { - background-color: var(--color-primary); - position: fixed; - bottom: $-m; - right: $-l; - padding: 5px 7px; - cursor: pointer; - color: #FFF; - fill: #FFF; - svg { - width: math.div($btt-size, 1.5); - height: math.div($btt-size, 1.5); - margin-inline-end: 4px; - } - width: $btt-size; - height: $btt-size; - border-radius: $btt-size; - transition: all ease-in-out 180ms; - opacity: 0; - z-index: 999; - overflow: hidden; - &:hover { - width: $btt-size*3.4; - opacity: 1 !important; - } - .inner { - width: $btt-size*3.4; - } - span { - position: relative; - vertical-align: top; - line-height: 2; - } -} - .skip-to-content-link { position: fixed; top: -52px; diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 2f649423d..76d220952 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -49,7 +49,7 @@ @include('common.footer') - <div back-to-top class="primary-background print-hidden"> + <div component="back-to-top" class="back-to-top print-hidden"> <div class="inner"> @icon('chevron-up') <span>{{ trans('common.back_to_top') }}</span> </div>