Upgraded app to Laravel 5.7

This commit is contained in:
Dan Brown
2019-09-06 23:36:16 +01:00
parent 213e9d2941
commit 6917ea088f
179 changed files with 829 additions and 115 deletions

View File

@ -0,0 +1,59 @@
class BackToTop {
constructor(elem) {
this.elem = elem;
this.targetElem = document.getElementById('header');
this.showing = false;
this.breakPoint = 1200;
if (document.body.classList.contains('flexbox')) {
this.elem.style.display = 'none';
return;
}
this.elem.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.showing = true;
setTimeout(() => {
this.elem.style.opacity = 0.4;
}, 1);
} else if (this.showing && scrollTopPos < this.breakPoint) {
this.elem.style.opacity = 0;
this.showing = false;
setTimeout(() => {
this.elem.style.display = 'none';
}, 500);
}
}
scrollToTop() {
let targetTop = this.targetElem.getBoundingClientRect().top;
let scrollElem = document.documentElement.scrollTop ? document.documentElement : document.body;
let duration = 300;
let start = Date.now();
let scrollStart = this.targetElem.getBoundingClientRect().top;
function setPos() {
let percentComplete = (1-((Date.now() - start) / duration));
let target = Math.abs(percentComplete * scrollStart);
if (percentComplete > 0) {
scrollElem.scrollTop = target;
requestAnimationFrame(setPos.bind(this));
} else {
scrollElem.scrollTop = targetTop;
}
}
requestAnimationFrame(setPos.bind(this));
}
}
export default BackToTop;

View File

@ -0,0 +1,204 @@
import Sortable from "sortablejs";
// Auto sort control
const sortOperations = {
name: function(a, b) {
const aName = a.getAttribute('data-name').trim().toLowerCase();
const bName = b.getAttribute('data-name').trim().toLowerCase();
return aName.localeCompare(bName);
},
created: function(a, b) {
const aTime = Number(a.getAttribute('data-created'));
const bTime = Number(b.getAttribute('data-created'));
return bTime - aTime;
},
updated: function(a, b) {
const aTime = Number(a.getAttribute('data-updated'));
const bTime = Number(b.getAttribute('data-updated'));
return bTime - aTime;
},
chaptersFirst: function(a, b) {
const aType = a.getAttribute('data-type');
const bType = b.getAttribute('data-type');
if (aType === bType) {
return 0;
}
return (aType === 'chapter' ? -1 : 1);
},
chaptersLast: function(a, b) {
const aType = a.getAttribute('data-type');
const bType = b.getAttribute('data-type');
if (aType === bType) {
return 0;
}
return (aType === 'chapter' ? 1 : -1);
},
};
class BookSort {
constructor(elem) {
this.elem = elem;
this.sortContainer = elem.querySelector('[book-sort-boxes]');
this.input = elem.querySelector('[book-sort-input]');
const initialSortBox = elem.querySelector('.sort-box');
this.setupBookSortable(initialSortBox);
this.setupSortPresets();
window.$events.listen('entity-select-confirm', this.bookSelect.bind(this));
}
/**
* Setup the handlers for the preset sort type buttons.
*/
setupSortPresets() {
let lastSort = '';
let reverse = false;
const reversibleTypes = ['name', 'created', 'updated'];
this.sortContainer.addEventListener('click', event => {
const sortButton = event.target.closest('.sort-box-options [data-sort]');
if (!sortButton) return;
event.preventDefault();
const sortLists = sortButton.closest('.sort-box').querySelectorAll('ul');
const sort = sortButton.getAttribute('data-sort');
reverse = (lastSort === sort) ? !reverse : false;
let sortFunction = sortOperations[sort];
if (reverse && reversibleTypes.includes(sort)) {
sortFunction = function(a, b) {
return 0 - sortOperations[sort](a, b)
};
}
for (let list of sortLists) {
const directItems = Array.from(list.children).filter(child => child.matches('li'));
directItems.sort(sortFunction).forEach(sortedItem => {
list.appendChild(sortedItem);
});
}
lastSort = sort;
this.updateMapInput();
});
}
/**
* Handle book selection from the entity selector.
* @param {Object} entityInfo
*/
bookSelect(entityInfo) {
const alreadyAdded = this.elem.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
if (alreadyAdded) return;
const entitySortItemUrl = entityInfo.link + '/sort-item';
window.$http.get(entitySortItemUrl).then(resp => {
const wrap = document.createElement('div');
wrap.innerHTML = resp.data;
const newBookContainer = wrap.children[0];
this.sortContainer.append(newBookContainer);
this.setupBookSortable(newBookContainer);
});
}
/**
* Setup the given book container element to have sortable items.
* @param {Element} bookContainer
*/
setupBookSortable(bookContainer) {
const sortElems = [bookContainer.querySelector('.sort-list')];
sortElems.push(...bookContainer.querySelectorAll('.entity-list-item + ul'));
const bookGroupConfig = {
name: 'book',
pull: ['book', 'chapter'],
put: ['book', 'chapter'],
};
const chapterGroupConfig = {
name: 'chapter',
pull: ['book', 'chapter'],
put: function(toList, fromList, draggedElem) {
return draggedElem.getAttribute('data-type') === 'page';
}
};
for (let sortElem of sortElems) {
new Sortable(sortElem, {
group: sortElem.classList.contains('sort-list') ? bookGroupConfig : chapterGroupConfig,
animation: 150,
fallbackOnBody: true,
swapThreshold: 0.65,
onSort: this.updateMapInput.bind(this),
dragClass: 'bg-white',
ghostClass: 'primary-background-light',
});
}
}
/**
* Update the input with our sort data.
*/
updateMapInput() {
const pageMap = this.buildEntityMap();
this.input.value = JSON.stringify(pageMap);
}
/**
* Build up a mapping of entities with their ordering and nesting.
* @returns {Array}
*/
buildEntityMap() {
const entityMap = [];
const lists = this.elem.querySelectorAll('.sort-list');
for (let list of lists) {
const bookId = list.closest('[data-type="book"]').getAttribute('data-id');
const directChildren = Array.from(list.children)
.filter(elem => elem.matches('[data-type="page"], [data-type="chapter"]'));
for (let i = 0; i < directChildren.length; i++) {
this.addBookChildToMap(directChildren[i], i, bookId, entityMap);
}
}
return entityMap;
}
/**
* Parse a sort item and add it to a data-map array.
* Parses sub0items if existing also.
* @param {Element} childElem
* @param {Number} index
* @param {Number} bookId
* @param {Array} entityMap
*/
addBookChildToMap(childElem, index, bookId, entityMap) {
const type = childElem.getAttribute('data-type');
const parentChapter = false;
const childId = childElem.getAttribute('data-id');
entityMap.push({
id: childId,
sort: index,
parentChapter: parentChapter,
type: type,
book: bookId
});
const subPages = childElem.querySelectorAll('[data-type="page"]');
for (let i = 0; i < subPages.length; i++) {
entityMap.push({
id: subPages[i].getAttribute('data-id'),
sort: i,
parentChapter: childId,
type: 'page',
book: bookId
});
}
}
}
export default BookSort;

View File

@ -0,0 +1,58 @@
class BreadcrumbListing {
constructor(elem) {
this.elem = elem;
this.searchInput = elem.querySelector('input');
this.loadingElem = elem.querySelector('.loading-container');
this.entityListElem = elem.querySelector('.breadcrumb-listing-entity-list');
// this.loadingElem.style.display = 'none';
const entityDescriptor = elem.getAttribute('breadcrumb-listing').split(':');
this.entityType = entityDescriptor[0];
this.entityId = Number(entityDescriptor[1]);
this.elem.addEventListener('show', this.onShow.bind(this));
this.searchInput.addEventListener('input', this.onSearch.bind(this));
}
onShow() {
this.loadEntityView();
}
onSearch() {
const input = this.searchInput.value.toLowerCase().trim();
const listItems = this.entityListElem.querySelectorAll('.entity-list-item');
for (let listItem of listItems) {
const match = !input || listItem.textContent.toLowerCase().includes(input);
listItem.style.display = match ? 'flex' : 'none';
listItem.classList.toggle('hidden', !match);
}
}
loadEntityView() {
this.toggleLoading(true);
const params = {
'entity_id': this.entityId,
'entity_type': this.entityType,
};
window.$http.get('/search/entity/siblings', params).then(resp => {
this.entityListElem.innerHTML = resp.data;
}).catch(err => {
console.error(err);
}).then(() => {
this.toggleLoading(false);
this.onSearch();
});
}
toggleLoading(show = false) {
this.loadingElem.style.display = show ? 'block' : 'none';
}
}
export default BreadcrumbListing;

View File

@ -0,0 +1,33 @@
import {slideUp, slideDown} from "../services/animations";
class ChapterToggle {
constructor(elem) {
this.elem = elem;
this.isOpen = elem.classList.contains('open');
elem.addEventListener('click', this.click.bind(this));
}
open() {
const list = this.elem.parentNode.querySelector('.inset-list');
this.elem.classList.add('open');
this.elem.setAttribute('aria-expanded', 'true');
slideDown(list, 240);
}
close() {
const list = this.elem.parentNode.querySelector('.inset-list');
this.elem.classList.remove('open');
this.elem.setAttribute('aria-expanded', 'false');
slideUp(list, 240);
}
click(event) {
event.preventDefault();
this.isOpen ? this.close() : this.open();
this.isOpen = !this.isOpen;
}
}
export default ChapterToggle;

View File

@ -0,0 +1,41 @@
import {slideDown, slideUp} from "../services/animations";
/**
* Collapsible
* Provides some simple logic to allow collapsible sections.
*/
class Collapsible {
constructor(elem) {
this.elem = elem;
this.trigger = elem.querySelector('[collapsible-trigger]');
this.content = elem.querySelector('[collapsible-content]');
if (!this.trigger) return;
this.trigger.addEventListener('click', this.toggle.bind(this));
}
open() {
this.elem.classList.add('open');
this.trigger.setAttribute('aria-expanded', 'true');
slideDown(this.content, 300);
}
close() {
this.elem.classList.remove('open');
this.trigger.setAttribute('aria-expanded', 'false');
slideUp(this.content, 300);
}
toggle() {
if (this.elem.classList.contains('open')) {
this.close();
} else {
this.open();
}
}
}
export default Collapsible;

View File

@ -0,0 +1,34 @@
class CustomCheckbox {
constructor(elem) {
this.elem = elem;
this.checkbox = elem.querySelector('input[type=checkbox]');
this.display = elem.querySelector('[role="checkbox"]');
this.checkbox.addEventListener('change', this.stateChange.bind(this));
this.elem.addEventListener('keydown', this.onKeyDown.bind(this));
}
onKeyDown(event) {
const isEnterOrPress = event.keyCode === 32 || event.keyCode === 13;
if (isEnterOrPress) {
event.preventDefault();
this.toggle();
}
}
toggle() {
this.checkbox.checked = !this.checkbox.checked;
this.checkbox.dispatchEvent(new Event('change'));
this.stateChange();
}
stateChange() {
const checked = this.checkbox.checked ? 'true' : 'false';
this.display.setAttribute('aria-checked', checked);
}
}
export default CustomCheckbox;

View File

@ -0,0 +1,152 @@
import {onSelect} from "../services/dom";
/**
* Dropdown
* Provides some simple logic to create simple dropdown menus.
*/
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]');
this.body = document.body;
this.showing = false;
this.setupListeners();
}
show(event = null) {
this.hideAll();
this.menu.style.display = 'block';
this.menu.classList.add('anim', 'menuIn');
this.toggle.setAttribute('aria-expanded', 'true');
if (this.moveMenu) {
// Move to body to prevent being trapped within scrollable sections
this.rect = this.menu.getBoundingClientRect();
this.body.appendChild(this.menu);
this.menu.style.position = 'fixed';
this.menu.style.left = `${this.rect.left}px`;
this.menu.style.top = `${this.rect.top}px`;
this.menu.style.width = `${this.rect.width}px`;
}
// Set listener to hide on mouse leave or window click
this.menu.addEventListener('mouseleave', this.hide.bind(this));
window.addEventListener('click', event => {
if (!this.menu.contains(event.target)) {
this.hide();
}
});
// Focus on first input if existing
const input = this.menu.querySelector('input');
if (input !== null) input.focus();
this.showing = true;
const showEvent = new Event('show');
this.container.dispatchEvent(showEvent);
if (event) {
event.stopPropagation();
}
}
hideAll() {
for (let dropdown of window.components.dropdown) {
dropdown.hide();
}
}
hide() {
this.menu.style.display = 'none';
this.menu.classList.remove('anim', 'menuIn');
this.toggle.setAttribute('aria-expanded', 'false');
if (this.moveMenu) {
this.menu.style.position = '';
this.menu.style.left = '';
this.menu.style.top = '';
this.menu.style.width = '';
this.container.appendChild(this.menu);
}
this.showing = false;
}
getFocusable() {
return Array.from(this.menu.querySelectorAll('[tabindex],[href],button,input:not([type=hidden])'));
}
focusNext() {
const focusable = this.getFocusable();
const currentIndex = focusable.indexOf(document.activeElement);
let newIndex = currentIndex + 1;
if (newIndex >= focusable.length) {
newIndex = 0;
}
focusable[newIndex].focus();
}
focusPrevious() {
const focusable = this.getFocusable();
const currentIndex = focusable.indexOf(document.activeElement);
let newIndex = currentIndex - 1;
if (newIndex < 0) {
newIndex = focusable.length - 1;
}
focusable[newIndex].focus();
}
setupListeners() {
// Hide menu on option click
this.container.addEventListener('click', event => {
const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
if (possibleChildren.includes(event.target)) {
this.hide();
}
});
onSelect(this.toggle, event => {
event.stopPropagation();
this.show(event);
if (event instanceof KeyboardEvent) {
this.focusNext();
}
});
// Keyboard navigation
const keyboardNavigation = event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
this.focusNext();
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
this.focusPrevious();
event.preventDefault();
} else if (event.key === 'Escape') {
this.hide();
this.toggle.focus();
event.stopPropagation();
}
};
this.container.addEventListener('keydown', keyboardNavigation);
if (this.moveMenu) {
this.menu.addEventListener('keydown', keyboardNavigation);
}
// Hide menu on enter press or escape
this.menu.addEventListener('keydown ', event => {
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
this.hide();
}
});
}
}
export default DropDown;

View File

@ -0,0 +1,49 @@
class EditorToolbox {
constructor(elem) {
// Elements
this.elem = elem;
this.buttons = elem.querySelectorAll('[toolbox-tab-button]');
this.contentElements = elem.querySelectorAll('[toolbox-tab-content]');
this.toggleButton = elem.querySelector('[toolbox-toggle]');
// Toolbox toggle button click
this.toggleButton.addEventListener('click', this.toggle.bind(this));
// Tab button click
this.elem.addEventListener('click', event => {
let button = event.target.closest('[toolbox-tab-button]');
if (button === null) return;
let name = button.getAttribute('toolbox-tab-button');
this.setActiveTab(name, true);
});
// Set the first tab as active on load
this.setActiveTab(this.contentElements[0].getAttribute('toolbox-tab-content'));
}
toggle() {
this.elem.classList.toggle('open');
const expanded = this.elem.classList.contains('open') ? 'true' : 'false';
this.toggleButton.setAttribute('aria-expanded', expanded);
}
setActiveTab(tabName, openToolbox = false) {
// Set button visibility
for (let i = 0, len = this.buttons.length; i < len; i++) {
this.buttons[i].classList.remove('active');
let bName = this.buttons[i].getAttribute('toolbox-tab-button');
if (bName === tabName) this.buttons[i].classList.add('active');
}
// Set content visibility
for (let i = 0, len = this.contentElements.length; i < len; i++) {
this.contentElements[i].style.display = 'none';
let cName = this.contentElements[i].getAttribute('toolbox-tab-content');
if (cName === tabName) this.contentElements[i].style.display = 'block';
}
if (openToolbox) this.elem.classList.add('open');
}
}
export default EditorToolbox;

View File

@ -0,0 +1,20 @@
class EntityPermissionsEditor {
constructor(elem) {
this.permissionsTable = elem.querySelector('[permissions-table]');
// Handle toggle all event
this.restrictedCheckbox = elem.querySelector('[name=restricted]');
this.restrictedCheckbox.addEventListener('change', this.updateTableVisibility.bind(this));
}
updateTableVisibility() {
this.permissionsTable.style.display =
this.restrictedCheckbox.checked
? null
: 'none';
}
}
export default EntityPermissionsEditor;

View File

@ -0,0 +1,47 @@
class EntitySelectorPopup {
constructor(elem) {
this.elem = elem;
window.EntitySelectorPopup = this;
this.callback = null;
this.selection = null;
this.selectButton = elem.querySelector('.entity-link-selector-confirm');
this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this));
window.$events.listen('entity-select-change', this.onSelectionChange.bind(this));
window.$events.listen('entity-select-confirm', this.onSelectionConfirm.bind(this));
}
show(callback) {
this.callback = callback;
this.elem.components.overlay.show();
}
hide() {
this.elem.components.overlay.hide();
}
onSelectButtonClick() {
this.hide();
if (this.selection !== null && this.callback) this.callback(this.selection);
}
onSelectionConfirm(entity) {
this.hide();
if (this.callback && entity) this.callback(entity);
}
onSelectionChange(entity) {
this.selection = entity;
if (entity === null) {
this.selectButton.setAttribute('disabled', 'true');
} else {
this.selectButton.removeAttribute('disabled');
}
}
}
export default EntitySelectorPopup;

