Resolved conflicts

This commit is contained in:
Bharadwaja G
2017-08-24 12:21:43 +05:30
145 changed files with 4076 additions and 1751 deletions

View File

@ -0,0 +1,53 @@
class BackToTop {
constructor(elem) {
this.elem = elem;
this.targetElem = document.getElementById('header');
this.showing = false;
this.breakPoint = 1200;
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));
}
}
module.exports = BackToTop;

View File

@ -0,0 +1,67 @@
class ChapterToggle {
constructor(elem) {
this.elem = elem;
this.isOpen = elem.classList.contains('open');
elem.addEventListener('click', this.click.bind(this));
}
open() {
let list = this.elem.parentNode.querySelector('.inset-list');
this.elem.classList.add('open');
list.style.display = 'block';
list.style.height = '';
let height = list.getBoundingClientRect().height;
list.style.height = '0px';
list.style.overflow = 'hidden';
list.style.transition = 'height ease-in-out 240ms';
let transitionEndBound = onTransitionEnd.bind(this);
function onTransitionEnd() {
list.style.overflow = '';
list.style.height = '';
list.style.transition = '';
list.removeEventListener('transitionend', transitionEndBound);
}
setTimeout(() => {
list.style.height = `${height}px`;
list.addEventListener('transitionend', transitionEndBound)
}, 1);
}
close() {
let list = this.elem.parentNode.querySelector('.inset-list');
this.elem.classList.remove('open');
list.style.display = 'block';
list.style.height = list.getBoundingClientRect().height + 'px';
list.style.overflow = 'hidden';
list.style.transition = 'height ease-in-out 240ms';
let transitionEndBound = onTransitionEnd.bind(this);
function onTransitionEnd() {
list.style.overflow = '';
list.style.height = '';
list.style.transition = '';
list.style.display = 'none';
list.removeEventListener('transitionend', transitionEndBound);
}
setTimeout(() => {
list.style.height = `0px`;
list.addEventListener('transitionend', transitionEndBound)
}, 1);
}
click(event) {
event.preventDefault();
this.isOpen ? this.close() : this.open();
this.isOpen = !this.isOpen;
}
}
module.exports = ChapterToggle;

View File

@ -0,0 +1,48 @@
/**
* Dropdown
* Provides some simple logic to create simple dropdown menus.
*/
class DropDown {
constructor(elem) {
this.container = elem;
this.menu = elem.querySelector('ul');
this.toggle = elem.querySelector('[dropdown-toggle]');
this.setupListeners();
}
show() {
this.menu.style.display = 'block';
this.menu.classList.add('anim', 'menuIn');
this.container.addEventListener('mouseleave', this.hide.bind(this));
// Focus on first input if existing
let input = this.menu.querySelector('input');
if (input !== null) input.focus();
}
hide() {
this.menu.style.display = 'none';
this.menu.classList.remove('anim', 'menuIn');
}
setupListeners() {
// Hide menu on option click
this.container.addEventListener('click', event => {
let possibleChildren = Array.from(this.menu.querySelectorAll('a'));
if (possibleChildren.indexOf(event.target) !== -1) this.hide();
});
// Show dropdown on toggle click
this.toggle.addEventListener('click', this.show.bind(this));
// Hide menu on enter press
this.container.addEventListener('keypress', event => {
if (event.keyCode !== 13) return true;
event.preventDefault();
this.hide();
return false;
});
}
}
module.exports = DropDown;

View File

@ -0,0 +1,65 @@
class ExpandToggle {
constructor(elem) {
this.elem = elem;
this.isOpen = false;
this.selector = elem.getAttribute('expand-toggle');
elem.addEventListener('click', this.click.bind(this));
}
open(elemToToggle) {
elemToToggle.style.display = 'block';
elemToToggle.style.height = '';
let height = elemToToggle.getBoundingClientRect().height;
elemToToggle.style.height = '0px';
elemToToggle.style.overflow = 'hidden';
elemToToggle.style.transition = 'height ease-in-out 240ms';
let transitionEndBound = onTransitionEnd.bind(this);
function onTransitionEnd() {
elemToToggle.style.overflow = '';
elemToToggle.style.height = '';
elemToToggle.style.transition = '';
elemToToggle.removeEventListener('transitionend', transitionEndBound);
}
setTimeout(() => {
elemToToggle.style.height = `${height}px`;
elemToToggle.addEventListener('transitionend', transitionEndBound)
}, 1);
}
close(elemToToggle) {
elemToToggle.style.display = 'block';
elemToToggle.style.height = elemToToggle.getBoundingClientRect().height + 'px';
elemToToggle.style.overflow = 'hidden';
elemToToggle.style.transition = 'all ease-in-out 240ms';
let transitionEndBound = onTransitionEnd.bind(this);
function onTransitionEnd() {
elemToToggle.style.overflow = '';
elemToToggle.style.height = '';
elemToToggle.style.transition = '';
elemToToggle.style.display = 'none';
elemToToggle.removeEventListener('transitionend', transitionEndBound);
}
setTimeout(() => {
elemToToggle.style.height = `0px`;
elemToToggle.addEventListener('transitionend', transitionEndBound)
}, 1);
}
click(event) {
event.preventDefault();
let matchingElems = document.querySelectorAll(this.selector);
for (let i = 0, len = matchingElems.length; i < len; i++) {
this.isOpen ? this.close(matchingElems[i]) : this.open(matchingElems[i]);
}
this.isOpen = !this.isOpen;
}
}
module.exports = ExpandToggle;

View File

@ -0,0 +1,28 @@
let componentMapping = {
'dropdown': require('./dropdown'),
'overlay': require('./overlay'),
'back-to-top': require('./back-top-top'),
'notification': require('./notification'),
'chapter-toggle': require('./chapter-toggle'),
'expand-toggle': require('./expand-toggle'),
};
window.components = {};
let componentNames = Object.keys(componentMapping);
for (let i = 0, len = componentNames.length; i < len; i++) {
let name = componentNames[i];
let elems = document.querySelectorAll(`[${name}]`);
if (elems.length === 0) continue;
let component = componentMapping[name];
if (typeof window.components[name] === "undefined") window.components[name] = [];
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[name] = instance;
window.components[name].push(instance);
}
}

View File

