public function getPageAjax(int $pageId)
{
$page = $this->pageRepo->getById($pageId);
+ $page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
+ $page->addHidden(['book']);
return response()->json($page);
}
function kebabToCamel(kebab) {
const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1);
const words = kebab.split('-');
- return words[0] + words.slice(1).map(ucFirst).join();
+ return words[0] + words.slice(1).map(ucFirst).join('');
}
/**
class MarkdownEditor {
- constructor(elem) {
- this.elem = elem;
+ setup() {
+ this.elem = this.$el;
- const pageEditor = document.getElementById('page-editor');
- this.pageId = pageEditor.getAttribute('page-id');
- this.textDirection = pageEditor.getAttribute('text-direction');
+ this.pageId = this.$opts.pageId;
+ this.textDirection = this.$opts.textDirection;
this.markdown = new MarkdownIt({html: true});
this.markdown.use(mdTasksLists, {label: true});
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
- this.display.addEventListener('load', () => {
+ const displayLoad = () => {
this.displayDoc = this.display.contentDocument;
this.init();
- });
+ };
+
+ if (this.display.contentDocument.readyState === 'complete') {
+ displayLoad();
+ } else {
+ this.display.addEventListener('load', displayLoad.bind(this));
+ }
- window.$events.emitPublic(elem, 'editor-markdown::setup', {
+ window.$events.emitPublic(this.elem, 'editor-markdown::setup', {
markdownIt: this.markdown,
displayEl: this.display,
codeMirrorInstance: this.cm,
--- /dev/null
+import * as Dates from "../services/dates";
+import {onSelect} from "../services/dom";
+
+/**
+ * Page Editor
+ * @extends {Component}
+ */
+class PageEditor {
+ setup() {
+ // Options
+ this.draftsEnabled = this.$opts.draftsEnabled === 'true';
+ this.editorType = this.$opts.editorType;
+ this.pageId = Number(this.$opts.pageId);
+ this.isNewDraft = this.$opts.pageNewDraft === 'true';
+ this.hasDefaultTitle = this.$opts.isDefaultTitle || false;
+
+ // Elements
+ this.container = this.$el;
+ this.titleElem = this.$refs.titleContainer.querySelector('input');
+ this.saveDraftButton = this.$refs.saveDraft;
+ this.discardDraftButton = this.$refs.discardDraft;
+ this.discardDraftWrap = this.$refs.discardDraftWrap;
+ this.draftDisplay = this.$refs.draftDisplay;
+ this.draftDisplayIcon = this.$refs.draftDisplayIcon;
+ this.changelogInput = this.$refs.changelogInput;
+ this.changelogDisplay = this.$refs.changelogDisplay;
+
+ // Translations
+ this.draftText = this.$opts.draftText;
+ this.autosaveFailText = this.$opts.autosaveFailText;
+ this.editingPageText = this.$opts.editingPageText;
+ this.draftDiscardedText = this.$opts.draftDiscardedText;
+ this.setChangelogText = this.$opts.setChangelogText;
+
+ // State data
+ this.editorHTML = '';
+ this.editorMarkdown = '';
+ this.autoSave = {
+ interval: null,
+ frequency: 30000,
+ last: 0,
+ };
+ this.draftHasError = false;
+
+ if (this.pageId !== 0 && this.draftsEnabled) {
+ window.setTimeout(() => {
+ this.startAutoSave();
+ }, 1000);
+ }
+ this.draftDisplay.innerHTML = this.draftText;
+
+ this.setupListeners();
+ this.setInitialFocus();
+ }
+
+ setupListeners() {
+ // Listen to save events from editor
+ window.$events.listen('editor-save-draft', this.saveDraft.bind(this));
+ window.$events.listen('editor-save-page', this.savePage.bind(this));
+
+ // Listen to content changes from the editor
+ window.$events.listen('editor-html-change', html => {
+ this.editorHTML = html;
+ });
+ window.$events.listen('editor-markdown-change', markdown => {
+ this.editorMarkdown = markdown;
+ });
+
+ // Changelog controls
+ this.changelogInput.addEventListener('change', this.updateChangelogDisplay.bind(this));
+
+ // Draft Controls
+ onSelect(this.saveDraftButton, this.saveDraft.bind(this));
+ onSelect(this.discardDraftButton, this.discardDraft.bind(this));
+ }
+
+ setInitialFocus() {
+ if (this.hasDefaultTitle) {
+ return this.titleElem.select();
+ }
+
+ window.setTimeout(() => {
+ window.$events.emit('editor::focus', '');
+ }, 500);
+ }
+
+ startAutoSave() {
+ let lastContent = this.titleElem.value.trim() + '::' + this.editorHTML;
+ this.autoSaveInterval = window.setInterval(() => {
+ // Stop if manually saved recently to prevent bombarding the server
+ let savedRecently = (Date.now() - this.autoSave.last < (this.autoSave.frequency)/2);
+ if (savedRecently) return;
+ const newContent = this.titleElem.value.trim() + '::' + this.editorHTML;
+ if (newContent !== lastContent) {
+ lastContent = newContent;
+ this.saveDraft();
+ }
+
+ }, this.autoSave.frequency);
+ }
+
+ savePage() {
+ this.container.closest('form').submit();
+ }
+
+ async saveDraft() {
+ const data = {
+ name: this.titleElem.value.trim(),
+ html: this.editorHTML,
+ };
+
+ if (this.editorType === 'markdown') {
+ data.markdown = this.editorMarkdown;
+ }
+
+ try {
+ const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data);
+ this.draftHasError = false;
+ if (!this.isNewDraft) {
+ this.toggleDiscardDraftVisibility(true);
+ }
+ this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`);
+ this.autoSave.last = Date.now();
+ } catch (err) {
+ if (!this.draftHasError) {
+ this.draftHasError = true;
+ window.$events.emit('error', this.autosaveFailText);
+ }
+ }
+
+ }
+
+ draftNotifyChange(text) {
+ this.draftDisplay.innerText = text;
+ this.draftDisplayIcon.classList.add('visible');
+ window.setTimeout(() => {
+ this.draftDisplayIcon.classList.remove('visible');
+ }, 2000);
+ }
+
+ async discardDraft() {
+ let response;
+ try {
+ response = await window.$http.get(`/ajax/page/${this.pageId}`);
+ } catch (e) {
+ return console.error(e);
+ }
+
+ if (this.autoSave.interval) {
+ window.clearInterval(this.autoSave.interval);
+ }
+
+ this.draftDisplay.innerText = this.editingPageText;
+ this.toggleDiscardDraftVisibility(false);
+ window.$events.emit('editor-html-update', response.data.html || '');
+ window.$events.emit('editor-markdown-update', response.data.markdown || response.data.html);
+
+ this.titleElem.value = response.data.name;
+ window.setTimeout(() => {
+ this.startAutoSave();
+ }, 1000);
+ window.$events.emit('success', this.draftDiscardedText);
+
+ }
+
+ updateChangelogDisplay() {
+ let summary = this.changelogInput.value.trim();
+ if (summary.length === 0) {
+ summary = this.setChangelogText;
+ } else if (summary.length > 16) {
+ summary = summary.slice(0, 16) + '...';
+ }
+ this.changelogDisplay.innerText = summary;
+ }
+
+ toggleDiscardDraftVisibility(show) {
+ this.discardDraftWrap.classList.toggle('hidden', !show);
+ }
+
+}
+
+export default PageEditor;
\ No newline at end of file
});
}
-function drawIoPlugin(drawioUrl, isDarkMode) {
+function drawIoPlugin(drawioUrl, isDarkMode, pageId) {
let pageEditor = null;
let currentNode = null;
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) {
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');
+ setup() {
+ this.elem = this.$el;
+
+ this.pageId = this.$opts.pageId;
+ this.textDirection = this.$opts.textDirection;
this.isDarkMode = document.documentElement.classList.contains('dark-mode');
this.plugins = "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor media";
this.loadPlugins();
this.tinyMceConfig = this.getTinyMceConfig();
- window.$events.emitPublic(elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
+ window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
window.tinymce.init(this.tinyMceConfig);
}
const drawioUrlElem = document.querySelector('[drawio-url]');
if (drawioUrlElem) {
const url = drawioUrlElem.getAttribute('drawio-url');
- drawIoPlugin(url, this.isDarkMode);
+ drawIoPlugin(url, this.isDarkMode, this.pageId);
this.plugins += ' drawio';
}
+++ /dev/null
-import * as Dates from "../services/dates";
-
-let autoSaveFrequency = 30;
-
-let autoSave = false;
-let draftErroring = false;
-
-let currentContent = {
- title: false,
- html: false
-};
-
-let lastSave = 0;
-
-function mounted() {
- let elem = this.$el;
- this.draftsEnabled = elem.getAttribute('drafts-enabled') === 'true';
- this.editorType = elem.getAttribute('editor-type');
- this.pageId= Number(elem.getAttribute('page-id'));
- this.isNewDraft = Number(elem.getAttribute('page-new-draft')) === 1;
- this.isUpdateDraft = Number(elem.getAttribute('page-update-draft')) === 1;
- this.titleElem = elem.querySelector('input[name=name]');
- this.hasDefaultTitle = this.titleElem.closest('[is-default-value]') !== null;
-
- if (this.pageId !== 0 && this.draftsEnabled) {
- window.setTimeout(() => {
- this.startAutoSave();
- }, 1000);
- }
-
- if (this.isUpdateDraft || this.isNewDraft) {
- this.draftText = trans('entities.pages_editing_draft');
- } else {
- this.draftText = trans('entities.pages_editing_page');
- }
-
- // Listen to save events from editor
- window.$events.listen('editor-save-draft', this.saveDraft);
- window.$events.listen('editor-save-page', this.savePage);
-
- // Listen to content changes from the editor
- window.$events.listen('editor-html-change', html => {
- this.editorHTML = html;
- });
- window.$events.listen('editor-markdown-change', markdown => {
- this.editorMarkdown = markdown;
- });
-
- this.setInitialFocus();
-}
-
-let data = {
- draftsEnabled: false,
- editorType: 'wysiwyg',
- pagedId: 0,
- isNewDraft: false,
- isUpdateDraft: false,
-
- draftText: '',
- draftUpdated : false,
- changeSummary: '',
-
- editorHTML: '',
- editorMarkdown: '',
-
- hasDefaultTitle: false,
- titleElem: null,
-};
-
-let methods = {
-
- setInitialFocus() {
- if (this.hasDefaultTitle) {
- this.titleElem.select();
- } else {
- window.setTimeout(() => {
- this.$events.emit('editor::focus', '');
- }, 500);
- }
- },
-
- startAutoSave() {
- currentContent.title = this.titleElem.value.trim();
- currentContent.html = this.editorHTML;
-
- autoSave = window.setInterval(() => {
- // Return if manually saved recently to prevent bombarding the server
- if (Date.now() - lastSave < (1000 * autoSaveFrequency)/2) return;
- const newTitle = this.titleElem.value.trim();
- const newHtml = this.editorHTML;
-
- if (newTitle !== currentContent.title || newHtml !== currentContent.html) {
- currentContent.html = newHtml;
- currentContent.title = newTitle;
- this.saveDraft();
- }
-
- }, 1000 * autoSaveFrequency);
- },
-
- saveDraft() {
- if (!this.draftsEnabled) return;
-
- const data = {
- name: this.titleElem.value.trim(),
- html: this.editorHTML
- };
-
- if (this.editorType === 'markdown') data.markdown = this.editorMarkdown;
-
- const url = window.baseUrl(`/ajax/page/${this.pageId}/save-draft`);
- window.$http.put(url, data).then(response => {
- draftErroring = false;
- if (!this.isNewDraft) this.isUpdateDraft = true;
- this.draftNotifyChange(`${response.data.message} ${Dates.utcTimeStampToLocalTime(response.data.timestamp)}`);
- lastSave = Date.now();
- }, errorRes => {
- if (draftErroring) return;
- window.$events.emit('error', trans('errors.page_draft_autosave_fail'));
- draftErroring = true;
- });
- },
-
- savePage() {
- this.$el.closest('form').submit();
- },
-
- draftNotifyChange(text) {
- this.draftText = text;
- this.draftUpdated = true;
- window.setTimeout(() => {
- this.draftUpdated = false;
- }, 2000);
- },
-
- discardDraft() {
- let url = window.baseUrl(`/ajax/page/${this.pageId}`);
- window.$http.get(url).then(response => {
- if (autoSave) window.clearInterval(autoSave);
-
- this.draftText = trans('entities.pages_editing_page');
- this.isUpdateDraft = false;
- window.$events.emit('editor-html-update', response.data.html);
- window.$events.emit('editor-markdown-update', response.data.markdown || response.data.html);
-
- this.titleElem.value = response.data.name;
- window.setTimeout(() => {
- this.startAutoSave();
- }, 1000);
- window.$events.emit('success', trans('entities.pages_draft_discarded'));
- });
- },
-
-};
-
-let computed = {
- changeSummaryShort() {
- let len = this.changeSummary.length;
- if (len === 0) return trans('entities.pages_edit_set_changelog');
- if (len <= 16) return this.changeSummary;
- return this.changeSummary.slice(0, 16) + '...';
- }
-};
-
-export default {
- mounted, data, methods, computed,
-};
\ No newline at end of file
}
import imageManager from "./image-manager";
-import pageEditor from "./page-editor";
let vueMapping = {
'image-manager': imageManager,
- 'page-editor': pageEditor,
};
window.vues = {};
-<div class="page-editor flex-fill flex" id="page-editor"
- drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}"
+<div component="page-editor" class="page-editor flex-fill flex"
+ option:page-editor:drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}"
@if(config('services.drawio'))
drawio-url="{{ is_string(config('services.drawio')) ? config('services.drawio') : 'https://www.draw.io/?embed=1&proto=json&spin=1' }}"
@endif
- editor-type="{{ setting('app-editor') }}"
- page-id="{{ $model->id ?? 0 }}"
- text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
- page-new-draft="{{ $model->draft ?? 0 }}"
- page-update-draft="{{ $model->isDraft ?? 0 }}">
-
- @exposeTranslations([
- 'entities.pages_editing_draft',
- 'entities.pages_editing_page',
- 'errors.page_draft_autosave_fail',
- 'entities.pages_editing_page',
- 'entities.pages_draft_discarded',
- 'entities.pages_edit_set_changelog',
- ])
+ @if($model->name === trans('entities.pages_initial_name'))
+ option:page-editor:has-default-title="true"
+ @endif
+ option:page-editor:editor-type="{{ setting('app-editor') }}"
+ option:page-editor:page-id="{{ $model->id ?? '0' }}"
+ option:page-editor:page-new-draft="{{ ($model->draft ?? false) ? 'true' : 'false' }}"
+ option:page-editor:draft-text="{{ ($model->draft || $model->isDraft) ? trans('entities.pages_editing_draft') : trans('entities.pages_editing_page') }}"
+ option:page-editor:autosave-fail-text="{{ trans('errors.page_draft_autosave_fail') }}"
+ option:page-editor:editing-page-text="{{ trans('entities.pages_editing_page') }}"
+ option:page-editor:draft-discarded-text="{{ trans('entities.pages_draft_discarded') }}"
+ option:page-editor:set-changelog-text="{{ trans('entities.pages_edit_set_changelog') }}">
{{--Header Bar--}}
<div class="primary-background-light toolbar page-edit-toolbar">
</div>
<div class="text-center px-m py-xs">
- <div v-show="draftsEnabled"
- component="dropdown"
+ <div component="dropdown"
option:dropdown:move-menu="true"
- class="dropdown-container draft-display text">
- <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" title="{{ trans('entities.pages_edit_draft_options') }}" class="text-primary text-button"><span class="faded-text" v-text="draftText"></span> @icon('more')</button>
- @icon('check-circle', ['class' => 'text-pos draft-notification svg-icon', ':class' => '{visible: draftUpdated}'])
+ class="dropdown-container draft-display text {{ $draftsEnabled ? '' : 'hidden' }}">
+ <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" title="{{ trans('entities.pages_edit_draft_options') }}" class="text-primary text-button"><span refs="page-editor@draftDisplay" class="faded-text"></span> @icon('more')</button>
+ @icon('check-circle', ['class' => 'text-pos draft-notification svg-icon', 'refs' => 'page-editor@draftDisplayIcon'])
<ul refs="dropdown@menu" class="dropdown-menu" role="menu">
<li>
- <button type="button" @click="saveDraft()" class="text-pos">@icon('save'){{ trans('entities.pages_edit_save_draft') }}</button>
+ <button refs="page-editor@saveDraft" type="button" class="text-pos">@icon('save'){{ trans('entities.pages_edit_save_draft') }}</button>
</li>
- <li v-if="isNewDraft">
+ @if ($model->draft)
+ <li>
<a href="{{ $model->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('entities.pages_edit_delete_draft') }}</a>
</li>
- <li v-if="isUpdateDraft">
- <button type="button" @click="discardDraft" class="text-neg">@icon('cancel'){{ trans('entities.pages_edit_discard_draft') }}</button>
+ @endif
+ <li refs="page-editor@discardDraftWrap" class="{{ ($model->isDraft ?? false) ? '' : 'hidden' }}">
+ <button refs="page-editor@discardDraft" type="button" class="text-neg">@icon('cancel'){{ trans('entities.pages_edit_discard_draft') }}</button>
</li>
</ul>
</div>
</div>
- <div class="action-buttons px-m py-xs" v-cloak>
+ <div class="action-buttons px-m py-xs">
<div component="dropdown" dropdown-move-menu class="dropdown-container">
- <button refs="dropdown@toggle" type="button" aria-haspopup="true" aria-expanded="false" class="text-primary text-button">@icon('edit') <span v-text="changeSummaryShort"></span></button>
+ <button refs="dropdown@toggle" type="button" aria-haspopup="true" aria-expanded="false" class="text-primary text-button">@icon('edit') <span refs="page-editor@changelogDisplay">{{ trans('entities.pages_edit_set_changelog') }}</span></button>
<ul refs="dropdown@menu" class="wide dropdown-menu">
<li class="px-l py-m">
<p class="text-muted pb-s">{{ trans('entities.pages_edit_enter_changelog_desc') }}</p>
- <input name="summary" id="summary-input" type="text" placeholder="{{ trans('entities.pages_edit_enter_changelog') }}" v-model="changeSummary" />
+ <input refs="page-editor@changelogInput"
+ name="summary"
+ id="summary-input"
+ type="text"
+ placeholder="{{ trans('entities.pages_edit_enter_changelog') }}" />
</li>
</ul>
<span>{{-- Prevents button jumping on menu show --}}</span>
</div>
{{--Title input--}}
- <div class="title-input page-title clearfix" v-pre>
- <div class="input" @if($model->name === trans('entities.pages_initial_name')) is-default-value @endif>
+ <div class="title-input page-title clearfix">
+ <div refs="page-editor@titleContainer" class="input">
@include('form.text', ['name' => 'name', 'model' => $model, 'placeholder' => trans('entities.pages_title')])
</div>
</div>
</div>
- <button type="submit" id="save-button-mobile" title="{{ trans('entities.pages_save') }}" class="text-primary text-button hide-over-m page-save-mobile-button">@icon('save')</button>
+ <button type="submit"
+ id="save-button-mobile"
+ title="{{ trans('entities.pages_save') }}"
+ class="text-primary text-button hide-over-m page-save-mobile-button">@icon('save')</button>
</div>
\ No newline at end of file
-<div v-pre id="markdown-editor" markdown-editor class="flex-fill flex code-fill">
+<div id="markdown-editor" component="markdown-editor"
+ option:markdown-editor:page-id="{{ $model->id ?? 0 }}"
+ option:markdown-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+ class="flex-fill flex code-fill">
@exposeTranslations([
'errors.image_upload_error',
])
-<div wysiwyg-editor class="flex-fill flex">
+<div component="wysiwyg-editor"
+ option:wysiwyg-editor:page-id="{{ $model->id ?? 0 }}"
+ option:wysiwyg-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+ class="flex-fill flex">
@exposeTranslations([
'errors.image_upload_error',
<?php namespace Tests\Entity;
+use BookStack\Entities\Page;
use BookStack\Entities\Repos\PageRepo;
use Tests\BrowserKitTest;
->dontSeeInElement('.book-contents', 'New Page');
}
+ public function test_page_html_in_ajax_fetch_response()
+ {
+ $this->asAdmin();
+ $page = Page::query()->first();
+
+ $this->getJson('/ajax/page/' . $page->id);
+ $this->seeJson([
+ 'html' => $page->html,
+ ]);
+ }
+
}