View File

@ -0,0 +1,135 @@
class EntitySelector {
constructor(elem) {
this.elem = elem;
this.search = '';
this.lastClick = 0;
this.selectedItemData = null;
const entityTypes = elem.hasAttribute('entity-types') ? elem.getAttribute('entity-types') : 'page,book,chapter';
const entityPermission = elem.hasAttribute('entity-permission') ? elem.getAttribute('entity-permission') : 'view';
this.searchUrl = window.baseUrl(`/ajax/search/entities?types=${encodeURIComponent(entityTypes)}&permission=${encodeURIComponent(entityPermission)}`);
this.input = elem.querySelector('[entity-selector-input]');
this.searchInput = elem.querySelector('[entity-selector-search]');
this.loading = elem.querySelector('[entity-selector-loading]');
this.resultsContainer = elem.querySelector('[entity-selector-results]');
this.addButton = elem.querySelector('[entity-selector-add-button]');
this.elem.addEventListener('click', this.onClick.bind(this));
let lastSearch = 0;
this.searchInput.addEventListener('input', event => {
lastSearch = Date.now();
this.showLoading();
setTimeout(() => {
if (Date.now() - lastSearch < 199) return;
this.searchEntities(this.searchInput.value);
}, 200);
});
this.searchInput.addEventListener('keydown', event => {
if (event.keyCode === 13) event.preventDefault();
});
if (this.addButton) {
this.addButton.addEventListener('click', event => {
if (this.selectedItemData) {
this.confirmSelection(this.selectedItemData);
this.unselectAll();
}
});
}
this.showLoading();
this.initialLoad();
}
showLoading() {
this.loading.style.display = 'block';
this.resultsContainer.style.display = 'none';
}
hideLoading() {
this.loading.style.display = 'none';
this.resultsContainer.style.display = 'block';
}
initialLoad() {
window.$http.get(this.searchUrl).then(resp => {
this.resultsContainer.innerHTML = resp.data;
this.hideLoading();
})
}
searchEntities(searchTerm) {
this.input.value = '';
let url = `${this.searchUrl}&term=${encodeURIComponent(searchTerm)}`;
window.$http.get(url).then(resp => {
this.resultsContainer.innerHTML = resp.data;
this.hideLoading();
});
}
isDoubleClick() {
let now = Date.now();
let answer = now - this.lastClick < 300;
this.lastClick = now;
return answer;
}
onClick(event) {
const listItem = event.target.closest('[data-entity-type]');
if (listItem) {
event.preventDefault();
event.stopPropagation();
this.selectItem(listItem);
}
}
selectItem(item) {
const isDblClick = this.isDoubleClick();
const type = item.getAttribute('data-entity-type');
const id = item.getAttribute('data-entity-id');
const isSelected = (!item.classList.contains('selected') || isDblClick);
this.unselectAll();
this.input.value = isSelected ? `${type}:${id}` : '';
const link = item.getAttribute('href');
const name = item.querySelector('.entity-list-item-name').textContent;
const data = {id: Number(id), name: name, link: link};
if (isSelected) {
item.classList.add('selected');
this.selectedItemData = data;
} else {
window.$events.emit('entity-select-change', null)
}
if (!isDblClick && !isSelected) return;
if (isDblClick) {
this.confirmSelection(data);
}
if (isSelected) {
window.$events.emit('entity-select-change', data)
}
}
confirmSelection(data) {
window.$events.emit('entity-select-confirm', data);
}
unselectAll() {
let selected = this.elem.querySelectorAll('.selected');
for (let selectedElem of selected) {
selectedElem.classList.remove('selected', 'primary-background');
}
this.selectedItemData = null;
}
}
export default EntitySelector;

View File

@ -0,0 +1,45 @@
import {slideUp, slideDown} from "../services/animations";
class ExpandToggle {
constructor(elem) {
this.elem = elem;
// Component state
this.isOpen = elem.getAttribute('expand-toggle-is-open') === 'yes';
this.updateEndpoint = elem.getAttribute('expand-toggle-update-endpoint');
this.selector = elem.getAttribute('expand-toggle');
// Listener setup
elem.addEventListener('click', this.click.bind(this));
}
open(elemToToggle) {
slideDown(elemToToggle, 200);
}
close(elemToToggle) {
slideUp(elemToToggle, 200);
}
click(event) {
event.preventDefault();
const matchingElems = document.querySelectorAll(this.selector);
for (let match of matchingElems) {
this.isOpen ? this.close(match) : this.open(match);
}
this.isOpen = !this.isOpen;
this.updateSystemAjax(this.isOpen);
}
updateSystemAjax(isOpen) {
window.$http.patch(this.updateEndpoint, {
expand: isOpen ? 'true' : 'false'
});
}
}
export default ExpandToggle;

View File

@ -0,0 +1,31 @@
class HeaderMobileToggle {
constructor(elem) {
this.elem = elem;
this.toggleButton = elem.querySelector('.mobile-menu-toggle');
this.menu = elem.querySelector('.header-links');
this.open = false;
this.toggleButton.addEventListener('click', this.onToggle.bind(this));
this.onWindowClick = this.onWindowClick.bind(this);
}
onToggle(event) {
this.open = !this.open;
this.menu.classList.toggle('show', this.open);
if (this.open) {
window.addEventListener('click', this.onWindowClick)
} else {
window.removeEventListener('click', this.onWindowClick)
}
event.stopPropagation();
}
onWindowClick(event) {
this.onToggle(event);
}
}
module.exports = HeaderMobileToggle;

View File

@ -0,0 +1,22 @@
class HomepageControl {
constructor(elem) {
this.elem = elem;
this.typeControl = elem.querySelector('[name="setting-app-homepage-type"]');
this.pagePickerContainer = elem.querySelector('[page-picker-container]');
this.typeControl.addEventListener('change', this.controlPagePickerVisibility.bind(this));
this.controlPagePickerVisibility();
}
controlPagePickerVisibility() {
const showPagePicker = this.typeControl.value === 'page';
this.pagePickerContainer.style.display = (showPagePicker ? 'block' : 'none');
}
}
export default HomepageControl;

View File

@ -0,0 +1,55 @@
class ImagePicker {
constructor(elem) {
this.elem = elem;
this.imageElem = elem.querySelector('img');
this.imageInput = elem.querySelector('input[type=file]');
this.resetInput = elem.querySelector('input[data-reset-input]');
this.removeInput = elem.querySelector('input[data-remove-input]');
this.defaultImage = elem.getAttribute('data-default-image');
const resetButton = elem.querySelector('button[data-action="reset-image"]');
resetButton.addEventListener('click', this.reset.bind(this));
const removeButton = elem.querySelector('button[data-action="remove-image"]');
if (removeButton) {
removeButton.addEventListener('click', this.removeImage.bind(this));
}
this.imageInput.addEventListener('change', this.fileInputChange.bind(this));
}
fileInputChange() {
this.resetInput.setAttribute('disabled', 'disabled');
if (this.removeInput) {
this.removeInput.setAttribute('disabled', 'disabled');
}
for (let file of this.imageInput.files) {
this.imageElem.src = window.URL.createObjectURL(file);
}
this.imageElem.classList.remove('none');
}
reset() {
this.imageInput.value = '';
this.imageElem.src = this.defaultImage;
this.resetInput.removeAttribute('disabled');
if (this.removeInput) {
this.removeInput.setAttribute('disabled', 'disabled');
}
this.imageElem.classList.remove('none');
}
removeImage() {
this.imageInput.value = '';
this.imageElem.classList.add('none');
this.removeInput.removeAttribute('disabled');
this.resetInput.setAttribute('disabled', 'disabled');
}
}
export default ImagePicker;

View File

@ -0,0 +1,103 @@
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 entityPermissionsEditor from "./entity-permissions-editor";
import templateManager from "./template-manager";
import newUserPassword from "./new-user-password";
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,
'entity-permissions-editor': entityPermissionsEditor,
'template-manager': templateManager,
'new-user-password': newUserPassword,
};
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] = [];
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);
}
}
/**
* 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);
}
}
window.components.init = initAll;
export default initAll;

View File

@ -0,0 +1,45 @@
/**
* ListSortControl
* Manages the logic for the control which provides list sorting options.
*/
class ListSortControl {
constructor(elem) {
this.elem = elem;
this.menu = elem.querySelector('ul');
this.sortInput = elem.querySelector('[name="sort"]');
this.orderInput = elem.querySelector('[name="order"]');
this.form = elem.querySelector('form');
this.menu.addEventListener('click', event => {
if (event.target.closest('[data-sort-value]') !== null) {
this.sortOptionClick(event);
}
});
this.elem.addEventListener('click', event => {
if (event.target.closest('[data-sort-dir]') !== null) {
this.sortDirectionClick(event);
}
});
}
sortOptionClick(event) {
const sortOption = event.target.closest('[data-sort-value]');
this.sortInput.value = sortOption.getAttribute('data-sort-value');
event.preventDefault();
this.form.submit();
}
sortDirectionClick(event) {
const currentDir = this.orderInput.value;
const newDir = (currentDir === 'asc') ? 'desc' : 'asc';
this.orderInput.value = newDir;
event.preventDefault();
this.form.submit();
}
}
export default ListSortControl;

View File

@ -0,0 +1,538 @@
import MarkdownIt from "markdown-it";
import mdTasksLists from 'markdown-it-task-lists';
import code from '../services/code';
import {debounce} from "../services/util";
import DrawIO from "../services/drawio";
class MarkdownEditor {
constructor(elem) {
this.elem = elem;
const pageEditor = document.getElementById('page-editor');
this.pageId = pageEditor.getAttribute('page-id');
this.textDirection = pageEditor.getAttribute('text-direction');
this.markdown = new MarkdownIt({html: true});
this.markdown.use(mdTasksLists, {label: true});
this.display = this.elem.querySelector('.markdown-display');
this.displayStylesLoaded = false;
this.input = this.elem.querySelector('textarea');
this.htmlInput = this.elem.querySelector('input[name=html]');
this.cm = code.markdownEditor(this.input);
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
this.display.addEventListener('load', () => {
this.displayDoc = this.display.contentDocument;
this.init();
});
}
init() {
let lastClick = 0;
// Prevent markdown display link click redirect
this.displayDoc.addEventListener('click', event => {
let isDblClick = Date.now() - lastClick < 300;
let link = event.target.closest('a');
if (link !== null) {
event.preventDefault();
window.open(link.getAttribute('href'));
return;
}
let drawing = event.target.closest('[drawio-diagram]');
if (drawing !== null && isDblClick) {
this.actionEditDrawing(drawing);
return;
}
lastClick = Date.now();
});
// Button actions
this.elem.addEventListener('click', event => {
let button = event.target.closest('button[data-action]');
if (button === null) return;
let action = button.getAttribute('data-action');
if (action === 'insertImage') this.actionInsertImage();
if (action === 'insertLink') this.actionShowLinkSelector();
if (action === 'insertDrawing' && (event.ctrlKey || event.metaKey)) {
this.actionShowImageManager();
return;
}
if (action === 'insertDrawing') this.actionStartDrawing();
});
// Mobile section toggling
this.elem.addEventListener('click', event => {
const toolbarLabel = event.target.closest('.editor-toolbar-label');
if (!toolbarLabel) return;
const currentActiveSections = this.elem.querySelectorAll('.markdown-editor-wrap');
for (let activeElem of currentActiveSections) {
activeElem.classList.remove('active');
}
toolbarLabel.closest('.markdown-editor-wrap').classList.add('active');
});
window.$events.listen('editor-markdown-update', value => {
this.cm.setValue(value);
this.updateAndRender();
});
this.codeMirrorSetup();
this.listenForBookStackEditorEvents();
// Scroll to text if needed.
const queryParams = (new URL(window.location)).searchParams;
const scrollText = queryParams.get('content-text');
if (scrollText) {
this.scrollToText(scrollText);
}
}
// Update the input content and render the display.
updateAndRender() {
const content = this.cm.getValue();
this.input.value = content;
const html = this.markdown.render(content);
window.$events.emit('editor-html-change', html);
window.$events.emit('editor-markdown-change', content);
// Set body content
this.displayDoc.body.className = 'page-content';
this.displayDoc.body.innerHTML = html;
this.htmlInput.value = html;
// Copy styles from page head and set custom styles for editor
this.loadStylesIntoDisplay();
}
loadStylesIntoDisplay() {
if (this.displayStylesLoaded) return;
this.displayDoc.documentElement.className = 'markdown-editor-display';
this.displayDoc.head.innerHTML = '';
const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
for (let style of styles) {
const copy = style.cloneNode(true);
this.displayDoc.head.appendChild(copy);
}
this.displayStylesLoaded = true;
}
onMarkdownScroll(lineCount) {
const elems = this.displayDoc.body.children;
if (elems.length <= lineCount) return;
const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
topElem.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth'});
}
codeMirrorSetup() {
const cm = this.cm;
const context = this;
// Text direction
// cm.setOption('direction', this.textDirection);
cm.setOption('direction', 'ltr'); // Will force to remain as ltr for now due to issues when HTML is in editor.
// Custom key commands
let metaKey = code.getMetaKey();
const extraKeys = {};
// Insert Image shortcut
extraKeys[`${metaKey}-Alt-I`] = function(cm) {
let selectedText = cm.getSelection();
let newText = `![${selectedText}](http://)`;
let cursorPos = cm.getCursor('from');
cm.replaceSelection(newText);
cm.setCursor(cursorPos.line, cursorPos.ch + newText.length -1);
};
// Save draft
extraKeys[`${metaKey}-S`] = cm => {window.$events.emit('editor-save-draft')};
// Save page
extraKeys[`${metaKey}-Enter`] = cm => {window.$events.emit('editor-save-page')};
// Show link selector
extraKeys[`Shift-${metaKey}-K`] = cm => {this.actionShowLinkSelector()};
// Insert Link
extraKeys[`${metaKey}-K`] = cm => {insertLink()};
// FormatShortcuts
extraKeys[`${metaKey}-1`] = cm => {replaceLineStart('##');};
extraKeys[`${metaKey}-2`] = cm => {replaceLineStart('###');};
extraKeys[`${metaKey}-3`] = cm => {replaceLineStart('####');};
extraKeys[`${metaKey}-4`] = cm => {replaceLineStart('#####');};
extraKeys[`${metaKey}-5`] = cm => {replaceLineStart('');};
extraKeys[`${metaKey}-d`] = cm => {replaceLineStart('');};
extraKeys[`${metaKey}-6`] = cm => {replaceLineStart('>');};
extraKeys[`${metaKey}-q`] = cm => {replaceLineStart('>');};
extraKeys[`${metaKey}-7`] = cm => {wrapSelection('\n```\n', '\n```');};
extraKeys[`${metaKey}-8`] = cm => {wrapSelection('`', '`');};
extraKeys[`Shift-${metaKey}-E`] = cm => {wrapSelection('`', '`');};
extraKeys[`${metaKey}-9`] = cm => {wrapSelection('<p class="callout info">', '</p>');};
cm.setOption('extraKeys', extraKeys);
// Update data on content change
cm.on('change', (instance, changeObj) => {
this.updateAndRender();
});
const onScrollDebounced = debounce((instance) => {
// Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
let scroll = instance.getScrollInfo();
let atEnd = scroll.top + scroll.clientHeight === scroll.height;
if (atEnd) {
this.onMarkdownScroll(-1);
return;
}
let lineNum = instance.lineAtHeight(scroll.top, 'local');
let range = instance.getRange({line: 0, ch: null}, {line: lineNum, ch: null});
let parser = new DOMParser();
let doc = parser.parseFromString(this.markdown.render(range), 'text/html');
let totalLines = doc.documentElement.querySelectorAll('body > *');
this.onMarkdownScroll(totalLines.length);
}, 100);
// Handle scroll to sync display view
cm.on('scroll', instance => {
onScrollDebounced(instance);
});
// Handle image paste
cm.on('paste', (cm, event) => {
const clipboardItems = event.clipboardData.items;
if (!event.clipboardData || !clipboardItems) return;
// Don't handle if clipboard includes text content
for (let clipboardItem of clipboardItems) {
if (clipboardItem.type.includes('text/')) {
return;
}
}
for (let clipboardItem of clipboardItems) {
if (clipboardItem.type.includes("image")) {
uploadImage(clipboardItem.getAsFile());
}
}
});
// Handle image & content drag n drop
cm.on('drop', (cm, event) => {
const templateId = event.dataTransfer.getData('bookstack/template');
if (templateId) {
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
cm.setCursor(cursorPos);
event.preventDefault();
window.$http.get(`/templates/${templateId}`).then(resp => {
const content = resp.data.markdown || resp.data.html;
cm.replaceSelection(content);
});
}
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
cm.setCursor(cursorPos);
event.stopPropagation();
event.preventDefault();
for (let i = 0; i < event.dataTransfer.files.length; i++) {
uploadImage(event.dataTransfer.files[i]);
}
}
});
// Helper to replace editor content
function replaceContent(search, replace) {
let text = cm.getValue();
let cursor = cm.listSelections();
cm.setValue(text.replace(search, replace));
cm.setSelections(cursor);
}
// Helper to replace the start of the line
function replaceLineStart(newStart) {
let cursor = cm.getCursor();
let lineContent = cm.getLine(cursor.line);
let lineLen = lineContent.length;
let lineStart = lineContent.split(' ')[0];
// Remove symbol if already set
if (lineStart === newStart) {
lineContent = lineContent.replace(`${newStart} `, '');
cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
cm.setCursor({line: cursor.line, ch: cursor.ch - (newStart.length + 1)});
return;
}
let alreadySymbol = /^[#>`]/.test(lineStart);
let posDif = 0;
if (alreadySymbol) {
posDif = newStart.length - lineStart.length;
lineContent = lineContent.replace(lineStart, newStart).trim();
} else if (newStart !== '') {
posDif = newStart.length + 1;
lineContent = newStart + ' ' + lineContent;
}
cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
cm.setCursor({line: cursor.line, ch: cursor.ch + posDif});
}
function wrapLine(start, end) {
let cursor = cm.getCursor();
let lineContent = cm.getLine(cursor.line);
let lineLen = lineContent.length;
let newLineContent = lineContent;
if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
} else {
newLineContent = `${start}${lineContent}${end}`;
}
cm.replaceRange(newLineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
cm.setCursor({line: cursor.line, ch: cursor.ch + start.length});
}
function wrapSelection(start, end) {
let selection = cm.getSelection();
if (selection === '') return wrapLine(start, end);
let newSelection = selection;
let frontDiff = 0;
let endDiff = 0;
if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
newSelection = selection.slice(start.length, selection.length - end.length);
endDiff = -(end.length + start.length);
} else {
newSelection = `${start}${selection}${end}`;
endDiff = start.length + end.length;
}
let selections = cm.listSelections()[0];
cm.replaceSelection(newSelection);
let headFirst = selections.head.ch <= selections.anchor.ch;
selections.head.ch += headFirst ? frontDiff : endDiff;
selections.anchor.ch += headFirst ? endDiff : frontDiff;
cm.setSelections([selections]);
}
// Handle image upload and add image into markdown content
function uploadImage(file) {
if (file === null || file.type.indexOf('image') !== 0) return;
let ext = 'png';
if (file.name) {
let fileNameMatches = file.name.match(/\.(.+)$/);
if (fileNameMatches.length > 1) ext = fileNameMatches[1];
}
// Insert image into markdown
const id = "image-" + Math.random().toString(16).slice(2);
const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
const selectedText = cm.getSelection();
const placeHolderText = `![${selectedText}](${placeholderImage})`;
const cursor = cm.getCursor();
cm.replaceSelection(placeHolderText);
cm.setCursor({line: cursor.line, ch: cursor.ch + selectedText.length + 3});
const remoteFilename = "image-" + Date.now() + "." + ext;
const formData = new FormData();
formData.append('file', file, remoteFilename);
formData.append('uploaded_to', context.pageId);
window.$http.post('/images/gallery', formData).then(resp => {
const newContent = `[![${selectedText}](${resp.data.thumbs.display})](${resp.data.url})`;
replaceContent(placeHolderText, newContent);
}).catch(err => {
window.$events.emit('error', trans('errors.image_upload_error'));
replaceContent(placeHolderText, selectedText);
console.log(err);
});
}
function insertLink() {
let cursorPos = cm.getCursor('from');
let selectedText = cm.getSelection() || '';
let newText = `[${selectedText}]()`;
cm.focus();
cm.replaceSelection(newText);
let cursorPosDiff = (selectedText === '') ? -3 : -1;
cm.setCursor(cursorPos.line, cursorPos.ch + newText.length+cursorPosDiff);
}
this.updateAndRender();
}
actionInsertImage() {
const cursorPos = this.cm.getCursor('from');
window.ImageManager.show(image => {
let selectedText = this.cm.getSelection();
let newText = "[![" + (selectedText || image.name) + "](" + image.thumbs.display + ")](" + image.url + ")";
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
}, 'gallery');
}
actionShowImageManager() {
const cursorPos = this.cm.getCursor('from');
window.ImageManager.show(image => {
this.insertDrawing(image, cursorPos);
}, 'drawio');
}
// Show the popup link selector and insert a link when finished
actionShowLinkSelector() {
const cursorPos = this.cm.getCursor('from');
window.EntitySelectorPopup.show(entity => {
let selectedText = this.cm.getSelection() || entity.name;
let newText = `[${selectedText}](${entity.link})`;
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
});
}
// Show draw.io if enabled and handle save.
actionStartDrawing() {
if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true') return;
let cursorPos = this.cm.getCursor('from');
DrawIO.show(() => {
return Promise.resolve('');
}, (pngData) => {
// let id = "image-" + Math.random().toString(16).slice(2);
// let loadingImage = window.baseUrl('/loading.gif');
let data = {
image: pngData,
uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
};
window.$http.post(window.baseUrl('/images/drawio'), data).then(resp => {
this.insertDrawing(resp.data, cursorPos);
DrawIO.close();
}).catch(err => {
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
});
});
}
insertDrawing(image, originalCursor) {
const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(originalCursor.line, originalCursor.ch + newText.length);
}
// Show draw.io if enabled and handle save.
actionEditDrawing(imgContainer) {
const drawingDisabled = document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true';
if (drawingDisabled) {
return;
}
const cursorPos = this.cm.getCursor('from');
const drawingId = imgContainer.getAttribute('drawio-diagram');
DrawIO.show(() => {
return DrawIO.load(drawingId);
}, (pngData) => {
let data = {
image: pngData,
uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
};
window.$http.post(window.baseUrl(`/images/drawio`), data).then(resp => {
let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
let newContent = this.cm.getValue().split('\n').map(line => {
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
return newText;
}
return line;
}).join('\n');
this.cm.setValue(newContent);
this.cm.setCursor(cursorPos);
this.cm.focus();
DrawIO.close();
}).catch(err => {
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
});
});
}
// Scroll to a specified text
scrollToText(searchText) {
if (!searchText) {
return;
}
const content = this.cm.getValue();
const lines = content.split(/\r?\n/);
let lineNumber = lines.findIndex(line => {
return line && line.indexOf(searchText) !== -1;
});
if (lineNumber === -1) {
return;
}
this.cm.scrollIntoView({
line: lineNumber,
}, 200);
this.cm.focus();
// set the cursor location.
this.cm.setCursor({
line: lineNumber,
char: lines[lineNumber].length
})
}
listenForBookStackEditorEvents() {
function getContentToInsert({html, markdown}) {
return markdown || html;
}
// Replace editor content
window.$events.listen('editor::replace', (eventContent) => {
const markdown = getContentToInsert(eventContent);
this.cm.setValue(markdown);
});
// Append editor content
window.$events.listen('editor::append', (eventContent) => {
const cursorPos = this.cm.getCursor('from');
const markdown = getContentToInsert(eventContent);
const content = this.cm.getValue() + '\n' + markdown;
this.cm.setValue(content);
this.cm.setCursor(cursorPos.line, cursorPos.ch);
});
// Prepend editor content
window.$events.listen('editor::prepend', (eventContent) => {
const cursorPos = this.cm.getCursor('from');
const markdown = getContentToInsert(eventContent);
const content = markdown + '\n' + this.cm.getValue();
this.cm.setValue(content);
const prependLineCount = markdown.split('\n').length;
this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
});
}
}
export default MarkdownEditor ;

