Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/text.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package-lock.json5
-rw-r--r--package.json4
-rw-r--r--src/components/EditorWrapper.vue57
-rw-r--r--src/components/SessionList.vue28
-rw-r--r--src/components/ViewerComponent.vue3
-rw-r--r--src/extensions/UserColor.js224
-rw-r--r--src/extensions/index.js2
-rw-r--r--src/files.js2
-rw-r--r--src/helpers/files.js2
-rw-r--r--src/main.js3
-rw-r--r--src/public.js2
-rw-r--r--src/store.js43
12 files changed, 366 insertions, 9 deletions
diff --git a/package-lock.json b/package-lock.json
index d00cabbb5..a0fcbd10e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23557,6 +23557,11 @@
"date-format-parse": "^0.2.6"
}
},
+ "vuex": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.6.0.tgz",
+ "integrity": "sha512-W74OO2vCJPs9/YjNjW8lLbj+jzT24waTo2KShI8jLvJW8OaIkgb3wuAMA7D+ZiUxDOx3ubwSZTaJBip9G8a3aQ=="
+ },
"w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
diff --git a/package.json b/package.json
index 9526a1486..421f389dc 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"dependencies": {
"@nextcloud/auth": "^1.3.0",
"@nextcloud/axios": "^1.5.0",
+ "@nextcloud/browser-storage": "^0.1.1",
"@nextcloud/event-bus": "^1.2.0",
"@nextcloud/initial-state": "^1.2.0",
"@nextcloud/l10n": "^1.4.1",
@@ -55,7 +56,8 @@
"tiptap-extensions": "^1.33.2",
"tiptap-utils": "^1.11.0",
"vue": "^2.6.12",
- "vue-click-outside": "^1.0.7"
+ "vue-click-outside": "^1.0.7",
+ "vuex": "^3.6.0"
},
"engines": {
"node": ">=10.0.0"
diff --git a/src/components/EditorWrapper.vue b/src/components/EditorWrapper.vue
index a2d6b27de..4609b0705 100644
--- a/src/components/EditorWrapper.vue
+++ b/src/components/EditorWrapper.vue
@@ -33,7 +33,7 @@
{{ t('text', 'File could not be loaded. Please check your internet connection.') }} <a class="button primary" @click="reconnect">{{ t('text', 'Reconnect') }}</a>
</p>
</div>
- <div v-if="currentSession && active" id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !initialLoading && !hasConnectionIssue, 'richEditor': isRichEditor}">
+ <div v-if="currentSession && active" id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !initialLoading && !hasConnectionIssue, 'richEditor': isRichEditor, 'show-color-annotations': showAuthorAnnotations}">
<div id="editor">
<MenuBar v-if="!syncError && !readOnly"
ref="menubar"
@@ -74,6 +74,7 @@
import Vue from 'vue'
import escapeHtml from 'escape-html'
import moment from '@nextcloud/moment'
+import { mapState } from 'vuex'
import { SyncService, ERROR_TYPE, IDLE_TIMEOUT } from './../services/SyncService'
import { endpointUrl, getRandomGuestName } from './../helpers'
@@ -82,10 +83,11 @@ import { createEditor, markdownit, createMarkdownSerializer, serializePlainText,
import { EditorContent } from 'tiptap'
import { Collaboration } from 'tiptap-extensions'
-import { Keymap } from './../extensions'
+import { Keymap, UserColor } from './../extensions'
import isMobile from './../mixins/isMobile'
-
import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip'
+import { getVersion, receiveTransaction } from 'prosemirror-collab'
+import { Step } from 'prosemirror-transform'
const EDITOR_PUSH_DEBOUNCE = 200
@@ -171,6 +173,9 @@ export default {
}
},
computed: {
+ ...mapState({
+ showAuthorAnnotations: state => state.showAuthorAnnotations,
+ }),
lastSavedStatus() {
let status = (this.dirtyStateIndicator ? '*' : '')
if (!this.isMobile) {
@@ -328,6 +333,34 @@ export default {
this.syncService.sendSteps()
}
},
+ update: ({ steps, version, editor }) => {
+ const { state, view, schema } = editor
+
+ if (getVersion(state) > version) {
+ return
+ }
+
+ const tr = receiveTransaction(
+ state,
+ steps.map(item => Step.fromJSON(schema, item.step)),
+ steps.map(item => item.clientID),
+ )
+ tr.setMeta('clientID', steps[0].clientID)
+ view.dispatch(tr)
+ },
+ }),
+ new UserColor({
+ clientID: this.currentSession.id,
+ color: (clientID) => {
+ const session = this.sessions.find(item => '' + item.id === '' + clientID)
+ return session ? session.color : 'var(--color-background-hover)'
+ },
+ name: (clientID) => {
+ const session = this.sessions.find(item => '' + item.id === '' + clientID)
+ return session ? (
+ session.userId ? session.userId : session.guestName
+ ) : null
+ },
}),
new Keymap({
'Mod-s': () => {
@@ -351,10 +384,14 @@ export default {
.on('sync', ({ steps, document }) => {
this.hasConnectionIssue = false
try {
- this.tiptap.extensions.options.collaboration.update({
- version: document.currentVersion,
- steps,
- })
+ for (let i = 0; i < steps.length; i++) {
+ // FIXME: seems pretty bad performance wise (maybe grouping the steps by user in the backend would be good)
+ this.tiptap.extensions.options.collaboration.update({
+ version: document.currentVersion,
+ steps: [steps[i]],
+ editor: this.tiptap,
+ })
+ }
this.syncService.state = this.tiptap.state
this.updateLastSavedStatus()
} catch (e) {
@@ -500,6 +537,12 @@ export default {
height: 100%;
overflow: hidden;
position: absolute;
+
+ &:not(.show-color-annotations)::v-deep .author-annotation {
+ background-color: transparent !important;
+ color: var(--color-main-text) !important;
+ }
+
.ProseMirror {
margin-top: 0 !important;
}
diff --git a/src/components/SessionList.vue b/src/components/SessionList.vue
index 58d508a6a..5e5c59c1a 100644
--- a/src/components/SessionList.vue
+++ b/src/components/SessionList.vue
@@ -23,7 +23,7 @@
<template>
<div class="session-list">
<div v-tooltip.bottom="editorsTooltip" class="avatar-list" @click="popoverVisible=!popoverVisible">
- <div v-if="sessionsPopover.length > 0" class="avatardiv icon-more" />
+ <div class="avatardiv icon-more" />
<Avatar v-for="session in sessionsVisible"
:key="session.id"
:user="session.userId ? session.userId : session.guestName"
@@ -35,6 +35,14 @@
<div v-show="popoverVisible" class="popovermenu menu-right">
<PopoverMenu :menu="sessionsPopover" />
<slot />
+ <input id="toggle-color-annotations"
+ v-model="showAuthorAnnotations"
+ type="checkbox"
+ class="checkbox">
+ <label for="toggle-color-annotations">{{ t('text', 'Show color annotations') }}</label>
+ <p class="hint">
+ {{ t('text', 'Color annotations will only show during editing sessions, they are not persisted after closing the document.') }}
+ </p>
</div>
</div>
</template>
@@ -70,6 +78,14 @@ export default {
}
},
computed: {
+ showAuthorAnnotations: {
+ get() {
+ return this.$store.state.showAuthorAnnotations
+ },
+ set(value) {
+ this.$store.commit('setShowAuthorAnnotations', value)
+ },
+ },
editorsTooltip() {
if (this.sessionsPopover.length > 0) {
const first = this.activeSessions.slice(0, 3).map((session) => session.guestName ? session.guestName : session.displayName).join(', ')
@@ -135,6 +151,7 @@ export default {
/deep/ .popovermenu {
margin-right: -4px;
+ min-width: 240px;
img {
padding: 0;
width: 32px !important;
@@ -169,4 +186,13 @@ export default {
.popovermenu {
display: block;
}
+
+ label {
+ display: block;
+ margin: 8px;
+ }
+ .hint {
+ margin: 8px;
+ color: var(--color-text-maxcontrast);
+ }
</style>
diff --git a/src/components/ViewerComponent.vue b/src/components/ViewerComponent.vue
index 952f7b8b3..bf86ff5c4 100644
--- a/src/components/ViewerComponent.vue
+++ b/src/components/ViewerComponent.vue
@@ -29,6 +29,8 @@
</template>
<script>
+import store from './../store'
+
export default {
name: 'ViewerComponent',
components: {
@@ -57,6 +59,7 @@ export default {
},
},
beforeMount() {
+ this.$store = store
// FIXME Dirty fix to avoid recreating the component on stable16
if (typeof this.$parent.$parent !== 'undefined' && this.$parent.$parent.onResize) {
window.removeEventListener('resize', this.$parent.$parent.onResize)
diff --git a/src/extensions/UserColor.js b/src/extensions/UserColor.js
new file mode 100644
index 000000000..344c08f79
--- /dev/null
+++ b/src/extensions/UserColor.js
@@ -0,0 +1,224 @@
+/*
+ * @copyright Copyright (c) 2020 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, Plugin } from 'tiptap'
+import { Decoration, DecorationSet } from 'prosemirror-view'
+
+const contrastRatio = (value) => {
+ const hexCode = value.charAt(0) === '#'
+ ? value.substr(1, 6)
+ : value
+
+ const hexR = parseInt(hexCode.substr(0, 2), 16)
+ const hexG = parseInt(hexCode.substr(2, 2), 16)
+ const hexB = parseInt(hexCode.substr(4, 2), 16)
+ return (hexR + hexG + hexB) / (255 * 3)
+}
+function updateBlameMap(map, transform, id) {
+ const result = []; const mapping = transform.mapping
+ for (let i = 0; i < map.length; i++) {
+ const span = map[i]
+ const from = mapping.map(span.from, 1); const to = mapping.map(span.to, -1)
+ if (from < to) result.push(new Span(from, to, span.commit))
+ }
+
+ for (let i = 0; i < mapping.maps.length; i++) {
+ const map = mapping.maps[i]; const after = mapping.slice(i + 1)
+ map.forEach((_s, _e, start, end) => {
+ insertIntoBlameMap(result, after.map(start, 1), after.map(end, -1), id)
+ })
+ }
+
+ return result
+}
+
+function insertIntoBlameMap(map, from, to, commit) {
+ if (from >= to) {
+ return
+ }
+ let pos = 0
+ let next
+ for (; pos < map.length; pos++) {
+ next = map[pos]
+ if (next.commit === commit) {
+ if (next.to >= from) break
+ } else if (next.to > from) { // Different commit, not before
+ if (next.from < from) { // Sticks out to the left (loop below will handle right side)
+ const left = new Span(next.from, from, next.commit)
+ if (next.to > to) map.splice(pos++, 0, left)
+ else map[pos++] = left
+ }
+ break
+ }
+ }
+
+ // eslint-ignore
+ while ((next = map[pos])) {
+ if (next.commit === commit) {
+ if (next.from > to) break
+ from = Math.min(from, next.from)
+ to = Math.max(to, next.to)
+ map.splice(pos, 1)
+ } else {
+ if (next.from >= to) break
+ if (next.to > to) {
+ map[pos] = new Span(to, next.to, next.commit)
+ break
+ } else {
+ map.splice(pos, 1)
+ }
+ }
+ }
+
+ map.splice(pos, 0, new Span(from, to, commit))
+}
+
+class TrackState {
+
+ constructor(blameMap, commits, uncommittedSteps, uncommittedMaps) {
+ // The blame map is a data structure that lists a sequence of
+ // document ranges, along with the commit that inserted them. This
+ // can be used to, for example, highlight the part of the document
+ // that was inserted by a commit.
+ this.blameMap = blameMap
+ // The commit history, as an array of objects.
+ this.commits = commits
+ // Inverted steps and their maps corresponding to the changes that
+ // have been made since the last commit.
+ this.uncommittedSteps = uncommittedSteps
+ this.uncommittedMaps = uncommittedMaps
+ }
+
+ // Apply a transform to this state
+ applyTransform(transform) {
+ // Invert the steps in the transaction, to be able to save them in
+ // the next commit
+ const inverted
+ = transform.steps.map((step, i) => step.invert(transform.docs[i]))
+ const newBlame = updateBlameMap(this.blameMap, transform, this.commits.length)
+ // Create a new state—since these are part of the editor state, a
+ // persistent data structure, they must not be mutated.
+ return new TrackState(newBlame, this.commits,
+ this.uncommittedSteps.concat(inverted),
+ this.uncommittedMaps.concat(transform.mapping.maps))
+ }
+
+ // When a transaction is marked as a commit, this is used to put any
+ // uncommitted steps into a new commit.
+ applyCommit(message, time, author) {
+ if (this.uncommittedSteps.length === 0) return this
+ const commit = new Commit(message, time, this.uncommittedSteps, this.uncommittedMaps, author)
+ return new TrackState(this.blameMap, this.commits.concat(commit), [], [])
+ }
+
+}
+
+class Span {
+
+ constructor(from, to, commit) {
+ this.from = from; this.to = to; this.commit = commit
+ }
+
+}
+
+class Commit {
+
+ constructor(message, time, steps, maps, author) {
+ this.message = message
+ this.time = time
+ this.steps = steps
+ this.maps = maps
+ this.author = author
+ }
+
+}
+
+class UserColor extends Extension {
+
+ get name() {
+ return 'users'
+ }
+
+ get defaultOptions() {
+ return {
+ clientID: 0,
+ color: (clientID) => {
+ return '#' + Math.floor((Math.abs(Math.sin(clientID) * 16777215)) % 16777215).toString(16) + 'aa'
+ },
+ name: (clientID) => {
+ return null
+ },
+ }
+ }
+
+ get plugins() {
+ return [
+ new Plugin({
+ clientID: this.options.clientID,
+ color: this.options.color,
+ name: this.options.name,
+ state: {
+ init(_, instance) {
+ return {
+ tracked: new TrackState([new Span(0, instance.doc.content.size, null)], [], [], []),
+ deco: DecorationSet.empty,
+ }
+ },
+ apply(tr, instance, oldState, state) {
+ let { tracked, decos } = instance
+ let tState = this.getState(oldState).tracked
+ if (tr.docChanged) {
+ tracked = tracked.applyTransform(tr)
+ const clientID = tr.getMeta('clientID') ? tr.getMeta('clientID') : this.spec.clientID
+ tracked = tracked.applyCommit(clientID, new Date(tr.time), {
+ clientID,
+ color: this.spec.color(clientID),
+ name: this.spec.name(clientID),
+ })
+ tState = tracked
+ }
+
+ decos = tState.blameMap
+ .filter(span => typeof tState.commits[span.commit] !== 'undefined')
+ .map(span => {
+ const commit = tState.commits[span.commit]
+ return Decoration.inline(span.from, span.to, {
+ class: 'author-annotation',
+ style: 'background-color: ' + commit.author.color + '; color:' + (contrastRatio(commit.author.color) > 0.4 ? '#fff' : '#000'),
+ title: commit.author.name,
+ })
+ })
+ return { tracked, deco: DecorationSet.create(state.doc, decos) }
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state).deco
+ },
+ },
+ }),
+ ]
+ }
+
+}
+
+export default UserColor
diff --git a/src/extensions/index.js b/src/extensions/index.js
index c50468fb9..856c8bd4e 100644
--- a/src/extensions/index.js
+++ b/src/extensions/index.js
@@ -21,7 +21,9 @@
*/
import Keymap from './Keymap'
+import UserColor from './UserColor'
export {
Keymap,
+ UserColor,
}
diff --git a/src/files.js b/src/files.js
index ca7d397a6..a8890d5ce 100644
--- a/src/files.js
+++ b/src/files.js
@@ -25,6 +25,7 @@ import { registerFileActionFallback, registerFileCreate, FilesWorkspacePlugin }
import FilesSettings from './views/FilesSettings'
import { loadState } from '@nextcloud/initial-state'
import { linkTo } from '@nextcloud/router'
+import store from './store'
__webpack_nonce__ = btoa(OC.requestToken) // eslint-disable-line
__webpack_public_path__ = linkTo('text', 'js/') // eslint-disable-line
@@ -46,6 +47,7 @@ document.addEventListener('DOMContentLoaded', () => {
Vue.prototype.OCA = window.OCA
const vm = new Vue({
render: h => h(FilesSettings, {}),
+ store,
})
const el = vm.$mount().$el
OCA.Files.Settings.register(new OCA.Files.Settings.Setting('text', {
diff --git a/src/helpers/files.js b/src/helpers/files.js
index 205e26bc1..a8d3f81f1 100644
--- a/src/helpers/files.js
+++ b/src/helpers/files.js
@@ -23,6 +23,7 @@
import { openMimetypes } from './mime'
import RichWorkspace from '../views/RichWorkspace'
import { imagePath } from '@nextcloud/router'
+import store from '../store'
const FILE_ACTION_IDENTIFIER = 'Edit with text app'
@@ -158,6 +159,7 @@ const FilesWorkspacePlugin = {
propsData: {
path: fileList.getCurrentDirectory(),
},
+ store,
}).$mount(this.el)
fileList.$el.on('urlChanged', data => {
diff --git a/src/main.js b/src/main.js
index 1aed86f45..acc9add9a 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,3 +1,5 @@
+import store from './store'
+
__webpack_nonce__ = btoa(OC.requestToken) // eslint-disable-line
__webpack_public_path__ = OC.linkTo('text', 'js/') // eslint-disable-line
@@ -12,6 +14,7 @@ if (document.getElementById('app-content')) {
const DirectEditing = imports[1].default
const vm = new Vue({
render: h => h(DirectEditing),
+ store,
})
vm.$mount(document.getElementById('app-content'))
})
diff --git a/src/public.js b/src/public.js
index f8c031264..cf28f0ce0 100644
--- a/src/public.js
+++ b/src/public.js
@@ -6,6 +6,7 @@ import {
} from './helpers/files'
import { openMimetypes } from './helpers/mime'
import { loadState } from '@nextcloud/initial-state'
+import store from './store'
__webpack_nonce__ = btoa(OC.requestToken) // eslint-disable-line
__webpack_public_path__ = OC.linkTo('text', 'js/') // eslint-disable-line
@@ -47,6 +48,7 @@ documentReady(() => {
mime: mimetype,
},
}),
+ store,
})
vm.$mount(document.getElementById('preview'))
})
diff --git a/src/store.js b/src/store.js
new file mode 100644
index 000000000..a2d84806d
--- /dev/null
+++ b/src/store.js
@@ -0,0 +1,43 @@
+/*
+ * @copyright Copyright (c) 2020 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 Vue from 'vue'
+import Vuex from 'vuex'
+import { getBuilder } from '@nextcloud/browser-storage'
+
+const persistentStorage = getBuilder('text').persist().build()
+
+Vue.use(Vuex)
+
+const store = new Vuex.Store({
+ state: {
+ showAuthorAnnotations: persistentStorage.getItem('showAuthorAnnotations') === 'true',
+ },
+ mutations: {
+ setShowAuthorAnnotations(state, value) {
+ state.showAuthorAnnotations = value
+ persistentStorage.setItem('showAuthorAnnotations', '' + value)
+ },
+ },
+})
+
+export default store