require('codemirror/mode/css/css');
require('codemirror/mode/clike/clike');
+require('codemirror/mode/diff/diff');
require('codemirror/mode/go/go');
require('codemirror/mode/htmlmixed/htmlmixed');
require('codemirror/mode/javascript/javascript');
'c++': 'clike',
'c#': 'clike',
csharp: 'clike',
+ diff: 'diff',
go: 'go',
html: 'htmlmixed',
javascript: 'javascript',
ruby: 'ruby',
rb: 'ruby',
shell: 'shell',
+ sh: 'shell',
bash: 'shell',
toml: 'toml',
sql: 'sql',
module.exports.highlight = function() {
let codeBlocks = document.querySelectorAll('.page-content pre');
-
for (let i = 0; i < codeBlocks.length; i++) {
- let innerCodeElem = codeBlocks[i].querySelector('code[class^=language-]');
- let mode = '';
- if (innerCodeElem !== null) {
- let langName = innerCodeElem.className.replace('language-', '');
- if (typeof modeMap[langName] !== 'undefined') mode = modeMap[langName];
- }
- codeBlocks[i].innerHTML = codeBlocks[i].innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
- let content = codeBlocks[i].textContent;
- console.log('MODE', mode);
-
- CodeMirror(function(elt) {
- codeBlocks[i].parentNode.replaceChild(elt, codeBlocks[i]);
- }, {
- value: content,
- mode: mode,
- lineNumbers: true,
- theme: 'base16-light',
- readOnly: true
- });
+ highlightElem(codeBlocks[i]);
+ }
+};
+
+function highlightElem(elem) {
+ let innerCodeElem = elem.querySelector('code[class^=language-]');
+ let mode = '';
+ if (innerCodeElem !== null) {
+ let langName = innerCodeElem.className.replace('language-', '');
+ mode = getMode(langName);
}
+ elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
+ let content = elem.textContent;
+
+ CodeMirror(function(elt) {
+ elem.parentNode.replaceChild(elt, elem);
+ }, {
+ value: content,
+ mode: mode,
+ lineNumbers: true,
+ theme: 'base16-light',
+ readOnly: true
+ });
+}
+
+/**
+ * Search for a codemirror code based off a user suggestion
+ * @param suggestion
+ * @returns {string}
+ */
+function getMode(suggestion) {
+ suggestion = suggestion.trim().replace(/^\./g, '').toLowerCase();
+ return (typeof modeMap[suggestion] !== 'undefined') ? modeMap[suggestion] : '';
+}
+
+module.exports.highlightElem = highlightElem;
+
+module.exports.wysiwygView = function(elem) {
+ let doc = elem.ownerDocument;
+ let codeElem = elem.querySelector('code');
+
+ let lang = (elem.className || '').replace('language-', '');
+ if (lang === '' && codeElem) {
+ lang = (codeElem.className || '').replace('language-', '')
+ }
+
+ elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
+ let content = elem.textContent;
+ let newWrap = doc.createElement('div');
+ let newTextArea = doc.createElement('textarea');
+
+ newWrap.className = 'CodeMirrorContainer';
+ newWrap.setAttribute('data-lang', lang);
+ newTextArea.style.display = 'none';
+ elem.parentNode.replaceChild(newWrap, elem);
+
+ newWrap.appendChild(newTextArea);
+ newWrap.contentEditable = false;
+ newTextArea.textContent = content;
+
+ let cm = CodeMirror(function(elt) {
+ newWrap.appendChild(elt);
+ }, {
+ value: content,
+ mode: getMode(lang),
+ lineNumbers: true,
+ theme: 'base16-light',
+ readOnly: true
+ });
+ setTimeout(() => {
+ cm.refresh();
+ }, 300);
+ return {wrap: newWrap, editor: cm};
+};
+
+module.exports.popupEditor = function(elem, modeSuggestion) {
+ let content = elem.textContent;
+ return CodeMirror(function(elt) {
+ elem.parentNode.insertBefore(elt, elem);
+ elem.style.display = 'none';
+ }, {
+ value: content,
+ mode: getMode(modeSuggestion),
+ lineNumbers: true,
+ theme: 'base16-light',
+ lineWrapping: true
+ });
+};
+
+module.exports.setMode = function(cmInstance, modeSuggestion) {
+ cmInstance.setOption('mode', getMode(modeSuggestion));
+};
+module.exports.setContent = function(cmInstance, codeContent) {
+ cmInstance.setValue(codeContent);
+ setTimeout(() => {
+ cmInstance.refresh();
+ }, 10);
};
module.exports.markdownEditor = function(elem) {
let content = elem.textContent;
- let cm = CodeMirror(function(elt) {
+ return CodeMirror(function (elt) {
elem.parentNode.insertBefore(elt, elem);
elem.style.display = 'none';
}, {
value: content,
- mode: "markdown",
+ mode: "markdown",
lineNumbers: true,
theme: 'base16-light',
lineWrapping: true
});
- return cm;
};
"use strict";
+const Code = require('../code');
+
/**
* Handle pasting images from clipboard.
* @param e - event
// Other block shortcuts
editor.addShortcut('meta+q', '', ['FormatBlock', false, 'blockquote']);
editor.addShortcut('meta+d', '', ['FormatBlock', false, 'p']);
- editor.addShortcut('meta+e', '', ['FormatBlock', false, 'pre']);
+ editor.addShortcut('meta+e', '', ['codeeditor', false, 'pre']);
editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']);
}
+
+/**
+ * Create and enable our custom code plugin
+ */
+function codePlugin() {
+
+ function elemIsCodeBlock(elem) {
+ return elem.className === 'CodeMirrorContainer';
+ }
+
+ function showPopup(editor) {
+ let selectedNode = editor.selection.getNode();
+ console.log('show ppoe');
+
+ 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) {
+ let textArea = $codeMirrorContainer[0].querySelector('textarea');
+ let code = textArea.textContent;
+ let lang = $codeMirrorContainer[0].getAttribute('data-lang');
+
+ $codeMirrorContainer.removeAttr('contentEditable');
+ let $pre = $('<pre></pre>');
+ $pre.append($('<code></code>').each((index, elem) => {
+ // Needs to be textContent since innerText produces BR:s
+ elem.textContent = code;
+ }).attr('class', `language-${lang}`));
+ $codeMirrorContainer.replaceWith($pre);
+ }
+
+ window.tinymce.PluginManager.add('codeeditor', (editor, url) => {
+
+ let $ = 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) => {
+ let $elem = $(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) => {
+ console.log('COVERT');
+ codeMirrorContainerToPre($(elem));
+ });
+
+ let 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);
+ });
+ });
+ });
+
+ });
+}
+
module.exports = function() {
+ codePlugin();
let settings = {
selector: '#html-editor',
content_css: [
window.baseUrl('/css/styles.css'),
window.baseUrl('/libs/material-design-iconic-font/css/material-design-iconic-font.min.css')
],
+ branding: false,
body_class: 'page-content',
browser_spellcheck: true,
relative_urls: false,
paste_data_images: false,
extended_valid_elements: 'pre[*]',
automatic_uploads: false,
- valid_children: "-div[p|pre|h1|h2|h3|h4|h5|h6|blockquote]",
- plugins: "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codesample",
+ valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre]",
+ plugins: "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor",
imagetools_toolbar: 'imageoptions',
- toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen codesample",
+ toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen",
content_style: "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 Tiny", format: "h5"},
{title: "Paragraph", format: "p", exact: true, classes: ''},
{title: "Blockquote", format: "blockquote"},
- {title: "Code Block", icon: "code", format: "pre"},
+ {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'}}
- ]}
+ ]},
],
style_formats_merge: 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'},
--- /dev/null
+const codeLib = require('../code');
+
+const methods = {
+ show() {
+ if (!this.editor) this.editor = codeLib.popupEditor(this.$refs.editor, this.language);
+ this.$refs.overlay.style.display = 'flex';
+ },
+ hide() {
+ this.$refs.overlay.style.display = 'none';
+ },
+ updateEditorMode(language) {
+ codeLib.setMode(this.editor, language);
+ },
+ updateLanguage(lang) {
+ this.language = lang;
+ this.updateEditorMode(lang);
+ },
+ open(code, language, callback) {
+ this.show();
+ this.updateEditorMode(language);
+ this.language = language;
+ codeLib.setContent(this.editor, code);
+ this.code = code;
+ this.callback = callback;
+ },
+ save() {
+ if (!this.callback) return;
+ this.callback(this.editor.getValue(), this.language);
+ this.hide();
+ }
+};
+
+const data = {
+ editor: null,
+ language: '',
+ code: '',
+ callback: null
+};
+
+module.exports = {
+ methods,
+ data
+};
\ No newline at end of file
let vueMapping = {
'search-system': require('./search'),
'entity-dashboard': require('./entity-search'),
+ 'code-editor': require('./code-editor')
};
+window.vues = {};
+
Object.keys(vueMapping).forEach(id => {
if (exists(id)) {
let config = vueMapping[id];
config.el = '#' + id;
- new Vue(config);
+ window.vues[id] = new Vue(config);
}
});
\ No newline at end of file
-webkit-tap-highlight-color: transparent;
-webkit-font-variant-ligatures: contextual;
font-variant-ligatures: contextual;
+ &:after {
+ content: none;
+ display: none;
+ }
}
.CodeMirror-wrap pre {
word-wrap: break-word;
.image-picker .none {
display: none;
+}
+
+#code-editor .CodeMirror {
+ height: 400px;
+}
+
+#code-editor .lang-options {
+ max-width: 400px;
+ margin-bottom: $-s;
+ a {
+ margin-right: $-xs;
+ text-decoration: underline;
+ }
}
\ No newline at end of file
font-size: 12px;
background-color: #f5f5f5;
border: 1px solid #DDD;
+ padding-left: 31px;
+ position: relative;
+ padding-top: 3px;
+ padding-bottom: 3px;
+ &:after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ width: 29px;
+ left: 0;
+ background-color: #f5f5f5;
+ height: 100%;
+ border-right: 1px solid #DDD;
+ }
}
border: 0;
font-size: 1em;
display: block;
+ line-height: 1.6;
}
/*
* Text colors
'image_preview' => 'Image Preview',
'image_upload_success' => 'Image uploaded successfully',
'image_update_success' => 'Image details successfully updated',
- 'image_delete_success' => 'Image successfully deleted'
+ 'image_delete_success' => 'Image successfully deleted',
+
+ /**
+ * Code editor
+ */
+ 'code_editor' => 'Edit Code',
+ 'code_language' => 'Code Language',
+ 'code_content' => 'Code Content',
+ 'code_save' => 'Save Code',
];
\ No newline at end of file
@else
<p class="text-muted">{{ trans('entities.books_empty_contents') }}</p>
<p>
+ @if(userCan('page-create', $book))
<a href="{{ $book->getUrl('/page/create') }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.books_empty_create_page') }}</a>
+ @endif
+ @if(userCan('page-create', $book) && userCan('chapter-create', $book))
<em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em>
+ @endif
+ @if(userCan('chapter-create', $book))
<a href="{{ $book->getUrl('/chapter/create') }}" class="text-chapter"><i class="zmdi zmdi-collection-bookmark"></i>{{ trans('entities.books_empty_add_chapter') }}</a>
+ @endif
</p>
<hr>
@endif
--- /dev/null
+<div id="code-editor">
+ <div class="overlay" ref="overlay" v-cloak @click="hide()">
+ <div class="popup-body" @click.stop>
+
+ <div class="popup-header primary-background">
+ <div class="popup-title">{{ trans('components.code_editor') }}</div>
+ <button class="popup-close neg corner-button button" @click="hide()">x</button>
+ </div>
+
+ <div class="padded">
+ <div class="form-group">
+ <label for="code-editor-language">{{ trans('components.code_language') }}</label>
+ <div class="lang-options">
+ <small>
+ <a @click="updateLanguage('CSS')">CSS</a>
+ <a @click="updateLanguage('C')">C</a>
+ <a @click="updateLanguage('C++')">C++</a>
+ <a @click="updateLanguage('C#')">C#</a>
+ <a @click="updateLanguage('Go')">Go</a>
+ <a @click="updateLanguage('HTML')">HTML</a>
+ <a @click="updateLanguage('Java')">Java</a>
+ <a @click="updateLanguage('JavaScript')">JavaScript</a>
+ <a @click="updateLanguage('JSON')">JSON</a>
+ <a @click="updateLanguage('PHP')">PHP</a>
+ <a @click="updateLanguage('MarkDown')">MarkDown</a>
+ <a @click="updateLanguage('Nginx')">Nginx</a>
+ <a @click="updateLanguage('Python')">Python</a>
+ <a @click="updateLanguage('Ruby')">Ruby</a>
+ <a @click="updateLanguage('shell')">Shell/Bash</a>
+ <a @click="updateLanguage('SQL')">SQL</a>
+ <a @click="updateLanguage('XML')">XML</a>
+ <a @click="updateLanguage('YAML')">YAML</a>
+ </small>
+ </div>
+ <input @keypress.enter="save()" id="code-editor-language" type="text" @input="updateEditorMode(language)" v-model="language">
+ </div>
+
+ <div class="form-group">
+ <label for="code-editor-content">{{ trans('components.code_content') }}</label>
+ <textarea ref="editor" v-model="code"></textarea>
+ </div>
+
+ <div class="form-group">
+ <button type="button" class="button pos" @click="save()">{{ trans('components.code_save') }}</button>
+ </div>
+
+ </div>
+
+ </div>
+ </div>
+</div>
\ No newline at end of file
</div>
@include('components.image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])
+ @include('components.code-editor')
@include('components.entity-selector-popup')
@stop
\ No newline at end of file
<?php namespace Tests;
+use BookStack\Entity;
use BookStack\Role;
use BookStack\Services\PermissionService;
use Illuminate\Contracts\Console\Kernel;
];
}
+ /**
+ * Helper for updating entity permissions.
+ * @param Entity $entity
+ */
+ protected function updateEntityPermissions(Entity $entity)
+ {
+ $restrictionService = $this->app[PermissionService::class];
+ $restrictionService->buildJointPermissionsForEntity($entity);
+ }
+
/**
* Quick way to create a new user
* @param array $attributes
<?php namespace Tests;
+use BookStack\Page;
use BookStack\Repos\PermissionsRepo;
use BookStack\Role;
+use Laravel\BrowserKitTesting\HttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class RolesTest extends BrowserKitTest
{
->see('Cannot be deleted');
}
-
-
public function test_image_delete_own_permission()
{
$this->giveUserPermissions($this->user, ['image-update-all']);
->dontSeeInDatabase('images', ['id' => $image->id]);
}
+ public function test_role_permission_removal()
+ {
+ // To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a.
+ $page = Page::first();
+ $viewerRole = \BookStack\Role::getRole('viewer');
+ $viewer = $this->getViewer();
+ $this->actingAs($viewer)->visit($page->getUrl())->assertResponseOk();
+
+ $this->asAdmin()->put('/settings/roles/' . $viewerRole->id, [
+ 'display_name' => $viewerRole->display_name,
+ 'description' => $viewerRole->description,
+ 'permission' => []
+ ])->assertResponseStatus(302);
+
+ $this->expectException(HttpException::class);
+ $this->actingAs($viewer)->visit($page->getUrl())->assertResponseStatus(404);
+ }
+
+ public function test_empty_state_actions_not_visible_without_permission()
+ {
+ $admin = $this->getAdmin();
+ // Book links
+ $book = factory(\BookStack\Book::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
+ $this->updateEntityPermissions($book);
+ $this->actingAs($this->getViewer())->visit($book->getUrl())
+ ->dontSee('Create a new page')
+ ->dontSee('Add a chapter');
+
+ // Chapter links
+ $chapter = factory(\BookStack\Chapter::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
+ $this->updateEntityPermissions($chapter);
+ $this->actingAs($this->getViewer())->visit($chapter->getUrl())
+ ->dontSee('Create a new page')
+ ->dontSee('Sort the current book');
+ }
+
}