mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-30 04:23:11 +03: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;
|
Reference in New Issue
Block a user