diff options
-rw-r--r-- | package-lock.json | 5 | ||||
-rw-r--r-- | package.json | 4 | ||||
-rw-r--r-- | src/components/EditorWrapper.vue | 57 | ||||
-rw-r--r-- | src/components/SessionList.vue | 28 | ||||
-rw-r--r-- | src/components/ViewerComponent.vue | 3 | ||||
-rw-r--r-- | src/extensions/UserColor.js | 224 | ||||
-rw-r--r-- | src/extensions/index.js | 2 | ||||
-rw-r--r-- | src/files.js | 2 | ||||
-rw-r--r-- | src/helpers/files.js | 2 | ||||
-rw-r--r-- | src/main.js | 3 | ||||
-rw-r--r-- | src/public.js | 2 | ||||
-rw-r--r-- | src/store.js | 43 |
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 |