diff options
-rw-r--r-- | css/prosemirror.scss | 119 | ||||
-rw-r--r-- | src/EditorFactory.js | 38 | ||||
-rw-r--r-- | src/EditorSync.js | 299 | ||||
-rw-r--r-- | src/components/CollisionResolveDialog.vue | 8 | ||||
-rw-r--r-- | src/components/EditorWrapper.vue | 141 | ||||
-rw-r--r-- | src/components/GuestNameDialog.vue | 67 | ||||
-rw-r--r-- | src/components/ReadOnlyEditor.vue | 8 | ||||
-rw-r--r-- | src/extensions/Keymap.js | 1 | ||||
-rw-r--r-- | src/helpers.js | 1 | ||||
-rw-r--r-- | src/main.js | 2 | ||||
-rw-r--r-- | src/marks/index.js | 4 | ||||
-rw-r--r-- | src/public.js | 1 | ||||
-rw-r--r-- | src/services/PollingBackend.js | 27 | ||||
-rw-r--r-- | src/services/SyncService.js | 30 |
14 files changed, 321 insertions, 425 deletions
diff --git a/css/prosemirror.scss b/css/prosemirror.scss new file mode 100644 index 000000000..575c7033d --- /dev/null +++ b/css/prosemirror.scss @@ -0,0 +1,119 @@ +/* Document rendering styles */ + +.ProseMirror { + margin-top: 44px; + height: 100%; + position: relative; + word-wrap: break-word; + white-space: pre-wrap; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + padding: 4px 8px 4px 14px; + line-height: 150%; + font-size: 14px; + outline: none; +} + +.ProseMirror p:first-child, +.ProseMirror h1:first-child, +.ProseMirror h2:first-child, +.ProseMirror h3:first-child, +.ProseMirror h4:first-child, +.ProseMirror h5:first-child, +.ProseMirror h6:first-child { + margin-top: 10px; +} + +.ProseMirror a { + color: var(--color-primary); + text-decoration: underline; +} + +.ProseMirror p { + margin-bottom: 1em; + line-height: 150%; +} +.ProseMirror em { + font-style: italic; +} + +.ProseMirror h1 { + font-size: 24px; +} +.ProseMirror h2 { + font-size: 22px; +} +.ProseMirror h3 { + font-size: 20px; +} +.ProseMirror h4 { + font-size: 18px; +} +.ProseMirror h5 { + font-size: 16px; +} +.ProseMirror h6 { + font-size: 14px; +} +.ProseMirror h1, +.ProseMirror h2, +.ProseMirror h3, +.ProseMirror h4, +.ProseMirror h5, +.ProseMirror h6 { + font-weight: 600; + margin-top: 10px; + margin-bottom: 20px; +} + +.ProseMirror img { + cursor: default; + max-height: 50vh; + max-width: 100%; +} + +.ProseMirror-focused .ProseMirror-gapcursor { + display: block; +} +/* Add space around the hr to make clicking it easier */ + +.ProseMirror hr { + padding: 2px 10px; + border: none; + margin: 1em 0; + width: 100%; +} + +.ProseMirror hr:after { + content: ""; + display: block; + height: 1px; + background-color: silver; + line-height: 2px; +} + +.ProseMirror pre { + white-space: pre-wrap; + background-color: var(--color-background-dark); + border-radius: 5px; + padding: 5px; + padding-left: 11px; +} + +.ProseMirror li { + position: relative; +} + +.ProseMirror ul, .ProseMirror ol { + padding-left: 30px; +} + +.ProseMirror ul li { + list-style-type: disc; +} + +.ProseMirror blockquote { + padding-left: 1em; + border-left: 3px solid var(--color-text-lighter); + margin-left: 0; margin-right: 0; +} diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 2e2e435fc..871164aac 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -19,11 +19,10 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -import {Editor} from 'tiptap'; +import { Editor } from 'tiptap' import { HardBreak, Heading, - Bold, Code, Link, BulletList, @@ -37,31 +36,30 @@ import { import { Strong, Italic } from './marks' import MarkdownIt from 'markdown-it' -const createEditor = ({content, onUpdate, extensions}) => { - extensions = extensions ? extensions : [] +const createEditor = ({ content, onUpdate, extensions }) => { + extensions = extensions || [] return new Editor({ content: content, onUpdate: onUpdate, extensions: [ - new HardBreak, - new Heading, - new Code, - new Strong, - new Italic, - new BulletList, - new OrderedList, - new Blockquote, - new CodeBlock, - new ListItem, - new Link, - new Image, + new HardBreak(), + new Heading(), + new Code(), + new Strong(), + new Italic(), + new BulletList(), + new OrderedList(), + new Blockquote(), + new CodeBlock(), + new ListItem(), + new Link(), + new Image(), new History() - ].concat(extensions), + ].concat(extensions) }) } -const markdownit = MarkdownIt('commonmark', {html: false}); - +const markdownit = MarkdownIt('commonmark', { html: false }) export default createEditor -export { markdownit, createEditor} +export { markdownit, createEditor } diff --git a/src/EditorSync.js b/src/EditorSync.js deleted file mode 100644 index 69ea9bc5f..000000000 --- a/src/EditorSync.js +++ /dev/null @@ -1,299 +0,0 @@ -/* - * @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 axios from 'nextcloud-axios' -import { schema, defaultMarkdownSerializer } from 'prosemirror-markdown' -import { receiveTransaction, sendableSteps, getVersion } from 'prosemirror-collab' -import { Step } from 'prosemirror-transform' -import { endpointUrl } from './helpers' - -/** - * Minimum inverval to refetch the document changes - * @type {number} - */ -const FETCH_INTERVAL = 200 - -/** - * Maximum interval between refetches of document state if multiple users have joined - * @type {number} - */ -const FETCH_INTERVAL_MAX = 2000 - -/** - * Interval to check for changes when there is only one user joined - * @type {number} - */ -const FETCH_INTERVAL_SINGLE_EDITOR = 5000 - -const MIN_PUSH_RETRY = 500 -const MAX_PUSH_RETRY = 10000 - -/* Timeout after that a PUSH_FAILURE error is emitted */ -const WARNING_PUSH_RETRY = 5000 - -/* Timeout for sessions to be marked as disconnected */ -const COLLABORATOR_DISCONNECT_TIME = 20 - -const ERROR_TYPE = { - /** - * Failed to save collaborative document due to external change - * collission needs to be resolved manually - */ - SAVE_COLLISSION: 0, - /** - * Failed to push changes for MAX_REBASE_RETRY times - */ - PUSH_FAILURE: 1 -} - - -class EditorSync { - - constructor(data, shareToken) { - this.view = null - this.session = data.session - this.document = data.document - this.steps = [] - this.stepClientIDs = [] - this.lock = false - this.retryTime = MIN_PUSH_RETRY - this.dirty = false - this.fetchInverval = FETCH_INTERVAL - this.shareToken = shareToken - - this.onSyncHandlers = [] - this.onErrorHandlers = [] - this.onStateChangeHandlers = [] - - // example for polling - // the interval will be adjusted dynamically depending on the time without any change - this.fetcher = setInterval(() => this.fetchSteps(), this.fetchInverval) - } - - isPublic() { - return !!this.shareToken - } - - destroy() { - clearInterval(this.fetcher) - } - - onSync(handler) { - this.onSyncHandlers.push(handler) - } - - onStateChange(handler) { - this.onStateChangeHandlers.push(handler) - } - - triggerStateChange() { - this.onStateChangeHandlers.forEach((handler) => handler()) - } - - onError(handler) { - this.onErrorHandlers.push(handler) - } - - content() { - return defaultMarkdownSerializer.serialize(this.view.state.doc) - } - - forceSave() { - this._forcedSave = true - } - - manualSave() { - this._manualSave = true - } - - fetchSteps() { - if (this.lock) { - return - } - this.lock = true - this.triggerStateChange() - const authority = this - let autosaveContent - if ( - this._forcedSave || this._manualSave - || (!sendableSteps(this.view.state) && (authority.steps.length > this.document.lastSavedVersion)) - ) { - autosaveContent = this.content() - } - axios.post(endpointUrl('session/sync', this.isPublic()), { - documentId: this.document.id, - sessionId: this.session.id, - sessionToken: this.session.token, - version: authority.steps.length, - autosaveContent, - force: !!this._forcedSave, - manualSave: !!this._manualSave, - token: this.shareToken - }).then((response) => { - if (this.document.lastSavedVersion < response.data.document.lastSavedVersion) { - console.debug('Saved document', response.data.document) - this.document = response.data.document - } - if (!this.view.props.editable) { - this.view.setProps({ editable: () => true }) - } - - this.onSyncHandlers.forEach((handler) => handler(response.data)) - - if (response.data.steps.length === 0) { - this.lock = false - if (response.data.sessions.filter((session) => session.lastContact > Date.now() / 1000 - COLLABORATOR_DISCONNECT_TIME).length < 2) { - this.maximumRefetchTimer() - } else { - this.increaseRefetchTimer() - } - return - } - - for (let i = 0; i < response.data.steps.length; i++) { - let steps = response.data.steps[i].data.map(j => Step.fromJSON(schema, j)) - steps.forEach(step => { - authority.steps.push(step) - authority.stepClientIDs.push(response.data.steps[i].sessionId) - }) - } - let newData = authority.stepsSince(getVersion(authority.view.state)) - /*authority.view.dispatch( - receiveTransaction(authority.view.state, newData.steps, newData.clientIDs) - )*/ - console.debug('Synced new steps, current version is ' + getVersion(authority.view.state)) - this.lock = false - this._forcedSave = false - // this.sendSteps() - this.resetRefetchTimer() - }).catch((e) => { - this.lock = false - // this.sendSteps() - if (e.response.status === 409) { - console.error('Conflict during file save, please resolve') - this.view.setProps({ editable: () => false }) - // TODO recover - this.onErrorHandlers.forEach((handler) => handler(ERROR_TYPE.SAVE_COLLISSION, { - outsideChange: e.response.data.outsideChange - })) - } - }) - this._manualSave = false - this._forcedSave = false - } - - resetRefetchTimer() { - this.fetchInverval = FETCH_INTERVAL - clearInterval(this.fetcher) - this.fetcher = setInterval(() => this.fetchSteps(), this.fetchInverval) - - } - - increaseRefetchTimer() { - this.fetchInverval = Math.min(this.fetchInverval + 100, FETCH_INTERVAL_MAX) - clearInterval(this.fetcher) - this.fetcher = setInterval(() => this.fetchSteps(), this.fetchInverval) - } - - maximumRefetchTimer() { - this.fetchInverval = FETCH_INTERVAL_SINGLE_EDITOR - clearInterval(this.fetcher) - this.fetcher = setInterval(() => this.fetchSteps(), this.fetchInverval) - } - - stepsSince(version) { - return { - steps: this.steps.slice(version), - clientIDs: this.stepClientIDs.slice(version) - } - } - - carefulRetry(callback) { - let newRetry = this.retryTime ? Math.min(this.retryTime * 2, MAX_PUSH_RETRY) : MIN_PUSH_RETRY - if (newRetry > WARNING_PUSH_RETRY && this.retryTime < WARNING_PUSH_RETRY) { - OC.Notification.showTemporary('Changes could not be sent yet') - this.view.setProps({ editable: () => false }) - this.onErrorHandlers.forEach((handler) => handler(ERROR_TYPE.PUSH_FAILURE, {})) - // TODO recover - } - this.retryTime = newRetry - setTimeout(callback, this.retryTime) - } - - carefulRetryReset() { - this.retryTime = MIN_PUSH_RETRY - } - - sendSteps() { - let sendable = sendableSteps(this.view.state) - if (!sendable) { - this.dirty = false - this.triggerStateChange() - return - } - this.dirty = true - this.triggerStateChange() - if (this.lock) { - setTimeout(() => { - this.sendSteps() - }, 500) - return - } - this.lock = true - const authority = this - let steps = sendable.steps - axios.post(endpointUrl('session/push', this.isPublic()), { - documentId: this.document.id, - sessionId: this.session.id, - sessionToken: this.session.token, - steps: steps.map(s => s.toJSON()) || [], - version: getVersion(authority.view.state), - token: this.shareToken - }).then((response) => { - // sucessfully applied steps on the server - /*steps.forEach(step => { - authority.steps.push(step) - authority.stepClientIDs.push(this.session.id) - }) - let newData = authority.stepsSince(getVersion(authority.view.state)) - authority.view.dispatch( - receiveTransaction(authority.view.state, newData.steps, newData.clientIDs) - )*/ - this.carefulRetryReset() - this.lock = false - this.fetchSteps() - }).catch((e) => { - console.error('failed to apply steps due to collission, retrying') - // TODO: increase retry counter to check against MAX_REBASE_RETRY - this.lock = false - // TODO: remove if we have state machine - this.fetchSteps() - - this.carefulRetry(() => { - this.sendSteps() - }) - }) - } - -} - -export { EditorSync, ERROR_TYPE, endpointUrl } diff --git a/src/components/CollisionResolveDialog.vue b/src/components/CollisionResolveDialog.vue index a64d09a51..d3ee725ed 100644 --- a/src/components/CollisionResolveDialog.vue +++ b/src/components/CollisionResolveDialog.vue @@ -21,7 +21,7 @@ --> <template> - <div class="collision-resolve-dialog" id="resolve-conflicts"> + <div id="resolve-conflicts" class="collision-resolve-dialog"> <button @click="$emit('resolveUseThisVersion')"> Use your version </button> @@ -32,9 +32,9 @@ </template> <script> - export default { - name: 'CollisionResolveDialog' - } +export default { + name: 'CollisionResolveDialog' +} </script> <style scoped lang="scss"> diff --git a/src/components/EditorWrapper.vue b/src/components/EditorWrapper.vue index ca694a8fc..478463e3f 100644 --- a/src/components/EditorWrapper.vue +++ b/src/components/EditorWrapper.vue @@ -22,14 +22,14 @@ <template> <div id="editor-container"> - <div id="editor-session-list" v-if="currentSession && active"> + <div v-if="currentSession && active" id="editor-session-list"> <div v-tooltip="lastSavedStatusTooltip" class="save-status" :class="lastSavedStatusClass"> {{ lastSavedStatus }} </div> <avatar v-for="session in activeSessions" :key="session.id" - :user="session.userId" - :display-name="session.guestName ? session.guestName : session.displayName" - :style="sessionStyle(session)" /> + :user="session.userId" + :display-name="session.guestName ? session.guestName : session.displayName" + :style="sessionStyle(session)" /> </div> <div v-if="currentSession && active"> <p v-if="hasSyncCollission" class="msg icon-error"> @@ -38,45 +38,62 @@ </div> <div v-if="currentSession && active" id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !initialLoading}"> <div id="editor"> - <editor-menu-bar :editor="tiptap" v-slot="{ commands, isActive }" v-if="!syncError && !readOnly"> + <editor-menu-bar v-if="!syncError && !readOnly" v-slot="{ commands, isActive }" :editor="tiptap"> <div class="menubar"> - <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> - <button :class="{ 'is-active': isActive.heading({ level: 2 }) }" @click="commands.heading({ level: 2 })">H2</button> - <button :class="{ 'is-active': isActive.heading({ level: 3 }) }" @click="commands.heading({ level: 3 })">H3</button> - <Actions> - <ActionButton icon="icon-paragraph" @click="commands.heading({ level: 4 })">Heading 4</ActionButton> - <ActionButton icon="icon-paragraph" @click="commands.heading({ level: 5 })">Heading 5</ActionButton> - <ActionButton icon="icon-paragraph" @click="commands.heading({ level: 6 })">Heading 6</ActionButton> - <ActionButton icon="icon-code" @click="commands.code_block()">Code block</ActionButton> - <ActionButton icon="icon-quote" @click="commands.blockquote()">Blockquote</ActionButton> - </Actions> - - <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 v-if="!isPublic" class="icon-image" @click="showImagePrompt(commands.image)"></button> + <button class="icon-bold" :class="{ 'is-active': isActive.strong() }" @click="commands.strong" /> + <button class="icon-italic" :class="{ 'is-active': isActive.em() }" @click="commands.em" /> + <button class="icon-code" :class="{ 'is-active': isActive.code() }" @click="commands.code" /> + + <button :class="{ 'is-active': isActive.heading({ level: 1 }) }" @click="commands.heading({ level: 1 })"> + H1 + </button> + <button :class="{ 'is-active': isActive.heading({ level: 2 }) }" @click="commands.heading({ level: 2 })"> + H2 + </button> + <button :class="{ 'is-active': isActive.heading({ level: 3 }) }" @click="commands.heading({ level: 3 })"> + H3 + </button> + <actions> + <action-button icon="icon-paragraph" @click="commands.heading({ level: 4 })"> + Heading 4 + </action-button> + <action-button icon="icon-paragraph" @click="commands.heading({ level: 5 })"> + Heading 5 + </action-button> + <action-button icon="icon-paragraph" @click="commands.heading({ level: 6 })"> + Heading 6 + </action-button> + <action-button icon="icon-code" @click="commands.code_block()"> + Code block + </action-button> + <action-button icon="icon-quote" @click="commands.blockquote()"> + Blockquote + </action-button> + </actions> + + <button class="icon-ul" :class="{ 'is-active': isActive.bullet_list() }" @click="commands.bullet_list" /> + <button class="icon-ol" :class="{ 'is-active': isActive.ordered_list() }" @click="commands.ordered_list" /> + + <button v-if="!isPublic" class="icon-image" @click="showImagePrompt(commands.image)" /> </div> </editor-menu-bar> - <editor-menu-bubble v-if="!readOnly" class="menububble" :editor="tiptap" @hide="hideLinkMenu" v-slot="{ commands, isActive, getMarkAttrs, menu }"> + <editor-menu-bubble v-if="!readOnly" v-slot="{ commands, isActive, getMarkAttrs, menu }" class="menububble" + :editor="tiptap" @hide="hideLinkMenu"> <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)"> - <input class="menububble__input" type="text" v-model="linkUrl" placeholder="https://" ref="linkInput" @keydown.esc="hideLinkMenu" /> - <button class="menububble__button" @click="setLinkUrl(commands.link, null)" type="button"></button> + <form v-if="linkMenuIsActive" class="menububble__form" @submit.prevent="setLinkUrl(commands.link, linkUrl)"> + <input ref="linkInput" v-model="linkUrl" class="menububble__input" + type="text" placeholder="https://" @keydown.esc="hideLinkMenu"> + <button class="menububble__button" type="button" @click="setLinkUrl(commands.link, null)" /> </form> <template v-else> <button - class="menububble__button" - @click="showLinkMenu(getMarkAttrs('link'))" - :class="{ 'is-active': isActive.link() }" - ><span>{{ isActive.link() ? 'Update Link' : 'Add Link'}}</span></button> + class="menububble__button" + :class="{ 'is-active': isActive.link() }" + @click="showLinkMenu(getMarkAttrs('link'))"> + <span>{{ isActive.link() ? 'Update Link' : 'Add Link' }}</span> + </button> </template> - </div> </editor-menu-bubble> <editor-content class="editor__content" :editor="tiptap" /> @@ -97,9 +114,9 @@ import { SyncService, ERROR_TYPE } from './../services/SyncService' import { endpointUrl } from './../helpers' import { createEditor, markdownit } from './../EditorFactory' -import { defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown' +import { defaultMarkdownSerializer } from 'prosemirror-markdown' -import { Editor, EditorContent, EditorMenuBar, EditorMenuBubble } from 'tiptap' +import { EditorContent, EditorMenuBar, EditorMenuBubble } from 'tiptap' import { Collaboration } from 'tiptap-extensions' import { Keymap } from './../extensions' import { getVersion } from 'prosemirror-collab' @@ -111,7 +128,7 @@ import ActionButton from 'nextcloud-vue/dist/Components/ActionButton' import ReadOnlyEditor from './ReadOnlyEditor' import GuestNameDialog from './GuestNameDialog' -import CollisionResolveDialog from './CollisionResolveDialog'; +import CollisionResolveDialog from './CollisionResolveDialog' const COLLABORATOR_IDLE_TIME = 5 const COLLABORATOR_DISCONNECT_TIME = 20 @@ -173,7 +190,7 @@ export default { guestNameConfirmed: false, linkUrl: null, - linkMenuIsActive: false, + linkMenuIsActive: false } }, computed: { @@ -215,7 +232,7 @@ export default { return this.dirty }, hasUnsavedChanges() { - return this.syncService && this.tiptap && this.tiptap.state && 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) => { @@ -260,7 +277,7 @@ export default { this.lastSavedString = window.moment(this.document.lastSavedVersionTime * 1000).fromNow() } }, - initSession () { + initSession() { if (!this.hasDocumentParameters) { this.$emit('error', 'No valid file provided') return @@ -272,30 +289,26 @@ export default { return defaultMarkdownSerializer.serialize(document) } }) - .on('opened', ({document, session}) => { + .on('opened', ({ document, session }) => { this.currentSession = session this.document = document this.readOnly = document.readOnly }) - .on('change', ({document, sessions}) => { + .on('change', ({ document, sessions }) => { if (this.document.baseVersionEtag !== '' && document.baseVersionEtag !== this.document.baseVersionEtag) { this.resolveUseServerVersion() return } - this.updateSessions.bind(this)(sessions); + this.updateSessions.bind(this)(sessions) this.document = document this.syncError = null - this.tiptap.setOptions({editable: !this.readOnly}) + this.tiptap.setOptions({ editable: !this.readOnly }) }) - .on('loaded', ({document, session, documentSource}) => { - const documentData = {document, session} - const initialDocument = defaultMarkdownParser.parse(documentSource) - + .on('loaded', ({ document, session, documentSource }) => { this.tiptap = createEditor({ content: markdownit.render(documentSource), - onUpdate: ({state}) => { - console.log("=> FROM doc") - console.log(defaultMarkdownSerializer.serialize(state.doc)) + onUpdate: ({ state }) => { + console.debug(defaultMarkdownSerializer.serialize(state.doc)) this.syncService.state = state }, extensions: [ @@ -305,7 +318,7 @@ export default { version: this.syncService.steps.length, clientID: this.currentSession.id, // debounce changes so we can save some bandwidth - debounce: 250, + debounce: EDITOR_PUSH_DEBOUNCE, onSendable: ({ sendable }) => { // This is not working properly with polling and the careful retry logic this.syncService.sendSteps() @@ -314,17 +327,16 @@ export default { new Keymap({ 'Ctrl-s': () => { this.syncService.save() - console.log('save', this); - return true; + return true } }) - ], + ] }) this.syncService.state = this.tiptap.state this.$emit('update:loaded', true) this.tiptap.focus('end') }) - .on('sync', ({steps, document}) => { + .on('sync', ({ steps, document }) => { this.tiptap.extensions.options.collaboration.update({ version: document.currentVersion, steps: steps @@ -338,7 +350,7 @@ export default { type: ERROR_TYPE.SAVE_COLLISSION, data: data } - this.tiptap.setOptions({editable: false}) + this.tiptap.setOptions({ editable: false }) } }) @@ -348,12 +360,12 @@ export default { } this.dirty = state.dirty }) - this.syncService.open({ fileId: this.fileId, filePath: this.filePath}) + this.syncService.open({ fileId: this.fileId, filePath: this.filePath }) }, resolveUseThisVersion() { this.syncService.forceSave() - this.tiptap.setOptions({editable: true && !this.readOnly}) + this.tiptap.setOptions({ editable: true && !this.readOnly }) }, resolveUseServerVersion() { @@ -364,10 +376,8 @@ export default { }, updateSessions(sessions) { - this.sessions = sessions.sort((a,b) => b.lastContact - a.lastContact) + this.sessions = sessions.sort((a, b) => b.lastContact - a.lastContact) let currentSessionIds = this.sessions.map((session) => session.userId) - const stillExistingSessions = Object.keys(this.filteredSessions) - .filter(sessionId => currentSessionIds.includes(sessionId)) const removedSessions = Object.keys(this.filteredSessions) .filter(sessionId => !currentSessionIds.includes(sessionId)) @@ -412,13 +422,13 @@ export default { showImagePrompt(command) { const _command = command - OC.dialogs.filepicker('Insert an image', (file) => { + OC.dialogs.filepicker('Insert an image', (file) => { const src = OC.generateUrl('/core/preview.png?') + `file=${file}&x=1024&y=1024&a=true` _command({ src }) // TODO: check permissions // TODO: check for available preview }, false, false) - }, + } } } </script> @@ -519,7 +529,6 @@ export default { } } - $color-white: #fff; $color-black: #000; @@ -577,7 +586,7 @@ export default { } .editor__content { - max-width: 800px; + max-width: 500px; margin: auto; } diff --git a/src/components/GuestNameDialog.vue b/src/components/GuestNameDialog.vue new file mode 100644 index 000000000..e774f7333 --- /dev/null +++ b/src/components/GuestNameDialog.vue @@ -0,0 +1,67 @@ +<!-- + - @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/>. + - + --> + +<template> + <div class="guest-name-dialog"> + <p>{{ t('text', 'Please enter a name to identify you as a public editor:') }}</p> + <form @submit.prevent="setGuestName()"> + <input ref="guestNameField" type="text" :value="value"> + <input type="submit" class="icon-confirm" value=""> + </form> + </div> +</template> + +<script> +export default { + name: 'GuestNameDialog', + props: { + value: { + type: String, + default: '' + } + }, + methods: { + setGuestName() { + this.$emit('input', this.$refs.guestNameField.value) + } + } +} +</script> + +<style scoped lang="scss"> + .guest-name-dialog { + padding: 30px; + text-align: center; + + form { + display: flex; + width: 100%; + max-width: 200px; + margin: auto; + margin-top: 30px; + + input[type=text] { + flex-grow: 1; + } + } + } +</style> diff --git a/src/components/ReadOnlyEditor.vue b/src/components/ReadOnlyEditor.vue index 6272abdef..4440cb8f1 100644 --- a/src/components/ReadOnlyEditor.vue +++ b/src/components/ReadOnlyEditor.vue @@ -21,16 +21,16 @@ --> <template> - <EditorContent id="read-only-editor" v-if="editor" :editor="editor"></EditorContent> + <editor-content v-if="editor" id="read-only-editor" :editor="editor" /> </template> <script> import { EditorContent } from 'tiptap' -import {createEditor, markdownit} from '../EditorFactory' +import { createEditor, markdownit } from '../EditorFactory' export default { name: 'ReadOnlyEditor', - components: {EditorContent}, + components: { EditorContent }, props: { content: { type: String, @@ -46,7 +46,7 @@ export default { this.editor = createEditor({ content: markdownit.render(this.content) }) - this.editor.setOptions({editable: false}) + this.editor.setOptions({ editable: false }) }, beforeDestroy() { this.editor.destroy() diff --git a/src/extensions/Keymap.js b/src/extensions/Keymap.js index 7eac49618..f5e74755e 100644 --- a/src/extensions/Keymap.js +++ b/src/extensions/Keymap.js @@ -31,4 +31,5 @@ export default class Keymap extends Extension { keys({ schema }) { return this.options } + } diff --git a/src/helpers.js b/src/helpers.js index c126937a7..f68d18d46 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -41,7 +41,6 @@ const endpointUrl = (endpoint, isPublic = false) => { return `${_baseUrl}/${endpoint}` } - export { documentReady, endpointUrl diff --git a/src/main.js b/src/main.js index f1afc053e..f4639a102 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,6 @@ import Vue from 'vue' -import Editor from './components/Editor' +import Editor from './components/EditorWrapper' __webpack_nonce__ = btoa(OC.requestToken) // eslint-disable-line __webpack_public_path__ = OC.linkTo('text', 'js/') // eslint-disable-line diff --git a/src/marks/index.js b/src/marks/index.js index 3466a0fac..0768b83ba 100644 --- a/src/marks/index.js +++ b/src/marks/index.js @@ -28,15 +28,19 @@ import { Bold, Italic as TipTapItalic } from 'tiptap-extensions' */ class Strong extends Bold { + get name() { return 'strong' } + } class Italic extends TipTapItalic { + get name() { return 'em' } + } /** Strike is currently unsupported by prosemirror-markdown */ diff --git a/src/public.js b/src/public.js index 313d84788..0cc42fe24 100644 --- a/src/public.js +++ b/src/public.js @@ -4,7 +4,6 @@ import { documentReady } from './helpers' __webpack_nonce__ = btoa(OC.requestToken) // eslint-disable-line __webpack_public_path__ = OC.linkTo('text', 'js') // eslint-disable-line -console.log(__webpack_public_path__) Vue.prototype.t = t Vue.prototype.OCA = OCA diff --git a/src/services/PollingBackend.js b/src/services/PollingBackend.js index 6042a68d6..23140a640 100644 --- a/src/services/PollingBackend.js +++ b/src/services/PollingBackend.js @@ -21,9 +21,8 @@ */ import axios from 'nextcloud-axios' import { endpointUrl } from '../helpers' -import {ERROR_TYPE} from '../EditorSync' -import {sendableSteps} from 'prosemirror-collab'; - +import { ERROR_TYPE } from './SyncService' +import { sendableSteps } from 'prosemirror-collab' /** * Minimum inverval to refetch the document changes @@ -86,11 +85,10 @@ class PollingBackend { return } this.lock = true - const authority = this let autosaveContent - if (this._forcedSave || this._manualSave || - (!sendableSteps(this._authority.state) && - (this._authority.steps.length > this._authority.document.lastSavedVersion)) + if (this._forcedSave || this._manualSave + || (!sendableSteps(this._authority.state) + && (this._authority.steps.length > this._authority.document.lastSavedVersion)) ) { autosaveContent = this._authority._getContent() } @@ -111,7 +109,7 @@ class PollingBackend { this._authority.document = response.data.document this._authority.sessions = response.data.sessions - this._authority.emit('change', {document: this._authority.document, sessions: this._authority.sessions }) + this._authority.emit('change', { document: this._authority.document, sessions: this._authority.sessions }) if (response.data.steps.length === 0) { this.lock = false @@ -120,8 +118,8 @@ class PollingBackend { } else { this.increaseRefetchTimer() } - this._authority.emit('stateChange', { dirty: false}) - this._authority.emit('stateChange', { initialLoading: true}) + this._authority.emit('stateChange', { dirty: false }) + this._authority.emit('stateChange', { initialLoading: true }) return } @@ -156,8 +154,7 @@ class PollingBackend { return } this.lock = true - const authority = this - let sendable = (typeof _sendable === 'function') ? _sendable() : _sendable; + let sendable = (typeof _sendable === 'function') ? _sendable() : _sendable let steps = sendable.steps axios.post(endpointUrl('session/push', !!this._authority.options.shareToken), { documentId: this._authority.document.id, @@ -176,9 +173,9 @@ class PollingBackend { this.lock = false this._fetchSteps() - /*this.carefulRetry(() => { + /* this.carefulRetry(() => { this.sendSteps(sendable) - })*/ + }) */ }) } @@ -209,7 +206,7 @@ class PollingBackend { let newRetry = this.retryTime ? Math.min(this.retryTime * 2, MAX_PUSH_RETRY) : MIN_PUSH_RETRY if (newRetry > WARNING_PUSH_RETRY && this.retryTime < WARNING_PUSH_RETRY) { OC.Notification.showTemporary('Changes could not be sent yet') - this._authority.emit('error',ERROR_TYPE.PUSH_FAILURE, {}) + this._authority.emit('error', ERROR_TYPE.PUSH_FAILURE, {}) } this.retryTime = newRetry setTimeout(callback, this.retryTime) diff --git a/src/services/SyncService.js b/src/services/SyncService.js index 0207555f2..8d93540b5 100644 --- a/src/services/SyncService.js +++ b/src/services/SyncService.js @@ -28,7 +28,7 @@ import { getVersion, sendableSteps } from 'prosemirror-collab' const defaultOptions = { shareToken: null, serialize: (document) => document -}; +} const ERROR_TYPE = { /** @@ -41,11 +41,12 @@ const ERROR_TYPE = { */ PUSH_FAILURE: 1, - LOAD_ERROR: 2, + LOAD_ERROR: 2 } class SyncService { - constructor (options) { + + constructor(options) { this.eventHandlers = { /* Document state */ opened: [], @@ -59,7 +60,7 @@ class SyncService { /* error */ error: [], /* Events for session and document meta data */ - change: [], + change: [] } this.backend = new PollingBackend(this) @@ -79,13 +80,13 @@ class SyncService { return this } - open({fileId, filePath}) { - return this._openDocument({fileId, filePath}).then(() => { + open({ fileId, filePath }) { + return this._openDocument({ fileId, filePath }).then(() => { this.emit('opened', { document: this.document, - session: this.session, + session: this.session }) - return this._fetchDocument().then(({data}) => { + return this._fetchDocument().then(({ data }) => { this.emit('loaded', { document: this.document, session: this.session, @@ -102,7 +103,7 @@ class SyncService { }) } - _openDocument({fileId, filePath}) { + _openDocument({ fileId, filePath }) { return axios.get(endpointUrl('session/create', !!this.options.shareToken), { params: { fileId: fileId, @@ -132,7 +133,7 @@ class SyncService { } sendSteps(_sendable) { - let sendable = _sendable ? _sendable : sendableSteps(this.state) + let sendable = _sendable || sendableSteps(this.state) if (!sendable) { return } @@ -146,7 +147,7 @@ class SyncService { } } - _receiveSteps({steps, document}) { + _receiveSteps({ steps, document }) { let newSteps = [] for (let i = 0; i < steps.length; i++) { let singleSteps = steps[i].data @@ -158,9 +159,9 @@ class SyncService { }) }) } - console.log(newSteps); - this.emit('sync', {steps: newSteps, document}) - console.log('receivedSteps', 'newVersion', getVersion(this.state)) + console.debug(newSteps) + this.emit('sync', { steps: newSteps, document }) + console.debug('receivedSteps', 'newVersion', getVersion(this.state)) } _getVersion() { @@ -219,6 +220,7 @@ class SyncService { console.error('Event not found', event) } } + } export default SyncService |