Stackedit/src/components/menus/HistoryMenu.vue
Benoit Schweblin 1b2d48ff22 Removed monetizejs sponsorship support.
Reduced time counter increment interval.
Added badge service.
Refactored user service.
Replaced Google+ with People API.
2019-06-22 22:19:01 +01:00

455 lines
13 KiB
Vue

<template>
<div class="history side-bar__panel side-bar__panel--menu">
<div class="side-bar__info">
<p v-if="syncLocations.length > 1">
<select slot="field" class="textfield" v-model="syncLocationId" @keydown.enter="resolve()">
<option v-for="location in syncLocations" :key="location.id" :value="location.id">
{{ location.description }}
</option>
</select>
</p>
<p v-if="!historyContext">Synchronize <b>{{currentFileName}}</b> to enable revision history or <a href="javascript:void(0)" @click="signin">sign in with Google</a> to synchronize your main workspace.</p>
<p v-else-if="loading">Loading history…</p>
<p v-else-if="!revisionsWithSpacer.length"><b>{{currentFileName}}</b> has no history.</p>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-else>
<div class="menu-entry__icon menu-entry__icon--image">
<icon-provider :provider-id="syncLocation.providerId"></icon-provider>
</div>
<span v-if="syncLocation.url">
The following revisions are stored in <a :href="syncLocation.url" target="_blank">{{ syncLocationProviderName }}</a>.
</span>
<span v-else>
The following revisions are stored in {{ syncLocationProviderName }}.
</span>
</div>
</div>
<div>
<div class="revision" v-for="revision in revisionsWithSpacer" :key="revision.id">
<div class="history__spacer" v-if="revision.spacer"></div>
<a class="revision__button button flex flex--row" href="javascript:void(0)" @click="open(revision)">
<div class="revision__icon">
<user-image :user-id="revision.sub"></user-image>
</div>
<div class="revision__header flex flex--column">
<user-name :user-id="revision.sub"></user-name>
<div class="revision__created">{{revision.created | formatTime}}</div>
</div>
</a>
</div>
</div>
<div class="history__spacer history__spacer--last" v-if="revisions.length"></div>
<div class="flex flex--row flex--end" v-if="showMoreButton">
<button class="history__button button" @click="showMore">More</button>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapGetters } from 'vuex';
import providerRegistry from '../../services/providers/common/providerRegistry';
import MenuEntry from './common/MenuEntry';
import UserImage from '../UserImage';
import UserName from '../UserName';
import EditorClassApplier from '../common/EditorClassApplier';
import PreviewClassApplier from '../common/PreviewClassApplier';
import utils from '../../services/utils';
import googleHelper from '../../services/providers/helpers/googleHelper';
import syncSvc from '../../services/syncSvc';
import store from '../../store';
import badgeSvc from '../../services/badgeSvc';
let editorClassAppliers = [];
let previewClassAppliers = [];
let cachedHistoryContextHash;
let revisionsPromise;
let revisionContentPromises;
const pageSize = 30;
const spacerThreshold = 6 * 60 * 60 * 1000; // 6h
export default {
components: {
MenuEntry,
UserImage,
UserName,
},
data: () => ({
allRevisions: [],
loading: false,
showCount: pageSize,
syncLocationId: null,
}),
computed: {
...mapGetters('data', [
'syncDataByItemId',
]),
...mapGetters('syncLocation', {
syncLocations: 'currentWithWorkspaceSyncLocation',
}),
...mapState('content', [
'revisionContent',
]),
syncLocation() {
return utils.someResult(this.syncLocations, (syncLocation) => {
if (syncLocation.id === this.syncLocationId) {
return syncLocation;
}
return null;
});
},
syncLocationProviderName() {
if (!this.syncLocation) {
return null;
}
return providerRegistry.providersById[this.syncLocation.providerId].name;
},
currentFileName() {
return store.getters['file/current'].name;
},
historyContext() {
const { syncLocation } = this;
if (syncLocation) {
const provider = providerRegistry.providersById[syncLocation.providerId];
const token = provider.getToken(syncLocation);
const fileId = store.getters['file/current'].id;
const contentId = `${fileId}/content`;
const historyContext = {
token,
fileId,
contentId,
syncLocation: this.syncLocation,
};
if (syncLocation.id !== 'main') {
return historyContext;
}
// Add syncData for workspace sync location
const { syncDataByItemId } = this;
const fileSyncData = syncDataByItemId[fileId];
const contentSyncData = syncDataByItemId[contentId];
if (fileSyncData && contentSyncData) {
return {
...historyContext,
fileSyncDataId: fileSyncData.id,
contentSyncDataId: contentSyncData.id,
};
}
}
return null;
},
historyContextHash() {
return utils.serializeObject(this.historyContext);
},
revisions() {
return this.allRevisions.slice()
.sort((revision1, revision2) => revision2.created - revision1.created)
.slice(0, this.showCount);
},
revisionsWithSpacer() {
let previousCreated = 0;
return this.revisions.map((revision) => {
const revisionWithSpacer = {
...revision,
spacer: revision.created + spacerThreshold < previousCreated,
};
previousCreated = revision.created;
return revisionWithSpacer;
});
},
showMoreButton() {
return this.showCount < this.allRevisions.length;
},
},
methods: {
...mapMutations('content', [
'setRevisionContent',
]),
async signin() {
try {
await googleHelper.signin();
syncSvc.requestSync();
} catch (e) {
// Cancel
}
},
close() {
store.dispatch('data/setSideBarPanel', 'menu');
},
showMore() {
this.showCount += pageSize;
},
open(revision) {
let revisionContentPromise = revisionContentPromises[revision.id];
if (!revisionContentPromise) {
const historyContext = utils.deepCopy(this.historyContext);
if (historyContext) {
const provider = providerRegistry.providersById[this.syncLocation.providerId];
revisionContentPromise = new Promise((resolve, reject) => store.dispatch(
'queue/enqueue',
() => provider.getFileRevisionContent({
...historyContext,
revisionId: revision.id,
})
.then(resolve, reject),
));
revisionContentPromises[revision.id] = revisionContentPromise;
revisionContentPromise.catch((err) => {
store.dispatch('notification/error', err);
revisionContentPromises[revision.id] = null;
});
}
}
if (revisionContentPromise) {
revisionContentPromise.then(revisionContent =>
store.dispatch('content/setRevisionContent', revisionContent));
}
},
refreshHighlighters() {
const { revisionContent } = this;
editorClassAppliers.forEach(editorClassApplier => editorClassApplier.stop());
editorClassAppliers = [];
previewClassAppliers.forEach(previewClassApplier => previewClassApplier.stop());
previewClassAppliers = [];
if (revisionContent) {
let offset = 0;
revisionContent.diffs.forEach(([type, text]) => {
if (type) {
const classes = ['revision-diff', `revision-diff--${type > 0 ? 'insert' : 'delete'}`];
const offsets = {
start: offset,
end: offset + text.length,
};
editorClassAppliers.push(new EditorClassApplier(
[`revision-diff--${utils.uid()}`, ...classes],
offsets,
));
previewClassAppliers.push(new PreviewClassApplier(
[`revision-diff--${utils.uid()}`, ...classes],
offsets,
));
}
offset += text.length;
});
}
},
},
watch: {
// Fix syncLocationId
syncLocation: {
immediate: true,
handler(value) {
const firstSyncLocation = this.syncLocations[0];
if (firstSyncLocation) {
if (!value) {
this.syncLocationId = firstSyncLocation.id;
} else if (value.id !== firstSyncLocation.id) {
badgeSvc.addBadge('chooseHistory');
}
}
},
},
// Load revision list on context changes
historyContextHash: {
immediate: true,
handler() {
this.allRevisions = [];
const historyContext = utils.deepCopy(this.historyContext);
if (historyContext) {
if (this.historyContextHash !== cachedHistoryContextHash) {
this.setRevisionContent();
cachedHistoryContextHash = this.historyContextHash;
revisionContentPromises = {};
const provider = providerRegistry.providersById[this.syncLocation.providerId];
revisionsPromise = new Promise((resolve, reject) => store.dispatch(
'queue/enqueue',
() => provider
.listFileRevisions(historyContext)
.then(resolve, reject),
))
.catch((err) => {
store.dispatch('notification/error', err);
cachedHistoryContextHash = null;
return [];
});
}
if (revisionsPromise) {
this.loading = true;
revisionsPromise.then((revisions) => {
this.loading = false;
this.allRevisions = revisions;
});
}
}
},
},
// Load each revision on revision list changes
revisions(revisions) {
const { historyContext } = this;
if (historyContext) {
store.dispatch(
'queue/enqueue',
() => utils.awaitSequence(revisions, async (revision) => {
// Make sure revisions and historyContext haven't changed
if (!this.destroyed
&& this.revisions === revisions
&& this.historyContext === historyContext
) {
const provider = providerRegistry.providersById[this.syncLocation.providerId];
await provider.loadFileRevision({
...historyContext,
revision,
});
}
}),
);
}
},
// Refresh highlighters on open/close revision
revisionContent: {
immediate: true,
handler() {
this.refreshHighlighters();
},
},
},
created() {
// Close revision on escape
this.onKeyup = (evt) => {
if (evt.which === 27) {
// Esc key
this.setRevisionContent();
}
};
window.addEventListener('keyup', this.onKeyup);
},
destroyed() {
// Close revision
this.setRevisionContent();
// Remove highlighters
this.refreshHighlighters();
// Remove event listener
window.removeEventListener('keyup', this.onKeyup);
// Cancel loading revisions
this.destroyed = true;
},
};
</script>
<style lang="scss">
@import '../../styles/variables.scss';
.history__button {
font-size: 14px;
margin-top: 0.5em;
}
.history__spacer {
position: relative;
height: 40px;
&::before {
content: '';
position: absolute;
height: 100%;
top: 0;
left: 19px;
border-left: 2px dotted $hr-color;
}
}
.history__spacer--last {
height: 20px;
}
.revision__button {
text-align: left;
padding: 10px;
height: auto;
text-transform: none;
position: relative;
&::before {
content: '';
position: absolute;
height: 100%;
top: 0;
left: 19px;
border-left: 2px solid $hr-color;
}
&:active,
&:focus,
&:hover {
&::before {
display: none;
}
}
.revision:first-child &::before {
height: 67%;
top: 33%;
}
}
.revision__icon {
height: 20px;
width: 20px;
margin-right: 12px;
flex: none;
border-radius: $border-radius-base;
overflow: hidden;
position: relative;
}
.revision__header {
font-size: 15px;
width: 100%;
line-height: 1.33;
}
.revision__created {
font-size: 0.75em;
opacity: 0.6;
}
.layout--revision {
.cledit-section *,
.cl-preview-section * {
color: transparentize($editor-color-light, 0.5) !important;
.app--dark & {
color: transparentize($editor-color-dark, 0.5) !important;
}
}
.cledit-section .revision-diff {
color: $editor-color-light !important;
.app--dark & {
color: $editor-color-dark !important;
}
}
.cl-preview-section .revision-diff {
color: $body-color-light !important;
.app--dark & {
color: $body-color-dark !important;
}
}
.revision-diff {
padding: 0.25em 0;
&.revision-diff--insert {
background-color: mix(#fff, $selection-highlighting-color, 60%);
}
&.revision-diff--delete {
background-color: mix(#fff, $error-color, 60%);
text-decoration: line-through;
}
}
}
</style>