mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-05-21 22:36:05 +08:00
Upgraded app to Laravel 5.7
This commit is contained in:
59
resources/js/components/back-to-top.js
Normal file
59
resources/js/components/back-to-top.js
Normal 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;
|
204
resources/js/components/book-sort.js
Normal file
204
resources/js/components/book-sort.js
Normal 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;
|
58
resources/js/components/breadcrumb-listing.js
Normal file
58
resources/js/components/breadcrumb-listing.js
Normal 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;
|
33
resources/js/components/chapter-toggle.js
Normal file
33
resources/js/components/chapter-toggle.js
Normal 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;
|
41
resources/js/components/collapsible.js
Normal file
41
resources/js/components/collapsible.js
Normal 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;
|
34
resources/js/components/custom-checkbox.js
Normal file
34
resources/js/components/custom-checkbox.js
Normal 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;
|
152
resources/js/components/dropdown.js
Normal file
152
resources/js/components/dropdown.js
Normal 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;
|
49
resources/js/components/editor-toolbox.js
Normal file
49
resources/js/components/editor-toolbox.js
Normal 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;
|
20
resources/js/components/entity-permissions-editor.js
Normal file
20
resources/js/components/entity-permissions-editor.js
Normal 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;
|
47
resources/js/components/entity-selector-popup.js
Normal file
47
resources/js/components/entity-selector-popup.js
Normal 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;
|
135
resources/js/components/entity-selector.js
Normal file
135
resources/js/components/entity-selector.js
Normal 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;
|
45
resources/js/components/expand-toggle.js
Normal file
45
resources/js/components/expand-toggle.js
Normal 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;
|
31
resources/js/components/header-mobile-toggle.js
Normal file
31
resources/js/components/header-mobile-toggle.js
Normal 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;
|
22
resources/js/components/homepage-control.js
Normal file
22
resources/js/components/homepage-control.js
Normal 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;
|
55
resources/js/components/image-picker.js
Normal file
55
resources/js/components/image-picker.js
Normal 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;
|
103
resources/js/components/index.js
Normal file
103
resources/js/components/index.js
Normal 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;
|
45
resources/js/components/list-sort-control.js
Normal file
45
resources/js/components/list-sort-control.js
Normal 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;
|
538
resources/js/components/markdown-editor.js
Normal file
538
resources/js/components/markdown-editor.js
Normal 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 = ``;
|
||||
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 = ``;
|
||||
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 = `[](${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 = "[](" + 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 ;
|
28
resources/js/components/new-user-password.js
Normal file
28
resources/js/components/new-user-password.js
Normal 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;
|
46
resources/js/components/notification.js
Normal file
46
resources/js/components/notification.js
Normal 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;
|
56
resources/js/components/overlay.js
Normal file
56
resources/js/components/overlay.js
Normal 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;
|
189
resources/js/components/page-comments.js
Normal file
189
resources/js/components/page-comments.js
Normal 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;
|
201
resources/js/components/page-display.js
Normal file
201
resources/js/components/page-display.js
Normal 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;
|
62
resources/js/components/page-picker.js
Normal file
62
resources/js/components/page-picker.js
Normal 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;
|
66
resources/js/components/permissions-table.js
Normal file
66
resources/js/components/permissions-table.js
Normal 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;
|
56
resources/js/components/setting-app-color-picker.js
Normal file
56
resources/js/components/setting-app-color-picker.js
Normal 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;
|
56
resources/js/components/shelf-sort.js
Normal file
56
resources/js/components/shelf-sort.js
Normal 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;
|
16
resources/js/components/sidebar.js
Normal file
16
resources/js/components/sidebar.js
Normal 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;
|
94
resources/js/components/template-manager.js
Normal file
94
resources/js/components/template-manager.js
Normal 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;
|
23
resources/js/components/toggle-switch.js
Normal file
23
resources/js/components/toggle-switch.js
Normal 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;
|
113
resources/js/components/tri-layout.js
Normal file
113
resources/js/components/tri-layout.js
Normal 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;
|
663
resources/js/components/wysiwyg-editor.js
Normal file
663
resources/js/components/wysiwyg-editor.js
Normal 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
32
resources/js/index.js
Normal 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();
|
106
resources/js/services/animations.js
Normal file
106
resources/js/services/animations.js
Normal 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);
|
||||
}
|
280
resources/js/services/code.js
Normal file
280
resources/js/services/code.js
Normal 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,
|
||||
};
|
24
resources/js/services/dates.js
Normal file
24
resources/js/services/dates.js
Normal 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}`;
|
||||
}
|
75
resources/js/services/dom.js
Normal file
75
resources/js/services/dom.js
Normal 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;
|
||||
}
|
88
resources/js/services/drawio.js
Normal file
88
resources/js/services/drawio.js
Normal 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};
|
28
resources/js/services/events.js
Normal file
28
resources/js/services/events.js
Normal 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;
|
146
resources/js/services/http.js
Normal file
146
resources/js/services/http.js
Normal 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,
|
||||
};
|
120
resources/js/services/translations.js
Normal file
120
resources/js/services/translations.js
Normal 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;
|
48
resources/js/services/util.js
Normal file
48
resources/js/services/util.js
Normal 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);
|
||||
}
|
142
resources/js/vues/attachment-manager.js
Normal file
142
resources/js/vues/attachment-manager.js
Normal 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,
|
||||
};
|
43
resources/js/vues/code-editor.js
Normal file
43
resources/js/vues/code-editor.js
Normal 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
|
||||
};
|
131
resources/js/vues/components/autosuggest.js
Normal file
131
resources/js/vues/components/autosuggest.js
Normal 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};
|
80
resources/js/vues/components/dropzone.js
Normal file
80
resources/js/vues/components/dropzone.js
Normal 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
|
||||
};
|
44
resources/js/vues/entity-dashboard.js
Normal file
44
resources/js/vues/entity-dashboard.js
Normal 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
|
||||
};
|
204
resources/js/vues/image-manager.js
Normal file
204
resources/js/vues/image-manager.js
Normal 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},
|
||||
};
|
150
resources/js/vues/page-editor.js
Normal file
150
resources/js/vues/page-editor.js
Normal 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
193
resources/js/vues/search.js
Normal 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
|
||||
};
|
68
resources/js/vues/tag-manager.js
Normal file
68
resources/js/vues/tag-manager.js
Normal 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
40
resources/js/vues/vues.js
Normal 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;
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user