@ -0,0 +1,41 @@
class Notification {
constructor(elem) {
this.elem = elem;
this.type = elem.getAttribute('notification');
this.textElem = elem.querySelector('span');
this.autohide = this.elem.hasAttribute('data-autohide');
window.Events.listen(this.type, text => {
this.show(text);
});
elem.addEventListener('click', this.hide.bind(this));
if (elem.hasAttribute('data-show')) this.show(this.textElem.textContent);
this.hideCleanup = this.hideCleanup.bind(this);
}
show(textToShow = '') {
this.elem.removeEventListener('transitionend', this.hideCleanup);
this.textElem.textContent = textToShow;
this.elem.style.display = 'block';
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);
}
}
module.exports = Notification;

View File

@ -0,0 +1,39 @@
class Overlay {
constructor(elem) {
this.container = elem;
elem.addEventListener('click', event => {
if (event.target === elem) return this.hide();
});
let closeButtons = elem.querySelectorAll('.overlay-close');
for (let i=0; i < closeButtons.length; i++) {
closeButtons[i].addEventListener('click', this.hide.bind(this));
}
}
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';
this.container.style.opacity = '';
} else {
requestAnimationFrame(setOpacity.bind(this));
}
}
requestAnimationFrame(setOpacity.bind(this));
}
hide() { this.toggle(false); }
show() { this.toggle(true); }
}
module.exports = Overlay;

View File

