diff options
author | Julius Härtl <jus@bitgrid.net> | 2019-05-08 18:46:33 +0300 |
---|---|---|
committer | Julius Härtl <jus@bitgrid.net> | 2019-05-08 18:46:33 +0300 |
commit | 6b5cb314c748d5725f32fbca5e5b15be67749569 (patch) | |
tree | e1206fcf5136e1e5815471619f2bd90ff2b0e4e4 | |
parent | ca5c912de08221c1e1087190a24f4d630f0ccb98 (diff) |
Add collision handling frontend and rework fetching
Signed-off-by: Julius Härtl <jus@bitgrid.net>
-rw-r--r-- | css/style.scss | 15 | ||||
-rw-r--r-- | src/collab.js | 46 | ||||
-rw-r--r-- | src/components/Editor.vue | 74 |
3 files changed, 85 insertions, 50 deletions
diff --git a/css/style.scss b/css/style.scss index c27227535..ca43c5ded 100644 --- a/css/style.scss +++ b/css/style.scss @@ -75,7 +75,7 @@ li.ProseMirror-selectednode:after { } .ProseMirror-menuseparator { - border-right: 1px solid var(--color-text-maxcontrast); + border: 0; margin-right: 3px; } @@ -176,14 +176,22 @@ li.ProseMirror-selectednode:after { color: var(--color-text-light); padding: 1px 6px; top: 0; left: 0; right: 0; - background: transparent; + background: var(--color-main-background-translucent); z-index: 10; -moz-box-sizing: border-box; box-sizing: border-box; overflow: visible; } +.has-conflicts, +#editor-wrapper.icon-loading { + .ProseMirror-menubar { + display: none; + } +} + .ProseMirror { + margin-top: 44px; overflow: scroll; height: 100%; position: relative; @@ -282,6 +290,7 @@ li.ProseMirror-selectednode:after { .ProseMirror-example-setup-style img { cursor: default; max-height: 50vh; + max-width: 100%; } .ProseMirror-prompt { @@ -345,6 +354,8 @@ div[contenteditable=false] { border: none !important; width: 100%; background-color: transparent; + color: var(--color-main-text); + opacity: 1; } .ProseMirror p:first-child, diff --git a/src/collab.js b/src/collab.js index 4135516e2..8a0dec498 100644 --- a/src/collab.js +++ b/src/collab.js @@ -21,7 +21,7 @@ */ import axios from 'nextcloud-axios' -import {schema, defaultMarkdownParser, defaultMarkdownSerializer} from "prosemirror-markdown" +import {schema, defaultMarkdownSerializer} from "prosemirror-markdown" import {receiveTransaction, sendableSteps, getVersion} from 'prosemirror-collab'; import {Step} from 'prosemirror-transform'; @@ -65,9 +65,8 @@ const ERROR_TYPE = { PUSH_FAILURE: 1, } -// TODO to fetch changes more frequently while typing -// we either need to have a state machine similar to the prosemirror example to fetch -// changes inbetween push tries or return updates with the push error +const URL_SYNC = OC.generateUrl('/apps/text/session/sync') +const URL_PUSH = OC.generateUrl('/apps/text/session/push'); class EditorSync { constructor(doc, data) { @@ -91,6 +90,10 @@ class EditorSync { this.fetcher = setInterval(() => this.fetchSteps(), this.fetchInverval) } + destroy() { + clearInterval(this.fetcher) + } + onSync(handler) { this.onSyncHandlers.push(handler) } @@ -127,23 +130,20 @@ class EditorSync { this.triggerStateChange() const authority = this; let autosaveContent = undefined - // TODO only send if not saved already if ( - this._forcedSave || + this._forcedSave || this._manualSave || (!sendableSteps(this.view.state) && (authority.steps.length > this.document.lastSavedVersion)) ) { autosaveContent = this.content() } - axios.get(OC.generateUrl('/apps/text/session/sync'), { - params: { - documentId: this.document.id, - sessionId: this.session.id, - token: this.session.token, - version: authority.steps.length, - autosaveContent, - force: !!this._forcedSave, - manualSave: !!this._manualSave - } + axios.post(URL_SYNC, { + documentId: this.document.id, + sessionId: this.session.id, + token: this.session.token, + version: authority.steps.length, + autosaveContent, + force: !!this._forcedSave, + manualSave: !!this._manualSave }).then((response) => { if (this.document.lastSavedVersion < response.data.document.lastSavedVersion) { console.debug('Saved document', response.data.document) @@ -151,8 +151,6 @@ class EditorSync { } this.view.setProps({editable: () => true}) - - this.onSyncHandlers.forEach((handler) => handler(response.data)) if (response.data.steps.length === 0) { @@ -178,11 +176,13 @@ class EditorSync { ) console.log(getVersion(authority.view.state)) this.lock = false; - this.sendSteps() + this._forcedSave = false; + //this.sendSteps() this.resetRefetchTimer(); }).catch((e) => { this.lock = false; - this.sendSteps() + console.log('fetch error sendSteps') + //this.sendSteps() if (e.response.status === 409) { console.log('Conflict during file save, please resolve') this.view.setProps({editable: () => false}) @@ -192,6 +192,8 @@ class EditorSync { })) } }) + this._manualSave = false; + this._forcedSave = false; } resetRefetchTimer() { @@ -253,7 +255,7 @@ class EditorSync { this.lock = true const authority = this let steps = sendable.steps - axios.post(OC.generateUrl('/apps/text/session/push'), { + axios.post(URL_PUSH, { documentId: this.document.id, sessionId: this.session.id, token: this.session.token, @@ -271,6 +273,7 @@ class EditorSync { ) this.carefulRetryReset() this.lock = false + this.fetchSteps() }).catch((e) => @@ -282,6 +285,7 @@ class EditorSync { this.fetchSteps() this.carefulRetry(() => { + console.log('carefulRetry sendSteps') this.sendSteps() }) }) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 5d7725bb9..0e6c3c289 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -26,9 +26,12 @@ <div class="save-status" :class="lastSavedStatusClass" v-tooltip="lastSavedStatusTooltip">{{ lastSavedStatus }}</div> <avatar v-for="session in activeSessions" :key="session.id" :user="session.userId" :displayName="session.displayName" :style="sessionStyle(session)"></avatar> </div> + <div><!-- needs a div wrapping to not force rerendering of the editor --> + <p class="msg error" v-if="lastSavedStatusTooltip.content">{{ lastSavedStatusTooltip.content }}</p> + </div> <div id="editor-wrapper" :class="{'has-conflicts': syncError && syncError.type === ERROR_TYPE.SAVE_COLLISSION, 'icon-loading': !initialLoading}"> - <div id="editor"></div> - <div id="remote" v-if="syncError && syncError.type === ERROR_TYPE.SAVE_COLLISSION"></div> + <div id="editor" ref="editor" v-once></div> + <div id="remote" ref="remote" v-show="syncError && syncError.type === ERROR_TYPE.SAVE_COLLISSION"></div> </div> <div v-if="syncError && syncError.type === ERROR_TYPE.SAVE_COLLISSION" id="resolve-conflicts"> <button @click="resolveUseThisVersion">Use your version</button> @@ -140,9 +143,7 @@ // TODO: implement conflict resolving return { content: 'The document has been changed outside of the editor. The changes cannot be applied.', - show: true, - trigger: 'manual', - placement: 'bottom' + placement: 'left' } } }, @@ -178,7 +179,7 @@ } } ).then((fileContent) => { - const {editor, authority} = this.initEditor(response.data, fileContent.data); + const {editor, authority} = this.initEditor(this.$refs.editor, response.data, fileContent.data); this.authority = authority this.authority.onSync((data) => { this.syncError = null @@ -188,7 +189,7 @@ this.sessions = data.sessions }) this.authority.onError((error, data) => { - if (error === ERROR_TYPE.SAVE_COLLISSION) { + if (error === ERROR_TYPE.SAVE_COLLISSION && (!this.syncError || this.syncError.type !== ERROR_TYPE.SAVE_COLLISSION)) { this.syncError = { type: ERROR_TYPE.SAVE_COLLISSION, data: data @@ -201,6 +202,7 @@ this.authority.onStateChange(() => { this.dirty = this.authority.dirty if (!this.initialLoading) { + // TODO: this doesn't work with a limit on the steps fetched this.initialLoading = !this.authority.dirty } }) @@ -219,48 +221,44 @@ if (this.remoteView) { return; } - this.remoteView = new EditorView(document.querySelector("#remote"), { + this.remoteView = new EditorView(this.$refs.remote, { state: EditorState.create({ doc: defaultMarkdownParser.parse(this.syncError.data.outsideChange), plugins: [ ...exampleSetup({schema}) ] }), - focus() { this.view.focus() }, - destroy() { this.view.destroy() } }) - view.setProps({editable: () => false}) + this.remoteView.setProps({editable: () => false}) }, resolveUseThisVersion() { this.authority.forceSave() - this.removeRemoteView() + this.remoteView.destroy() + this.remoteView = null; + this.authority.view.setProps({editable: () => true}) }, resolveUseServerVersion() { - this.removeRemoteView() - }, - - removeRemoteView() { this.remoteView.destroy() + this.remoteView = null; + this.authority.view.destroy() + this.initSession() }, - initEditor: (data, fileContent) => { + initEditor: (ref, data, fileContent) => { const authority = new EditorSync(defaultMarkdownParser.parse(fileContent), data) - const sendStepsDebounce = () => { - console.log('debounced SENDSTEPS') - authority.sendSteps() - } + const sendStepsDebounce = () => authority.sendSteps() const sendStepsDebounced = debounce(sendStepsDebounce, EDITOR_PUSH_DEBOUNCE, { maxWait: 500 }) - const view = new EditorView(document.querySelector("#editor"), { + const view = new EditorView(ref, { state: EditorState.create({ doc: authority.doc, plugins: [ keymap({ "Ctrl-s": (state, dispatch, event) => { - authority.forceSave() + authority.manualSave() authority.fetchSteps() return true; } @@ -273,7 +271,11 @@ ] }), focus() { this.view.focus() }, - destroy() { this.view.destroy() }, + destroy() { + console.log('destroy view') + this.view.destroy() + authority.destroy() + }, dispatchTransaction: (transaction) => { const state = view.state.apply(transaction); view.updateState(state); @@ -312,7 +314,10 @@ #editor-wrapper { display: flex; + width: 100%; height: 100%; + overflow: hidden; + position: absolute; &.icon-loading { #editor { opacity: 0.3; @@ -322,6 +327,15 @@ #resolve-conflicts { display: flex; + position: fixed; + z-index: 10000; + bottom: 0; + max-width: 900px; + width: 100vw; + margin: auto; + padding: 10px; + filter: drop-shadow(0px 0px 5px #000); + button { margin: auto; } @@ -332,12 +346,18 @@ overflow-y: scroll; } - #editor-container.has-conflicts { - #remove, #editor { + .msg.error { + padding: 11px; + } + + #editor-container #editor-wrapper.has-conflicts { + height: calc(100% - 50px); + + #remote, #editor { width: 50%; } - #remote .ProseMirror-menubar { + .ProseMirror-menubar { visibility: hidden; } } |