diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index b216c19a8..57d70fb32 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -163,6 +163,8 @@ class PageController extends Controller 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); } diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 4908dcd73..a0269ea61 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -129,7 +129,7 @@ function parseOpts(name, element) { 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(''); } /** diff --git a/resources/js/components/markdown-editor.js b/resources/js/components/markdown-editor.js index cc9a7b859..6e646c72b 100644 --- a/resources/js/components/markdown-editor.js +++ b/resources/js/components/markdown-editor.js @@ -8,12 +8,11 @@ import DrawIO from "../services/drawio"; 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}); @@ -27,12 +26,18 @@ class MarkdownEditor { this.onMarkdownScroll = this.onMarkdownScroll.bind(this); - this.display.addEventListener('load', () => { + const displayLoad = () => { this.displayDoc = this.display.contentDocument; this.init(); - }); + }; - window.$events.emitPublic(elem, 'editor-markdown::setup', { + if (this.display.contentDocument.readyState === 'complete') { + displayLoad(); + } else { + this.display.addEventListener('load', displayLoad.bind(this)); + } + + window.$events.emitPublic(this.elem, 'editor-markdown::setup', { markdownIt: this.markdown, displayEl: this.display, codeMirrorInstance: this.cm, diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js new file mode 100644 index 000000000..4fb472e7e --- /dev/null +++ b/resources/js/components/page-editor.js @@ -0,0 +1,182 @@ +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 diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 5956b5e7a..5e3ce8d96 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -236,7 +236,7 @@ function codePlugin() { }); } -function drawIoPlugin(drawioUrl, isDarkMode) { +function drawIoPlugin(drawioUrl, isDarkMode, pageId) { let pageEditor = null; let currentNode = null; @@ -270,7 +270,6 @@ function drawIoPlugin(drawioUrl, isDarkMode) { 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) { @@ -410,19 +409,19 @@ function listenForBookStackEditorEvents(editor) { 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); } @@ -433,7 +432,7 @@ class WysiwygEditor { 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'; } diff --git a/resources/js/vues/page-editor.js b/resources/js/vues/page-editor.js deleted file mode 100644 index a79ad2049..000000000 --- a/resources/js/vues/page-editor.js +++ /dev/null @@ -1,167 +0,0 @@ -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 diff --git a/resources/js/vues/vues.js b/resources/js/vues/vues.js index d0bd529ef..faa191b95 100644 --- a/resources/js/vues/vues.js +++ b/resources/js/vues/vues.js @@ -5,11 +5,9 @@ function exists(id) { } import imageManager from "./image-manager"; -import pageEditor from "./page-editor"; let vueMapping = { 'image-manager': imageManager, - 'page-editor': pageEditor, }; window.vues = {}; diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index 47a9369ce..d153aed99 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -1,22 +1,19 @@ -
- - @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--}}
@@ -27,33 +24,38 @@
-
-
+
{{--Title input--}} -
-
name === trans('entities.pages_initial_name')) is-default-value @endif> +
+
@include('form.text', ['name' => 'name', 'model' => $model, 'placeholder' => trans('entities.pages_title')])
@@ -86,5 +88,8 @@
- +
\ No newline at end of file diff --git a/resources/views/pages/markdown-editor.blade.php b/resources/views/pages/markdown-editor.blade.php index 85afbea06..a004dbd8b 100644 --- a/resources/views/pages/markdown-editor.blade.php +++ b/resources/views/pages/markdown-editor.blade.php @@ -1,4 +1,7 @@ -
+
@exposeTranslations([ 'errors.image_upload_error', ]) diff --git a/resources/views/pages/wysiwyg-editor.blade.php b/resources/views/pages/wysiwyg-editor.blade.php index 1a67ee76f..2804612cd 100644 --- a/resources/views/pages/wysiwyg-editor.blade.php +++ b/resources/views/pages/wysiwyg-editor.blade.php @@ -1,4 +1,7 @@ -
+
@exposeTranslations([ 'errors.image_upload_error', diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php index 5c984940d..a0cf9e5fc 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -1,5 +1,6 @@ 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, + ]); + } + }