Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
Julius Härtl 2019-05-15 15:42:33 +02:00
Родитель bd0818afd9
Коммит 6016c09079
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4C614C6ED2CDE6DF
6 изменённых файлов: 275 добавлений и 73 удалений

Просмотреть файл

@ -35,6 +35,8 @@
"prosemirror-schema-list": "^1.0.3",
"prosemirror-state": "^1.2.3",
"prosemirror-view": "^1.9.6",
"tiptap": "^1.19.2",
"tiptap-extensions": "^1.19.3",
"uuid": "^3.3.2",
"v-tooltip": "^2.0.2",
"vue": "^2.6.10",

Просмотреть файл

@ -35,7 +35,97 @@
</p>
</div>
<div id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !initialLoading}">
<div v-once id="editor" ref="editor" />
<div id="editor">
<editor-menu-bar :editor="tiptap" v-slot="{ commands, isActive }">
<div class="menubar">
<Actions>
<ActionButton
icon="icon-info"
:class="{ 'is-active': isActive.bold() }"
@click="commands.bold">
B </ActionButton>
</Actions>
<button
class="menubar__button"
:class="{ 'is-active': isActive.bold() }"
@click="commands.bold"
>
B
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.italic() }"
@click="commands.italic"
>
I
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
H1
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
H2
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
H3
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.bullet_list() }"
@click="commands.bullet_list"
>
-
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.ordered_list() }"
@click="commands.ordered_list"
>
1.
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.blockquote() }"
@click="commands.blockquote"
>
"
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.code() }"
@click="commands.code"
>
{}
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.paragraph() }"
@click="commands.paragraph"
>
P
</button>
</div>
</editor-menu-bar>
<editor-content class="editor__content" :editor="tiptap" />
</div>
<read-only-editor v-if="hasSyncCollission" :content="syncError.data.outsideChange" />
</div>
<div v-if="hasSyncCollission" id="resolve-conflicts">
@ -50,22 +140,43 @@
</template>
<script>
import Vue from 'vue'
import debounce from 'lodash/debounce'
import { EditorSync, ERROR_TYPE } from './../EditorSync'
import SyncService from './../services/SyncService'
import { endpointUrl } from './../helpers'
import {collab, getVersion, receiveTransaction, sendableSteps} from 'prosemirror-collab'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { exampleSetup } from 'prosemirror-example-setup'
import { schema, defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown'
import { keymap } from 'prosemirror-keymap'
import { getVersion } from 'prosemirror-collab'
import { defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown'
import { Editor, EditorContent, EditorMenuBar } from 'tiptap'
import {
HardBreak,
Heading,
Bold,
Code,
Italic,
Strike,
Link,
Underline,
BulletList,
OrderedList,
ListItem,
Blockquote,
CodeBlock,
History,
Collaboration,
} from 'tiptap-extensions'
import { Keymap } from './../extensions'
import MarkdownIt from 'markdown-it'
import Avatar from 'nextcloud-vue/dist/Components/Avatar'
import ReadOnlyEditor from './ReadOnlyEditor'
import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip'
import Actions from 'nextcloud-vue/dist/Components/Actions'
import ActionButton from 'nextcloud-vue/dist/Components/ActionButton'
const COLLABORATOR_IDLE_TIME = 5
const COLLABORATOR_DISCONNECT_TIME = 20
@ -74,8 +185,11 @@ const EDITOR_PUSH_DEBOUNCE = 200
export default {
name: 'Editor',
components: {
Avatar,
ReadOnlyEditor
Avatar,Actions,
ReadOnlyEditor,
EditorContent,
EditorMenuBar,
ActionButton
},
directives: {
Tooltip
@ -100,6 +214,8 @@ export default {
},
data() {
return {
editor: null,
tiptap: null,
/** @type EditorSync */
authority: null,
/** @type SyncService */
@ -128,6 +244,24 @@ export default {
}
}
},
filteredSessions() {
let filteredSessions = {}
for (let index in this.sessions) {
let session = this.sessions[index]
if (!session.userId) {
session.userId = session.id
}
if (this.filteredSessions.hasOwnProperty(session.userId)) {
if (filteredSessions[session.userId].lastContact < session.lastContact) {
filteredSessions[session.userId] = session
}
} else {
filteredSessions[session.userId] = session
}
}
return filteredSessions
},
lastSavedStatus() {
return (this.hasUnsavedChanges || this.hasUnpushedChanges ? '*' : '') + this.lastSavedString
},
@ -154,7 +288,7 @@ export default {
return this.dirty
},
hasUnsavedChanges() {
return this.authority && this.document.lastSavedVersion !== getVersion(this.authority.view.state)
return this.authority && this.document.lastSavedVersion !== getVersion(this.tiptap.state)
},
backendUrl() {
return (endpoint) => {
@ -176,7 +310,6 @@ export default {
this.currentSession = null
this.syncService.close()
this.syncService = null
this.view.destroy()
}
},
methods: {
@ -192,7 +325,6 @@ export default {
}
this.syncService = new SyncService({
shareToken: this.shareToken,
schema: schema,
serialize: (document) => {
return defaultMarkdownSerializer.serialize(document)
}
@ -203,21 +335,7 @@ export default {
})
.on('change', ({document, sessions}) => {
this.sessions = sessions.sort((a,b) => b.lastContact - a.lastContact)
this.filteredSessions = {}
for (let index in this.sessions) {
let session = this.sessions[index]
if (!session.userId) {
session.userId = session.id
}
if (this.filteredSessions.hasOwnProperty(session.userId)) {
if (this.filteredSessions[session.userId].lastContact < session.lastContact) {
this.filteredSessions[session.userId] = session
}
} else {
this.filteredSessions[session.userId] = session
}
}
console.log(this.filteredSessions)
this.document = document
})
@ -228,42 +346,58 @@ export default {
const sendStepsDebounce = () => this.syncService.sendSteps()
const sendStepsDebounced = debounce(sendStepsDebounce, EDITOR_PUSH_DEBOUNCE, { maxWait: 5000 })
this.view = new EditorView(this.$refs.editor, {
state: EditorState.create({
doc: initialDocument,
plugins: [
keymap({
'Ctrl-s': () => {
//authority.manualSave()
//authority.fetchSteps()
return true
}
}),
...exampleSetup({ schema, floatingMenu: false }),
collab({
version: this.syncService.steps.length,
clientID: this.currentSession.id
})
]
}),
dispatchTransaction: (transaction) => {
const state = this.view.state.apply(transaction)
this.view.updateState(state)
/** tiptap */
this.markdownit = MarkdownIt('commonmark', {html: false});
this.tiptap = new Editor({
content: "<p>Hello world</p>", //this.markdownit.render(documentSource),
onUpdate: ({state}) => {
console.log(defaultMarkdownSerializer.serialize(state.doc))
this.syncService.state = state
sendStepsDebounced()
}
},
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Bold(),
new Underline,
new Strike,
new Code(),
new Italic(),
new BulletList(),
new OrderedList(),
new Blockquote(),
new CodeBlock(),
new ListItem,
new Link,
new History(),
new Collaboration({
// the initial version we start with
// version is an integer which is incremented with every change
version: this.syncService.steps.length,
clientID: this.currentSession.id,
// debounce changes so we can save some bandwidth
debounce: 250,
onSendable: ({ sendable }) => {
// This is not working properly with polling and the careful retry logic
this.syncService.sendSteps(sendable)
}
}),
new Keymap({
'Ctrl-s': () => {
this.syncService.save()
console.log('save', this);
return true;
}
})
],
})
this.syncService.state = this.view.state
this.syncService.state = this.tiptap.state
this.$emit('update:loaded', true)
})
.on('sync', (steps) => {
let newData = this.syncService.stepsSince(getVersion(this.view.state))
this.view.dispatch(
receiveTransaction(this.view.state, newData.steps, newData.clientIDs)
)
this.syncService.state = this.view.state
.on('sync', ({steps, document}) => {
this.tiptap.extensions.options.collaboration.update({
version: document.version,
steps: steps
})
})
.on('stateChange', (state) => {
if (state.initialLoading && !this.initialLoading) {

34
src/extensions/Keymap.js Normal file
Просмотреть файл

@ -0,0 +1,34 @@
/*
* @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 { Extension } from 'tiptap'
export default class Keymap extends Extension {
get name() {
return 'save'
}
keys({ schema }) {
return this.options
}
}

27
src/extensions/index.js Normal file
Просмотреть файл

@ -0,0 +1,27 @@
/*
* @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 Keymap from './Keymap'
export {
Keymap
}

Просмотреть файл

@ -116,7 +116,7 @@ class PollingBackend {
return
}
this._authority._receiveSteps(response.data.steps)
this._authority._receiveSteps(response.data)
this.lock = false
this._forcedSave = false
this.resetRefetchTimer()
@ -137,23 +137,24 @@ class PollingBackend {
this._forcedSave = false
}
sendSteps(sendable) {
sendSteps(_sendable) {
this._authority.emit('stateChange', { dirty: true })
if (this.lock) {
setTimeout(() => {
this._authority.sendSteps(sendable)
this._authority.sendSteps(_sendable)
}, 100)
return
}
this.lock = true
const authority = this
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,
sessionId: this._authority.session.id,
sessionToken: this._authority.session.token,
steps: steps.map(s => s.toJSON()) || [],
version: this._authority._getVersion(),
steps: steps.map(s => s.toJSON ? s.toJSON() : s) || [],
version: sendable.version,
token: this._authority.options.shareToken
}).then((response) => {
this._authority.emit('stateChange', { dirty: false })
@ -165,9 +166,9 @@ class PollingBackend {
this.lock = false
this._fetchSteps()
this.carefulRetry(() => {
/*this.carefulRetry(() => {
this.sendSteps(sendable)
})
})*/
})
}

Просмотреть файл

@ -28,7 +28,6 @@ import {getVersion, sendableSteps} from 'prosemirror-collab'
const defaultOptions = {
shareToken: null,
schema: null,
serialize: (document) => document
};
@ -131,8 +130,8 @@ class SyncService {
)
}
sendSteps() {
let sendable = sendableSteps(this.state)
sendSteps(_sendable) {
let sendable = _sendable ? _sendable : sendableSteps(this.state)
if (!sendable) {
return
}
@ -146,15 +145,20 @@ class SyncService {
}
}
_receiveSteps(steps) {
_receiveSteps({steps, document}) {
let newSteps = []
for (let i = 0; i < steps.length; i++) {
let singleSteps = steps[i].data.map(j => Step.fromJSON(this.options.schema, j))
let singleSteps = steps[i].data
singleSteps.forEach(step => {
this.steps.push(step)
this.stepClientIDs.push(steps[i].sessionId)
newSteps.push({
step,
clientID: steps[i].sessionId
})
})
}
this.emit('sync', steps)
this.emit('sync', {steps: newSteps, document})
console.log('receivedSteps', 'newVersion', getVersion(this.state))
}
_getVersion() {