From 76d02cd4725633c87c7885a319411a0671e814ae Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 24 Jun 2020 20:38:08 +0100 Subject: [PATCH] Started attempt at formalising component system used in BookStack Added a document to try to define things. Updated the loading so components are registed dynamically. Added some standardised ways to reference other elems & define options --- dev/docs/components.md | 56 +++++ resources/js/components/dropdown.js | 12 +- resources/js/components/index.js | 204 ++++++++++-------- resources/views/comments/comment.blade.php | 6 +- resources/views/common/header.blade.php | 6 +- resources/views/pages/form.blade.php | 15 +- resources/views/pages/revisions.blade.php | 12 +- .../partials/breadcrumb-listing.blade.php | 6 +- .../partials/entity-export-menu.blade.php | 6 +- resources/views/partials/sort.blade.php | 6 +- 10 files changed, 213 insertions(+), 116 deletions(-) create mode 100644 dev/docs/components.md diff --git a/dev/docs/components.md b/dev/docs/components.md new file mode 100644 index 000000000..2f59ae344 --- /dev/null +++ b/dev/docs/components.md @@ -0,0 +1,56 @@ +# JavaScript Components + +This document details the format for JavaScript components in BookStack. + +#### Defining a Component in JS + +```js +class Dropdown { + setup() { + } +} +``` + +#### Using a Component in HTML + +A component is used like so: + +```html +
+ + + +
+``` + +The names will be parsed and new component instance will be created if a matching name is found in the `components/index.js` componentMapping. + +#### Element References + +Within a component you'll often need to refer to other element instances. This can be done like so: + +```html +
+ View more +
+``` + +You can then access the span element as `this.$refs.toggle` in your component. + +#### Component Options + +```html +
+
+``` + +Will result with `this.$opts` being: + +```json +{ + "delay": "500", + "show": "" +} +``` \ No newline at end of file diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index 367c956ce..7b1ce3055 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -3,14 +3,16 @@ import {onSelect} from "../services/dom"; /** * Dropdown * Provides some simple logic to create simple dropdown menus. + * @extends {Component} */ class DropDown { - constructor(elem) { - this.container = elem; - this.menu = elem.querySelector('.dropdown-menu, [dropdown-menu]'); - this.moveMenu = elem.hasAttribute('dropdown-move-menu'); - this.toggle = elem.querySelector('[dropdown-toggle]'); + setup() { + this.container = this.$el; + this.menu = this.$refs.menu; + this.toggle = this.$refs.toggle; + this.moveMenu = this.$opts.moveMenu; + this.direction = (document.dir === 'rtl') ? 'right' : 'left'; this.body = document.body; this.showing = false; diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 112827330..da194e438 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -1,109 +1,145 @@ -import dropdown from "./dropdown"; -import overlay from "./overlay"; -import backToTop from "./back-to-top"; -import notification from "./notification"; -import chapterToggle from "./chapter-toggle"; -import expandToggle from "./expand-toggle"; -import entitySelectorPopup from "./entity-selector-popup"; -import entitySelector from "./entity-selector"; -import sidebar from "./sidebar"; -import pagePicker from "./page-picker"; -import pageComments from "./page-comments"; -import wysiwygEditor from "./wysiwyg-editor"; -import markdownEditor from "./markdown-editor"; -import editorToolbox from "./editor-toolbox"; -import imagePicker from "./image-picker"; -import collapsible from "./collapsible"; -import toggleSwitch from "./toggle-switch"; -import pageDisplay from "./page-display"; -import shelfSort from "./shelf-sort"; -import homepageControl from "./homepage-control"; -import headerMobileToggle from "./header-mobile-toggle"; -import listSortControl from "./list-sort-control"; -import triLayout from "./tri-layout"; -import breadcrumbListing from "./breadcrumb-listing"; -import permissionsTable from "./permissions-table"; -import customCheckbox from "./custom-checkbox"; -import bookSort from "./book-sort"; -import settingAppColorPicker from "./setting-app-color-picker"; -import settingColorPicker from "./setting-color-picker"; -import entityPermissionsEditor from "./entity-permissions-editor"; -import templateManager from "./template-manager"; -import newUserPassword from "./new-user-password"; -import detailsHighlighter from "./details-highlighter"; -import codeHighlighter from "./code-highlighter"; +const componentMapping = {}; -const componentMapping = { - 'dropdown': dropdown, - 'overlay': overlay, - 'back-to-top': backToTop, - 'notification': notification, - 'chapter-toggle': chapterToggle, - 'expand-toggle': expandToggle, - 'entity-selector-popup': entitySelectorPopup, - 'entity-selector': entitySelector, - 'sidebar': sidebar, - 'page-picker': pagePicker, - 'page-comments': pageComments, - 'wysiwyg-editor': wysiwygEditor, - 'markdown-editor': markdownEditor, - 'editor-toolbox': editorToolbox, - 'image-picker': imagePicker, - 'collapsible': collapsible, - 'toggle-switch': toggleSwitch, - 'page-display': pageDisplay, - 'shelf-sort': shelfSort, - 'homepage-control': homepageControl, - 'header-mobile-toggle': headerMobileToggle, - 'list-sort-control': listSortControl, - 'tri-layout': triLayout, - 'breadcrumb-listing': breadcrumbListing, - 'permissions-table': permissionsTable, - 'custom-checkbox': customCheckbox, - 'book-sort': bookSort, - 'setting-app-color-picker': settingAppColorPicker, - 'setting-color-picker': settingColorPicker, - 'entity-permissions-editor': entityPermissionsEditor, - 'template-manager': templateManager, - 'new-user-password': newUserPassword, - 'details-highlighter': detailsHighlighter, - 'code-highlighter': codeHighlighter, -}; +const definitionFiles = require.context('./', false, /\.js$/); +for (const fileName of definitionFiles.keys()) { + const name = fileName.replace('./', '').split('.')[0]; + if (name !== 'index') { + componentMapping[name] = definitionFiles(fileName).default; + } +} window.components = {}; -const componentNames = Object.keys(componentMapping); - /** * Initialize components of the given name within the given element. * @param {String} componentName * @param {HTMLElement|Document} parentElement */ -function initComponent(componentName, parentElement) { - let elems = parentElement.querySelectorAll(`[${componentName}]`); - if (elems.length === 0) return; - - let component = componentMapping[componentName]; - if (typeof window.components[componentName] === "undefined") window.components[componentName] = []; +function searchForComponentInParent(componentName, parentElement) { + const elems = parentElement.querySelectorAll(`[${componentName}]`); for (let j = 0, jLen = elems.length; j < jLen; j++) { - let instance = new component(elems[j]); - if (typeof elems[j].components === 'undefined') elems[j].components = {}; - elems[j].components[componentName] = instance; - window.components[componentName].push(instance); + 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; + instance.$refs = parseRefs(name, element); + instance.$opts = parseOpts(name, element); + 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 prefix = `${name}@` + const refElems = element.querySelectorAll(`[refs*="${prefix}"]`); + for (const el of refElems) { + const refNames = el.getAttribute('refs') + .split(' ') + .filter(str => str.startsWith(prefix)) + .map(str => str.replace(prefix, '')); + for (const ref of refNames) { + refs[ref] = el; + } + } + return refs; +} + +/** + * Parse out the element component options. + * @param {String} name + * @param {Element} element + * @return {Object} + */ +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; - for (let i = 0, len = componentNames.length; i < len; i++) { - initComponent(componentNames[i], parentElement); + + // 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; export default initAll; + +/** + * @typedef Component + * @property {HTMLElement} $el + * @property {Object} $refs + * @property {Object} $opts + */ \ No newline at end of file diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index ea96a9250..322477ebd 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -27,9 +27,9 @@ @endif @if(userCan('comment-delete', $comment)) -