View File

@ -0,0 +1,28 @@
class NewUserPassword {
constructor(elem) {
this.elem = elem;
this.inviteOption = elem.querySelector('input[name=send_invite]');
if (this.inviteOption) {
this.inviteOption.addEventListener('change', this.inviteOptionChange.bind(this));
this.inviteOptionChange();
}
}
inviteOptionChange() {
const inviting = (this.inviteOption.value === 'true');
const passwordBoxes = this.elem.querySelectorAll('input[type=password]');
for (const input of passwordBoxes) {
input.disabled = inviting;
}
const container = this.elem.querySelector('#password-input-container');
if (container) {
container.style.display = inviting ? 'none' : 'block';
}
}
}
export default NewUserPassword;

View File

@ -0,0 +1,46 @@
class Notification {
constructor(elem) {
this.elem = elem;
this.type = elem.getAttribute('notification');
this.textElem = elem.querySelector('span');
this.autohide = this.elem.hasAttribute('data-autohide');
this.elem.style.display = 'grid';
window.$events.listen(this.type, text => {
this.show(text);
});
elem.addEventListener('click', this.hide.bind(this));
if (elem.hasAttribute('data-show')) {
setTimeout(() => this.show(this.textElem.textContent), 100);
}
this.hideCleanup = this.hideCleanup.bind(this);
}
show(textToShow = '') {
this.elem.removeEventListener('transitionend', this.hideCleanup);
this.textElem.textContent = textToShow;
this.elem.style.display = 'grid';
setTimeout(() => {
this.elem.classList.add('showing');
}, 1);
if (this.autohide) setTimeout(this.hide.bind(this), 2000);
}
hide() {
this.elem.classList.remove('showing');
this.elem.addEventListener('transitionend', this.hideCleanup);
}
hideCleanup() {
this.elem.style.display = 'none';
this.elem.removeEventListener('transitionend', this.hideCleanup);
}
}
export default Notification;

View File

@ -0,0 +1,56 @@
class Overlay {
constructor(elem) {
this.container = elem;
elem.addEventListener('click', event => {
if (event.target === elem) return this.hide();
});
window.addEventListener('keyup', event => {
if (event.key === 'Escape') {
this.hide();
}
});
let closeButtons = elem.querySelectorAll('.popup-header-close');
for (let i=0; i < closeButtons.length; i++) {
closeButtons[i].addEventListener('click', this.hide.bind(this));
}
}
hide() { this.toggle(false); }
show() { this.toggle(true); }
toggle(show = true) {
let start = Date.now();
let duration = 240;
function setOpacity() {
let elapsedTime = (Date.now() - start);
let targetOpacity = show ? (elapsedTime / duration) : 1-(elapsedTime / duration);
this.container.style.opacity = targetOpacity;
if (elapsedTime > duration) {
this.container.style.display = show ? 'flex' : 'none';
if (show) {
this.focusOnBody();
}
this.container.style.opacity = '';
} else {
requestAnimationFrame(setOpacity.bind(this));
}
}
requestAnimationFrame(setOpacity.bind(this));
}
focusOnBody() {
const body = this.container.querySelector('.popup-body');
if (body) {
body.focus();
}
}
}
export default Overlay;

View File

@ -0,0 +1,189 @@
import MarkdownIt from "markdown-it";
import {scrollAndHighlightElement} from "../services/util";
const md = new MarkdownIt({ html: false });
class PageComments {
constructor(elem) {
this.elem = elem;
this.pageId = Number(elem.getAttribute('page-id'));
this.editingComment = null;
this.parentId = null;
this.container = elem.querySelector('[comment-container]');
this.formContainer = elem.querySelector('[comment-form-container]');
if (this.formContainer) {
this.form = this.formContainer.querySelector('form');
this.formInput = this.form.querySelector('textarea');
this.form.addEventListener('submit', this.saveComment.bind(this));
}
this.elem.addEventListener('click', this.handleAction.bind(this));
this.elem.addEventListener('submit', this.updateComment.bind(this));
}
handleAction(event) {
let actionElem = event.target.closest('[action]');
if (event.target.matches('a[href^="#"]')) {
const id = event.target.href.split('#')[1];
scrollAndHighlightElement(document.querySelector('#' + id));
}
if (actionElem === null) return;
event.preventDefault();
let action = actionElem.getAttribute('action');
if (action === 'edit') this.editComment(actionElem.closest('[comment]'));
if (action === 'closeUpdateForm') this.closeUpdateForm();
if (action === 'delete') this.deleteComment(actionElem.closest('[comment]'));
if (action === 'addComment') this.showForm();
if (action === 'hideForm') this.hideForm();
if (action === 'reply') this.setReply(actionElem.closest('[comment]'));
if (action === 'remove-reply-to') this.removeReplyTo();
}
closeUpdateForm() {
if (!this.editingComment) return;
this.editingComment.querySelector('[comment-content]').style.display = 'block';
this.editingComment.querySelector('[comment-edit-container]').style.display = 'none';
}
editComment(commentElem) {
this.hideForm();
if (this.editingComment) this.closeUpdateForm();
commentElem.querySelector('[comment-content]').style.display = 'none';
commentElem.querySelector('[comment-edit-container]').style.display = 'block';
let textArea = commentElem.querySelector('[comment-edit-container] textarea');
let lineCount = textArea.value.split('\n').length;
textArea.style.height = ((lineCount * 20) + 40) + 'px';
this.editingComment = commentElem;
}
updateComment(event) {
let form = event.target;
event.preventDefault();
let text = form.querySelector('textarea').value;
let reqData = {
text: text,
html: md.render(text),
parent_id: this.parentId || null,
};
this.showLoading(form);
let commentId = this.editingComment.getAttribute('comment');
window.$http.put(window.baseUrl(`/ajax/comment/${commentId}`), reqData).then(resp => {
let newComment = document.createElement('div');
newComment.innerHTML = resp.data;
this.editingComment.innerHTML = newComment.children[0].innerHTML;
window.$events.emit('success', window.trans('entities.comment_updated_success'));
window.components.init(this.editingComment);
this.closeUpdateForm();
this.editingComment = null;
this.hideLoading(form);
});
}
deleteComment(commentElem) {
let id = commentElem.getAttribute('comment');
this.showLoading(commentElem.querySelector('[comment-content]'));
window.$http.delete(window.baseUrl(`/ajax/comment/${id}`)).then(resp => {
commentElem.parentNode.removeChild(commentElem);
window.$events.emit('success', window.trans('entities.comment_deleted_success'));
this.updateCount();
this.hideForm();
});
}
saveComment(event) {
event.preventDefault();
event.stopPropagation();
let text = this.formInput.value;
let reqData = {
text: text,
html: md.render(text),
parent_id: this.parentId || null,
};
this.showLoading(this.form);
window.$http.post(window.baseUrl(`/ajax/page/${this.pageId}/comment`), reqData).then(resp => {
let newComment = document.createElement('div');
newComment.innerHTML = resp.data;
let newElem = newComment.children[0];
this.container.appendChild(newElem);
window.components.init(newElem);
window.$events.emit('success', window.trans('entities.comment_created_success'));
this.resetForm();
this.updateCount();
});
}
updateCount() {
let count = this.container.children.length;
this.elem.querySelector('[comments-title]').textContent = window.trans_choice('entities.comment_count', count, {count});
}
resetForm() {
this.formInput.value = '';
this.formContainer.appendChild(this.form);
this.hideForm();
this.removeReplyTo();
this.hideLoading(this.form);
}
showForm() {
this.formContainer.style.display = 'block';
this.formContainer.parentNode.style.display = 'block';
this.elem.querySelector('[comment-add-button-container]').style.display = 'none';
this.formInput.focus();
this.formInput.scrollIntoView({behavior: "smooth"});
}
hideForm() {
this.formContainer.style.display = 'none';
this.formContainer.parentNode.style.display = 'none';
const addButtonContainer = this.elem.querySelector('[comment-add-button-container]');
if (this.getCommentCount() > 0) {
this.elem.appendChild(addButtonContainer)
} else {
const countBar = this.elem.querySelector('[comment-count-bar]');
countBar.appendChild(addButtonContainer);
}
addButtonContainer.style.display = 'block';
}
getCommentCount() {
return this.elem.querySelectorAll('.comment-box[comment]').length;
}
setReply(commentElem) {
this.showForm();
this.parentId = Number(commentElem.getAttribute('local-id'));
this.elem.querySelector('[comment-form-reply-to]').style.display = 'block';
let replyLink = this.elem.querySelector('[comment-form-reply-to] a');
replyLink.textContent = `#${this.parentId}`;
replyLink.href = `#comment${this.parentId}`;
}
removeReplyTo() {
this.parentId = null;
this.elem.querySelector('[comment-form-reply-to]').style.display = 'none';
}
showLoading(formElem) {
let groups = formElem.querySelectorAll('.form-group');
for (let i = 0, len = groups.length; i < len; i++) {
groups[i].style.display = 'none';
}
formElem.querySelector('.form-group.loading').style.display = 'block';
}
hideLoading(formElem) {
let groups = formElem.querySelectorAll('.form-group');
for (let i = 0, len = groups.length; i < len; i++) {
groups[i].style.display = 'block';
}
formElem.querySelector('.form-group.loading').style.display = 'none';
}
}
export default PageComments;

View File

