Revision history

This commit is contained in:
benweet 2017-11-26 20:58:24 +00:00
parent 815c87b5e3
commit 6b6ef52bb4
30 changed files with 635 additions and 166 deletions

View File

@ -21,6 +21,10 @@ import store from '../store';
Vue.directive('focus', {
inserted(el) {
el.focus();
const value = el.value;
if (value && el.setSelectionRange) {
el.setSelectionRange(0, value.length);
}
},
});

View File

@ -16,11 +16,11 @@ export default {
}
if (scrollElt) {
const clEditor = cledit(preElt, scrollElt);
clEditor.on('contentChanged', value => this.$emit('changed', value));
clEditor.init({
content: this.value,
sectionHighlighter: section => Prism.highlight(section.text, Prism.languages[this.lang]),
});
clEditor.on('contentChanged', value => this.$emit('changed', value));
clEditor.toggleEditable(!this.disabled);
}
},

View File

@ -1,5 +1,5 @@
<template>
<div class="layout">
<div class="layout" :class="{'layout--revision': revisionContent}">
<div class="layout__panel flex flex--row" :class="{'flex--end': styles.showSideBar}">
<div class="layout__panel layout__panel--explorer" v-show="styles.showExplorer" :aria-hidden="!styles.showExplorer" :style="{width: styles.layoutOverflow ? '100%' : constants.explorerWidth + 'px'}">
<explorer></explorer>
@ -75,6 +75,9 @@ export default {
FindReplace,
},
computed: {
...mapState('content', [
'revisionContent',
]),
...mapState('discussion', [
'stickyComment',
]),
@ -155,11 +158,6 @@ $editor-background: #fff;
.sticky-comment,
.current-discussion {
background-color: mix(#000, $editor-background, 6.7%);
}
.comment-list__current-discussion,
.sticky-comment,
.current-discussion {
border-color: $editor-background;
}
}
@ -177,11 +175,6 @@ $preview-background: #f3f3f3;
.sticky-comment,
.current-discussion {
background-color: mix(#000, $preview-background, 6.7%);
}
.comment-list__current-discussion,
.sticky-comment,
.current-discussion {
border-color: $preview-background;
}
}

View File

@ -189,7 +189,7 @@ export default {
.modal__inner-2 {
margin: 40px 10px 100px;
background-color: #fff;
background-color: #f8f8f8;
padding: 40px 50px 30px;
border-radius: $border-radius-base;
position: relative;

View File

@ -1,88 +1,58 @@
<template>
<nav class="navigation-bar" :class="{'navigation-bar--editor': styles.showEditor}">
<nav class="navigation-bar" :class="{'navigation-bar--editor': styles.showEditor && !revisionContent}">
<!-- Explorer -->
<div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button">
<button class="navigation-bar__button button" @click="toggleExplorer()" v-title="'Toggle explorer'">
<icon-folder></icon-folder>
</button>
<button class="navigation-bar__button button" @click="toggleExplorer()" v-title="'Toggle explorer'"><icon-folder></icon-folder></button>
</div>
<!-- Side bar -->
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button">
<button class="navigation-bar__button navigation-bar__button--stackedit button" @click="toggleSideBar()" v-title="'Toggle side bar'">
<icon-provider provider-id="stackedit"></icon-provider>
</button>
<button class="navigation-bar__button navigation-bar__button--stackedit button" @click="toggleSideBar()" v-title="'Toggle side bar'"><icon-provider provider-id="stackedit"></icon-provider></button>
</div>
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--title flex flex--row">
<!-- Spinner -->
<div class="navigation-bar__spinner">
<div v-if="!offline && showSpinner" class="spinner"></div>
<icon-sync-off v-if="offline"></icon-sync-off>
</div>
<!-- Title -->
<div class="navigation-bar__title navigation-bar__title--fake text-input"></div>
<div class="navigation-bar__title navigation-bar__title--text text-input" :style="{width: titleWidth + 'px'}">{{title}}</div>
<input class="navigation-bar__title navigation-bar__title--input text-input" :class="{'navigation-bar__title--focus': titleFocus, 'navigation-bar__title--scrolling': titleScrolling}" :style="{width: titleWidth + 'px'}" @focus="editTitle(true)" @blur="editTitle(false)" @keyup.enter="submitTitle()" @keyup.esc="submitTitle(true)" @mouseenter="titleHover = true" @mouseleave="titleHover = false" v-model="title">
<!-- Sync/Publish -->
<div class="flex flex--row" :class="{'navigation-bar__hidden': styles.hideLocations}">
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in syncLocations" :key="location.id" :href="location.url" target="_blank" v-title="'Synchronized location'">
<icon-provider :provider-id="location.providerId"></icon-provider>
</a>
<button class="navigation-bar__button navigation-bar__button--sync button" :disabled="!isSyncPossible || isSyncRequested || offline" @click="requestSync" v-title="'Synchronize now'">
<icon-sync></icon-sync>
</button>
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in publishLocations" :key="location.id" :href="location.url" target="_blank" v-title="'Publish location'">
<icon-provider :provider-id="location.providerId"></icon-provider>
</a>
<button class="navigation-bar__button navigation-bar__button--publish button" :disabled="!publishLocations.length || isPublishRequested || offline" @click="requestPublish"v-title="'Publish now'">
<icon-upload></icon-upload>
</button>
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in syncLocations" :key="location.id" :href="location.url" target="_blank" v-title="'Synchronized location'"><icon-provider :provider-id="location.providerId"></icon-provider></a>
<button class="navigation-bar__button navigation-bar__button--sync button" :disabled="!isSyncPossible || isSyncRequested || offline" @click="requestSync" v-title="'Synchronize now'"><icon-sync></icon-sync></button>
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in publishLocations" :key="location.id" :href="location.url" target="_blank" v-title="'Publish location'"><icon-provider :provider-id="location.providerId"></icon-provider></a>
<button class="navigation-bar__button navigation-bar__button--publish button" :disabled="!publishLocations.length || isPublishRequested || offline" @click="requestPublish"v-title="'Publish now'"><icon-upload></icon-upload></button>
</div>
<!-- Revision -->
<div class="flex flex--row" v-if="revisionContent">
<button class="navigation-bar__button navigation-bar__button--revision navigation-bar__button--restore button" @click="restoreRevision">Restore</button>
<button class="navigation-bar__button navigation-bar__button--revision button" @click="setRevisionContent()" v-title="'Close revision'"><icon-close></icon-close></button>
</div>
</div>
<div class="navigation-bar__inner navigation-bar__inner--edit-buttons">
<button class="navigation-bar__button button" @click="undo" v-title="'Undo'" :disabled="!canUndo">
<icon-undo></icon-undo>
</button>
<button class="navigation-bar__button button" @click="redo" v-title="'Redo'" :disabled="!canRedo">
<icon-redo></icon-redo>
</button>
<button class="navigation-bar__button button" @click="undo" v-title="'Undo'" :disabled="!canUndo"><icon-undo></icon-undo></button>
<button class="navigation-bar__button button" @click="redo" v-title="'Redo'" :disabled="!canRedo"><icon-redo></icon-redo></button>
<div class="navigation-bar__spacer"></div>
<button class="navigation-bar__button button" @click="pagedownClick('bold')" v-title="'Bold'">
<icon-format-bold></icon-format-bold>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('italic')" v-title="'Italic'">
<icon-format-italic></icon-format-italic>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('strikethrough')" v-title="'Strikethrough'">
<icon-format-strikethrough></icon-format-strikethrough>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('heading')" v-title="'Heading'">
<icon-format-size></icon-format-size>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('ulist')" v-title="'Unordered list'">
<icon-format-list-bulleted></icon-format-list-bulleted>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('olist')" v-title="'Ordered list'">
<icon-format-list-numbers></icon-format-list-numbers>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('table')" v-title="'Table'">
<icon-table></icon-table>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('quote')" v-title="'Blockquote'">
<icon-format-quote-close></icon-format-quote-close>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('code')" v-title="'Code'">
<icon-code-tags></icon-code-tags>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('link')" v-title="'Link'">
<icon-link-variant></icon-link-variant>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('image')" v-title="'Image'">
<icon-file-image></icon-file-image>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('hr')" v-title="'Horizontal rule'">
<icon-format-horizontal-rule></icon-format-horizontal-rule>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('bold')" v-title="'Bold'"><icon-format-bold></icon-format-bold></button>
<button class="navigation-bar__button button" @click="pagedownClick('italic')" v-title="'Italic'"><icon-format-italic></icon-format-italic></button>
<button class="navigation-bar__button button" @click="pagedownClick('strikethrough')" v-title="'Strikethrough'"><icon-format-strikethrough></icon-format-strikethrough></button>
<button class="navigation-bar__button button" @click="pagedownClick('heading')" v-title="'Heading'"><icon-format-size></icon-format-size></button>
<button class="navigation-bar__button button" @click="pagedownClick('ulist')" v-title="'Unordered list'"><icon-format-list-bulleted></icon-format-list-bulleted></button>
<button class="navigation-bar__button button" @click="pagedownClick('olist')" v-title="'Ordered list'"><icon-format-list-numbers></icon-format-list-numbers></button>
<button class="navigation-bar__button button" @click="pagedownClick('table')" v-title="'Table'"><icon-table></icon-table></button>
<button class="navigation-bar__button button" @click="pagedownClick('quote')" v-title="'Blockquote'"><icon-format-quote-close></icon-format-quote-close></button>
<button class="navigation-bar__button button" @click="pagedownClick('code')" v-title="'Code'"><icon-code-tags></icon-code-tags></button>
<button class="navigation-bar__button button" @click="pagedownClick('link')" v-title="'Link'"><icon-link-variant></icon-link-variant></button>
<button class="navigation-bar__button button" @click="pagedownClick('image')" v-title="'Image'"><icon-file-image></icon-file-image></button>
<button class="navigation-bar__button button" @click="pagedownClick('hr')" v-title="'Horizontal rule'"><icon-format-horizontal-rule></icon-format-horizontal-rule></button>
</div>
</nav>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { mapState, mapMutations, mapGetters, mapActions } from 'vuex';
import editorSvc from '../services/editorSvc';
import syncSvc from '../services/syncSvc';
import publishSvc from '../services/publishSvc';
@ -109,6 +79,9 @@ export default {
'canUndo',
'canRedo',
]),
...mapState('content', [
'revisionContent',
]),
...mapGetters('layout', [
'styles',
]),
@ -155,6 +128,12 @@ export default {
},
},
methods: {
...mapMutations('content', [
'setRevisionContent',
]),
...mapActions('content', [
'restoreRevision',
]),
...mapActions('data', [
'toggleExplorer',
'toggleSideBar',
@ -176,9 +155,7 @@ export default {
}
},
pagedownClick(name) {
if (this.$store.getters['content/current'].id &&
this.$store.getters['layout/styles'].showEditor
) {
if (this.$store.getters['content/isCurrentEditable']) {
editorSvc.pagedownEditor.uiManager.doClick(name);
}
},
@ -292,8 +269,30 @@ $button-size: 36px;
}
}
.navigation-bar__button--revision {
width: 38px;
&:first-child {
margin-left: 10px;
}
&:last-child {
margin-right: 10px;
}
}
.navigation-bar__button--restore {
width: auto;
}
.navigation-bar__title {
margin: 0 4px;
font-size: 22px;
.layout--revision & {
position: absolute;
left: -9999px;
}
}
.navigation-bar__title,
@ -301,7 +300,6 @@ $button-size: 36px;
display: inline-block;
color: $navbar-color;
background-color: transparent;
font-size: 22px;
}
.navigation-bar__button--sync,
@ -385,6 +383,10 @@ $button-size: 36px;
}
}
.navigation-bar__button--revision {
display: inline-block;
}
.navigation-bar__title--input {
cursor: pointer;

View File

@ -15,6 +15,7 @@
<main-menu v-if="panel === 'menu'"></main-menu>
<sync-menu v-else-if="panel === 'sync'"></sync-menu>
<publish-menu v-else-if="panel === 'publish'"></publish-menu>
<history-menu v-else-if="panel === 'history'"></history-menu>
<export-menu v-else-if="panel === 'export'"></export-menu>
<more-menu v-else-if="panel === 'more'"></more-menu>
<div v-else-if="panel === 'help'" class="side-bar__panel side-bar__panel--help">
@ -34,6 +35,7 @@ import Toc from './Toc';
import MainMenu from './menus/MainMenu';
import SyncMenu from './menus/SyncMenu';
import PublishMenu from './menus/PublishMenu';
import HistoryMenu from './menus/HistoryMenu';
import ExportMenu from './menus/ExportMenu';
import MoreMenu from './menus/MoreMenu';
import markdownSample from '../data/markdownSample.md';
@ -45,6 +47,7 @@ const panelNames = {
toc: 'Table of contents',
sync: 'Synchronize',
publish: 'Publish',
history: 'File history',
export: 'Export to disk',
more: 'More',
};
@ -55,6 +58,7 @@ export default {
MainMenu,
SyncMenu,
PublishMenu,
HistoryMenu,
ExportMenu,
MoreMenu,
},
@ -90,6 +94,7 @@ export default {
hr {
margin: 10px 40px;
display: none;
border-top: 1px solid $hr-color;
}
* + hr {

View File

@ -24,6 +24,7 @@ export default {
.user-image {
width: 100%;
height: 100%;
background-color: #fff;
background-repeat: no-repeat;
background-position: center;
background-size: contain;

View File

@ -37,11 +37,14 @@ export default class PreviewClassApplier {
applyClass() {
const offset = this.offsetGetter();
if (offset && offset.start !== offset.end) {
if (offset) {
const offsetStart = editorSvc.getPreviewOffset(offset.start, editorSvc.sectionDescList);
const offsetEnd = editorSvc.getPreviewOffset(offset.end, editorSvc.sectionDescList);
if (offsetStart != null && offsetEnd != null && offsetStart !== offsetEnd) {
const start = cledit.Utils.findContainer(
editorSvc.previewElt, Math.min(offset.start, offset.end));
editorSvc.previewElt, Math.min(offsetStart, offsetEnd));
const end = cledit.Utils.findContainer(
editorSvc.previewElt, Math.max(offset.start, offset.end));
editorSvc.previewElt, Math.max(offsetStart, offsetEnd));
const range = document.createRange();
range.setStart(start.container, start.offsetInContainer);
range.setEnd(end.container, end.offsetInContainer);
@ -53,6 +56,7 @@ export default class PreviewClassApplier {
this.lastEltCount = this.eltCollection.length;
}
}
}
removeClass() {
utils.unwrapRange(this.eltCollection);

View File

@ -113,7 +113,7 @@ textarea {
}
.textfield {
background-color: transparent;
background-color: #fff;
border: 0;
font-family: inherit;
font-weight: 400;
@ -131,7 +131,7 @@ textarea {
&[disabled] {
cursor: not-allowed;
background-color: #f8f8f8;
background-color: #f2f2f2;
color: #999;
}
}
@ -164,6 +164,10 @@ textarea {
align-items: center;
}
.user-name {
font-weight: 600;
}
.side-title {
height: 44px;
line-height: 36px;
@ -210,6 +214,10 @@ textarea {
position: absolute;
top: 0;
height: 100%;
& > * {
border-left: 2px solid transparent;
}
}
.gutter__background {

View File

@ -9,7 +9,7 @@
html,
body {
color: rgba(0, 0, 0, 0.75);
color: $body-color;
font-size: 16px;
font-family: $font-family-main;
font-variant-ligatures: common-ligatures;

View File

@ -1,5 +1,6 @@
$font-family-main: Lato, 'Helvetica Neue', Helvetica, sans-serif;
$font-family-monospace: 'Roboto Mono', 'Lucida Sans Typewriter', 'Lucida Console', monaco, Courrier, monospace;
$body-color: rgba(0, 0, 0, 0.75);
$line-height-base: 1.67;
$line-height-title: 1.33;
$font-size-monospace: 0.85em;
@ -9,7 +10,7 @@ $selection-highlighting-color: #ff9632;
$info-bg: transparentize($selection-highlighting-color, 0.85);
$code-border-radius: 2px;
$link-color: #0c93e4;
$error-color: #f20;
$error-color: #f31;
$border-radius-base: 2px;
$hr-color: rgba(128, 128, 128, 0.2);
$navbar-color: rgba(255, 255, 255, 0.67);

View File

@ -163,6 +163,14 @@ export default {
() => this.updateStickyTrigger,
() => this.updateSticky(),
{ immediate: true });
// Move preview discussions once sectionDescWithDiffsList have been calculated
if (!editorSvc.sectionDescWithDiffsList) {
editorSvc.$once('sectionDescWithDiffsList', () => {
this.updateTops();
this.updateSticky();
});
}
},
destroyed() {
this.scrollerElt.removeEventListener('scroll', this.updateSticky);
@ -199,10 +207,6 @@ export default {
}
}
.user-name {
font-weight: 600;
}
/* use div selector to avoid collision with Prism */
div.comment {
padding: 5px 10px 10px;

View File

@ -21,7 +21,11 @@ export default {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
let offset;
if (editorSvc.clEditor.selectionMgr.hasFocus()) {
// Show the button if content is not a revision and has the focus
if (
!this.$store.state.content.revisionContent &&
editorSvc.clEditor.selectionMgr.hasFocus()
) {
this.selection = editorSvc.getTrimmedSelection();
if (this.selection) {
const text = editorSvc.clEditor.getContent();

View File

@ -21,7 +21,11 @@ export default {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
let offset;
if (editorSvc.previewSelectionRange) {
// Show the button if content is not a revision and preview selection is not empty
if (
!this.$store.state.content.revisionContent &&
editorSvc.previewSelectionRange
) {
this.selection = editorSvc.getTrimmedSelection();
if (this.selection) {
const text = editorSvc.previewTextWithDiffsList;

View File

@ -0,0 +1,260 @@
<template>
<div class="side-bar__panel side-bar__panel--history">
<a class="revision button flex flex--row" href="javascript:void(0)" v-for="revision in revisions" :key="revision.id" @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 class="flex flex--row flex--end" v-if="showMoreButton">
<button class="revision__button button" @click="showMore">More</button>
</div>
</div>
</template>
<script>
import { mapMutations } from 'vuex';
import googleDriveAppDataProvider from '../../services/providers/googleDriveAppDataProvider';
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 editorSvc from '../../services/editorSvc';
let editorClassAppliers = [];
let previewClassAppliers = [];
let cachedFileId;
let revisionsPromise;
let revisionContentPromises;
const pageSize = 50;
export default {
components: {
MenuEntry,
UserImage,
UserName,
},
data: () => ({
allRevisions: [],
showCount: pageSize,
}),
computed: {
revisions() {
return this.allRevisions.slice(0, this.showCount);
},
showMoreButton() {
return this.showCount < this.allRevisions.length;
},
},
methods: {
...mapMutations('content', [
'setRevisionContent',
]),
close() {
this.$store.dispatch('data/setSideBarPanel', 'menu');
},
showMore() {
this.showCount += pageSize;
},
open(revision) {
let revisionContentPromise = revisionContentPromises[revision.id];
if (!revisionContentPromise) {
revisionContentPromise = new Promise((resolve, reject) => {
const loginToken = this.$store.getters['data/loginToken'];
const currentFile = this.$store.getters['file/current'];
this.$store.dispatch('queue/enqueue',
() => Promise.resolve()
.then(() => googleDriveAppDataProvider.getRevisionContent(
loginToken, currentFile.id, revision.id))
.then(resolve, reject));
});
revisionContentPromises[revision.id] = revisionContentPromise;
revisionContentPromise.catch(() => {
revisionContentPromises[revision.id] = null;
});
}
revisionContentPromise.then(revisionContent =>
this.$store.dispatch('content/setRevisionContent', revisionContent));
},
refreshHighlighters() {
const revisionContent = this.$store.state.content.revisionContent;
editorClassAppliers.forEach(editorClassApplier => editorClassApplier.stop());
editorClassAppliers = [];
previewClassAppliers.forEach(previewClassApplier => previewClassApplier.stop());
previewClassAppliers = [];
if (revisionContent) {
editorSvc.$once('sectionDescWithDiffsList', () => {
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;
});
});
}
},
},
created() {
// Watch file changes
this.$watch(
() => this.$store.getters['file/current'].id,
(id) => {
this.allRevisions = [];
if (id) {
if (id !== cachedFileId) {
this.setRevisionContent();
cachedFileId = id;
revisionContentPromises = {};
const loginToken = this.$store.getters['data/loginToken'];
const currentFile = this.$store.getters['file/current'];
revisionsPromise = new Promise((resolve, reject) => {
this.$store.dispatch('queue/enqueue',
() => Promise.resolve()
.then(() => googleDriveAppDataProvider.listRevisions(loginToken, currentFile.id))
.then((revisions) => {
resolve(revisions.sort(
(revision1, revision2) => revision2.created - revision1.created));
})
.catch(reject));
});
revisionsPromise.catch(() => {
cachedFileId = null;
return [];
});
}
revisionsPromise.then((revisions) => {
this.allRevisions = revisions;
});
}
}, { immediate: true });
// Watch diffs changes
this.$watch(
() => this.$store.state.content.revisionContent,
() => this.refreshHighlighters());
// Close revision
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);
},
};
</script>
<style lang="scss">
@import '../common/variables.scss';
.side-bar__panel--history {
padding: 5px 5px 50px;
}
.revision {
text-align: left;
padding: 15px;
height: auto;
text-transform: none;
position: relative;
&::before {
content: '';
position: absolute;
height: 100%;
top: 0;
left: 24px;
border-left: 2px solid $hr-color;
}
&:active,
&:focus,
&:hover {
&::before {
display: none;
}
}
&: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%;
}
.revision__created {
font-size: 0.75em;
opacity: 0.5;
}
.revision__button {
font-size: 14px;
margin-top: 0.5em;
}
.layout--revision {
.cledit-section *,
.cl-preview-section * {
color: rgba(0, 0, 0, 0.15) !important;
}
.cledit-section .revision-diff {
color: $editor-color !important;
}
.cl-preview-section .revision-diff {
color: $body-color !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>

View File

@ -28,6 +28,12 @@
<div>File properties</div>
<span>Add metadata and configure extensions.</span>
</menu-entry>
<menu-entry @click.native="history">
<icon-history slot="icon"></icon-history>
<div>File history</div>
<span>Track and restore file revisions.</span>
</menu-entry>
<hr>
<menu-entry @click.native="setPanel('toc')">
<icon-toc slot="icon"></icon-toc>
Table of contents
@ -113,6 +119,17 @@ export default {
return this.$store.dispatch('modal/open', 'fileProperties')
.catch(() => {}); // Cancel
},
history() {
const loginToken = this.$store.getters['data/loginToken'];
if (!loginToken) {
this.$store.dispatch('modal/signInForHistory')
.then(() => googleHelper.signin())
.then(() => syncSvc.requestSync())
.catch(() => { }); // Cancel
} else {
this.setPanel('history');
}
},
print() {
print();
},

View File

@ -1,5 +1,5 @@
<template>
<a href="javascript:void(0)" class="menu-entry button flex flex--row flex--align-center">
<a class="menu-entry button flex flex--row flex--align-center" href="javascript:void(0)">
<div class="menu-entry__icon flex flex--column flex--center">
<slot name="icon"></slot>
</div>
@ -17,6 +17,7 @@
padding: 10px;
height: auto;
font-size: 17px;
line-height: 1.5;
text-transform: none;
div div {

View File

@ -53,7 +53,7 @@ export default {
position: absolute;
top: 8px;
right: 8px;
color: rgba(0, 0, 0, 0.2);
color: rgba(0, 0, 0, 0.5);
width: 30px;
height: 30px;
padding: 2px;
@ -61,7 +61,7 @@ export default {
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.3);
color: rgba(0, 0, 0, 0.67);
}
}

5
src/icons/History.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
<path d="M11,7V12.11L15.71,14.9L16.5,13.62L12.5,11.25V7M12.5,2C8.97,2 5.91,3.92 4.27,6.77L2,4.5V11H8.5L5.75,8.25C6.96,5.73 9.5,4 12.5,4C16.64,4 20,7.36 20,11.5C20,15.64 16.64,19 12.5,19C9.23,19 6.47,16.91 5.44,14H3.34C4.44,18.03 8.11,21 12.5,21C17.74,21 22,16.75 22,11.5C22,6.25 17.75,2 12.5,2Z" />
</svg>
</template>

View File

@ -47,6 +47,7 @@ import Undo from './Undo';
import Redo from './Redo';
import ContentSave from './ContentSave';
import Message from './Message';
import History from './History';
Vue.component('iconProvider', Provider);
Vue.component('iconFormatBold', FormatBold);
@ -96,3 +97,4 @@ Vue.component('iconUndo', Undo);
Vue.component('iconRedo', Redo);
Vue.component('iconContentSave', ContentSave);
Vue.component('iconMessage', Message);
Vue.component('iconHistory', History);

View File

@ -124,9 +124,17 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
for (let i = 0; i < item[1].length; i += 1) {
const section = this.conversionCtx.sectionList[sectionIdx];
if (item[0] === 0) {
const sectionDesc = this.sectionDescList[sectionDescIdx];
let sectionDesc = this.sectionDescList[sectionDescIdx];
sectionDescIdx += 1;
sectionDesc.editorElt = section.elt;
if (sectionDesc.editorElt !== section.elt) {
// Force textToPreviewDiffs computation
sectionDesc = {
...sectionDesc,
section,
editorElt: section.elt,
textToPreviewDiffs: null,
};
}
newSectionDescList.push(sectionDesc);
previewHtml += sectionDesc.html;
sectionIdx += 1;
@ -230,7 +238,8 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
}, 500),
/**
* Make the diff between editor's markdown and preview's html.
* Compute the diffs between editor's markdown and preview's html
* asynchronously unless there is only one section to compute.
*/
makeTextToPreviewDiffs() {
if (editorSvc.sectionDescList &&
@ -244,7 +253,9 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
if (hasOne) {
return true;
}
if (!sectionDesc.previewText) {
sectionDesc.previewText = sectionDesc.previewElt.textContent;
}
sectionDesc.textToPreviewDiffs = diffMatchPatch.diff_main(
sectionDesc.section.text, sectionDesc.previewText);
hasOne = true;
@ -540,7 +551,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
// Disable editor if hidden or if no content is loaded
store.watch(
() => store.getters['content/current'].id && store.getters['layout/styles'].showEditor,
() => store.getters['content/isCurrentEditable'],
editable => this.clEditor.toggleEditable(!!editable), {
immediate: true,
});

View File

@ -217,26 +217,28 @@ export default {
store.watch(
() => store.getters['discussion/currentFileDiscussions'],
(discussions) => {
// Editor class appliers
const oldEditorClassAppliers = editorClassAppliers;
editorClassAppliers = {};
Object.keys(discussions).forEach((discussionId) => {
const classApplier = oldEditorClassAppliers[discussionId] || new EditorClassApplier(
() => {
const classes = [`discussion-editor-highlighting--${discussionId}`, 'discussion-editor-highlighting'];
const classGetter = (type, discussionId) => () => {
const classes = [`discussion-${type}-highlighting--${discussionId}`, `discussion-${type}-highlighting`];
if (store.state.discussion.currentDiscussionId === discussionId) {
classes.push('discussion-editor-highlighting--selected');
classes.push(`discussion-${type}-highlighting--selected`);
}
return classes;
},
() => {
};
const offsetGetter = discussionId => () => {
const startMarker = discussionMarkers[`${discussionId}:start`];
const endMarker = discussionMarkers[`${discussionId}:end`];
return startMarker && endMarker && {
start: startMarker.offset,
end: endMarker.offset,
};
}, { discussionId });
};
// Editor class appliers
const oldEditorClassAppliers = editorClassAppliers;
editorClassAppliers = {};
Object.keys(discussions).forEach((discussionId) => {
const classApplier = oldEditorClassAppliers[discussionId] || new EditorClassApplier(
classGetter('editor', discussionId), offsetGetter(discussionId), { discussionId });
editorClassAppliers[discussionId] = classApplier;
});
Object.keys(oldEditorClassAppliers).forEach((discussionId) => {
@ -250,21 +252,7 @@ export default {
previewClassAppliers = {};
Object.keys(discussions).forEach((discussionId) => {
const classApplier = oldPreviewClassAppliers[discussionId] || new PreviewClassApplier(
() => {
const classes = [`discussion-preview-highlighting--${discussionId}`, 'discussion-preview-highlighting'];
if (store.state.discussion.currentDiscussionId === discussionId) {
classes.push('discussion-preview-highlighting--selected');
}
return classes;
},
() => {
const startMarker = discussionMarkers[`${discussionId}:start`];
const endMarker = discussionMarkers[`${discussionId}:end`];
return startMarker && endMarker && {
start: this.getPreviewOffset(startMarker.offset),
end: this.getPreviewOffset(endMarker.offset),
};
}, { discussionId });
classGetter('preview', discussionId), offsetGetter(discussionId), { discussionId });
previewClassAppliers[discussionId] = classApplier;
});
Object.keys(oldPreviewClassAppliers).forEach((discussionId) => {

View File

@ -52,12 +52,14 @@ export default {
restoreScrollPosition() {
const scrollPosition = store.getters['contentState/current'].scrollPosition;
if (scrollPosition && this.sectionDescMeasuredList) {
const objectToScroll = this.getObjectToScroll();
const sectionDesc = this.sectionDescMeasuredList[scrollPosition.sectionIdx];
if (sectionDesc) {
const scrollTop = sectionDesc[objectToScroll.dimensionKey].startOffset +
(sectionDesc[objectToScroll.dimensionKey].height * scrollPosition.posInSection);
objectToScroll.elt.scrollTop = Math.floor(scrollTop);
const editorScrollTop = sectionDesc.editorDimension.startOffset +
(sectionDesc.editorDimension.height * scrollPosition.posInSection);
this.editorElt.parentNode.scrollTop = Math.floor(editorScrollTop);
const previewScrollTop = sectionDesc.previewDimension.startOffset +
(sectionDesc.previewDimension.height * scrollPosition.posInSection);
this.previewElt.parentNode.scrollTop = Math.floor(previewScrollTop);
}
}
},
@ -65,13 +67,17 @@ export default {
/**
* Get the offset in the preview corresponding to the offset of the markdown in the editor
*/
getPreviewOffset(editorOffset) {
if (!this.sectionDescWithDiffsList) {
getPreviewOffset(editorOffset, sectionDescList = this.sectionDescWithDiffsList) {
if (!sectionDescList) {
return null;
}
let offset = editorOffset;
let previewOffset = 0;
this.sectionDescWithDiffsList.some((sectionDesc) => {
sectionDescList.some((sectionDesc) => {
if (!sectionDesc.textToPreviewDiffs) {
previewOffset = null;
return true;
}
if (sectionDesc.section.text.length >= offset) {
previewOffset += diffMatchPatch.diff_xIndex(sectionDesc.textToPreviewDiffs, offset);
return true;
@ -86,13 +92,17 @@ export default {
/**
* Get the offset of the markdown in the editor corresponding to the offset in the preview
*/
getEditorOffset(previewOffset) {
if (!this.sectionDescWithDiffsList) {
getEditorOffset(previewOffset, sectionDescList = this.sectionDescWithDiffsList) {
if (!sectionDescList) {
return null;
}
let offset = previewOffset;
let editorOffset = 0;
this.sectionDescWithDiffsList.some((sectionDesc) => {
sectionDescList.some((sectionDesc) => {
if (!sectionDesc.textToPreviewDiffs) {
editorOffset = null;
return true;
}
if (sectionDesc.previewText.length >= offset) {
const previewToTextDiffs = sectionDesc.textToPreviewDiffs
.map(diff => [-diff[0], diff[1]]);

View File

@ -4,8 +4,7 @@ import editorSvc from '../../services/editorSvc';
import syncSvc from '../../services/syncSvc';
// Skip shortcuts if modal is open or editor is hidden
Mousetrap.prototype.stopCallback = () => store.getters['modal/config'] ||
!store.getters['content/current'].id || !store.getters['layout/styles'].showEditor;
Mousetrap.prototype.stopCallback = () => store.getters['modal/config'] || !store.getters['content/isCurrentEditable'];
const pagedownHandler = name => () => {
editorSvc.pagedownEditor.uiManager.doClick(name);

View File

@ -114,4 +114,18 @@ export default providerRegistry.register({
},
}));
},
listRevisions(token, fileId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
return googleHelper.getFileRevisions(token, syncData.id)
.then(revisions => revisions.map(revision => ({
id: revision.id,
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId,
created: new Date(revision.modifiedTime).getTime(),
})));
},
getRevisionContent(token, fileId, revisionId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
return googleHelper.downloadFileRevision(token, syncData.id, revisionId)
.then(content => JSON.parse(content));
},
});

View File

@ -337,6 +337,44 @@ export default {
url: `https://www.googleapis.com/drive/v3/files/${id}`,
})));
},
getFileRevisions(token, id) {
return this.refreshToken(token, driveAppDataScopes)
.then((refreshedToken) => {
const revisions = [];
const getPage = pageToken => this.request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}/revisions`,
params: {
pageToken,
pageSize: 1000,
fields: 'nextPageToken,revisions(id,modifiedTime,lastModifyingUser/permissionId,lastModifyingUser/displayName,lastModifyingUser/photoLink)',
},
}).then((res) => {
res.body.revisions.forEach((revision) => {
store.commit('userInfo/addItem', {
id: revision.lastModifyingUser.permissionId,
name: revision.lastModifyingUser.displayName,
imageUrl: revision.lastModifyingUser.photoLink,
});
revisions.push(revision);
});
if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken);
}
return revisions;
});
return getPage();
});
},
downloadFileRevision(token, fileId, revisionId) {
return this.refreshToken(token, driveAppDataScopes)
.then(refreshedToken => this.request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${fileId}/revisions/${revisionId}?alt=media`,
raw: true,
}).then(res => res.body));
},
uploadBlogger(
token, blogUrl, blogId, postId, title, content, labels, isDraft, published, isPage,
) {

View File

@ -1,4 +1,4 @@
import googleHelper from '../services/providers/helpers/googleHelper';
import googleHelper from './providers/helpers/googleHelper';
import store from '../store';
const promised = {};
@ -19,8 +19,10 @@ export default {
if (!store.state.offline) {
promised[userId] = true;
googleHelper.getUser(userId)
.catch(() => {
.catch((err) => {
if (err.status !== 404) {
promised[userId] = false;
}
});
}
}

View File

@ -1,23 +1,103 @@
import DiffMatchPatch from 'diff-match-patch';
import moduleTemplate from './moduleTemplate';
import empty from '../data/emptyContent';
import utils from '../services/utils';
import cledit from '../libs/cledit';
const diffMatchPatch = new DiffMatchPatch();
const module = moduleTemplate(empty);
module.state = {
...module.state,
revisionContent: null,
};
module.mutations = {
...module.mutations,
setRevisionContent: (state, value) => {
if (value) {
state.revisionContent = {
...empty(),
...value,
id: utils.uid(),
hash: Date.now(),
};
} else {
state.revisionContent = null;
}
},
};
module.getters = {
...module.getters,
current: (state, getters, rootState, rootGetters) =>
state.itemMap[`${rootGetters['file/current'].id}/content`] || empty(),
current: (state, getters, rootState, rootGetters) => {
if (state.revisionContent) {
return state.revisionContent;
}
return state.itemMap[`${rootGetters['file/current'].id}/content`] || empty();
},
currentProperties: (state, getters) => utils.computeProperties(getters.current.properties),
isCurrentEditable: (state, getters, rootState, rootGetters) =>
!state.revisionContent &&
getters.current.id &&
rootGetters['layout/styles'].showEditor,
};
module.actions = {
...module.actions,
patchCurrent({ getters, commit }, value) {
patchCurrent({ state, getters, commit }, value) {
const id = getters.current.id;
if (id && !state.revisionContent) {
commit('patchItem', {
...value,
id: getters.current.id,
id,
});
}
},
setRevisionContent({ state, rootGetters, commit }, value) {
const currentFile = rootGetters['file/current'];
const currentContent = state.itemMap[`${currentFile.id}/content`];
if (currentContent) {
const diffs = diffMatchPatch.diff_main(currentContent.text, value.text);
diffMatchPatch.diff_cleanupSemantic(diffs);
commit('setRevisionContent', {
text: diffs.map(([, text]) => text).join(''),
diffs,
originalText: value.text,
});
}
},
restoreRevision({ state, getters, commit, dispatch }) {
const revisionContent = state.revisionContent;
if (revisionContent) {
dispatch('modal/fileRestoration', null, { root: true })
.then(() => {
// Close revision
commit('setRevisionContent');
const currentContent = utils.deepCopy(getters.current);
if (currentContent) {
// Restore text and move discussions
const diffs = diffMatchPatch.diff_main(
currentContent.text, revisionContent.originalText);
diffMatchPatch.diff_cleanupSemantic(diffs);
Object.keys(currentContent.discussions).forEach((discussionId) => {
const discussion = currentContent.discussions[discussionId];
const adjustOffset = (offsetName) => {
const marker = new cledit.Marker(discussion[offsetName], offsetName === 'end');
marker.adjustOffset(diffs);
discussion[offsetName] = marker.offset;
};
adjustOffset('start');
adjustOffset('end');
});
dispatch('patchCurrent', {
...currentContent,
text: revisionContent.originalText,
});
}
});
}
},
};

View File

@ -70,6 +70,11 @@ export default {
content: '<p>Files in the trash are automatically deleted after 7 days of inactivity.</p>',
resolveText: 'Ok',
}),
fileRestoration: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to revert some changes. Are you sure?</p>',
resolveText: 'Yes, revert',
rejectText: 'No',
}),
reset: ({ dispatch }) => dispatch('open', {
content: '<p>This will clean your local files and settings. Are you sure?</p>',
resolveText: 'Yes, clean',
@ -83,13 +88,19 @@ export default {
}),
signInForSponsorship: ({ dispatch }) => dispatch('open', {
type: 'signInForSponsorship',
content: `<p>You have to sign in with <b>Google</b> to enable your sponsorship.</p>
content: `<p>You have to sign in with Google to enable your sponsorship.</p>
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
}),
signInForComment: ({ dispatch }) => dispatch('open', {
content: `<p>You have to sign in with <b>Google</b> to start commenting.</p>
content: `<p>You have to sign in with Google to start commenting.</p>
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
}),
signInForHistory: ({ dispatch }) => dispatch('open', {
content: `<p>You have to sign in with Google to enable revision history.</p>
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',

View File

@ -37,7 +37,8 @@ export default {
}
const newQueue = queue
.then(() => checkOffline())
.then(() => cb()
.then(() => Promise.resolve()
.then(() => cb())
.catch((err) => {
console.error(err); // eslint-disable-line no-console
checkOffline();