@ -8,256 +8,6 @@ moment.locale('en-gb');
module.exports = function (ngApp, events) {
ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService',
function ($scope, $attrs, $http, $timeout, imageManagerService) {
$scope.images = [];
$scope.imageType = $attrs.imageType;
$scope.selectedImage = false;
$scope.dependantPages = false;
$scope.showing = false;
$scope.hasMore = false;
$scope.imageUpdateSuccess = false;
$scope.imageDeleteSuccess = false;
$scope.uploadedTo = $attrs.uploadedTo;
$scope.view = 'all';
$scope.searching = false;
$scope.searchTerm = '';
let page = 0;
let previousClickTime = 0;
let previousClickImage = 0;
let dataLoaded = false;
let callback = false;
let preSearchImages = [];
let preSearchHasMore = false;
/**
* Used by dropzone to get the endpoint to upload to.
* @returns {string}
*/
$scope.getUploadUrl = function () {
return window.baseUrl('/images/' + $scope.imageType + '/upload');
};
/**
* Cancel the current search operation.
*/
function cancelSearch() {
$scope.searching = false;
$scope.searchTerm = '';
$scope.images = preSearchImages;
$scope.hasMore = preSearchHasMore;
}
$scope.cancelSearch = cancelSearch;
/**
* Runs on image upload, Adds an image to local list of images
* and shows a success message to the user.
* @param file
* @param data
*/
$scope.uploadSuccess = function (file, data) {
$scope.$apply(() => {
$scope.images.unshift(data);
});
events.emit('success', trans('components.image_upload_success'));
};
/**
* Runs the callback and hides the image manager.
* @param returnData
*/
function callbackAndHide(returnData) {
if (callback) callback(returnData);
$scope.hide();
}
/**
* Image select action. Checks if a double-click was fired.
* @param image
*/
$scope.imageSelect = function (image) {
let dblClickTime = 300;
let currentTime = Date.now();
let timeDiff = currentTime - previousClickTime;
if (timeDiff < dblClickTime && image.id === previousClickImage) {
// If double click
callbackAndHide(image);
} else {
// If single
$scope.selectedImage = image;
$scope.dependantPages = false;
}
previousClickTime = currentTime;
previousClickImage = image.id;
};
/**
* Action that runs when the 'Select image' button is clicked.
* Runs the callback and hides the image manager.
*/
$scope.selectButtonClick = function () {
callbackAndHide($scope.selectedImage);
};
/**
* Show the image manager.
* Takes a callback to execute later on.
* @param doneCallback
*/
function show(doneCallback) {
callback = doneCallback;
$scope.showing = true;
$('#image-manager').find('.overlay').css('display', 'flex').hide().fadeIn(240);
// Get initial images if they have not yet been loaded in.
if (!dataLoaded) {
fetchData();
dataLoaded = true;
}
}
// Connects up the image manger so it can be used externally
// such as from TinyMCE.
imageManagerService.show = show;
imageManagerService.showExternal = function (doneCallback) {
$scope.$apply(() => {
show(doneCallback);
});
};
window.ImageManager = imageManagerService;
/**
* Hide the image manager
*/
$scope.hide = function () {
$scope.showing = false;
$('#image-manager').find('.overlay').fadeOut(240);
};
let baseUrl = window.baseUrl('/images/' + $scope.imageType + '/all/');
/**
* Fetch the list image data from the server.
*/
function fetchData() {
let url = baseUrl + page + '?';
let components = {};
if ($scope.uploadedTo) components['page_id'] = $scope.uploadedTo;
if ($scope.searching) components['term'] = $scope.searchTerm;
url += Object.keys(components).map((key) => {
return key + '=' + encodeURIComponent(components[key]);
}).join('&');
$http.get(url).then((response) => {
$scope.images = $scope.images.concat(response.data.images);
$scope.hasMore = response.data.hasMore;
page++;
});
}
$scope.fetchData = fetchData;
/**
* Start a search operation
*/
$scope.searchImages = function() {
if ($scope.searchTerm === '') {
cancelSearch();
return;
}
if (!$scope.searching) {
preSearchImages = $scope.images;
preSearchHasMore = $scope.hasMore;
}
$scope.searching = true;
$scope.images = [];
$scope.hasMore = false;
page = 0;
baseUrl = window.baseUrl('/images/' + $scope.imageType + '/search/');
fetchData();
};
/**
* Set the current image listing view.
* @param viewName
*/
$scope.setView = function(viewName) {
cancelSearch();
$scope.images = [];
$scope.hasMore = false;
page = 0;
$scope.view = viewName;
baseUrl = window.baseUrl('/images/' + $scope.imageType + '/' + viewName + '/');
fetchData();
};
/**
* Save the details of an image.
* @param event
*/
$scope.saveImageDetails = function (event) {
event.preventDefault();
let url = window.baseUrl('/images/update/' + $scope.selectedImage.id);
$http.put(url, this.selectedImage).then(response => {
events.emit('success', trans('components.image_update_success'));
}, (response) => {
if (response.status === 422) {
let errors = response.data;
let message = '';
Object.keys(errors).forEach((key) => {
message += errors[key].join('\n');
});
events.emit('error', message);
} else if (response.status === 403) {
events.emit('error', response.data.error);
}
});
};
/**
* Delete an image from system and notify of success.
* Checks if it should force delete when an image
* has dependant pages.
* @param event
*/
$scope.deleteImage = function (event) {
event.preventDefault();
let force = $scope.dependantPages !== false;
let url = window.baseUrl('/images/' + $scope.selectedImage.id);
if (force) url += '?force=true';
$http.delete(url).then((response) => {
$scope.images.splice($scope.images.indexOf($scope.selectedImage), 1);
$scope.selectedImage = false;
events.emit('success', trans('components.image_delete_success'));
}, (response) => {
// Pages failure
if (response.status === 400) {
$scope.dependantPages = response.data;
} else if (response.status === 403) {
events.emit('error', response.data.error);
}
});
};
/**
* Simple date creator used to properly format dates.
* @param stringDate
* @returns {Date}
*/
$scope.getDate = function (stringDate) {
return new Date(stringDate);
};
}]);
ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', '$sce',
function ($scope, $http, $attrs, $interval, $timeout, $sce) {
@ -379,7 +129,7 @@ module.exports = function (ngApp, events) {
*/
$scope.discardDraft = function () {
let url = window.baseUrl('/ajax/page/' + pageId);
$http.get(url).then((responseData) => {
$http.get(url).then(responseData => {
if (autoSave) $interval.cancel(autoSave);
$scope.draftText = trans('entities.pages_editing_page');
$scope.isUpdateDraft = false;
@ -395,284 +145,225 @@ module.exports = function (ngApp, events) {
}]);
ngApp.controller('PageTagController', ['$scope', '$http', '$attrs',
function ($scope, $http, $attrs) {
// Controller used to reply to and add new comments
ngApp.controller('CommentReplyController', ['$scope', '$http', '$timeout', function ($scope, $http, $timeout) {
const MarkdownIt = require("markdown-it");
const md = new MarkdownIt({html: true});
let vm = this;
const pageId = Number($attrs.pageId);
$scope.tags = [];
$scope.sortOptions = {
handle: '.handle',
items: '> tr',
containment: "parent",
axis: "y"
vm.saveComment = function () {
let pageId = $scope.comment.pageId || $scope.pageId;
let comment = $scope.comment.text;
if (!comment) {
return events.emit('warning', trans('errors.empty_comment'));
}
let commentHTML = md.render($scope.comment.text);
let serviceUrl = `/ajax/page/${pageId}/comment/`;
let httpMethod = 'post';
let reqObj = {
text: comment,
html: commentHTML
};
/**
* Push an empty tag to the end of the scope tags.
*/
function addEmptyTag() {
$scope.tags.push({
name: '',
value: ''
});
if ($scope.isEdit === true) {
// this will be set when editing the comment.
serviceUrl = `/ajax/page/${pageId}/comment/${$scope.comment.id}`;
httpMethod = 'put';
} else if ($scope.isReply === true) {
// if its reply, get the parent comment id
reqObj.parent_id = $scope.parentId;
}
$scope.addEmptyTag = addEmptyTag;
/**
* Get all tags for the current book and add into scope.
*/
function getTags() {
let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`);
$http.get(url).then((responseData) => {
$scope.tags = responseData.data;
addEmptyTag();
});
}
getTags();
/**
* Set the order property on all tags.
*/
function setTagOrder() {
for (let i = 0; i < $scope.tags.length; i++) {
$scope.tags[i].order = i;
$http[httpMethod](window.baseUrl(serviceUrl), reqObj).then(resp => {
if (!isCommentOpSuccess(resp)) {
return;
}
}
/**
* When an tag changes check if another empty editable
* field needs to be added onto the end.
* @param tag
*/
$scope.tagChange = function(tag) {
let cPos = $scope.tags.indexOf(tag);
if (cPos !== $scope.tags.length-1) return;
if (tag.name !== '' || tag.value !== '') {
addEmptyTag();
}
};
/**
* When an tag field loses focus check the tag to see if its
* empty and therefore could be removed from the list.
* @param tag
*/
$scope.tagBlur = function(tag) {
let isLast = $scope.tags.length - 1 === $scope.tags.indexOf(tag);
if (tag.name === '' && tag.value === '' && !isLast) {
let cPos = $scope.tags.indexOf(tag);
$scope.tags.splice(cPos, 1);
}
};
/**
* Remove a tag from the current list.
* @param tag
*/
$scope.removeTag = function(tag) {
let cIndex = $scope.tags.indexOf(tag);
$scope.tags.splice(cIndex, 1);
};
}]);
ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs',
function ($scope, $http, $attrs) {
const pageId = $scope.uploadedTo = $attrs.pageId;
let currentOrder = '';
$scope.files = [];
$scope.editFile = false;
$scope.file = getCleanFile();
$scope.errors = {
link: {},
edit: {}
};
function getCleanFile() {
return {
page_id: pageId
};
}
// Angular-UI-Sort options
$scope.sortOptions = {
handle: '.handle',
items: '> tr',
containment: "parent",
axis: "y",
stop: sortUpdate,
};
/**
* Event listener for sort changes.
* Updates the file ordering on the server.
* @param event
* @param ui
*/
function sortUpdate(event, ui) {
let newOrder = $scope.files.map(file => {return file.id}).join(':');
if (newOrder === currentOrder) return;
currentOrder = newOrder;
$http.put(window.baseUrl(`/attachments/sort/page/${pageId}`), {files: $scope.files}).then(resp => {
events.emit('success', resp.data.message);
}, checkError('sort'));
}
/**
* Used by dropzone to get the endpoint to upload to.
* @returns {string}
*/
$scope.getUploadUrl = function (file) {
let suffix = (typeof file !== 'undefined') ? `/${file.id}` : '';
return window.baseUrl(`/attachments/upload${suffix}`);
};
/**
* Get files for the current page from the server.
*/
function getFiles() {
let url = window.baseUrl(`/attachments/get/page/${pageId}`);
$http.get(url).then(resp => {
$scope.files = resp.data;
currentOrder = resp.data.map(file => {return file.id}).join(':');
}, checkError('get'));
}
getFiles();
/**
* Runs on file upload, Adds an file to local file list
* and shows a success message to the user.
* @param file
* @param data
*/
$scope.uploadSuccess = function (file, data) {
$scope.$apply(() => {
$scope.files.push(data);
});
events.emit('success', trans('entities.attachments_file_uploaded'));
};
/**
* Upload and overwrite an existing file.
* @param file
* @param data
*/
$scope.uploadSuccessUpdate = function (file, data) {
$scope.$apply(() => {
let search = filesIndexOf(data);
if (search !== -1) $scope.files[search] = data;
if ($scope.editFile) {
$scope.editFile = angular.copy(data);
data.link = '';
// hide the comments first, and then retrigger the refresh
if ($scope.isEdit) {
updateComment($scope.comment, resp.data);
$scope.$emit('evt.comment-success', $scope.comment.id);
} else {
$scope.comment.text = '';
if ($scope.isReply === true && $scope.parent.sub_comments) {
$scope.parent.sub_comments.push(resp.data.comment);
} else {
$scope.$emit('evt.new-comment', resp.data.comment);
}
$scope.$emit('evt.comment-success', null, true);
}
$scope.comment.is_hidden = true;
$timeout(function() {
$scope.comment.is_hidden = false;
});
events.emit('success', trans('entities.attachments_file_updated'));
};
/**
* Delete a file from the server and, on success, the local listing.
* @param file
*/
$scope.deleteFile = function(file) {
if (!file.deleting) {
file.deleting = true;
events.emit('success', trans(resp.data.message));
}, checkError);
};
function checkError(response) {
let msg = null;
if (isCommentOpSuccess(response)) {
// all good
return;
} else if (response.data) {
msg = response.data.message;
} else {
msg = trans('errors.comment_add');
}
if (msg) {
events.emit('success', msg);
}
}
}]);
// Controller used to delete comments
ngApp.controller('CommentDeleteController', ['$scope', '$http', '$timeout', function ($scope, $http, $timeout) {
let vm = this;
vm.delete = function(comment) {
$http.delete(window.baseUrl(`/ajax/comment/${comment.id}`)).then(resp => {
if (!isCommentOpSuccess(resp)) {
return;
}
$http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
events.emit('success', resp.data.message);
$scope.files.splice($scope.files.indexOf(file), 1);
}, checkError('delete'));
};
/**
* Attach a link to a page.
* @param file
*/
$scope.attachLinkSubmit = function(file) {
file.uploaded_to = pageId;
$http.post(window.baseUrl('/attachments/link'), file).then(resp => {
$scope.files.push(resp.data);
events.emit('success', trans('entities.attachments_link_attached'));
$scope.file = getCleanFile();
}, checkError('link'));
};
/**
* Start the edit mode for a file.
* @param file
*/
$scope.startEdit = function(file) {
$scope.editFile = angular.copy(file);
$scope.editFile.link = (file.external) ? file.path : '';
};
/**
* Cancel edit mode
*/
$scope.cancelEdit = function() {
$scope.editFile = false;
};
/**
* Update the name and link of a file.
* @param file
*/
$scope.updateFile = function(file) {
$http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
let search = filesIndexOf(resp.data);
if (search !== -1) $scope.files[search] = resp.data;
if ($scope.editFile && !file.external) {
$scope.editFile.link = '';
}
$scope.editFile = false;
events.emit('success', trans('entities.attachments_updated_success'));
}, checkError('edit'));
};
/**
* Get the url of a file.
*/
$scope.getFileUrl = function(file) {
return window.baseUrl('/attachments/' + file.id);
};
/**
* Search the local files via another file object.
* Used to search via object copies.
* @param file
* @returns int
*/
function filesIndexOf(file) {
for (let i = 0; i < $scope.files.length; i++) {
if ($scope.files[i].id == file.id) return i;
updateComment(comment, resp.data, $timeout, true);
}, function (resp) {
if (isCommentOpSuccess(resp)) {
events.emit('success', trans('entities.comment_deleted'));
} else {
events.emit('error', trans('error.comment_delete'));
}
return -1;
});
};
}]);
// Controller used to fetch all comments for a page
ngApp.controller('CommentListController', ['$scope', '$http', '$timeout', '$location', function ($scope, $http, $timeout, $location) {
let vm = this;
$scope.errors = {};
// keep track of comment levels
$scope.level = 1;
vm.totalCommentsStr = trans('entities.comments_loading');
vm.permissions = {};
vm.trans = window.trans;
$scope.$on('evt.new-comment', function (event, comment) {
// add the comment to the comment list.
vm.comments.push(comment);
++vm.totalComments;
setTotalCommentMsg();
event.stopPropagation();
event.preventDefault();
});
vm.canEditDelete = function (comment, prop) {
if (!comment.active) {
return false;
}
let propAll = prop + '_all';
let propOwn = prop + '_own';
if (vm.permissions[propAll]) {
return true;
}
/**
* Check for an error response in a ajax request.
* @param errorGroupName
*/
function checkError(errorGroupName) {
$scope.errors[errorGroupName] = {};
return function(response) {
if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') {
events.emit('error', response.data.error);
}
if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') {
$scope.errors[errorGroupName] = response.data.validation;
console.log($scope.errors[errorGroupName])
}
}
if (vm.permissions[propOwn] && comment.created_by.id === vm.current_user_id) {
return true;
}
}]);
return false;
};
vm.canComment = function () {
return vm.permissions.comment_create;
};
// check if there are is any direct linking
let linkedCommentId = $location.search().cm;
$timeout(function() {
$http.get(window.baseUrl(`/ajax/page/${$scope.pageId}/comments/`)).then(resp => {
if (!isCommentOpSuccess(resp)) {
// just show that no comments are available.
vm.totalComments = 0;
setTotalCommentMsg();
return;
}
vm.comments = resp.data.comments;
vm.totalComments = +resp.data.total;
vm.permissions = resp.data.permissions;
vm.current_user_id = resp.data.user_id;
setTotalCommentMsg();
if (!linkedCommentId) {
return;
}
$timeout(function() {
// wait for the UI to render.
focusLinkedComment(linkedCommentId);
});
}, checkError);
});
function setTotalCommentMsg () {
if (vm.totalComments === 0) {
vm.totalCommentsStr = trans('entities.no_comments');
} else if (vm.totalComments === 1) {
vm.totalCommentsStr = trans('entities.one_comment');
} else {
vm.totalCommentsStr = trans('entities.x_comments', {
numComments: vm.totalComments
});
}
}
function focusLinkedComment(linkedCommentId) {
let comment = angular.element('#' + linkedCommentId);
if (comment.length === 0) {
return;
}
window.setupPageShow.goToText(linkedCommentId);
}
function checkError(response) {
let msg = null;
if (isCommentOpSuccess(response)) {
// all good
return;
} else if (response.data) {
msg = response.data.message;
} else {
msg = trans('errors.comment_list');
}
if (msg) {
events.emit('success', msg);
}
}
}]);
function updateComment(comment, resp, $timeout, isDelete) {
comment.text = resp.comment.text;
comment.updated = resp.comment.updated;
comment.updated_by = resp.comment.updated_by;
comment.active = resp.comment.active;
if (isDelete && !resp.comment.active) {
comment.html = trans('entities.comment_deleted');
} else {
comment.html = resp.comment.html;
}
if (!$timeout) {
return;
}
comment.is_hidden = true;
$timeout(function() {
comment.is_hidden = false;
});
}
function isCommentOpSuccess(resp) {
if (resp && resp.data && resp.data.status === 'success') {
return true;
}
return false;
}
};

View File

@ -1,152 +1,10 @@
"use strict";
const DropZone = require("dropzone");
const MarkdownIt = require("markdown-it");
const mdTasksLists = require('markdown-it-task-lists');
const code = require('./code');
module.exports = function (ngApp, events) {
/**
* Common tab controls using simple jQuery functions.
*/
ngApp.directive('tabContainer', function() {
return {
restrict: 'A',
link: function (scope, element, attrs) {
const $content = element.find('[tab-content]');
const $buttons = element.find('[tab-button]');
if (attrs.tabContainer) {
let initial = attrs.tabContainer;
$buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
$content.hide().filter(`[tab-content="${initial}"]`).show();
} else {
$content.hide().first().show();
$buttons.first().addClass('selected');
}
$buttons.click(function() {
let clickedTab = $(this);
$buttons.removeClass('selected');
$content.hide();
let name = clickedTab.addClass('selected').attr('tab-button');
$content.filter(`[tab-content="${name}"]`).show();
});
}
};
});
/**
* Sub form component to allow inner-form sections to act like their own forms.
*/
ngApp.directive('subForm', function() {
return {
restrict: 'A',
link: function (scope, element, attrs) {
element.on('keypress', e => {
if (e.keyCode === 13) {
submitEvent(e);
}
});
element.find('button[type="submit"]').click(submitEvent);
function submitEvent(e) {
e.preventDefault();
if (attrs.subForm) scope.$eval(attrs.subForm);
}
}
};
});
/**
* DropZone
* Used for uploading images
*/
ngApp.directive('dropZone', [function () {
return {
restrict: 'E',
template: `
<div class="dropzone-container">
<div class="dz-message">{{message}}</div>
</div>
`,
scope: {
uploadUrl: '@',
eventSuccess: '=',
eventError: '=',
uploadedTo: '@',
},
link: function (scope, element, attrs) {
scope.message = attrs.message;
if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
let dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
url: scope.uploadUrl,
init: function () {
let dz = this;
dz.on('sending', function (file, xhr, data) {
let token = window.document.querySelector('meta[name=token]').getAttribute('content');
data.append('_token', token);
let uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
data.append('uploaded_to', uploadedTo);
});
if (typeof scope.eventSuccess !== 'undefined') dz.on('success', scope.eventSuccess);
dz.on('success', function (file, data) {
$(file.previewElement).fadeOut(400, function () {
dz.removeFile(file);
});
});
if (typeof scope.eventError !== 'undefined') dz.on('error', scope.eventError);
dz.on('error', function (file, errorMessage, xhr) {
console.log(errorMessage);
console.log(xhr);
function setMessage(message) {
$(file.previewElement).find('[data-dz-errormessage]').text(message);
}
if (xhr.status === 413) setMessage(trans('errors.server_upload_limit'));
if (errorMessage.file) setMessage(errorMessage.file[0]);
});
}
});
}
};
}]);
/**
* Dropdown
* Provides some simple logic to create small dropdown menus
*/
ngApp.directive('dropdown', [function () {
return {
restrict: 'A',
link: function (scope, element, attrs) {
const menu = element.find('ul');
element.find('[dropdown-toggle]').on('click', function () {
menu.show().addClass('anim menuIn');
let inputs = menu.find('input');
let hasInput = inputs.length > 0;
if (hasInput) {
inputs.first().focus();
element.on('keypress', 'input', event => {
if (event.keyCode === 13) {
event.preventDefault();
menu.hide();
menu.removeClass('anim menuIn');
return false;
}
});
}
element.mouseleave(function () {
menu.hide();
menu.removeClass('anim menuIn');
});
});
}
};
}]);
/**
* TinyMCE
* An angular wrapper around the tinyMCE editor.
@ -187,30 +45,6 @@ module.exports = function (ngApp, events) {
}
scope.tinymce.extraSetups.push(tinyMceSetup);
// Custom tinyMCE plugins
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'
});
});
tinymce.init(scope.tinymce);
}
}
@ -251,6 +85,21 @@ module.exports = function (ngApp, events) {
extraKeys[`${metaKey}-S`] = function(cm) {scope.$emit('save-draft');};
// Show link selector
extraKeys[`Shift-${metaKey}-K`] = function(cm) {showLinkSelector()};
// Insert Link
extraKeys[`${metaKey}-K`] = function(cm) {insertLink()};
// FormatShortcuts
extraKeys[`${metaKey}-1`] = function(cm) {replaceLineStart('##');};
extraKeys[`${metaKey}-2`] = function(cm) {replaceLineStart('###');};
extraKeys[`${metaKey}-3`] = function(cm) {replaceLineStart('####');};
extraKeys[`${metaKey}-4`] = function(cm) {replaceLineStart('#####');};
extraKeys[`${metaKey}-5`] = function(cm) {replaceLineStart('');};
extraKeys[`${metaKey}-d`] = function(cm) {replaceLineStart('');};
extraKeys[`${metaKey}-6`] = function(cm) {replaceLineStart('>');};
extraKeys[`${metaKey}-q`] = function(cm) {replaceLineStart('>');};
extraKeys[`${metaKey}-7`] = function(cm) {wrapSelection('\n```\n', '\n```');};
extraKeys[`${metaKey}-8`] = function(cm) {wrapSelection('`', '`');};
extraKeys[`Shift-${metaKey}-E`] = function(cm) {wrapSelection('`', '`');};
extraKeys[`${metaKey}-9`] = function(cm) {wrapSelection('<p class="callout info">', '</div>');};
cm.setOption('extraKeys', extraKeys);
// Update data on content change
@ -303,6 +152,73 @@ module.exports = function (ngApp, events) {
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 + (newLineContent.length - lineLen)});
}
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;
@ -345,10 +261,20 @@ module.exports = function (ngApp, events) {
});
}
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);
}
// Show the image manager and handle image insertion
function showImageManager() {
let cursorPos = cm.getCursor('from');
window.ImageManager.showExternal(image => {
window.ImageManager.show(image => {
let selectedText = cm.getSelection();
let newText = "![" + (selectedText || image.name) + "](" + image.thumbs.display + ")";
cm.focus();
@ -461,188 +387,6 @@ module.exports = function (ngApp, events) {
}
}]);
/**
* Tag Autosuggestions
* Listens to child inputs and provides autosuggestions depending on field type
* and input. Suggestions provided by server.
*/
ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
return {
restrict: 'A',
link: function (scope, elem, attrs) {
// Local storage for quick caching.
const localCache = {};
// Create suggestion element
const suggestionBox = document.createElement('ul');
suggestionBox.className = 'suggestion-box';
suggestionBox.style.position = 'absolute';
suggestionBox.style.display = 'none';
const $suggestionBox = $(suggestionBox);
// General state tracking
let isShowing = false;
let currentInput = false;
let active = 0;
// Listen to input events on autosuggest fields
elem.on('input focus', '[autosuggest]', function (event) {
let $input = $(this);
let val = $input.val();
let url = $input.attr('autosuggest');
let type = $input.attr('autosuggest-type');
// Add name param to request if for a value
if (type.toLowerCase() === 'value') {
let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
let nameVal = $nameInput.val();
if (nameVal !== '') {
url += '?name=' + encodeURIComponent(nameVal);
}
}
let suggestionPromise = getSuggestions(val.slice(0, 3), url);
suggestionPromise.then(suggestions => {
if (val.length === 0) {
displaySuggestions($input, suggestions.slice(0, 6));
} else {
suggestions = suggestions.filter(item => {
return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
}).slice(0, 4);
displaySuggestions($input, suggestions);
}
});
});
// Hide autosuggestions when input loses focus.
// Slight delay to allow clicks.
let lastFocusTime = 0;
elem.on('blur', '[autosuggest]', function (event) {
let startTime = Date.now();
setTimeout(() => {
if (lastFocusTime < startTime) {
$suggestionBox.hide();
isShowing = false;
}
}, 200)
});
elem.on('focus', '[autosuggest]', function (event) {
lastFocusTime = Date.now();
});
elem.on('keydown', '[autosuggest]', function (event) {
if (!isShowing) return;
let suggestionElems = suggestionBox.childNodes;
let suggestCount = suggestionElems.length;
// Down arrow
if (event.keyCode === 40) {
let newActive = (active === suggestCount - 1) ? 0 : active + 1;
changeActiveTo(newActive, suggestionElems);
}
// Up arrow
else if (event.keyCode === 38) {
let newActive = (active === 0) ? suggestCount - 1 : active - 1;
changeActiveTo(newActive, suggestionElems);
}
// Enter or tab key
else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
currentInput[0].value = suggestionElems[active].textContent;
currentInput.focus();
$suggestionBox.hide();
isShowing = false;
if (event.keyCode === 13) {
event.preventDefault();
return false;
}
}
});
// Change the active suggestion to the given index
function changeActiveTo(index, suggestionElems) {
suggestionElems[active].className = '';
active = index;
suggestionElems[active].className = 'active';
}
// Display suggestions on a field
let prevSuggestions = [];
function displaySuggestions($input, suggestions) {
// Hide if no suggestions
if (suggestions.length === 0) {
$suggestionBox.hide();
isShowing = false;
prevSuggestions = suggestions;
return;
}
// Otherwise show and attach to input
if (!isShowing) {
$suggestionBox.show();
isShowing = true;
}
if ($input !== currentInput) {
$suggestionBox.detach();
$input.after($suggestionBox);
currentInput = $input;
}
// Return if no change
if (prevSuggestions.join() === suggestions.join()) {
prevSuggestions = suggestions;
return;
}
// Build suggestions
$suggestionBox[0].innerHTML = '';
for (let i = 0; i < suggestions.length; i++) {
let suggestion = document.createElement('li');
suggestion.textContent = suggestions[i];
suggestion.onclick = suggestionClick;
if (i === 0) {
suggestion.className = 'active';
active = 0;
}
$suggestionBox[0].appendChild(suggestion);
}
prevSuggestions = suggestions;
}
// Suggestion click event
function suggestionClick(event) {
currentInput[0].value = this.textContent;
currentInput.focus();
$suggestionBox.hide();
isShowing = false;
}
// Get suggestions & cache
function getSuggestions(input, url) {
let hasQuery = url.indexOf('?') !== -1;
let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
// Get from local cache if exists
if (typeof localCache[searchUrl] !== 'undefined') {
return new Promise((resolve, reject) => {
resolve(localCache[searchUrl]);
});
}
return $http.get(searchUrl).then(response => {
localCache[searchUrl] = response.data;
return response.data;
});
}
}
}
}]);
ngApp.directive('entityLinkSelector', [function($http) {
return {
restrict: 'A',
@ -678,6 +422,7 @@ module.exports = function (ngApp, events) {
function hide() {
element.fadeOut(240);
}
scope.hide = hide;
// Listen to confirmation of entity selections (doubleclick)
events.listen('entity-select-confirm', entity => {
@ -789,4 +534,128 @@ module.exports = function (ngApp, events) {
}
};
}]);
ngApp.directive('commentReply', [function () {
return {
restrict: 'E',
templateUrl: 'comment-reply.html',
scope: {
pageId: '=',
parentId: '=',
parent: '='
},
link: function (scope, element) {
scope.isReply = true;
element.find('textarea').focus();
scope.$on('evt.comment-success', function (event) {
// no need for the event to do anything more.
event.stopPropagation();
event.preventDefault();
scope.closeBox();
});
scope.closeBox = function () {
element.remove();
scope.$destroy();
};
}
};
}]);
ngApp.directive('commentEdit', [function () {
return {
restrict: 'E',
templateUrl: 'comment-reply.html',
scope: {
comment: '='
},
link: function (scope, element) {
scope.isEdit = true;
element.find('textarea').focus();
scope.$on('evt.comment-success', function (event, commentId) {
// no need for the event to do anything more.
event.stopPropagation();
event.preventDefault();
if (commentId === scope.comment.id && !scope.isNew) {
scope.closeBox();
}
});
scope.closeBox = function () {
element.remove();
scope.$destroy();
};
}
};
}]);
ngApp.directive('commentReplyLink', ['$document', '$compile', function ($document, $compile) {
return {
scope: {
comment: '='
},
link: function (scope, element, attr) {
element.on('$destroy', function () {
element.off('click');
scope.$destroy();
});
element.on('click', function (e) {
e.preventDefault();
var $container = element.parents('.comment-actions').first();
if (!$container.length) {
console.error('commentReplyLink directive should be placed inside a container with class comment-box!');
return;
}
if (attr.noCommentReplyDupe) {
removeDupe();
}
compileHtml($container, scope, attr.isReply === 'true');
});
}
};
function compileHtml($container, scope, isReply) {
let lnkFunc = null;
if (isReply) {
lnkFunc = $compile('<comment-reply page-id="comment.pageId" parent-id="comment.id" parent="comment"></comment-reply>');
} else {
lnkFunc = $compile('<comment-edit comment="comment"></comment-add>');
}
var compiledHTML = lnkFunc(scope);
$container.append(compiledHTML);
}
function removeDupe() {
let $existingElement = $document.find('.comments-list comment-reply, .comments-list comment-edit');
if (!$existingElement.length) {
return;
}
$existingElement.remove();
}
}]);
ngApp.directive('commentDeleteLink', ['$window', function ($window) {
return {
controller: 'CommentDeleteController',
scope: {
comment: '='
},
link: function (scope, element, attr, ctrl) {
element.on('click', function(e) {
e.preventDefault();
var resp = $window.confirm(trans('entities.comment_delete_confirm'));
if (!resp) {
return;
}
ctrl.delete(scope.comment);
});
}
};
}]);
};

View File

@ -1,4 +1,5 @@
"use strict";
require("babel-polyfill");
// Url retrieval function
window.baseUrl = function(path) {
@ -8,37 +9,6 @@ window.baseUrl = function(path) {
return basePath + '/' + path;
};
const Vue = require("vue");
const axios = require("axios");
let axiosInstance = axios.create({
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=token]').getAttribute('content'),
'baseURL': window.baseUrl('')
}
});
window.$http = axiosInstance;
Vue.prototype.$http = axiosInstance;
require("./vues/vues");
// AngularJS - Create application and load components
const angular = require("angular");
require("angular-resource");
require("angular-animate");
require("angular-sanitize");
require("angular-ui-sortable");
let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
// Translation setup
// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
const Translations = require("./translations");
let translator = new Translations(window.translations);
window.trans = translator.get.bind(translator);
// Global Event System
class EventManager {
constructor() {
@ -63,13 +33,51 @@ class EventManager {
}
window.Events = new EventManager();
const Vue = require("vue");
const axios = require("axios");
let axiosInstance = axios.create({
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=token]').getAttribute('content'),
'baseURL': window.baseUrl('')
}
});
axiosInstance.interceptors.request.use(resp => {
return resp;
}, err => {
if (typeof err.response === "undefined" || typeof err.response.data === "undefined") return Promise.reject(err);
if (typeof err.response.data.error !== "undefined") window.Events.emit('error', err.response.data.error);
if (typeof err.response.data.message !== "undefined") window.Events.emit('error', err.response.data.message);
});
window.$http = axiosInstance;
Vue.prototype.$http = axiosInstance;
Vue.prototype.$events = window.Events;
// AngularJS - Create application and load components
const angular = require("angular");
require("angular-resource");
require("angular-animate");
require("angular-sanitize");
require("angular-ui-sortable");
let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
// Translation setup
// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
const Translations = require("./translations");
let translator = new Translations(window.translations);
window.trans = translator.get.bind(translator);
require("./vues/vues");
require("./components");
// Load in angular specific items
const Services = require('./services');
const Directives = require('./directives');
const Controllers = require('./controllers');
Services(ngApp, window.Events);
Directives(ngApp, window.Events);
Controllers(ngApp, window.Events);
@ -174,7 +182,7 @@ $('.overlay').click(function(event) {
if(navigator.userAgent.indexOf('MSIE')!==-1
|| navigator.appVersion.indexOf('Trident/') > 0
|| navigator.userAgent.indexOf('Safari') !== -1){
$('body').addClass('flexbox-support');
document.body.classList.add('flexbox-support');
}
// Page specific items

View File

@ -52,14 +52,36 @@ function editorPaste(e, editor) {
function registerEditorShortcuts(editor) {
// Headers
for (let i = 1; i < 5; i++) {
editor.addShortcut('meta+' + i, '', ['FormatBlock', false, 'h' + i]);
editor.shortcuts.add('meta+' + i, '', ['FormatBlock', false, 'h' + (i+1)]);
}
// Other block shortcuts
editor.addShortcut('meta+q', '', ['FormatBlock', false, 'blockquote']);
editor.addShortcut('meta+d', '', ['FormatBlock', false, 'p']);
editor.addShortcut('meta+e', '', ['codeeditor', false, 'pre']);
editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']);
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']);
// 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');
});
}
@ -120,7 +142,7 @@ function codePlugin() {
$codeMirrorContainer.replaceWith($pre);
}
window.tinymce.PluginManager.add('codeeditor', (editor, url) => {
window.tinymce.PluginManager.add('codeeditor', function(editor, url) {
let $ = editor.$;
@ -173,7 +195,32 @@ function codePlugin() {
});
}
function hrPlugin() {
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'
});
});
}
module.exports = function() {
hrPlugin();
codePlugin();
let settings = {
selector: '#html-editor',
@ -207,10 +254,10 @@ module.exports = function() {
{title: "Code Block", icon: "code", cmd: 'codeeditor', format: 'codeeditor'},
{title: "Inline Code", icon: "code", inline: "code"},
{title: "Callouts", items: [
{title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
{title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
{title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
{title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
{title: "Info", format: 'calloutinfo'},
{title: "Success", format: 'calloutsuccess'},
{title: "Warning", format: 'calloutwarning'},
{title: "Danger", format: 'calloutdanger'}
]},
],
style_formats_merge: false,
@ -219,6 +266,10 @@ module.exports = function() {
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) {
@ -232,7 +283,7 @@ module.exports = function() {
if (type === 'image') {
// Show image manager
window.ImageManager.showExternal(function (image) {
window.ImageManager.show(function (image) {
// Set popover link input to image url then fire change event
// to ensure the new value sticks
@ -314,7 +365,7 @@ module.exports = function() {
icon: 'image',
tooltip: 'Insert an image',
onclick: function () {
window.ImageManager.showExternal(function (image) {
window.ImageManager.show(function (image) {
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += '</a>';

View File

@ -1,5 +1,3 @@
"use strict";
// Configure ZeroClipboard
const Clipboard = require("clipboard");
const Code = require('../code');
@ -161,6 +159,8 @@ let setupPageShow = window.setupPageShow = function (pageId) {
}
});
// in order to call from other places.
window.setupPageShow.goToText = goToText;
};
module.exports = setupPageShow;

View File

@ -1,12 +0,0 @@
"use strict";
module.exports = function(ngApp, events) {
ngApp.factory('imageManagerService', function() {
return {
show: false,
showExternal: false
};
});
};

View File

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

View File

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

View File

@ -0,0 +1,60 @@
const DropZone = require("dropzone");
const template = `
<div class="dropzone-container">
<div class="dz-message">{{placeholder}}</div>
</div>
`;
const props = ['placeholder', 'uploadUrl', 'uploadedTo'];
// TODO - Remove jQuery usage
function mounted() {
let container = this.$el;
let _this = this;
new DropZone(container, {
url: function() {
return _this.uploadUrl;
},
init: function () {
let dz = this;
dz.on('sending', function (file, xhr, data) {
let token = window.document.querySelector('meta[name=token]').getAttribute('content');
data.append('_token', token);
let uploadedTo = typeof _this.uploadedTo === 'undefined' ? 0 : _this.uploadedTo;
data.append('uploaded_to', uploadedTo);
});
dz.on('success', function (file, data) {
_this.$emit('success', {file, data});
$(file.previewElement).fadeOut(400, function () {
dz.removeFile(file);
});
});
dz.on('error', function (file, errorMessage, xhr) {
_this.$emit('error', {file, errorMessage, xhr});
console.log(errorMessage);
console.log(xhr);
function setMessage(message) {
$(file.previewElement).find('[data-dz-errormessage]').text(message);
}
if (xhr.status === 413) setMessage(trans('errors.server_upload_limit'));
if (errorMessage.file) setMessage(errorMessage.file[0]);
});
}
});
}
function data() {
return {}
}
module.exports = {
template,
props,
mounted,
data,
};

View File

@ -0,0 +1,178 @@
const dropzone = require('./components/dropzone');
let page = 0;
let previousClickTime = 0;
let previousClickImage = 0;
let dataLoaded = false;
let callback = false;
let baseUrl = '';
let preSearchImages = [];
let preSearchHasMore = false;
const data = {
images: [],
imageType: false,
uploadedTo: false,
selectedImage: false,
dependantPages: false,
showing: false,
view: 'all',
hasMore: false,
searching: false,
searchTerm: '',
imageUpdateSuccess: false,
imageDeleteSuccess: false,
};
const methods = {
show(providedCallback) {
callback = providedCallback;
this.showing = true;
this.$el.children[0].components.overlay.show();
// Get initial images if they have not yet been loaded in.
if (dataLoaded) return;
this.fetchData();
dataLoaded = true;
},
hide() {
this.showing = false;
this.$el.children[0].components.overlay.hide();
},
fetchData() {
let url = baseUrl + page;
let query = {};
if (this.uploadedTo !== false) query.page_id = this.uploadedTo;
if (this.searching) query.term = this.searchTerm;
this.$http.get(url, {params: query}).then(response => {
this.images = this.images.concat(response.data.images);
this.hasMore = response.data.hasMore;
page++;
});
},
setView(viewName) {
this.cancelSearch();
this.images = [];
this.hasMore = false;
page = 0;
this.view = viewName;
baseUrl = window.baseUrl(`/images/${this.imageType}/${viewName}/`);
this.fetchData();
},
searchImages() {
if (this.searchTerm === '') return this.cancelSearch();
// Cache current settings for later
if (!this.searching) {
preSearchImages = this.images;
preSearchHasMore = this.hasMore;
}
this.searching = true;
this.images = [];
this.hasMore = false;
page = 0;
baseUrl = window.baseUrl(`/images/${this.imageType}/search/`);
this.fetchData();
},
cancelSearch() {
this.searching = false;
this.searchTerm = '';
this.images = preSearchImages;
this.hasMore = preSearchHasMore;
},
imageSelect(image) {
let dblClickTime = 300;
let currentTime = Date.now();
let timeDiff = currentTime - previousClickTime;
let isDblClick = timeDiff < dblClickTime && image.id === previousClickImage;
if (isDblClick) {
this.callbackAndHide(image);
} else {
this.selectedImage = image;
this.dependantPages = false;
}
previousClickTime = currentTime;
previousClickImage = image.id;
},
callbackAndHide(imageResult) {
if (callback) callback(imageResult);
this.hide();
},
saveImageDetails() {
let url = window.baseUrl(`/images/update/${this.selectedImage.id}`);
this.$http.put(url, this.selectedImage).then(response => {
this.$events.emit('success', trans('components.image_update_success'));
}).catch(error => {
if (error.response.status === 422) {
let errors = error.response.data;
let message = '';
Object.keys(errors).forEach((key) => {
message += errors[key].join('\n');
});
this.$events.emit('error', message);
}
});
},
deleteImage() {
let force = this.dependantPages !== false;
let url = window.baseUrl('/images/' + this.selectedImage.id);
if (force) url += '?force=true';
this.$http.delete(url).then(response => {
this.images.splice(this.images.indexOf(this.selectedImage), 1);
this.selectedImage = false;
this.$events.emit('success', trans('components.image_delete_success'));
}).catch(error=> {
if (error.response.status === 400) {
this.dependantPages = error.response.data;
}
});
},
getDate(stringDate) {
return new Date(stringDate);
},
uploadSuccess(event) {
this.images.unshift(event.data);
this.$events.emit('success', trans('components.image_upload_success'));
},
};
const computed = {
uploadUrl() {
return window.baseUrl(`/images/${this.imageType}/upload`);
}
};
function mounted() {
window.ImageManager = this;
this.imageType = this.$el.getAttribute('image-type');
this.uploadedTo = this.$el.getAttribute('uploaded-to');
baseUrl = window.baseUrl('/images/' + this.imageType + '/all/')
}
module.exports = {
mounted,
methods,
data,
computed,
components: {dropzone},
};

View File

@ -149,7 +149,7 @@ let methods = {
updateSearch(e) {
e.preventDefault();
window.location = '/search?term=' + encodeURIComponent(this.termString);
window.location = window.baseUrl('/search?term=' + encodeURIComponent(this.termString));
},
enableDate(optionName) {
@ -192,4 +192,4 @@ function created() {
module.exports = {
data, computed, methods, created
};
};

View File

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

View File

@ -6,16 +6,19 @@ function exists(id) {
let vueMapping = {
'search-system': require('./search'),
'entity-dashboard': require('./entity-search'),
'code-editor': require('./code-editor')
'entity-dashboard': require('./entity-dashboard'),
'code-editor': require('./code-editor'),
'image-manager': require('./image-manager'),
'tag-manager': require('./tag-manager'),
'attachment-manager': require('./attachment-manager'),
};
window.vues = {};
Object.keys(vueMapping).forEach(id => {
if (exists(id)) {
let config = vueMapping[id];
config.el = '#' + id;
window.vues[id] = new Vue(config);
}
});
let ids = Object.keys(vueMapping);
for (let i = 0, len = ids.length; i < len; i++) {
if (!exists(ids[i])) continue;
let config = vueMapping[ids[i]];
config.el = '#' + ids[i];
window.vues[ids[i]] = new Vue(config);
}