@ -0,0 +1,201 @@
import Clipboard from "clipboard/dist/clipboard.min";
import Code from "../services/code";
import * as DOM from "../services/dom";
import {scrollAndHighlightElement} from "../services/util";
class PageDisplay {
constructor(elem) {
this.elem = elem;
this.pageId = elem.getAttribute('page-display');
Code.highlight();
this.setupPointer();
this.setupNavHighlighting();
// Check the hash on load
if (window.location.hash) {
let text = window.location.hash.replace(/\%20/g, ' ').substr(1);
this.goToText(text);
}
// Sidebar page nav click event
const sidebarPageNav = document.querySelector('.sidebar-page-nav');
if (sidebarPageNav) {
DOM.onChildEvent(sidebarPageNav, 'a', 'click', (event, child) => {
event.preventDefault();
window.components['tri-layout'][0].showContent();
const contentId = child.getAttribute('href').substr(1);
this.goToText(contentId);
window.history.pushState(null, null, '#' + contentId);
});
}
}
goToText(text) {
const idElem = document.getElementById(text);
DOM.forEach('.page-content [data-highlighted]', elem => {
elem.removeAttribute('data-highlighted');
elem.style.backgroundColor = null;
});
if (idElem !== null) {
scrollAndHighlightElement(idElem);
} else {
const textElem = DOM.findText('.page-content > div > *', text);
if (textElem) {
scrollAndHighlightElement(textElem);
}
}
}
setupPointer() {
let pointer = document.getElementById('pointer');
if (!pointer) {
return;
}
// Set up pointer
pointer = pointer.parentNode.removeChild(pointer);
const pointerInner = pointer.querySelector('div.pointer');
// Instance variables
let pointerShowing = false;
let isSelection = false;
let pointerModeLink = true;
let pointerSectionId = '';
// Select all contents on input click
DOM.onChildEvent(pointer, 'input', 'click', (event, input) => {
input.select();
event.stopPropagation();
});
// Prevent closing pointer when clicked or focused
DOM.onEvents(pointer, ['click', 'focus'], event => {
event.stopPropagation();
});
// Pointer mode toggle
DOM.onChildEvent(pointer, 'span.icon', 'click', (event, icon) => {
event.stopPropagation();
pointerModeLink = !pointerModeLink;
icon.querySelector('[data-icon="include"]').style.display = (!pointerModeLink) ? 'inline' : 'none';
icon.querySelector('[data-icon="link"]').style.display = (pointerModeLink) ? 'inline' : 'none';
updatePointerContent();
});
// Set up clipboard
new Clipboard(pointer.querySelector('button'));
// Hide pointer when clicking away
DOM.onEvents(document.body, ['click', 'focus'], event => {
if (!pointerShowing || isSelection) return;
pointer = pointer.parentElement.removeChild(pointer);
pointerShowing = false;
});
let updatePointerContent = (element) => {
let inputText = pointerModeLink ? window.baseUrl(`/link/${this.pageId}#${pointerSectionId}`) : `{{@${this.pageId}#${pointerSectionId}}}`;
if (pointerModeLink && !inputText.startsWith('http')) {
inputText = window.location.protocol + "//" + window.location.host + inputText;
}
pointer.querySelector('input').value = inputText;
// Update anchor if present
const editAnchor = pointer.querySelector('#pointer-edit');
if (editAnchor && element) {
const editHref = editAnchor.dataset.editHref;
const elementId = element.id;
// get the first 50 characters.
const queryContent = element.textContent && element.textContent.substring(0, 50);
editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
}
};
// Show pointer when selecting a single block of tagged content
DOM.forEach('.page-content [id^="bkmrk"]', bookMarkElem => {
DOM.onEvents(bookMarkElem, ['mouseup', 'keyup'], event => {
event.stopPropagation();
let selection = window.getSelection();
if (selection.toString().length === 0) return;
// Show pointer and set link
pointerSectionId = bookMarkElem.id;
updatePointerContent(bookMarkElem);
bookMarkElem.parentNode.insertBefore(pointer, bookMarkElem);
pointer.style.display = 'block';
pointerShowing = true;
isSelection = true;
// Set pointer to sit near mouse-up position
requestAnimationFrame(() => {
const bookMarkBounds = bookMarkElem.getBoundingClientRect();
let pointerLeftOffset = (event.pageX - bookMarkBounds.left - 164);
if (pointerLeftOffset < 0) {
pointerLeftOffset = 0
}
const pointerLeftOffsetPercent = (pointerLeftOffset / bookMarkBounds.width) * 100;
pointerInner.style.left = pointerLeftOffsetPercent + '%';
setTimeout(() => {
isSelection = false;
}, 100);
});
});
});
}
setupNavHighlighting() {
// Check if support is present for IntersectionObserver
if (!('IntersectionObserver' in window) ||
!('IntersectionObserverEntry' in window) ||
!('intersectionRatio' in window.IntersectionObserverEntry.prototype)) {
return;
}
let pageNav = document.querySelector('.sidebar-page-nav');
// fetch all the headings.
let headings = document.querySelector('.page-content').querySelectorAll('h1, h2, h3, h4, h5, h6');
// if headings are present, add observers.
if (headings.length > 0 && pageNav !== null) {
addNavObserver(headings);
}
function addNavObserver(headings) {
// Setup the intersection observer.
let intersectOpts = {
rootMargin: '0px 0px 0px 0px',
threshold: 1.0
};
let pageNavObserver = new IntersectionObserver(headingVisibilityChange, intersectOpts);
// observe each heading
for (let heading of headings) {
pageNavObserver.observe(heading);
}
}
function headingVisibilityChange(entries, observer) {
for (let entry of entries) {
let isVisible = (entry.intersectionRatio === 1);
toggleAnchorHighlighting(entry.target.id, isVisible);
}
}
function toggleAnchorHighlighting(elementId, shouldHighlight) {
DOM.forEach('a[href="#' + elementId + '"]', anchor => {
anchor.closest('li').classList.toggle('current-heading', shouldHighlight);
});
}
}
}
export default PageDisplay;

View File

@ -0,0 +1,62 @@
class PagePicker {
constructor(elem) {
this.elem = elem;
this.input = elem.querySelector('input');
this.resetButton = elem.querySelector('[page-picker-reset]');
this.selectButton = elem.querySelector('[page-picker-select]');
this.display = elem.querySelector('[page-picker-display]');
this.defaultDisplay = elem.querySelector('[page-picker-default]');
this.buttonSep = elem.querySelector('span.sep');
this.value = this.input.value;
this.setupListeners();
}
setupListeners() {
this.selectButton.addEventListener('click', this.showPopup.bind(this));
this.display.parentElement.addEventListener('click', this.showPopup.bind(this));
this.resetButton.addEventListener('click', event => {
this.setValue('', '');
});
}
showPopup() {
window.EntitySelectorPopup.show(entity => {
this.setValue(entity.id, entity.name);
});
}
setValue(value, name) {
this.value = value;
this.input.value = value;
this.controlView(name);
}
controlView(name) {
let hasValue = this.value && this.value !== 0;
toggleElem(this.resetButton, hasValue);
toggleElem(this.buttonSep, hasValue);
toggleElem(this.defaultDisplay, !hasValue);
toggleElem(this.display, hasValue);
if (hasValue) {
let id = this.getAssetIdFromVal();
this.display.textContent = `#${id}, ${name}`;
this.display.href = window.baseUrl(`/link/${id}`);
}
}
getAssetIdFromVal() {
return Number(this.value);
}
}
function toggleElem(elem, show) {
let display = (elem.tagName === 'BUTTON' || elem.tagName === 'SPAN') ? 'inline-block' : 'block';
elem.style.display = show ? display : 'none';
}
export default PagePicker;

View File

@ -0,0 +1,66 @@
class PermissionsTable {
constructor(elem) {
this.container = elem;
// Handle toggle all event
const toggleAll = elem.querySelector('[permissions-table-toggle-all]');
toggleAll.addEventListener('click', this.toggleAllClick.bind(this));
// Handle toggle row event
const toggleRowElems = elem.querySelectorAll('[permissions-table-toggle-all-in-row]');
for (let toggleRowElem of toggleRowElems) {
toggleRowElem.addEventListener('click', this.toggleRowClick.bind(this));
}
// Handle toggle column event
const toggleColumnElems = elem.querySelectorAll('[permissions-table-toggle-all-in-column]');
for (let toggleColElem of toggleColumnElems) {
toggleColElem.addEventListener('click', this.toggleColumnClick.bind(this));
}
}
toggleAllClick(event) {
event.preventDefault();
this.toggleAllInElement(this.container);
}
toggleRowClick(event) {
event.preventDefault();
this.toggleAllInElement(event.target.closest('tr'));
}
toggleColumnClick(event) {
event.preventDefault();
const tableCell = event.target.closest('th,td');
const colIndex = Array.from(tableCell.parentElement.children).indexOf(tableCell);
const tableRows = tableCell.closest('table').querySelectorAll('tr');
const inputsToToggle = [];
for (let row of tableRows) {
const targetCell = row.children[colIndex];
if (targetCell) {
inputsToToggle.push(...targetCell.querySelectorAll('input[type=checkbox]'));
}
}
this.toggleAllInputs(inputsToToggle);
}
toggleAllInElement(domElem) {
const inputsToToggle = domElem.querySelectorAll('input[type=checkbox]');
this.toggleAllInputs(inputsToToggle);
}
toggleAllInputs(inputsToToggle) {
const currentState = inputsToToggle.length > 0 ? inputsToToggle[0].checked : false;
for (let checkbox of inputsToToggle) {
checkbox.checked = !currentState;
checkbox.dispatchEvent(new Event('change'));
}
}
}
export default PermissionsTable;

View File

@ -0,0 +1,56 @@
class SettingAppColorPicker {
constructor(elem) {
this.elem = elem;
this.colorInput = elem.querySelector('input[type=color]');
this.lightColorInput = elem.querySelector('input[name="setting-app-color-light"]');
this.resetButton = elem.querySelector('[setting-app-color-picker-reset]');
this.colorInput.addEventListener('change', this.updateColor.bind(this));
this.colorInput.addEventListener('input', this.updateColor.bind(this));
this.resetButton.addEventListener('click', event => {
this.colorInput.value = '#206ea7';
this.updateColor();
});
}
/**
* Update the app colors as a preview, and create a light version of the color.
*/
updateColor() {
const hexVal = this.colorInput.value;
const rgb = this.hexToRgb(hexVal);
const rgbLightVal = 'rgba('+ [rgb.r, rgb.g, rgb.b, '0.15'].join(',') +')';
this.lightColorInput.value = rgbLightVal;
const customStyles = document.getElementById('custom-styles');
const oldColor = customStyles.getAttribute('data-color');
const oldColorLight = customStyles.getAttribute('data-color-light');
customStyles.innerHTML = customStyles.innerHTML.split(oldColor).join(hexVal);
customStyles.innerHTML = customStyles.innerHTML.split(oldColorLight).join(rgbLightVal);
customStyles.setAttribute('data-color', hexVal);
customStyles.setAttribute('data-color-light', rgbLightVal);
}
/**
* Covert a hex color code to rgb components.
* @attribution https://stackoverflow.com/a/5624139
* @param hex
* @returns {*}
*/
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return {
r: result ? parseInt(result[1], 16) : 0,
g: result ? parseInt(result[2], 16) : 0,
b: result ? parseInt(result[3], 16) : 0
};
}
}
export default SettingAppColorPicker;

View File

@ -0,0 +1,56 @@
import Sortable from "sortablejs";
class ShelfSort {
constructor(elem) {
this.elem = elem;
this.input = document.getElementById('books-input');
this.shelfBooksList = elem.querySelector('[shelf-sort-assigned-books]');
this.initSortable();
this.setupListeners();
}
initSortable() {
const scrollBoxes = this.elem.querySelectorAll('.scroll-box');
for (let scrollBox of scrollBoxes) {
new Sortable(scrollBox, {
group: 'shelf-books',
ghostClass: 'primary-background-light',
animation: 150,
onSort: this.onChange.bind(this),
});
}
}
setupListeners() {
this.elem.addEventListener('click', event => {
const sortItem = event.target.closest('.scroll-box-item:not(.instruction)');
if (sortItem) {
event.preventDefault();
this.sortItemClick(sortItem);
}
});
}
/**
* Called when a sort item is clicked.
* @param {Element} sortItem
*/
sortItemClick(sortItem) {
const lists = this.elem.querySelectorAll('.scroll-box');
const newList = Array.from(lists).filter(list => sortItem.parentElement !== list);
if (newList.length > 0) {
newList[0].appendChild(sortItem);
}
this.onChange();
}
onChange() {
const shelfBookElems = Array.from(this.shelfBooksList.querySelectorAll('[data-id]'));
this.input.value = shelfBookElems.map(elem => elem.getAttribute('data-id')).join(',');
}
}
export default ShelfSort;

View File

@ -0,0 +1,16 @@
class Sidebar {
constructor(elem) {
this.elem = elem;
this.toggleElem = elem.querySelector('.sidebar-toggle');
this.toggleElem.addEventListener('click', this.toggle.bind(this));
}
toggle(show = true) {
this.elem.classList.toggle('open');
}
}
export default Sidebar;

View File

@ -0,0 +1,94 @@
import * as DOM from "../services/dom";
class TemplateManager {
constructor(elem) {
this.elem = elem;
this.list = elem.querySelector('[template-manager-list]');
this.searching = false;
// Template insert action buttons
DOM.onChildEvent(this.elem, '[template-action]', 'click', this.handleTemplateActionClick.bind(this));
// Template list pagination click
DOM.onChildEvent(this.elem, '.pagination a', 'click', this.handlePaginationClick.bind(this));
// Template list item content click
DOM.onChildEvent(this.elem, '.template-item-content', 'click', this.handleTemplateItemClick.bind(this));
// Template list item drag start
DOM.onChildEvent(this.elem, '.template-item', 'dragstart', this.handleTemplateItemDragStart.bind(this));
this.setupSearchBox();
}
handleTemplateItemClick(event, templateItem) {
const templateId = templateItem.closest('[template-id]').getAttribute('template-id');
this.insertTemplate(templateId, 'replace');
}
handleTemplateItemDragStart(event, templateItem) {
const templateId = templateItem.closest('[template-id]').getAttribute('template-id');
event.dataTransfer.setData('bookstack/template', templateId);
event.dataTransfer.setData('text/plain', templateId);
}
handleTemplateActionClick(event, actionButton) {
event.stopPropagation();
const action = actionButton.getAttribute('template-action');
const templateId = actionButton.closest('[template-id]').getAttribute('template-id');
this.insertTemplate(templateId, action);
}
async insertTemplate(templateId, action = 'replace') {
const resp = await window.$http.get(`/templates/${templateId}`);
const eventName = 'editor::' + action;
window.$events.emit(eventName, resp.data);
}
async handlePaginationClick(event, paginationLink) {
event.preventDefault();
const paginationUrl = paginationLink.getAttribute('href');
const resp = await window.$http.get(paginationUrl);
this.list.innerHTML = resp.data;
}
setupSearchBox() {
const searchBox = this.elem.querySelector('.search-box');
const input = searchBox.querySelector('input');
const submitButton = searchBox.querySelector('button');
const cancelButton = searchBox.querySelector('button.search-box-cancel');
async function performSearch() {
const searchTerm = input.value;
const resp = await window.$http.get(`/templates`, {
search: searchTerm
});
cancelButton.style.display = searchTerm ? 'block' : 'none';
this.list.innerHTML = resp.data;
}
performSearch = performSearch.bind(this);
// Searchbox enter press
searchBox.addEventListener('keypress', event => {
if (event.key === 'Enter') {
event.preventDefault();
performSearch();
}
});
// Submit button press
submitButton.addEventListener('click', event => {
performSearch();
});
// Cancel button press
cancelButton.addEventListener('click', event => {
input.value = '';
performSearch();
});
}
}
export default TemplateManager;

View File

@ -0,0 +1,23 @@
class ToggleSwitch {
constructor(elem) {
this.elem = elem;
this.input = elem.querySelector('input[type=hidden]');
this.checkbox = elem.querySelector('input[type=checkbox]');
this.checkbox.addEventListener('change', this.stateChange.bind(this));
}
stateChange() {
this.input.value = (this.checkbox.checked ? 'true' : 'false');
// Dispatch change event from hidden input so they can be listened to
// like a normal checkbox.
const changeEvent = new Event('change');
this.input.dispatchEvent(changeEvent);
}
}
export default ToggleSwitch;

View File

