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))
-