diff options
author | Julius Härtl <jus@bitgrid.net> | 2019-06-06 17:01:18 +0300 |
---|---|---|
committer | Julius Härtl <jus@bitgrid.net> | 2019-06-06 17:01:18 +0300 |
commit | 66ae668cd0abf5213475231e4a4057d9df2eccf1 (patch) | |
tree | ff213038f0ba59322b83b2ddf3337d905c2f93cd | |
parent | 68152421cdfac78c664cfc6365427004684ce1a2 (diff) |
Fix conflict handling in tiptap
Signed-off-by: Julius Härtl <jus@bitgrid.net>
-rw-r--r-- | lib/Db/Document.php | 1 | ||||
-rw-r--r-- | lib/Service/ApiService.php | 8 | ||||
-rw-r--r-- | src/components/Editor.vue | 92 | ||||
-rw-r--r-- | src/marks/Bold.js | 0 | ||||
-rw-r--r-- | src/marks/index.js | 47 | ||||
-rw-r--r-- | src/services/PollingBackend.js | 10 | ||||
-rw-r--r-- | src/services/SyncService.js | 7 |
7 files changed, 128 insertions, 37 deletions
diff --git a/lib/Db/Document.php b/lib/Db/Document.php index 07a1e64ce..e1d0965ee 100644 --- a/lib/Db/Document.php +++ b/lib/Db/Document.php @@ -50,6 +50,7 @@ class Document extends Entity implements \JsonSerializable { 'currentVersion' => $this->currentVersion, 'lastSavedVersion' => $this->lastSavedVersion, 'lastSavedVersionTime' => $this->lastSavedVersionTime, + 'baseVersionEtag' => $this->baseVersionEtag, ]; } diff --git a/lib/Service/ApiService.php b/lib/Service/ApiService.php index 82e4763d8..92fd034f9 100644 --- a/lib/Service/ApiService.php +++ b/lib/Service/ApiService.php @@ -110,8 +110,12 @@ class ApiService { try { $document = $this->documentService->autosave($documentId, $version, $autosaveContent, $force, $manualSave, $token); } catch (DocumentSaveConflictException $e) { - /** @var File $file */ - $file = $this->documentService->getFileByShareToken($token); + if ($token) { + /** @var File $file */ + $file = $this->documentService->getFileByShareToken($token); + } else { + $file = $this->documentService->getFileById($documentId); + } return new DataResponse([ 'outsideChange' => $file->getContent() ], 409); diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 0c2e20eba..e7cedffcf 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -36,11 +36,10 @@ </div> <div id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !initialLoading}"> <div id="editor"> - <editor-menu-bar :editor="tiptap" v-slot="{ commands, isActive }"> + <editor-menu-bar :editor="tiptap" v-slot="{ commands, isActive }" v-if="!syncError && !readOnly"> <div class="menubar"> - <button class="icon-bold" :class="{ 'is-active': isActive.bold() }" @click="commands.bold"></button> - <button class="icon-italic" :class="{ 'is-active': isActive.italic() }" @click="commands.italic"></button> - <button class="icon-underline" :class="{ 'is-active': isActive.underline() }" @click="commands.underline"></button> + <button class="icon-bold" :class="{ 'is-active': isActive.strong() }" @click="commands.strong"></button> + <button class="icon-italic" :class="{ 'is-active': isActive.em() }" @click="commands.em"></button> <button class="icon-code" :class="{ 'is-active': isActive.code() }" @click="commands.code"></button> <button :class="{ 'is-active': isActive.heading({ level: 1 }) }" @click="commands.heading({ level: 1 })">H1</button> @@ -57,10 +56,10 @@ <button class="icon-ul" :class="{ 'is-active': isActive.bullet_list() }" @click="commands.bullet_list"></button> <button class="icon-ol" :class="{ 'is-active': isActive.ordered_list() }" @click="commands.ordered_list"></button> - <button class="icon-image" @click="showImagePrompt(commands.image)"></button> + <button v-if="!isPublic" class="icon-image" @click="showImagePrompt(commands.image)"></button> </div> </editor-menu-bar> - <editor-menu-bubble class="menububble" :editor="tiptap" @hide="hideLinkMenu" v-slot="{ commands, isActive, getMarkAttrs, menu }"> + <editor-menu-bubble v-if="!readOnly" class="menububble" :editor="tiptap" @hide="hideLinkMenu" v-slot="{ commands, isActive, getMarkAttrs, menu }"> <div class="menububble" :class="{ 'is-active': menu.isActive }" :style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"> <form class="menububble__form" v-if="linkMenuIsActive" @submit.prevent="setLinkUrl(commands.link, linkUrl)"> @@ -82,7 +81,7 @@ </div> <read-only-editor v-if="hasSyncCollission" :content="syncError.data.outsideChange" /> </div> - <div v-if="hasSyncCollission" id="resolve-conflicts"> + <div v-if="hasSyncCollission && !readOnly" id="resolve-conflicts"> <button @click="resolveUseThisVersion"> Use your version </button> @@ -110,10 +109,7 @@ import { Heading, Bold, Code, - Italic, - Strike, Link, - Underline, BulletList, OrderedList, ListItem, @@ -123,6 +119,7 @@ import { History, Collaboration, } from 'tiptap-extensions' +import { Strong, Italic } from './../marks' import { Keymap } from './../extensions' import MarkdownIt from 'markdown-it' @@ -140,7 +137,8 @@ const EDITOR_PUSH_DEBOUNCE = 200 export default { name: 'Editor', components: { - Avatar,Actions, + Avatar, + Actions, ReadOnlyEditor, EditorContent, EditorMenuBar, @@ -172,8 +170,6 @@ export default { return { editor: null, tiptap: null, - /** @type EditorSync */ - authority: null, /** @type SyncService */ syncService: null, document: null, @@ -229,7 +225,7 @@ export default { return this.dirty }, hasUnsavedChanges() { - return this.authority && this.document.lastSavedVersion !== getVersion(this.tiptap.state) + return this.syncService && this.tiptap && this.tiptap.state && this.document.lastSavedVersion !== getVersion(this.tiptap.state) }, backendUrl() { return (endpoint) => { @@ -238,6 +234,16 @@ export default { }, hasDocumentParameters() { return this.fileId || this.shareToken + }, + isPublic() { + return document.getElementById('isPublic') && document.getElementById('isPublic') === '1' + }, + readOnly() { + if (OCA.Files.App) { + const file = OCA.Files.App.fileList.files.find((f) => f.id === this.fileId); + return !(file.permissions & OC.PERMISSION_UPDATE); + } + return true; } }, mounted() { @@ -275,36 +281,37 @@ export default { this.document = document }) .on('change', ({document, sessions}) => { + if (this.document.baseVersionEtag !== '' && document.baseVersionEtag !== this.document.baseVersionEtag) { + this.resolveUseServerVersion() + return + } this.updateSessions.bind(this)(sessions); this.document = document + this.syncError = null + this.tiptap.setOptions({editable: true && !this.readOnly}) }) .on('loaded', ({document, session, documentSource}) => { const documentData = {document, session} const initialDocument = defaultMarkdownParser.parse(documentSource) - const sendStepsDebounce = () => this.syncService.sendSteps() - const sendStepsDebounced = debounce(sendStepsDebounce, EDITOR_PUSH_DEBOUNCE, { maxWait: 5000 }) - - /** tiptap */ this.markdownit = MarkdownIt('commonmark', {html: false}); this.tiptap = new Editor({ - content: "<p> </p>", //this.markdownit.render(documentSource), + content: this.markdownit.render(documentSource), onUpdate: ({state}) => { + console.log("=> FROM doc") console.log(defaultMarkdownSerializer.serialize(state.doc)) this.syncService.state = state }, extensions: [ - new HardBreak(), - new Heading(), - new Bold(), - new Underline, - new Strike, - new Code(), - new Italic(), - new BulletList(), - new OrderedList(), - new Blockquote(), - new CodeBlock(), + new HardBreak, + new Heading, + new Code, + new Strong, + new Italic, + new BulletList, + new OrderedList, + new Blockquote, + new CodeBlock, new ListItem, new Link, new Image, @@ -318,7 +325,7 @@ export default { debounce: 250, onSendable: ({ sendable }) => { // This is not working properly with polling and the careful retry logic - this.syncService.sendSteps(sendable) + this.syncService.sendSteps() } }), new Keymap({ @@ -332,12 +339,25 @@ export default { }) this.syncService.state = this.tiptap.state this.$emit('update:loaded', true) + this.tiptap.focus('end') }) .on('sync', ({steps, document}) => { this.tiptap.extensions.options.collaboration.update({ - version: document.version, + version: document.currentVersion, steps: steps }) + this.syncService.state = this.tiptap.state + }) + .on('error', (error, data) => { + if (error === ERROR_TYPE.SAVE_COLLISSION && (!this.syncError || this.syncError.type !== ERROR_TYPE.SAVE_COLLISSION)) { + this.initialLoading = true + this.syncError = { + type: ERROR_TYPE.SAVE_COLLISSION, + data: data + } + this.tiptap.setOptions({editable: false}) + + } }) .on('stateChange', (state) => { if (state.initialLoading && !this.initialLoading) { @@ -349,12 +369,14 @@ export default { }, resolveUseThisVersion() { - this.authority.forceSave() - this.authority.view.setProps({ editable: () => true }) + this.syncService.forceSave() + this.tiptap.setOptions({editable: true && !this.readOnly}) }, resolveUseServerVersion() { - this.authority.view.destroy() + this.syncService.close() + this.syncService = null + this.tiptap.destroy() this.initSession() }, diff --git a/src/marks/Bold.js b/src/marks/Bold.js new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/marks/Bold.js diff --git a/src/marks/index.js b/src/marks/index.js new file mode 100644 index 000000000..3466a0fac --- /dev/null +++ b/src/marks/index.js @@ -0,0 +1,47 @@ +/* + * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> + * + * @author Julius Härtl <jus@bitgrid.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import { Bold, Italic as TipTapItalic } from 'tiptap-extensions' + +/** + * This file maps prosemirror mark names to tiptap classes, + * so we can reuse the prosemirror-markdown default parser for now + */ + +class Strong extends Bold { + get name() { + return 'strong' + } +} + +class Italic extends TipTapItalic { + get name() { + return 'em' + } +} + +/** Strike is currently unsupported by prosemirror-markdown */ + +export { + Strong, + Italic +} diff --git a/src/services/PollingBackend.js b/src/services/PollingBackend.js index df00cd206..6042a68d6 100644 --- a/src/services/PollingBackend.js +++ b/src/services/PollingBackend.js @@ -71,6 +71,16 @@ class PollingBackend { return !!this._authority.options.shareToken } + forceSave() { + this._forcedSave = true + this._fetchSteps() + } + + save() { + this._manualSave = true + this._fetchSteps() + } + _fetchSteps() { if (this.lock) { return diff --git a/src/services/SyncService.js b/src/services/SyncService.js index 46f4338ca..90c25102c 100644 --- a/src/services/SyncService.js +++ b/src/services/SyncService.js @@ -157,6 +157,7 @@ class SyncService { }) }) } + console.log(newSteps); this.emit('sync', {steps: newSteps, document}) console.log('receivedSteps', 'newVersion', getVersion(this.state)) } @@ -183,6 +184,12 @@ class SyncService { } } + forceSave() { + if (this.backend.forceSave) { + this.backend.forceSave() + } + } + close() { this.backend.disconnect() return axios.get( |