@ -0,0 +1,113 @@
class TriLayout {
constructor(elem) {
this.elem = elem;
this.lastLayoutType = 'none';
this.onDestroy = null;
this.scrollCache = {
'content': 0,
'info': 0,
};
this.lastTabShown = 'content';
// Bind any listeners
this.mobileTabClick = this.mobileTabClick.bind(this);
// Watch layout changes
this.updateLayout();
window.addEventListener('resize', event => {
this.updateLayout();
}, {passive: true});
}
updateLayout() {
let newLayout = 'tablet';
if (window.innerWidth <= 1000) newLayout = 'mobile';
if (window.innerWidth >= 1400) newLayout = 'desktop';
if (newLayout === this.lastLayoutType) return;
if (this.onDestroy) {
this.onDestroy();
this.onDestroy = null;
}
if (newLayout === 'desktop') {
this.setupDesktop();
} else if (newLayout === 'mobile') {
this.setupMobile();
}
this.lastLayoutType = newLayout;
}
setupMobile() {
const layoutTabs = document.querySelectorAll('[tri-layout-mobile-tab]');
for (let tab of layoutTabs) {
tab.addEventListener('click', this.mobileTabClick);
}
this.onDestroy = () => {
for (let tab of layoutTabs) {
tab.removeEventListener('click', this.mobileTabClick);
}
}
}
setupDesktop() {
//
}
/**
* Action to run when the mobile info toggle bar is clicked/tapped
* @param event
*/
mobileTabClick(event) {
const tab = event.target.getAttribute('tri-layout-mobile-tab');
this.showTab(tab);
}
/**
* Show the content tab.
* Used by the page-display component.
*/
showContent() {
this.showTab('content', false);
}
/**
* Show the given tab
* @param tabName
*/
showTab(tabName, scroll = true) {
this.scrollCache[this.lastTabShown] = document.documentElement.scrollTop;
// Set tab status
const tabs = document.querySelectorAll('.tri-layout-mobile-tab');
for (let tab of tabs) {
const isActive = (tab.getAttribute('tri-layout-mobile-tab') === tabName);
tab.classList.toggle('active', isActive);
}
// Toggle section
const showInfo = (tabName === 'info');
this.elem.classList.toggle('show-info', showInfo);
// Set the scroll position from cache
if (scroll) {
const pageHeader = document.querySelector('header');
const defaultScrollTop = pageHeader.getBoundingClientRect().bottom;
document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop;
setTimeout(() => {
document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop;
}, 50);
}
this.lastTabShown = tabName;
}
}
export default TriLayout;

View File

@ -0,0 +1,663 @@
import Code from "../services/code";
import DrawIO from "../services/drawio";
/**
* Handle pasting images from clipboard.
* @param {ClipboardEvent} event
* @param {WysiwygEditor} wysiwygComponent
* @param editor
*/
function editorPaste(event, editor, wysiwygComponent) {
const clipboardItems = event.clipboardData.items;
if (!event.clipboardData || !clipboardItems) return;
// Don't handle if clipboard includes text content
for (let clipboardItem of clipboardItems) {
if (clipboardItem.type.includes('text/')) {
return;
}
}
for (let clipboardItem of clipboardItems) {
if (!clipboardItem.type.includes("image")) {
continue;
}
const id = "image-" + Math.random().toString(16).slice(2);
const loadingImage = window.baseUrl('/loading.gif');
const file = clipboardItem.getAsFile();
setTimeout(() => {
editor.insertContent(`<p><img src="${loadingImage}" id="${id}"></p>`);
uploadImageFile(file, wysiwygComponent).then(resp => {
editor.dom.setAttrib(id, 'src', resp.thumbs.display);
}).catch(err => {
editor.dom.remove(id);
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
});
}, 10);
}
}
/**
* Upload an image file to the server
* @param {File} file
* @param {WysiwygEditor} wysiwygComponent
*/
async function uploadImageFile(file, wysiwygComponent) {
if (file === null || file.type.indexOf('image') !== 0) {
throw new Error(`Not an image file`);
}
let ext = 'png';
if (file.name) {
let fileNameMatches = file.name.match(/\.(.+)$/);
if (fileNameMatches.length > 1) ext = fileNameMatches[1];
}
const remoteFilename = "image-" + Date.now() + "." + ext;
const formData = new FormData();
formData.append('file', file, remoteFilename);
formData.append('uploaded_to', wysiwygComponent.pageId);
const resp = await window.$http.post(window.baseUrl('/images/gallery'), formData);
return resp.data;
}
function registerEditorShortcuts(editor) {
// Headers
for (let i = 1; i < 5; i++) {
editor.shortcuts.add('meta+' + i, '', ['FormatBlock', false, 'h' + (i+1)]);
}
// Other block shortcuts
editor.shortcuts.add('meta+5', '', ['FormatBlock', false, 'p']);
editor.shortcuts.add('meta+d', '', ['FormatBlock', false, 'p']);
editor.shortcuts.add('meta+6', '', ['FormatBlock', false, 'blockquote']);
editor.shortcuts.add('meta+q', '', ['FormatBlock', false, 'blockquote']);
editor.shortcuts.add('meta+7', '', ['codeeditor', false, 'pre']);
editor.shortcuts.add('meta+e', '', ['codeeditor', false, 'pre']);
editor.shortcuts.add('meta+8', '', ['FormatBlock', false, 'code']);
editor.shortcuts.add('meta+shift+E', '', ['FormatBlock', false, 'code']);
// Save draft shortcut
editor.shortcuts.add('meta+S', '', () => {
window.$events.emit('editor-save-draft');
});
// Save page shortcut
editor.shortcuts.add('meta+13', '', () => {
window.$events.emit('editor-save-page');
});
// Loop through callout styles
editor.shortcuts.add('meta+9', '', function() {
let selectedNode = editor.selection.getNode();
let formats = ['info', 'success', 'warning', 'danger'];
if (!selectedNode || selectedNode.className.indexOf('callout') === -1) {
editor.formatter.apply('calloutinfo');
return;
}
for (let i = 0; i < formats.length; i++) {
if (selectedNode.className.indexOf(formats[i]) === -1) continue;
let newFormat = (i === formats.length -1) ? formats[0] : formats[i+1];
editor.formatter.apply('callout' + newFormat);
return;
}
editor.formatter.apply('p');
});
}
/**
* Load custom HTML head content from the settings into the editor.
* @param editor
*/
function loadCustomHeadContent(editor) {
window.$http.get(window.baseUrl('/custom-head-content')).then(resp => {
if (!resp.data) return;
let head = editor.getDoc().querySelector('head');
head.innerHTML += resp.data;
});
}
/**
* Create and enable our custom code plugin
*/
function codePlugin() {
function elemIsCodeBlock(elem) {
return elem.className === 'CodeMirrorContainer';
}
function showPopup(editor) {
let selectedNode = editor.selection.getNode();
if (!elemIsCodeBlock(selectedNode)) {
let providedCode = editor.selection.getNode().textContent;
window.vues['code-editor'].open(providedCode, '', (code, lang) => {
let wrap = document.createElement('div');
wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`;
wrap.querySelector('code').innerText = code;
editor.formatter.toggle('pre');
let node = editor.selection.getNode();
editor.dom.setHTML(node, wrap.querySelector('pre').innerHTML);
editor.fire('SetContent');
});
return;
}
let lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
let currentCode = selectedNode.querySelector('textarea').textContent;
window.vues['code-editor'].open(currentCode, lang, (code, lang) => {
let editorElem = selectedNode.querySelector('.CodeMirror');
let cmInstance = editorElem.CodeMirror;
if (cmInstance) {
Code.setContent(cmInstance, code);
Code.setMode(cmInstance, lang);
}
let textArea = selectedNode.querySelector('textarea');
if (textArea) textArea.textContent = code;
selectedNode.setAttribute('data-lang', lang);
});
}
function codeMirrorContainerToPre(codeMirrorContainer) {
const textArea = codeMirrorContainer.querySelector('textarea');
const code = textArea.textContent;
const lang = codeMirrorContainer.getAttribute('data-lang');
codeMirrorContainer.removeAttribute('contentEditable');
const pre = document.createElement('pre');
const codeElem = document.createElement('code');
codeElem.classList.add(`language-${lang}`);
codeElem.textContent = code;
pre.appendChild(codeElem);
codeMirrorContainer.parentElement.replaceChild(pre, codeMirrorContainer);
}
window.tinymce.PluginManager.add('codeeditor', function(editor, url) {
const $ = editor.$;
editor.addButton('codeeditor', {
text: 'Code block',
icon: false,
cmd: 'codeeditor'
});
editor.addCommand('codeeditor', () => {
showPopup(editor);
});
// Convert
editor.on('PreProcess', function (e) {
$('div.CodeMirrorContainer', e.node).each((index, elem) => {
codeMirrorContainerToPre(elem);
});
});
editor.on('dblclick', event => {
let selectedNode = editor.selection.getNode();
if (!elemIsCodeBlock(selectedNode)) return;
showPopup(editor);
});
editor.on('SetContent', function () {
// Recover broken codemirror instances
$('.CodeMirrorContainer').filter((index ,elem) => {
return typeof elem.querySelector('.CodeMirror').CodeMirror === 'undefined';
}).each((index, elem) => {
codeMirrorContainerToPre(elem);
});
const codeSamples = $('body > pre').filter((index, elem) => {
return elem.contentEditable !== "false";
});
if (!codeSamples.length) return;
editor.undoManager.transact(function () {
codeSamples.each((index, elem) => {
Code.wysiwygView(elem);
});
});
});
});
}
function drawIoPlugin() {
let pageEditor = null;
let currentNode = null;
function isDrawing(node) {
return node.hasAttribute('drawio-diagram');
}
function showDrawingManager(mceEditor, selectedNode = null) {
pageEditor = mceEditor;
currentNode = selectedNode;
// Show image manager
window.ImageManager.show(function (image) {
if (selectedNode) {
let imgElem = selectedNode.querySelector('img');
pageEditor.dom.setAttrib(imgElem, 'src', image.url);
pageEditor.dom.setAttrib(selectedNode, 'drawio-diagram', image.id);
} else {
let imgHTML = `<div drawio-diagram="${image.id}" contenteditable="false"><img src="${image.url}"></div>`;
pageEditor.insertContent(imgHTML);
}
}, 'drawio');
}
function showDrawingEditor(mceEditor, selectedNode = null) {
pageEditor = mceEditor;
currentNode = selectedNode;
DrawIO.show(drawingInit, updateContent);
}
async function updateContent(pngData) {
const id = "image-" + Math.random().toString(16).slice(2);
const loadingImage = window.baseUrl('/loading.gif');
const pageId = Number(document.getElementById('page-editor').getAttribute('page-id'));
// Handle updating an existing image
if (currentNode) {
DrawIO.close();
let imgElem = currentNode.querySelector('img');
try {
const img = await DrawIO.upload(pngData, pageId);
pageEditor.dom.setAttrib(imgElem, 'src', img.url);
pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id);
} catch (err) {
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
}
return;
}
setTimeout(async () => {
pageEditor.insertContent(`<div drawio-diagram contenteditable="false"><img src="${loadingImage}" id="${id}"></div>`);
DrawIO.close();
try {
const img = await DrawIO.upload(pngData, pageId);
pageEditor.dom.setAttrib(id, 'src', img.url);
pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id);
} catch (err) {
pageEditor.dom.remove(id);
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
}
}, 5);
}
function drawingInit() {
if (!currentNode) {
return Promise.resolve('');
}
let drawingId = currentNode.getAttribute('drawio-diagram');
return DrawIO.load(drawingId);
}
window.tinymce.PluginManager.add('drawio', function(editor, url) {
editor.addCommand('drawio', () => {
let selectedNode = editor.selection.getNode();
showDrawingEditor(editor, isDrawing(selectedNode) ? selectedNode : null);
});
editor.addButton('drawio', {
type: 'splitbutton',
tooltip: 'Drawing',
image: `data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiMwMDAwMDAiICB4bWxucz0iaHR0cDovL3d3 dy53My5vcmcvMjAwMC9zdmciPgogICAgPHBhdGggZD0iTTIzIDdWMWgtNnYySDdWMUgxdjZoMnYx MEgxdjZoNnYtMmgxMHYyaDZ2LTZoLTJWN2gyek0zIDNoMnYySDNWM3ptMiAxOEgzdi0yaDJ2Mnpt MTItMkg3di0ySDVWN2gyVjVoMTB2MmgydjEwaC0ydjJ6bTQgMmgtMnYtMmgydjJ6TTE5IDVWM2gy djJoLTJ6bS01LjI3IDloLTMuNDlsLS43MyAySDcuODlsMy40LTloMS40bDMuNDEgOWgtMS42M2wt Ljc0LTJ6bS0zLjA0LTEuMjZoMi42MUwxMiA4LjkxbC0xLjMxIDMuODN6Ii8+CiAgICA8cGF0aCBk PSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+Cjwvc3ZnPg==`,
cmd: 'drawio',
menu: [
{
text: 'Drawing Manager',
onclick() {
let selectedNode = editor.selection.getNode();
showDrawingManager(editor, isDrawing(selectedNode) ? selectedNode : null);
}
}
]
});
editor.on('dblclick', event => {
let selectedNode = editor.selection.getNode();
if (!isDrawing(selectedNode)) return;
showDrawingEditor(editor, selectedNode);
});
editor.on('SetContent', function () {
const drawings = editor.$('body > div[drawio-diagram]');
if (!drawings.length) return;
editor.undoManager.transact(function () {
drawings.each((index, elem) => {
elem.setAttribute('contenteditable', 'false');
});
});
});
});
}
function customHrPlugin() {
window.tinymce.PluginManager.add('customhr', function (editor) {
editor.addCommand('InsertHorizontalRule', function () {
let hrElem = document.createElement('hr');
let cNode = editor.selection.getNode();
let parentNode = cNode.parentNode;
parentNode.insertBefore(hrElem, cNode);
});
editor.addButton('hr', {
icon: 'hr',
tooltip: 'Horizontal line',
cmd: 'InsertHorizontalRule'
});
editor.addMenuItem('hr', {
icon: 'hr',
text: 'Horizontal line',
cmd: 'InsertHorizontalRule',
context: 'insert'
});
});
}
function listenForBookStackEditorEvents(editor) {
// Replace editor content
window.$events.listen('editor::replace', ({html}) => {
editor.setContent(html);
});
// Append editor content
window.$events.listen('editor::append', ({html}) => {
const content = editor.getContent() + html;
editor.setContent(content);
});
// Prepend editor content
window.$events.listen('editor::prepend', ({html}) => {
const content = html + editor.getContent();
editor.setContent(content);
});
}
class WysiwygEditor {
constructor(elem) {
this.elem = elem;
const pageEditor = document.getElementById('page-editor');
this.pageId = pageEditor.getAttribute('page-id');
this.textDirection = pageEditor.getAttribute('text-direction');
this.plugins = "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor media";
this.loadPlugins();
this.tinyMceConfig = this.getTinyMceConfig();
window.tinymce.init(this.tinyMceConfig);
}
loadPlugins() {
codePlugin();
customHrPlugin();
if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') === 'true') {
drawIoPlugin();
this.plugins += ' drawio';
}
if (this.textDirection === 'rtl') {
this.plugins += ' directionality'
}
}
getToolBar() {
const textDirPlugins = this.textDirection === 'rtl' ? 'ltr rtl' : '';
return `undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr drawio media | removeformat code ${textDirPlugins} fullscreen`
}
getTinyMceConfig() {
const context = this;
return {
selector: '#html-editor',
content_css: [
window.baseUrl('/dist/styles.css'),
],
branding: false,
body_class: 'page-content',
browser_spellcheck: true,
relative_urls: false,
directionality : this.textDirection,
remove_script_host: false,
document_base_url: window.baseUrl('/'),
end_container_on_empty_block: true,
statusbar: false,
menubar: false,
paste_data_images: false,
extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram]',
automatic_uploads: false,
valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre],+div[img]",
plugins: this.plugins,
imagetools_toolbar: 'imageoptions',
toolbar: this.getToolBar(),
content_style: "html, body {background: #FFF;} body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}",
style_formats: [
{title: "Header Large", format: "h2"},
{title: "Header Medium", format: "h3"},
{title: "Header Small", format: "h4"},
{title: "Header Tiny", format: "h5"},
{title: "Paragraph", format: "p", exact: true, classes: ''},
{title: "Blockquote", format: "blockquote"},
{title: "Code Block", icon: "code", cmd: 'codeeditor', format: 'codeeditor'},
{title: "Inline Code", icon: "code", inline: "code"},
{title: "Callouts", items: [
{title: "Info", format: 'calloutinfo'},
{title: "Success", format: 'calloutsuccess'},
{title: "Warning", format: 'calloutwarning'},
{title: "Danger", format: 'calloutdanger'}
]},
],
style_formats_merge: false,
media_alt_source: false,
media_poster: false,
formats: {
codeeditor: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div'},
alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
calloutsuccess: {block: 'p', exact: true, attributes: {class: 'callout success'}},
calloutinfo: {block: 'p', exact: true, attributes: {class: 'callout info'}},
calloutwarning: {block: 'p', exact: true, attributes: {class: 'callout warning'}},
calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}}
},
file_browser_callback: function (field_name, url, type, win) {
if (type === 'file') {
window.EntitySelectorPopup.show(function(entity) {
const originalField = win.document.getElementById(field_name);
originalField.value = entity.link;
const mceForm = originalField.closest('.mce-form');
mceForm.querySelectorAll('input')[2].value = entity.name;
});
}
if (type === 'image') {
// Show image manager
window.ImageManager.show(function (image) {
// Set popover link input to image url then fire change event
// to ensure the new value sticks
win.document.getElementById(field_name).value = image.url;
if ("createEvent" in document) {
let evt = document.createEvent("HTMLEvents");
evt.initEvent("change", false, true);
win.document.getElementById(field_name).dispatchEvent(evt);
} else {
win.document.getElementById(field_name).fireEvent("onchange");
}
// Replace the actively selected content with the linked image
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += '</a>';
win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html);
}, 'gallery');
}
},
paste_preprocess: function (plugin, args) {
let content = args.content;
if (content.indexOf('<img src="file://') !== -1) {
args.content = '';
}
},
init_instance_callback: function(editor) {
loadCustomHeadContent(editor);
},
setup: function (editor) {
editor.on('ExecCommand change input NodeChange ObjectResized', editorChange);
editor.on('init', () => {
editorChange();
// Scroll to the content if needed.
const queryParams = (new URL(window.location)).searchParams;
const scrollId = queryParams.get('content-id');
if (scrollId) {
scrollToText(scrollId);
}
// Override for touch events to allow scroll on mobile
const container = editor.getContainer();
const toolbarButtons = container.querySelectorAll('.mce-btn');
for (let button of toolbarButtons) {
button.addEventListener('touchstart', event => {
event.stopPropagation();
});
}
window.editor = editor;
});
function editorChange() {
let content = editor.getContent();
window.$events.emit('editor-html-change', content);
}
function scrollToText(scrollId) {
const element = editor.dom.get(encodeURIComponent(scrollId).replace(/!/g, '%21'));
if (!element) {
return;
}
// scroll the element into the view and put the cursor at the end.
element.scrollIntoView();
editor.selection.select(element, true);
editor.selection.collapse(false);
editor.focus();
}
listenForBookStackEditorEvents(editor);
// TODO - Update to standardise across both editors
// Use events within listenForBookStackEditorEvents instead (Different event signature)
window.$events.listen('editor-html-update', html => {
editor.setContent(html);
editor.selection.select(editor.getBody(), true);
editor.selection.collapse(false);
editorChange(html);
});
registerEditorShortcuts(editor);
let wrap;
function hasTextContent(node) {
return node && !!( node.textContent || node.innerText );
}
editor.on('dragstart', function () {
let node = editor.selection.getNode();
if (node.nodeName !== 'IMG') return;
wrap = editor.dom.getParent(node, '.mceTemp');
if (!wrap && node.parentNode.nodeName === 'A' && !hasTextContent(node.parentNode)) {
wrap = node.parentNode;
}
});
editor.on('drop', function (event) {
let dom = editor.dom,
rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc());
// Template insertion
const templateId = event.dataTransfer.getData('bookstack/template');
if (templateId) {
event.preventDefault();
window.$http.get(`/templates/${templateId}`).then(resp => {
editor.selection.setRng(rng);
editor.undoManager.transact(function () {
editor.execCommand('mceInsertContent', false, resp.data.html);
});
});
}
// Don't allow anything to be dropped in a captioned image.
if (dom.getParent(rng.startContainer, '.mceTemp')) {
event.preventDefault();
} else if (wrap) {
event.preventDefault();
editor.undoManager.transact(function () {
editor.selection.setRng(rng);
editor.selection.setNode(wrap);
dom.remove(wrap);
});
}
wrap = null;
});
// Custom Image picker button
editor.addButton('image-insert', {
title: 'My title',
icon: 'image',
tooltip: 'Insert an image',
onclick: function () {
window.ImageManager.show(function (image) {
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += '</a>';
editor.execCommand('mceInsertContent', false, html);
}, 'gallery');
}
});
// Paste image-uploads
editor.on('paste', event => editorPaste(event, editor, context));
}
};
}
}
export default WysiwygEditor;

32
resources/js/index.js Normal file
View File

@ -0,0 +1,32 @@
// Url retrieval function
window.baseUrl = function(path) {
let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content');
if (basePath[basePath.length-1] === '/') basePath = basePath.slice(0, basePath.length-1);
if (path[0] === '/') path = path.slice(1);
return basePath + '/' + path;
};
// Set events and http services on window
import Events from "./services/events"
import httpInstance from "./services/http"
const eventManager = new Events();
window.$http = httpInstance;
window.$events = eventManager;
// Translation setup
// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
import Translations from "./services/translations"
const translator = new Translations();
window.trans = translator.get.bind(translator);
window.trans_choice = translator.getPlural.bind(translator);
// Make services available to Vue instances
import Vue from "vue"
Vue.prototype.$http = httpInstance;
Vue.prototype.$events = eventManager;
// Load Vues and components
import vues from "./vues/vues"
import components from "./components"
vues();
components();

View File

@ -0,0 +1,106 @@
/**
* Fade out the given element.
* @param {Element} element
* @param {Number} animTime
* @param {Function|null} onComplete
*/
export function fadeOut(element, animTime = 400, onComplete = null) {
animateStyles(element, {
opacity: ['1', '0']
}, animTime, () => {
element.style.display = 'none';
if (onComplete) onComplete();
});
}
/**
* Hide the element by sliding the contents upwards.
* @param {Element} element
* @param {Number} animTime
*/
export function slideUp(element, animTime = 400) {
const currentHeight = element.getBoundingClientRect().height;
const computedStyles = getComputedStyle(element);
const currentPaddingTop = computedStyles.getPropertyValue('padding-top');
const currentPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
const animStyles = {
height: [`${currentHeight}px`, '0px'],
overflow: ['hidden', 'hidden'],
paddingTop: [currentPaddingTop, '0px'],
paddingBottom: [currentPaddingBottom, '0px'],
};
animateStyles(element, animStyles, animTime, () => {
element.style.display = 'none';
});
}
/**
* 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) {
element.style.display = 'block';
const targetHeight = element.getBoundingClientRect().height;
const computedStyles = getComputedStyle(element);
const targetPaddingTop = computedStyles.getPropertyValue('padding-top');
const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
const animStyles = {
height: ['0px', `${targetHeight}px`],
overflow: ['hidden', 'hidden'],
paddingTop: ['0px', targetPaddingTop],
paddingBottom: ['0px', targetPaddingBottom],
};
animateStyles(element, animStyles, animTime);
}
/**
* 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<object, any>}
*/
const animateStylesCleanupMap = 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
* 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) {
const styleNames = Object.keys(styles);
for (let style of styleNames) {
element.style[style] = styles[style][0];
}
const cleanup = () => {
for (let style of styleNames) {
element.style[style] = null;
}
element.style.transition = null;
element.removeEventListener('transitionend', cleanup);
if (onComplete) onComplete();
};
setTimeout(() => {
requestAnimationFrame(() => {
element.style.transition = `all ease-in-out ${animTime}ms`;
for (let style of styleNames) {
element.style[style] = styles[style][1];
}
if (animateStylesCleanupMap.has(element)) {
const oldCleanup = animateStylesCleanupMap.get(element);
element.removeEventListener('transitionend', oldCleanup);
}
element.addEventListener('transitionend', cleanup);
animateStylesCleanupMap.set(element, cleanup);
});
}, 10);
}

View File

@ -0,0 +1,280 @@
import CodeMirror from "codemirror";
import Clipboard from "clipboard/dist/clipboard.min";
// Modes
import 'codemirror/mode/css/css';
import 'codemirror/mode/clike/clike';
import 'codemirror/mode/diff/diff';
import 'codemirror/mode/go/go';
import 'codemirror/mode/htmlmixed/htmlmixed';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/julia/julia';
import 'codemirror/mode/lua/lua';
import 'codemirror/mode/haskell/haskell';
import 'codemirror/mode/markdown/markdown';
import 'codemirror/mode/mllike/mllike';
import 'codemirror/mode/nginx/nginx';
import 'codemirror/mode/php/php';
import 'codemirror/mode/powershell/powershell';
import 'codemirror/mode/python/python';
import 'codemirror/mode/ruby/ruby';
import 'codemirror/mode/rust/rust';
import 'codemirror/mode/shell/shell';
import 'codemirror/mode/sql/sql';
import 'codemirror/mode/toml/toml';
import 'codemirror/mode/xml/xml';
import 'codemirror/mode/yaml/yaml';
// Addons
import 'codemirror/addon/scroll/scrollpastend';
const modeMap = {
css: 'css',
c: 'text/x-csrc',
java: 'text/x-java',
scala: 'text/x-scala',
kotlin: 'text/x-kotlin',
'c++': 'text/x-c++src',
'c#': 'text/x-csharp',
csharp: 'text/x-csharp',
diff: 'diff',
go: 'go',
haskell: 'haskell',
hs: 'haskell',
html: 'htmlmixed',
javascript: 'javascript',
json: {name: 'javascript', json: true},
js: 'javascript',
jl: 'julia',
julia: 'julia',
lua: 'lua',
md: 'markdown',
mdown: 'markdown',
markdown: 'markdown',
ml: 'mllike',
nginx: 'nginx',
powershell: 'powershell',
ocaml: 'mllike',
php: 'php',
py: 'python',
python: 'python',
ruby: 'ruby',
rust: 'rust',
rb: 'ruby',
rs: 'rust',
shell: 'shell',
sh: 'shell',
bash: 'shell',
toml: 'toml',
sql: 'text/x-sql',
xml: 'xml',
yaml: 'yaml',
yml: 'yaml',
};
/**
* Highlight pre elements on a page
*/
function highlight() {
let codeBlocks = document.querySelectorAll('.page-content pre, .comment-box .content pre');
for (let i = 0; i < codeBlocks.length; i++) {
highlightElem(codeBlocks[i]);
}
}
/**
* Add code highlighting to a single element.
* @param {HTMLElement} elem
*/
function highlightElem(elem) {
let innerCodeElem = elem.querySelector('code[class^=language-]');
let mode = '';
if (innerCodeElem !== null) {
let langName = innerCodeElem.className.replace('language-', '');
mode = getMode(langName);
}
elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
let content = elem.textContent.trim();
let cm = CodeMirror(function(elt) {
elem.parentNode.replaceChild(elt, elem);
}, {
value: content,
mode: mode,
lineNumbers: true,
theme: getTheme(),
readOnly: true
});
addCopyIcon(cm);
}
/**
* Add a button to a CodeMirror instance which copies the contents to the clipboard upon click.
* @param cmInstance
*/
function addCopyIcon(cmInstance) {
const copyIcon = `<svg viewBox="0 0 24 24" width="16" height="16" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none"/><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;
const copyButton = document.createElement('div');
copyButton.classList.add('CodeMirror-copy');
copyButton.innerHTML = copyIcon;
cmInstance.display.wrapper.appendChild(copyButton);
const clipboard = new Clipboard(copyButton, {
text: function(trigger) {
return cmInstance.getValue()
}
});
clipboard.on('success', event => {
copyButton.classList.add('success');
setTimeout(() => {
copyButton.classList.remove('success');
}, 240);
});
}
/**
* Search for a codemirror code based off a user suggestion
* @param suggestion
* @returns {string}
*/
function getMode(suggestion) {
suggestion = suggestion.trim().replace(/^\./g, '').toLowerCase();
return (typeof modeMap[suggestion] !== 'undefined') ? modeMap[suggestion] : '';
}
/**
* Ge the theme to use for CodeMirror instances.
* @returns {*|string}
*/
function getTheme() {
return window.codeTheme || 'base16-light';
}
/**
* Create a CodeMirror instance for showing inside the WYSIWYG editor.
* Manages a textarea element to hold code content.
* @param {HTMLElement} elem
* @returns {{wrap: Element, editor: *}}
*/
function wysiwygView(elem) {
let doc = elem.ownerDocument;
let codeElem = elem.querySelector('code');
let lang = (elem.className || '').replace('language-', '');
if (lang === '' && codeElem) {
lang = (codeElem.className || '').replace('language-', '')
}
elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
let content = elem.textContent;
let newWrap = doc.createElement('div');
let newTextArea = doc.createElement('textarea');
newWrap.className = 'CodeMirrorContainer';
newWrap.setAttribute('data-lang', lang);
newWrap.setAttribute('dir', 'ltr');
newTextArea.style.display = 'none';
elem.parentNode.replaceChild(newWrap, elem);
newWrap.appendChild(newTextArea);
newWrap.contentEditable = false;
newTextArea.textContent = content;
let cm = CodeMirror(function(elt) {
newWrap.appendChild(elt);
}, {
value: content,
mode: getMode(lang),
lineNumbers: true,
theme: getTheme(),
readOnly: true
});
setTimeout(() => {
cm.refresh();
}, 300);
return {wrap: newWrap, editor: cm};
}
/**
* Create a CodeMirror instance to show in the WYSIWYG pop-up editor
* @param {HTMLElement} elem
* @param {String} modeSuggestion
* @returns {*}
*/
function popupEditor(elem, modeSuggestion) {
let content = elem.textContent;
return CodeMirror(function(elt) {
elem.parentNode.insertBefore(elt, elem);
elem.style.display = 'none';
}, {
value: content,
mode: getMode(modeSuggestion),
lineNumbers: true,
theme: getTheme(),
lineWrapping: true
});
}
/**
* Set the mode of a codemirror instance.
* @param cmInstance
* @param modeSuggestion
*/
function setMode(cmInstance, modeSuggestion) {
cmInstance.setOption('mode', getMode(modeSuggestion));
}
/**
* Set the content of a cm instance.
* @param cmInstance
* @param codeContent
*/
function setContent(cmInstance, codeContent) {
cmInstance.setValue(codeContent);
setTimeout(() => {
cmInstance.refresh();
}, 10);
}
/**
* Get a CodeMirror instace to use for the markdown editor.
* @param {HTMLElement} elem
* @returns {*}
*/
function markdownEditor(elem) {
let content = elem.textContent;
return CodeMirror(function (elt) {
elem.parentNode.insertBefore(elt, elem);
elem.style.display = 'none';
}, {
value: content,
mode: "markdown",
lineNumbers: true,
theme: getTheme(),
lineWrapping: true,
scrollPastEnd: true,
});
}
/**
* Get the 'meta' key dependant on the user's system.
* @returns {string}
*/
function getMetaKey() {
let mac = CodeMirror.keyMap["default"] == CodeMirror.keyMap.macDefault;
return mac ? "Cmd" : "Ctrl";
}
export default {
highlight: highlight,
wysiwygView: wysiwygView,
popupEditor: popupEditor,
setMode: setMode,
setContent: setContent,
markdownEditor: markdownEditor,
getMetaKey: getMetaKey,
};

View File

@ -0,0 +1,24 @@
export function getCurrentDay() {
let date = new Date();
let month = date.getMonth() + 1;
let day = date.getDate();
return `${date.getFullYear()}-${(month>9?'':'0') + month}-${(day>9?'':'0') + day}`;
}
export function utcTimeStampToLocalTime(timestamp) {
let date = new Date(timestamp * 1000);
let hours = date.getHours();
let mins = date.getMinutes();
return `${(hours>9?'':'0') + hours}:${(mins>9?'':'0') + mins}`;
}
export function formatDateTime(date) {
let month = date.getMonth() + 1;
let day = date.getDate();
let hours = date.getHours();
let mins = date.getMinutes();
return `${date.getFullYear()}-${(month>9?'':'0') + month}-${(day>9?'':'0') + day} ${(hours>9?'':'0') + hours}:${(mins>9?'':'0') + mins}`;
}

View File

@ -0,0 +1,75 @@
/**
* Run the given callback against each element that matches the given selector.
* @param {String} selector
* @param {Function<Element>} callback
*/
export function forEach(selector, callback) {
const elements = document.querySelectorAll(selector);
for (let element of elements) {
callback(element);
}
}
/**
* Helper to listen to multiple DOM events
* @param {Element} listenerElement
* @param {Array<String>} events
* @param {Function<Event>} callback
*/
export function onEvents(listenerElement, events, callback) {
for (let eventName of events) {
listenerElement.addEventListener(eventName, 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 listenerElement
* @param callback
*/
export function onSelect(listenerElement, callback) {
listenerElement.addEventListener('click', callback);
listenerElement.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
callback(event);
}
});
}
/**
* 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, function(event) {
const matchingChild = event.target.closest(childSelector);
if (matchingChild) {
callback.call(matchingChild, event, matchingChild);
}
});
}
/**
* 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}
*/
export function findText(selector, text) {
const elements = document.querySelectorAll(selector);
text = text.toLowerCase();
for (let element of elements) {
if (element.textContent.toLowerCase().includes(text)) {
return element;
}
}
return null;
}

View File

@ -0,0 +1,88 @@
const drawIoUrl = 'https://www.draw.io/?embed=1&ui=atlas&spin=1&proto=json';
let iFrame = null;
let onInit, onSave;
/**
* Show the draw.io editor.
* @param onInitCallback - Must return a promise with the xml to load for the editor.
* @param onSaveCallback - Is called with the drawing data on save.
*/
function show(onInitCallback, onSaveCallback) {
onInit = onInitCallback;
onSave = onSaveCallback;
iFrame = document.createElement('iframe');
iFrame.setAttribute('frameborder', '0');
window.addEventListener('message', drawReceive);
iFrame.setAttribute('src', drawIoUrl);
iFrame.setAttribute('class', 'fullscreen');
iFrame.style.backgroundColor = '#FFFFFF';
document.body.appendChild(iFrame);
}
function close() {
drawEventClose();
}
function drawReceive(event) {
if (!event.data || event.data.length < 1) return;
let message = JSON.parse(event.data);
if (message.event === 'init') {
drawEventInit();
} else if (message.event === 'exit') {
drawEventClose();
} else if (message.event === 'save') {
drawEventSave(message);
} else if (message.event === 'export') {
drawEventExport(message);
}
}
function drawEventExport(message) {
if (onSave) {
onSave(message.data);
}
}
function drawEventSave(message) {
drawPostMessage({action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing'});
}
function drawEventInit() {
if (!onInit) return;
onInit().then(xml => {
drawPostMessage({action: 'load', autosave: 1, xml: xml});
});
}
function drawEventClose() {
window.removeEventListener('message', drawReceive);
if (iFrame) document.body.removeChild(iFrame);
}
function drawPostMessage(data) {
iFrame.contentWindow.postMessage(JSON.stringify(data), '*');
}
async function upload(imageData, pageUploadedToId) {
let data = {
image: imageData,
uploaded_to: pageUploadedToId,
};
const resp = await window.$http.post(window.baseUrl(`/images/drawio`), data);
return resp.data;
}
/**
* Load an existing image, by fetching it as Base64 from the system.
* @param drawingId
* @returns {Promise<string>}
*/
async function load(drawingId) {
const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`));
return `data:image/png;base64,${resp.data.content}`;
}
export default {show, close, upload, load};

View File

@ -0,0 +1,28 @@
/**
* Simple global events manager
*/
class Events {
constructor() {
this.listeners = {};
this.stack = [];
}
emit(eventName, eventData) {
this.stack.push({name: eventName, data: eventData});
if (typeof this.listeners[eventName] === 'undefined') return this;
let eventsToStart = this.listeners[eventName];
for (let i = 0; i < eventsToStart.length; i++) {
let event = eventsToStart[i];
event(eventData);
}
return this;
}
listen(eventName, callback) {
if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
this.listeners[eventName].push(callback);
return this;
}
}
export default Events;

View File

@ -0,0 +1,146 @@
/**
* Perform a HTTP GET request.
* Can easily pass query parameters as the second parameter.
* @param {String} url
* @param {Object} params
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function get(url, params = {}) {
return request(url, {
method: 'GET',
params,
});
}
/**
* Perform a HTTP POST request.
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function post(url, data = null) {
return dataRequest('POST', url, data);
}
/**
* Perform a HTTP PUT request.
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function put(url, data = null) {
return dataRequest('PUT', url, data);
}
/**
* Perform a HTTP PATCH request.
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function patch(url, data = null) {
return dataRequest('PATCH', url, data);
}
/**
* Perform a HTTP DELETE request.
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function performDelete(url, data = null) {
return dataRequest('DELETE', url, data);
}
/**
* Perform a HTTP request to the back-end that includes data in the body.
* Parses the body to JSON if an object, setting the correct headers.
* @param {String} method
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function dataRequest(method, url, data = null) {
const options = {
method: method,
body: data,
};
if (typeof data === 'object' && !(data instanceof FormData)) {
options.headers = {'Content-Type': 'application/json'};
options.body = JSON.stringify(data);
}
return request(url, options)
}
/**
* Create a new HTTP request, setting the required CSRF information
* to communicate with the back-end. Parses & formats the response.
* @param {String} url
* @param {Object} options
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function request(url, options = {}) {
if (!url.startsWith('http')) {
url = window.baseUrl(url);
}
if (options.params) {
const urlObj = new URL(url);
for (let paramName of Object.keys(options.params)) {
const value = options.params[paramName];
if (typeof value !== 'undefined' && value !== null) {
urlObj.searchParams.set(paramName, value);
}
}
url = urlObj.toString();
}
const csrfToken = document.querySelector('meta[name=token]').getAttribute('content');
options = Object.assign({}, options, {
'credentials': 'same-origin',
});
options.headers = Object.assign({}, options.headers || {}, {
'baseURL': window.baseUrl(''),
'X-CSRF-TOKEN': csrfToken,
});
const response = await fetch(url, options);
const content = await getResponseContent(response);
return {
data: content,
headers: response.headers,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
url: response.url,
original: response,
}
}
/**
* Get the content from a fetch response.
* Checks the content-type header to determine the format.
* @param response
* @returns {Promise<Object|String>}
*/
async function getResponseContent(response) {
const responseContentType = response.headers.get('Content-Type');
const subType = responseContentType.split('/').pop();
if (subType === 'javascript' || subType === 'json') {
return await response.json();
}
return await response.text();
}
export default {
get: get,
post: post,
put: put,
patch: patch,
delete: performDelete,
};

View File

@ -0,0 +1,120 @@
/**
* Translation Manager
* Handles the JavaScript side of translating strings
* in a way which fits with Laravel.
*/
class Translator {
/**
* Create an instance, Passing in the required translations
* @param translations
*/
constructor(translations) {
this.store = new Map();
this.parseTranslations();
}
/**
* Parse translations out of the page and place into the store.
*/
parseTranslations() {
const translationMetaTags = document.querySelectorAll('meta[name="translation"]');
for (let tag of translationMetaTags) {
const key = tag.getAttribute('key');
const value = tag.getAttribute('value');
this.store.set(key, value);
}
}
/**
* Get a translation, Same format as laravel's 'trans' helper
* @param key
* @param replacements
* @returns {*}
*/
get(key, replacements) {
const text = this.getTransText(key);
return this.performReplacements(text, replacements);
}
/**
* Get pluralised text, Dependant on the given count.
* Same format at laravel's 'trans_choice' helper.
* @param key
* @param count
* @param replacements
* @returns {*}
*/
getPlural(key, count, replacements) {
const text = this.getTransText(key);
const splitText = text.split('|');
const exactCountRegex = /^{([0-9]+)}/;
const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
let result = null;
for (let t of splitText) {
// Parse exact matches
const exactMatches = t.match(exactCountRegex);
if (exactMatches !== null && Number(exactMatches[1]) === count) {
result = t.replace(exactCountRegex, '').trim();
break;
}
// Parse range matches
const rangeMatches = t.match(rangeRegex);
if (rangeMatches !== null) {
const rangeStart = Number(rangeMatches[1]);
if (rangeStart <= count && (rangeMatches[2] === '*' || Number(rangeMatches[2]) >= count)) {
result = t.replace(rangeRegex, '').trim();
break;
}
}
}
if (result === null && splitText.length > 1) {
result = (count === 1) ? splitText[0] : splitText[1];
}
if (result === null) {
result = splitText[0];
}
return this.performReplacements(result, replacements);
}
/**
* Fetched translation text from the store for the given key.
* @param key
* @returns {String|Object}
*/
getTransText(key) {
const value = this.store.get(key);
if (value === undefined) {
console.warn(`Translation with key "${key}" does not exist`);
}
return value;
}
/**
* Perform replacements on a string.
* @param {String} string
* @param {Object} replacements
* @returns {*}
*/
performReplacements(string, replacements) {
if (!replacements) return string;
const replaceMatches = string.match(/:([\S]+)/g);
if (replaceMatches === null) return string;
replaceMatches.forEach(match => {
const key = match.substring(1);
if (typeof replacements[key] === 'undefined') return;
string = string.replace(match, replacements[key]);
});
return string;
}
}
export default Translator;

View File

@ -0,0 +1,48 @@
/**
* 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 func
* @param wait
* @param immediate
* @returns {Function}
*/
export function debounce(func, wait, immediate) {
let timeout;
return function() {
const context = this, args = arguments;
const later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
};
/**
* Scroll and highlight an element.
* @param {HTMLElement} element
*/
export function scrollAndHighlightElement(element) {
if (!element) return;
element.scrollIntoView({behavior: 'smooth'});
const color = document.getElementById('custom-styles').getAttribute('data-color-light');
const initColor = window.getComputedStyle(element).getPropertyValue('background-color');
element.style.backgroundColor = color;
setTimeout(() => {
element.classList.add('selectFade');
element.style.backgroundColor = initColor;
}, 10);
setTimeout(() => {
element.classList.remove('selectFade');
element.style.backgroundColor = '';
}, 3000);
}

View File

@ -0,0 +1,142 @@
import draggable from "vuedraggable";
import dropzone from "./components/dropzone";
function mounted() {
this.pageId = this.$el.getAttribute('page-id');
this.file = this.newFile();
this.$http.get(window.baseUrl(`/attachments/get/page/${this.pageId}`)).then(resp => {
this.files = resp.data;
}).catch(err => {
this.checkValidationErrors('get', err);
});
}
let data = {
pageId: null,
files: [],
fileToEdit: null,
file: {},
tab: 'list',
editTab: 'file',
errors: {link: {}, edit: {}, delete: {}}
};
const components = {dropzone, draggable};
let methods = {
newFile() {
return {page_id: this.pageId};
},
getFileUrl(file) {
if (file.external && file.path.indexOf('http') !== 0) {
return file.path;
}
return window.baseUrl(`/attachments/${file.id}`);
},
fileSortUpdate() {
this.$http.put(window.baseUrl(`/attachments/sort/page/${this.pageId}`), {files: this.files}).then(resp => {
this.$events.emit('success', resp.data.message);
}).catch(err => {
this.checkValidationErrors('sort', err);
});
},
startEdit(file) {
this.fileToEdit = Object.assign({}, file);
this.fileToEdit.link = file.external ? file.path : '';
this.editTab = file.external ? 'link' : 'file';
},
deleteFile(file) {
if (!file.deleting) {
return this.$set(file, 'deleting', true);
}
this.$http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
this.$events.emit('success', resp.data.message);
this.files.splice(this.files.indexOf(file), 1);
}).catch(err => {
this.checkValidationErrors('delete', err)
});
},
uploadSuccess(upload) {
this.files.push(upload.data);
this.$events.emit('success', trans('entities.attachments_file_uploaded'));
},
uploadSuccessUpdate(upload) {
let fileIndex = this.filesIndex(upload.data);
if (fileIndex === -1) {
this.files.push(upload.data)
} else {
this.files.splice(fileIndex, 1, upload.data);
}
if (this.fileToEdit && this.fileToEdit.id === upload.data.id) {
this.fileToEdit = Object.assign({}, upload.data);
}
this.$events.emit('success', trans('entities.attachments_file_updated'));
},
checkValidationErrors(groupName, err) {
if (typeof err.response.data === "undefined" && typeof err.response.data === "undefined") return;
this.errors[groupName] = err.response.data;
},
getUploadUrl(file) {
let url = window.baseUrl(`/attachments/upload`);
if (typeof file !== 'undefined') url += `/${file.id}`;
return url;
},
cancelEdit() {
this.fileToEdit = null;
},
attachNewLink(file) {
file.uploaded_to = this.pageId;
this.errors.link = {};
this.$http.post(window.baseUrl('/attachments/link'), file).then(resp => {
this.files.push(resp.data);
this.file = this.newFile();
this.$events.emit('success', trans('entities.attachments_link_attached'));
}).catch(err => {
this.checkValidationErrors('link', err);
});
},
updateFile(file) {
$http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
let search = this.filesIndex(resp.data);
if (search === -1) {
this.files.push(resp.data);
} else {
this.files.splice(search, 1, resp.data);
}
if (this.fileToEdit && !file.external) this.fileToEdit.link = '';
this.fileToEdit = false;
this.$events.emit('success', trans('entities.attachments_updated_success'));
}).catch(err => {
this.checkValidationErrors('edit', err);
});
},
filesIndex(file) {
for (let i = 0, len = this.files.length; i < len; i++) {
if (this.files[i].id === file.id) return i;
}
return -1;
}
};
export default {
data, methods, mounted, components,
};

View File

@ -0,0 +1,43 @@
import codeLib from "../services/code";
const methods = {
show() {
if (!this.editor) this.editor = codeLib.popupEditor(this.$refs.editor, this.language);
this.$refs.overlay.components.overlay.show();
},
hide() {
this.$refs.overlay.components.overlay.hide();
},
updateEditorMode(language) {
codeLib.setMode(this.editor, language);
},
updateLanguage(lang) {
this.language = lang;
this.updateEditorMode(lang);
},
open(code, language, callback) {
this.show();
this.updateEditorMode(language);
this.language = language;
codeLib.setContent(this.editor, code);
this.code = code;
this.callback = callback;
},
save() {
if (!this.callback) return;
this.callback(this.editor.getValue(), this.language);
this.hide();
}
};
const data = {
editor: null,
language: '',
code: '',
callback: null
};
export default {
methods,
data
};

View File

@ -0,0 +1,131 @@
const template = `
<div>
<input :value="value" :autosuggest-type="type" ref="input"
:placeholder="placeholder" :name="name"
@input="inputUpdate($event.target.value)" @focus="inputUpdate($event.target.value)"
@blur="inputBlur"
@keydown="inputKeydown"
:aria-label="placeholder"
/>
<ul class="suggestion-box" v-if="showSuggestions">
<li v-for="(suggestion, i) in suggestions"
@click="selectSuggestion(suggestion)"
:class="{active: (i === active)}">{{suggestion}}</li>
</ul>
</div>
`;
function data() {
return {
suggestions: [],
showSuggestions: false,
active: 0,
};
}
const ajaxCache = {};
const props = ['url', 'type', 'value', 'placeholder', 'name'];
function getNameInputVal(valInput) {
let parentRow = valInput.parentNode.parentNode;
let nameInput = parentRow.querySelector('[autosuggest-type="name"]');
return (nameInput === null) ? '' : nameInput.value;
}
const methods = {
inputUpdate(inputValue) {
this.$emit('input', inputValue);
let params = {};
if (this.type === 'value') {
let nameVal = getNameInputVal(this.$el);
if (nameVal !== "") params.name = nameVal;
}
this.getSuggestions(inputValue.slice(0, 3), params).then(suggestions => {
if (inputValue.length === 0) {
this.displaySuggestions(suggestions.slice(0, 6));
return;
}
// Filter to suggestions containing searched term
suggestions = suggestions.filter(item => {
return item.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1;
}).slice(0, 4);
this.displaySuggestions(suggestions);
});
},
inputBlur() {
setTimeout(() => {
this.$emit('blur');
this.showSuggestions = false;
}, 100);
},
inputKeydown(event) {
if (event.key === 'Enter') event.preventDefault();
if (!this.showSuggestions) return;
// Down arrow
if (event.key === 'ArrowDown') {
this.active = (this.active === this.suggestions.length - 1) ? 0 : this.active+1;
}
// Up Arrow
else if (event.key === 'ArrowUp') {
this.active = (this.active === 0) ? this.suggestions.length - 1 : this.active-1;
}
// Enter key
else if ((event.key === 'Enter') && !event.shiftKey) {
this.selectSuggestion(this.suggestions[this.active]);
}
// Escape key
else if (event.key === 'Escape') {
this.showSuggestions = false;
}
},
displaySuggestions(suggestions) {
if (suggestions.length === 0) {
this.suggestions = [];
this.showSuggestions = false;
return;
}
this.suggestions = suggestions;
this.showSuggestions = true;
this.active = 0;
},
selectSuggestion(suggestion) {
this.$refs.input.value = suggestion;
this.$refs.input.focus();
this.$emit('input', suggestion);
this.showSuggestions = false;
},
/**
* Get suggestions from BookStack. Store and use local cache if already searched.
* @param {String} input
* @param {Object} params
*/
getSuggestions(input, params) {
params.search = input;
const cacheKey = `${this.url}:${JSON.stringify(params)}`;
if (typeof ajaxCache[cacheKey] !== "undefined") {
return Promise.resolve(ajaxCache[cacheKey]);
}
return this.$http.get(this.url, params).then(resp => {
ajaxCache[cacheKey] = resp.data;
return resp.data;
});
}
};
export default {template, data, props, methods};

View File

@ -0,0 +1,80 @@
import DropZone from "dropzone";
import { fadeOut } from "../../services/animations";
const template = `
<div class="dropzone-container text-center">
<button type="button" class="dz-message">{{placeholder}}</button>
</div>
`;
const props = ['placeholder', 'uploadUrl', 'uploadedTo'];
function mounted() {
const container = this.$el;
const _this = this;
this._dz = new DropZone(container, {
addRemoveLinks: true,
dictRemoveFile: trans('components.image_upload_remove'),
timeout: Number(window.uploadTimeout) || 60000,
maxFilesize: Number(window.uploadLimit) || 256,
url: function() {
return _this.uploadUrl;
},
init: function () {
const dz = this;
dz.on('sending', function (file, xhr, data) {
const token = window.document.querySelector('meta[name=token]').getAttribute('content');
data.append('_token', token);
const uploadedTo = typeof _this.uploadedTo === 'undefined' ? 0 : _this.uploadedTo;
data.append('uploaded_to', uploadedTo);
xhr.ontimeout = function (e) {
dz.emit('complete', file);
dz.emit('error', file, trans('errors.file_upload_timeout'));
}
});
dz.on('success', function (file, data) {
_this.$emit('success', {file, data});
fadeOut(file.previewElement, 800, () => {
dz.removeFile(file);
});
});
dz.on('error', function (file, errorMessage, xhr) {
_this.$emit('error', {file, errorMessage, xhr});
function setMessage(message) {
const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]');
messsageEl.textContent = message;
}
if (xhr && xhr.status === 413) {
setMessage(trans('errors.server_upload_limit'))
} else if (errorMessage.file) {
setMessage(errorMessage.file);
}
});
}
});
}
function data() {
return {};
}
const methods = {
onClose: function () {
this._dz.removeAllFiles(true);
}
};
export default {
template,
props,
mounted,
data,
methods
};

View File

@ -0,0 +1,44 @@
let data = {
id: null,
type: '',
searching: false,
searchTerm: '',
searchResults: '',
};
let computed = {
};
let methods = {
searchBook() {
if (this.searchTerm.trim().length === 0) return;
this.searching = true;
this.searchResults = '';
let url = window.baseUrl(`/search/${this.type}/${this.id}`);
url += `?term=${encodeURIComponent(this.searchTerm)}`;
this.$http.get(url).then(resp => {
this.searchResults = resp.data;
});
},
checkSearchForm() {
this.searching = this.searchTerm > 0;
},
clearSearch() {
this.searching = false;
this.searchTerm = '';
}
};
function mounted() {
this.id = Number(this.$el.getAttribute('entity-id'));
this.type = this.$el.getAttribute('entity-type');
}
export default {
data, computed, methods, mounted
};

View File

@ -0,0 +1,204 @@
import * as Dates from "../services/dates";
import dropzone from "./components/dropzone";
let page = 1;
let previousClickTime = 0;
let previousClickImage = 0;
let dataLoaded = false;
let callback = false;
let baseUrl = '';
let preSearchImages = [];
let preSearchHasMore = false;
const data = {
images: [],
imageType: false,
uploadedTo: false,
selectedImage: false,
dependantPages: false,
showing: false,
filter: null,
hasMore: false,
searching: false,
searchTerm: '',
imageUpdateSuccess: false,
imageDeleteSuccess: false,
deleteConfirm: false,
};
const methods = {
show(providedCallback, imageType = null) {
callback = providedCallback;
this.showing = true;
this.$el.children[0].components.overlay.show();
// Get initial images if they have not yet been loaded in.
if (dataLoaded && imageType === this.imageType) return;
if (imageType) {
this.imageType = imageType;
this.resetState();
}
this.fetchData();
dataLoaded = true;
},
hide() {
if (this.$refs.dropzone) {
this.$refs.dropzone.onClose();
}
this.showing = false;
this.selectedImage = false;
this.$el.children[0].components.overlay.hide();
},
async fetchData() {
const params = {
page,
search: this.searching ? this.searchTerm : null,
uploaded_to: this.uploadedTo || null,
filter_type: this.filter,
};
const {data} = await this.$http.get(baseUrl, params);
this.images = this.images.concat(data.images);
this.hasMore = data.has_more;
page++;
},
setFilterType(filterType) {
this.filter = filterType;
this.resetState();
this.fetchData();
},
resetState() {
this.cancelSearch();
this.resetListView();
this.deleteConfirm = false;
baseUrl = window.baseUrl(`/images/${this.imageType}`);
},
resetListView() {
this.images = [];
this.hasMore = false;
page = 1;
},
searchImages() {
if (this.searchTerm === '') return this.cancelSearch();
// Cache current settings for later
if (!this.searching) {
preSearchImages = this.images;
preSearchHasMore = this.hasMore;
}
this.searching = true;
this.resetListView();
this.fetchData();
},
cancelSearch() {
if (!this.searching) return;
this.searching = false;
this.searchTerm = '';
this.images = preSearchImages;
this.hasMore = preSearchHasMore;
},
imageSelect(image) {
const dblClickTime = 300;
const currentTime = Date.now();
const timeDiff = currentTime - previousClickTime;
const isDblClick = timeDiff < dblClickTime && image.id === previousClickImage;
if (isDblClick) {
this.callbackAndHide(image);
} else {
this.selectedImage = image;
this.deleteConfirm = false;
this.dependantPages = false;
}
previousClickTime = currentTime;
previousClickImage = image.id;
},
callbackAndHide(imageResult) {
if (callback) callback(imageResult);
this.hide();
},
async saveImageDetails() {
let url = window.baseUrl(`/images/${this.selectedImage.id}`);
try {
await this.$http.put(url, this.selectedImage)
} catch (error) {
if (error.response.status === 422) {
let errors = error.response.data;
let message = '';
Object.keys(errors).forEach((key) => {
message += errors[key].join('\n');
});
this.$events.emit('error', message);
}
}
},
async deleteImage() {
if (!this.deleteConfirm) {
const url = window.baseUrl(`/images/usage/${this.selectedImage.id}`);
try {
const {data} = await this.$http.get(url);
this.dependantPages = data;
} catch (error) {
console.error(error);
}
this.deleteConfirm = true;
return;
}
const url = window.baseUrl(`/images/${this.selectedImage.id}`);
await this.$http.delete(url);
this.images.splice(this.images.indexOf(this.selectedImage), 1);
this.selectedImage = false;
this.$events.emit('success', trans('components.image_delete_success'));
this.deleteConfirm = false;
},
getDate(stringDate) {
return Dates.formatDateTime(new Date(stringDate));
},
uploadSuccess(event) {
this.images.unshift(event.data);
this.$events.emit('success', trans('components.image_upload_success'));
},
};
const computed = {
uploadUrl() {
return window.baseUrl(`/images/${this.imageType}`);
}
};
function mounted() {
window.ImageManager = this;
this.imageType = this.$el.getAttribute('image-type');
this.uploadedTo = this.$el.getAttribute('uploaded-to');
baseUrl = window.baseUrl('/images/' + this.imageType)
}
export default {
mounted,
methods,
data,
computed,
components: {dropzone},
};

View File

@ -0,0 +1,150 @@
import * as Dates from "../services/dates";
let autoSaveFrequency = 30;
let autoSave = false;
let draftErroring = false;
let currentContent = {
title: false,
html: false
};
let lastSave = 0;
function mounted() {
let elem = this.$el;
this.draftsEnabled = elem.getAttribute('drafts-enabled') === 'true';
this.editorType = elem.getAttribute('editor-type');
this.pageId= Number(elem.getAttribute('page-id'));
this.isNewDraft = Number(elem.getAttribute('page-new-draft')) === 1;
this.isUpdateDraft = Number(elem.getAttribute('page-update-draft')) === 1;
if (this.pageId !== 0 && this.draftsEnabled) {
window.setTimeout(() => {
this.startAutoSave();
}, 1000);
}
if (this.isUpdateDraft || this.isNewDraft) {
this.draftText = trans('entities.pages_editing_draft');
} else {
this.draftText = trans('entities.pages_editing_page');
}
// Listen to save events from editor
window.$events.listen('editor-save-draft', this.saveDraft);
window.$events.listen('editor-save-page', this.savePage);
// Listen to content changes from the editor
window.$events.listen('editor-html-change', html => {
this.editorHTML = html;
});
window.$events.listen('editor-markdown-change', markdown => {
this.editorMarkdown = markdown;
});
}
let data = {
draftsEnabled: false,
editorType: 'wysiwyg',
pagedId: 0,
isNewDraft: false,
isUpdateDraft: false,
draftText: '',
draftUpdated : false,
changeSummary: '',
editorHTML: '',
editorMarkdown: '',
};
let methods = {
startAutoSave() {
currentContent.title = document.getElementById('name').value.trim();
currentContent.html = this.editorHTML;
autoSave = window.setInterval(() => {
// Return if manually saved recently to prevent bombarding the server
if (Date.now() - lastSave < (1000 * autoSaveFrequency)/2) return;
const newTitle = document.getElementById('name').value.trim();
const newHtml = this.editorHTML;
if (newTitle !== currentContent.title || newHtml !== currentContent.html) {
currentContent.html = newHtml;
currentContent.title = newTitle;
this.saveDraft();
}
}, 1000 * autoSaveFrequency);
},
saveDraft() {
if (!this.draftsEnabled) return;
const data = {
name: document.getElementById('name').value.trim(),
html: this.editorHTML
};
if (this.editorType === 'markdown') data.markdown = this.editorMarkdown;
const url = window.baseUrl(`/ajax/page/${this.pageId}/save-draft`);
window.$http.put(url, data).then(response => {
draftErroring = false;
if (!this.isNewDraft) this.isUpdateDraft = true;
this.draftNotifyChange(`${response.data.message} ${Dates.utcTimeStampToLocalTime(response.data.timestamp)}`);
lastSave = Date.now();
}, errorRes => {
if (draftErroring) return;
window.$events.emit('error', trans('errors.page_draft_autosave_fail'));
draftErroring = true;
});
},
savePage() {
this.$el.closest('form').submit();
},
draftNotifyChange(text) {
this.draftText = text;
this.draftUpdated = true;
window.setTimeout(() => {
this.draftUpdated = false;
}, 2000);
},
discardDraft() {
let url = window.baseUrl(`/ajax/page/${this.pageId}`);
window.$http.get(url).then(response => {
if (autoSave) window.clearInterval(autoSave);
this.draftText = trans('entities.pages_editing_page');
this.isUpdateDraft = false;
window.$events.emit('editor-html-update', response.data.html);
window.$events.emit('editor-markdown-update', response.data.markdown || response.data.html);
document.getElementById('name').value = response.data.name;
window.setTimeout(() => {
this.startAutoSave();
}, 1000);
window.$events.emit('success', trans('entities.pages_draft_discarded'));
});
},
};
let computed = {
changeSummaryShort() {
let len = this.changeSummary.length;
if (len === 0) return trans('entities.pages_edit_set_changelog');
if (len <= 16) return this.changeSummary;
return this.changeSummary.slice(0, 16) + '...';
}
};
export default {
mounted, data, methods, computed,
};

193
resources/js/vues/search.js Normal file
View File

@ -0,0 +1,193 @@
import * as Dates from "../services/dates";
let data = {
terms: '',
termString : '',
search: {
type: {
page: true,
chapter: true,
book: true,
bookshelf: true,
},
exactTerms: [],
tagTerms: [],
option: {},
dates: {
updated_after: false,
updated_before: false,
created_after: false,
created_before: false,
}
}
};
let computed = {
};
let methods = {
appendTerm(term) {
this.termString += ' ' + term;
this.termString = this.termString.replace(/\s{2,}/g, ' ');
this.termString = this.termString.replace(/^\s+/, '');
this.termString = this.termString.replace(/\s+$/, '');
},
exactParse(searchString) {
this.search.exactTerms = [];
let exactFilter = /"(.+?)"/g;
let matches;
while ((matches = exactFilter.exec(searchString)) !== null) {
this.search.exactTerms.push(matches[1]);
}
},
exactChange() {
let exactFilter = /"(.+?)"/g;
this.termString = this.termString.replace(exactFilter, '');
let matchesTerm = this.search.exactTerms.filter(term => term.trim() !== '').map(term => `"${term}"`).join(' ');
this.appendTerm(matchesTerm);
},
addExact() {
this.search.exactTerms.push('');
setTimeout(() => {
let exactInputs = document.querySelectorAll('.exact-input');
exactInputs[exactInputs.length - 1].focus();
}, 100);
},
removeExact(index) {
this.search.exactTerms.splice(index, 1);
this.exactChange();
},
tagParse(searchString) {
this.search.tagTerms = [];
let tagFilter = /\[(.+?)\]/g;
let matches;
while ((matches = tagFilter.exec(searchString)) !== null) {
this.search.tagTerms.push(matches[1]);
}
},
tagChange() {
let tagFilter = /\[(.+?)\]/g;
this.termString = this.termString.replace(tagFilter, '');
let matchesTerm = this.search.tagTerms.filter(term => {
return term.trim() !== '';
}).map(term => {
return `[${term}]`
}).join(' ');
this.appendTerm(matchesTerm);
},
addTag() {
this.search.tagTerms.push('');
setTimeout(() => {
let tagInputs = document.querySelectorAll('.tag-input');
tagInputs[tagInputs.length - 1].focus();
}, 100);
},
removeTag(index) {
this.search.tagTerms.splice(index, 1);
this.tagChange();
},
typeParse(searchString) {
let typeFilter = /{\s?type:\s?(.*?)\s?}/;
let match = searchString.match(typeFilter);
let type = this.search.type;
if (!match) {
type.page = type.book = type.chapter = type.bookshelf = true;
return;
}
let splitTypes = match[1].replace(/ /g, '').split('|');
type.page = (splitTypes.indexOf('page') !== -1);
type.chapter = (splitTypes.indexOf('chapter') !== -1);
type.book = (splitTypes.indexOf('book') !== -1);
type.bookshelf = (splitTypes.indexOf('bookshelf') !== -1);
},
typeChange() {
let typeFilter = /{\s?type:\s?(.*?)\s?}/;
let type = this.search.type;
if (type.page === type.chapter === type.book === type.bookshelf) {
this.termString = this.termString.replace(typeFilter, '');
return;
}
let selectedTypes = Object.keys(type).filter(type => this.search.type[type]).join('|');
let typeTerm = '{type:'+selectedTypes+'}';
if (this.termString.match(typeFilter)) {
this.termString = this.termString.replace(typeFilter, typeTerm);
return;
}
this.appendTerm(typeTerm);
},
optionParse(searchString) {
let optionFilter = /{([a-z_\-:]+?)}/gi;
let matches;
while ((matches = optionFilter.exec(searchString)) !== null) {
this.search.option[matches[1].toLowerCase()] = true;
}
},
optionChange(optionName) {
let isChecked = this.search.option[optionName];
if (isChecked) {
this.appendTerm(`{${optionName}}`);
} else {
this.termString = this.termString.replace(`{${optionName}}`, '');
}
},
updateSearch(e) {
e.preventDefault();
window.location = window.baseUrl('/search?term=' + encodeURIComponent(this.termString));
},
enableDate(optionName) {
this.search.dates[optionName.toLowerCase()] = Dates.getCurrentDay();
this.dateChange(optionName);
},
dateParse(searchString) {
let dateFilter = /{([a-z_\-]+?):([a-z_\-0-9]+?)}/gi;
let dateTags = Object.keys(this.search.dates);
let matches;
while ((matches = dateFilter.exec(searchString)) !== null) {
if (dateTags.indexOf(matches[1]) === -1) continue;
this.search.dates[matches[1].toLowerCase()] = matches[2];
}
},
dateChange(optionName) {
let dateFilter = new RegExp('{\\s?'+optionName+'\\s?:([a-z_\\-0-9]+?)}', 'gi');
this.termString = this.termString.replace(dateFilter, '');
if (!this.search.dates[optionName]) return;
this.appendTerm(`{${optionName}:${this.search.dates[optionName]}}`);
},
dateRemove(optionName) {
this.search.dates[optionName] = false;
this.dateChange(optionName);
}
};
function created() {
this.termString = document.querySelector('[name=searchTerm]').value;
this.typeParse(this.termString);
this.exactParse(this.termString);
this.tagParse(this.termString);
this.optionParse(this.termString);
this.dateParse(this.termString);
}
export default {
data, computed, methods, created
};

View File

@ -0,0 +1,68 @@
import draggable from 'vuedraggable';
import autosuggest from './components/autosuggest';
const data = {
entityId: false,
entityType: null,
tags: [],
};
const components = {draggable, autosuggest};
const directives = {};
const methods = {
addEmptyTag() {
this.tags.push({name: '', value: '', key: Math.random().toString(36).substring(7)});
},
/**
* When an tag changes check if another empty editable field needs to be added onto the end.
* @param tag
*/
tagChange(tag) {
let tagPos = this.tags.indexOf(tag);
if (tagPos === this.tags.length-1 && (tag.name !== '' || tag.value !== '')) this.addEmptyTag();
},
/**
* When an tag field loses focus check the tag to see if its
* empty and therefore could be removed from the list.
* @param tag
*/
tagBlur(tag) {
let isLast = (this.tags.indexOf(tag) === this.tags.length-1);
if (tag.name !== '' || tag.value !== '' || isLast) return;
let cPos = this.tags.indexOf(tag);
this.tags.splice(cPos, 1);
},
removeTag(tag) {
let tagPos = this.tags.indexOf(tag);
if (tagPos === -1) return;
this.tags.splice(tagPos, 1);
},
getTagFieldName(index, key) {
return `tags[${index}][${key}]`;
},
};
function mounted() {
this.entityId = Number(this.$el.getAttribute('entity-id'));
this.entityType = this.$el.getAttribute('entity-type');
let url = window.baseUrl(`/ajax/tags/get/${this.entityType}/${this.entityId}`);
this.$http.get(url).then(response => {
let tags = response.data;
for (let i = 0, len = tags.length; i < len; i++) {
tags[i].key = Math.random().toString(36).substring(7);
}
this.tags = tags;
this.addEmptyTag();
});
}
export default {
data, methods, mounted, components, directives
};

40
resources/js/vues/vues.js Normal file
View File

@ -0,0 +1,40 @@
import Vue from "vue";
function exists(id) {
return document.getElementById(id) !== null;
}
import searchSystem from "./search";
import entityDashboard from "./entity-dashboard";
import codeEditor from "./code-editor";
import imageManager from "./image-manager";
import tagManager from "./tag-manager";
import attachmentManager from "./attachment-manager";
import pageEditor from "./page-editor";
let vueMapping = {
'search-system': searchSystem,
'entity-dashboard': entityDashboard,
'code-editor': codeEditor,
'image-manager': imageManager,
'tag-manager': tagManager,
'attachment-manager': attachmentManager,
'page-editor': pageEditor,
};
window.vues = {};
function load() {
let ids = Object.keys(vueMapping);
for (let i = 0, len = ids.length; i < len; i++) {
if (!exists(ids[i])) continue;
let config = vueMapping[ids[i]];
config.el = '#' + ids[i];
window.vues[ids[i]] = new Vue(config);
}
}
export default load;