New discussion gutter

This commit is contained in:
benweet 2017-11-15 08:12:56 +00:00
parent 8767adc505
commit fcff116d92
38 changed files with 1446 additions and 232 deletions

View File

@ -14,6 +14,8 @@ import Layout from './Layout';
import Modal from './Modal';
import Notification from './Notification';
import SplashScreen from './SplashScreen';
import timeSvc from '../services/timeSvc';
import store from '../store';
// Global directives
Vue.directive('focus', {
@ -48,6 +50,11 @@ Vue.directive('title', {
},
});
// Global filters
Vue.filter('formatTime', time =>
// Access the minute counter for reactive refresh
timeSvc.format(time, store.state.minuteCounter));
export default {
components: {
Layout,

View File

@ -1,18 +1,22 @@
<template>
<div class="editor">
<pre class="editor__inner markdown-highlighting" :style="{padding: styles.editorPadding}" :class="{monospaced: computedSettings.editor.monospacedFontOnly}"></pre>
<editor-new-discussion-button-gutter></editor-new-discussion-button-gutter>
<div class="gutter" :style="{left: styles.editorGutterLeft + 'px'}">
<comment-list v-if="styles.editorGutterWidth"></comment-list>
<editor-new-discussion-button></editor-new-discussion-button>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import EditorNewDiscussionButtonGutter from './gutters/EditorNewDiscussionButtonGutter';
import CommentList from './gutters/CommentList';
import EditorNewDiscussionButton from './gutters/EditorNewDiscussionButton';
export default {
components: {
EditorNewDiscussionButtonGutter,
CommentList,
EditorNewDiscussionButton,
},
computed: {
...mapGetters('layout', [
@ -35,26 +39,28 @@ export default {
}
};
editorElt.addEventListener('mouseover', onDiscussionEvt(discussionId =>
editorElt.getElementsByClassName(`discussion-editor-highlighting-${discussionId}`)
.cl_each(elt => elt.classList.add('discussion-editor-highlighting--hover')),
));
editorElt.addEventListener('mouseout', onDiscussionEvt(discussionId =>
editorElt.getElementsByClassName(`discussion-editor-highlighting-${discussionId}`)
.cl_each(elt => elt.classList.remove('discussion-editor-highlighting--hover')),
));
const classToggler = toggle => (discussionId) => {
editorElt.getElementsByClassName(`discussion-editor-highlighting--${discussionId}`)
.cl_each(elt => elt.classList.toggle('discussion-editor-highlighting--hover', toggle));
document.getElementsByClassName(`comment--discussion-${discussionId}`)
.cl_each(elt => elt.classList.toggle('comment--hover', toggle));
};
editorElt.addEventListener('mouseover', onDiscussionEvt(classToggler(true)));
editorElt.addEventListener('mouseout', onDiscussionEvt(classToggler(false)));
editorElt.addEventListener('click', onDiscussionEvt((discussionId) => {
this.$store.commit('discussion/setCurrentDiscussionId', discussionId);
}));
this.$watch(
() => this.$store.state.discussion.currentDiscussionId,
(discussionId, oldDiscussionId) => {
if (oldDiscussionId) {
editorElt.querySelectorAll(`.discussion-editor-highlighting-${oldDiscussionId}`)
editorElt.querySelectorAll(`.discussion-editor-highlighting--${oldDiscussionId}`)
.cl_each(elt => elt.classList.remove('discussion-editor-highlighting--selected'));
}
if (discussionId) {
editorElt.querySelectorAll(`.discussion-editor-highlighting-${discussionId}`)
editorElt.querySelectorAll(`.discussion-editor-highlighting--${discussionId}`)
.cl_each(elt => elt.classList.add('discussion-editor-highlighting--selected'));
}
});

View File

@ -286,7 +286,7 @@ export default {
}
.find-replace__button {
font-size: 16px;
font-size: 15px;
padding: 0 8px;
line-height: 28px;
height: 28px;
@ -298,6 +298,7 @@ export default {
font-weight: 600;
letter-spacing: -0.025em;
color: rgba(0, 0, 0, 0.25);
text-transform: none;
&:active,
&:focus,
@ -330,15 +331,15 @@ export default {
position: absolute;
top: 5px;
right: 5px;
color: rgba(0, 0, 0, 0.25);
width: 25px;
height: 25px;
padding: 2px;
color: rgba(0, 0, 0, 0.5);
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.33);
color: rgba(0, 0, 0, 0.75);
}
}

View File

@ -1,38 +1,46 @@
<template>
<div class="layout">
<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' }">
<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>
</div>
<div class="layout__panel flex flex--column" :style="{ width: styles.innerWidth + 'px' }">
<div class="layout__panel layout__panel--navigation-bar" v-show="styles.showNavigationBar" :style="{ height: constants.navigationBarHeight + 'px' }">
<div class="layout__panel flex flex--column" :style="{width: styles.innerWidth + 'px'}">
<div class="layout__panel layout__panel--navigation-bar" v-show="styles.showNavigationBar" :style="{height: constants.navigationBarHeight + 'px'}">
<navigation-bar></navigation-bar>
</div>
<div class="layout__panel flex flex--row" :style="{ height: styles.innerHeight + 'px' }">
<div class="layout__panel layout__panel--editor" v-show="styles.showEditor" :style="{ width: (styles.editorWidth + styles.editorGutterWidth) + 'px', fontSize: styles.fontSize + 'px' }">
<div class="gutter" v-if="styles.editorGutterWidth" :style="{left: styles.editorGutterLeft + 'px'}">
<div class="gutter__background"></div>
<div class="layout__panel flex flex--row" :style="{height: styles.innerHeight + 'px'}">
<div class="layout__panel layout__panel--editor" v-show="styles.showEditor" :style="{width: (styles.editorWidth + styles.editorGutterWidth) + 'px', fontSize: styles.fontSize + 'px'}">
<div class="gutter" :style="{left: styles.editorGutterLeft + 'px'}">
<div class="gutter__background" v-if="styles.editorGutterWidth" :style="{width: styles.editorGutterWidth + 'px'}"></div>
</div>
<editor></editor>
<div v-if="showFindReplace" class="layout__panel layout__panel--find-replace">
<div class="gutter" :style="{left: styles.editorGutterLeft + 'px'}">
<sticky-comment v-if="styles.editorGutterWidth && stickyComment === 'top'"></sticky-comment>
<current-discussion v-if="styles.editorGutterWidth"></current-discussion>
</div>
<div class="layout__panel layout__panel--find-replace" v-if="showFindReplace">
<find-replace></find-replace>
</div>
</div>
<div class="layout__panel layout__panel--button-bar" v-show="styles.showEditor" :style="{ width: constants.buttonBarWidth + 'px' }">
<div class="layout__panel layout__panel--button-bar" v-show="styles.showEditor" :style="{width: constants.buttonBarWidth + 'px'}">
<button-bar></button-bar>
</div>
<div class="layout__panel layout__panel--preview" v-show="styles.showPreview" :style="{ width: (styles.previewWidth + styles.previewGutterWidth) + 'px', fontSize: styles.fontSize + 'px' }">
<div class="gutter" v-if="styles.previewGutterWidth" :style="{left: styles.previewGutterLeft + 'px'}">
<div class="gutter__background"></div>
<div class="layout__panel layout__panel--preview" v-show="styles.showPreview" :style="{width: (styles.previewWidth + styles.previewGutterWidth) + 'px', fontSize: styles.fontSize + 'px'}">
<div class="gutter" :style="{left: styles.previewGutterLeft + 'px'}">
<div class="gutter__background" v-if="styles.previewGutterWidth" :style="{width: styles.previewGutterWidth + 'px'}"></div>
</div>
<preview></preview>
<div class="gutter" :style="{left: styles.previewGutterLeft + 'px'}">
<sticky-comment v-if="styles.previewGutterWidth && stickyComment === 'top'"></sticky-comment>
<current-discussion v-if="styles.previewGutterWidth"></current-discussion>
</div>
</div>
<div class="layout__panel layout__panel--status-bar" v-show="styles.showStatusBar" :style="{ height: constants.statusBarHeight + 'px' }">
</div>
<div class="layout__panel layout__panel--status-bar" v-show="styles.showStatusBar" :style="{height: constants.statusBarHeight + 'px'}">
<status-bar></status-bar>
</div>
</div>
<div class="layout__panel layout__panel--side-bar" v-show="styles.showSideBar" :style="{ width: styles.layoutOverflow ? '100%' : constants.sideBarWidth + 'px' }">
<div class="layout__panel layout__panel--side-bar" v-show="styles.showSideBar" :style="{width: styles.layoutOverflow ? '100%' : constants.sideBarWidth + 'px'}">
<side-bar></side-bar>
</div>
</div>
@ -40,7 +48,7 @@
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import { mapState, mapGetters, mapActions } from 'vuex';
import NavigationBar from './NavigationBar';
import ButtonBar from './ButtonBar';
import StatusBar from './StatusBar';
@ -48,6 +56,8 @@ import Explorer from './Explorer';
import SideBar from './SideBar';
import Editor from './Editor';
import Preview from './Preview';
import StickyComment from './gutters/StickyComment';
import CurrentDiscussion from './gutters/CurrentDiscussion';
import FindReplace from './FindReplace';
import editorSvc from '../services/editorSvc';
@ -60,9 +70,14 @@ export default {
SideBar,
Editor,
Preview,
StickyComment,
CurrentDiscussion,
FindReplace,
},
computed: {
...mapState('discussion', [
'stickyComment',
]),
...mapGetters('layout', [
'constants',
'styles',
@ -130,13 +145,45 @@ export default {
background-color: #007acc;
}
$editor-background: #fff;
.layout__panel--editor {
background-color: #fff;
background-color: $editor-background;
.gutter__background,
.comment-list__current-discussion,
.sticky-comment,
.current-discussion {
background-color: mix(#000, $editor-background, 6.7%);
}
.comment-list__current-discussion,
.sticky-comment,
.current-discussion {
border-color: $editor-background;
}
}
$preview-background: #f3f3f3;
.layout__panel--preview,
.layout__panel--button-bar {
background-color: $preview-background;
}
.layout__panel--button-bar,
.layout__panel--preview {
background-color: #f3f3f3;
.gutter__background,
.comment-list__current-discussion,
.sticky-comment,
.current-discussion {
background-color: mix(#000, $preview-background, 6.7%);
}
.comment-list__current-discussion,
.sticky-comment,
.current-discussion {
border-color: $preview-background;
}
}
.layout__panel--explorer,
@ -153,12 +200,4 @@ export default {
height: auto;
border-top-right-radius: $border-radius-base;
}
.gutter__background {
position: absolute;
width: 9999px;
height: 100%;
background-color: rgba(0, 0, 0, 0.05);
right: 0;
}
</style>

View File

@ -184,7 +184,7 @@ export default {
margin: 0 auto;
width: 100%;
min-width: 320px;
max-width: 500px;
max-width: 480px;
}
.modal__inner-2 {
@ -248,10 +248,6 @@ export default {
.modal__button-bar {
margin-top: 1.75rem;
text-align: right;
.button {
margin-left: 5px;
}
}
.form-entry {

View File

@ -3,12 +3,15 @@
<div class="preview__inner-1" @click="onClick" @scroll="onScroll">
<div class="preview__inner-2" :style="{padding: styles.previewPadding}">
</div>
<preview-new-discussion-button-gutter></preview-new-discussion-button-gutter>
<div class="gutter" :style="{left: styles.previewGutterLeft + 'px'}">
<comment-list v-if="styles.previewGutterWidth"></comment-list>
<preview-new-discussion-button></preview-new-discussion-button>
</div>
<div v-if="!styles.showEditor" class="preview__button-bar">
<div class="preview__button" @click="toggleEditor(true)">
</div>
<div v-if="!styles.showEditor" class="preview__corner">
<button class="preview__button button" @click="toggleEditor(true)" v-title="'Edit file'">
<icon-pen></icon-pen>
</div>
</button>
</div>
</div>
</template>
@ -16,13 +19,15 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import PreviewNewDiscussionButtonGutter from './gutters/PreviewNewDiscussionButtonGutter';
import CommentList from './gutters/CommentList';
import PreviewNewDiscussionButton from './gutters/PreviewNewDiscussionButton';
const appUri = `${window.location.protocol}//${window.location.host}`;
export default {
components: {
PreviewNewDiscussionButtonGutter,
CommentList,
PreviewNewDiscussionButton,
},
data: () => ({
previewTop: true,
@ -64,26 +69,28 @@ export default {
}
};
previewElt.addEventListener('mouseover', onDiscussionEvt(discussionId =>
previewElt.getElementsByClassName(`discussion-preview-highlighting-${discussionId}`)
.cl_each(elt => elt.classList.add('discussion-preview-highlighting--hover')),
));
previewElt.addEventListener('mouseout', onDiscussionEvt(discussionId =>
previewElt.getElementsByClassName(`discussion-preview-highlighting-${discussionId}`)
.cl_each(elt => elt.classList.remove('discussion-preview-highlighting--hover')),
));
const classToggler = toggle => (discussionId) => {
previewElt.getElementsByClassName(`discussion-preview-highlighting--${discussionId}`)
.cl_each(elt => elt.classList.toggle('discussion-preview-highlighting--hover', toggle));
document.getElementsByClassName(`comment--discussion-${discussionId}`)
.cl_each(elt => elt.classList.toggle('comment--hover', toggle));
};
previewElt.addEventListener('mouseover', onDiscussionEvt(classToggler(true)));
previewElt.addEventListener('mouseout', onDiscussionEvt(classToggler(false)));
previewElt.addEventListener('click', onDiscussionEvt((discussionId) => {
this.$store.commit('discussion/setCurrentDiscussionId', discussionId);
}));
this.$watch(
() => this.$store.state.discussion.currentDiscussionId,
(discussionId, oldDiscussionId) => {
if (oldDiscussionId) {
previewElt.querySelectorAll(`.discussion-preview-highlighting-${oldDiscussionId}`)
previewElt.querySelectorAll(`.discussion-preview-highlighting--${oldDiscussionId}`)
.cl_each(elt => elt.classList.remove('discussion-preview-highlighting--selected'));
}
if (discussionId) {
previewElt.querySelectorAll(`.discussion-preview-highlighting-${discussionId}`)
previewElt.querySelectorAll(`.discussion-preview-highlighting--${discussionId}`)
.cl_each(elt => elt.classList.add('discussion-preview-highlighting--selected'));
}
});
@ -113,23 +120,37 @@ export default {
margin-top: 0;
}
.preview__button-bar {
$corner-size: 110px;
.preview__corner {
position: absolute;
top: 10px;
right: 26px;
top: 0;
right: 0;
&::before {
content: '';
position: absolute;
right: 0;
border-top: $corner-size solid rgba(0, 0, 0, 0.075);
border-left: $corner-size solid transparent;
pointer-events: none;
}
}
.preview__button {
cursor: pointer;
color: rgba(0, 0, 0, 0.25);
position: absolute;
top: 15px;
right: 15px;
width: 40px;
height: 40px;
padding: 5px;
border-radius: $border-radius-base;
color: rgba(0, 0, 0, 0.25);
&:active,
&:focus,
&:hover {
background-color: rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.75);
color: rgba(0, 0, 0, 0.33);
background-color: transparent;
}
}
</style>

View File

@ -4,26 +4,18 @@
</template>
<script>
import googleHelper from '../services/providers/helpers/googleHelper';
const promised = {};
import userSvc from '../services/userSvc';
export default {
props: ['userId'],
computed: {
url() {
const userInfo = this.$store.state.userInfo.itemMap[this.userId];
return userInfo && `url('${userInfo.imageUrl}')`;
return userInfo && userInfo.imageUrl && `url('${userInfo.imageUrl}')`;
},
},
created() {
if (!promised[this.userId] && !this.$store.state.offline) {
promised[this.userId] = true;
googleHelper.getUser(this.userId)
.catch(() => {
promised[this.userId] = false;
});
}
userSvc.getInfo(this.userId);
},
};
</script>

View File

@ -0,0 +1,20 @@
<template>
<span class="user-name">{{name}}</span>
</template>
<script>
import userSvc from '../services/userSvc';
export default {
props: ['userId'],
computed: {
name() {
const userInfo = this.$store.state.userInfo.itemMap[this.userId];
return userInfo ? userInfo.name : 'Someone';
},
},
created() {
userSvc.getInfo(this.userId);
},
};
</script>

View File

@ -23,7 +23,9 @@ export default class PreviewClassApplier {
this.lastEltCount = this.eltCollection.length;
this.restoreClass = () => {
if (!this.eltCollection.length || this.eltCollection.length !== this.lastEltCount) {
if (!editorSvc.sectionDescWithDiffsList) {
this.removeClass();
} else if (!this.eltCollection.length || this.eltCollection.length !== this.lastEltCount) {
this.removeClass();
this.applyClass();
}

View File

@ -71,9 +71,9 @@ textarea {
height: auto;
padding: 6px 12px;
margin-bottom: 0;
font-size: 18px;
font-weight: 400;
line-height: 1.4;
text-transform: uppercase;
overflow: hidden;
text-align: center;
white-space: nowrap;
@ -212,6 +212,12 @@ textarea {
height: 100%;
}
.gutter__background {
position: absolute;
height: 100%;
right: 0;
}
.new-discussion-button {
color: rgba(0, 0, 0, 0.33);
position: absolute;
@ -230,13 +236,13 @@ textarea {
.discussion-editor-highlighting,
.discussion-preview-highlighting {
background-color: mix(#fff, $selection-highlighting-color, 50%);
background-color: mix(#fff, $selection-highlighting-color, 70%);
padding: 0.25em 0;
}
.discussion-editor-highlighting--hover,
.discussion-preview-highlighting--hover {
background-color: mix(#fff, $selection-highlighting-color, 25%);
background-color: mix(#fff, $selection-highlighting-color, 50%);
* {
background-color: transparent;
@ -245,7 +251,7 @@ textarea {
.discussion-editor-highlighting--selected,
.discussion-preview-highlighting--selected {
background-color: mix(#fff, $selection-highlighting-color, 10%);
background-color: mix(#fff, $selection-highlighting-color, 20%);
* {
background-color: transparent;
@ -256,10 +262,6 @@ textarea {
cursor: pointer;
}
.discussion-preview-highlighting--selected {
cursor: auto;
}
.hidden-rendering-container {
position: absolute;
width: 500px;

View File

@ -0,0 +1,79 @@
<template>
<div class="comment">
<div class="comment__header flex flex--row flex--space-between flex--align-center">
<div class="comment__user flex flex--row flex--align-center">
<div class="comment__user-image">
<user-image :user-id="comment.sub"></user-image>
</div>
<button class="comment__remove-button button" v-title="'Remove comment'" @click="removeComment">
<icon-delete></icon-delete>
</button>
<user-name :user-id="comment.sub"></user-name>
</div>
<div class="comment__created">{{comment.created | formatTime}}</div>
</div>
<div class="comment__text">
<div class="comment__text-inner" v-html="text"></div>
</div>
<div class="comment__buttons flex flex--row flex--end" v-if="showReply">
<button class="comment__button button" @click="setIsCommenting(true)">Reply</button>
</div>
</div>
</template>
<script>
import { mapMutations } from 'vuex';
import UserImage from '../UserImage';
import UserName from '../UserName';
import editorSvc from '../../services/editorSvc';
import htmlSanitizer from '../../libs/htmlSanitizer';
export default {
components: {
UserImage,
UserName,
},
props: ['comment'],
computed: {
showReply() {
return this.comment === this.$store.getters['discussion/currentDiscussionLastComment'] &&
!this.$store.state.discussion.isCommenting;
},
text() {
return htmlSanitizer.sanitizeHtml(editorSvc.converter.render(this.comment.text));
},
},
methods: {
...mapMutations('discussion', [
'setIsCommenting',
]),
removeComment() {
this.$store.dispatch('modal/commentDeletion')
.then(
() => this.$store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment }),
() => {}); // Cancel
},
},
mounted() {
const isSticky = this.$el.parentNode.classList.contains('sticky-comment');
if (isSticky) {
const commentId = this.$store.getters['discussion/currentDiscussionLastCommentId'];
const scrollerElt = this.$el.querySelector('.comment__text-inner');
let scrollerMirrorElt;
const getScrollerMirrorElt = () => {
if (!scrollerMirrorElt) {
scrollerMirrorElt = document.querySelector(
`.comment-list .comment--${commentId} .comment__text-inner`);
}
return scrollerMirrorElt || { scrollTop: 0 };
};
scrollerElt.scrollTop = getScrollerMirrorElt().scrollTop;
scrollerElt.addEventListener('scroll', () => {
getScrollerMirrorElt().scrollTop = scrollerElt.scrollTop;
});
}
},
};
</script>

View File

@ -0,0 +1,360 @@
<template>
<div class="comment-list" :class="stickyComment && 'comment-list--' + stickyComment" :style="{width: constants.gutterWidth + 'px'}">
<comment v-for="(comment, discussionId) in currentFileDiscussionLastComments" :key="discussionId" v-if="comment.discussionId !== currentDiscussionId" :comment="comment" class="comment--last" :class="'comment--discussion-' + discussionId" :style="{top: tops[discussionId] + 'px'}" @click.native="setCurrentDiscussionId(discussionId)"></comment>
<div class="comment-list__current-discussion" :style="{top: tops.current + 'px'}">
<comment v-for="(comment, id) in currentDiscussionComments" :key="id" :comment="comment" :class="'comment--' + id"></comment>
<new-comment v-if="isCommenting"></new-comment>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import Comment from './Comment';
import NewComment from './NewComment';
import editorSvc from '../../services/editorSvc';
import utils from '../../services/utils';
export default {
components: {
Comment,
NewComment,
},
data: () => ({
tops: {},
}),
computed: {
...mapGetters('layout', [
'constants',
'styles',
]),
...mapState('discussion', [
'currentDiscussionId',
'isCommenting',
'newCommentText',
'stickyComment',
]),
...mapGetters('discussion', [
'newDiscussion',
'currentDiscussion',
'currentFileDiscussions',
'currentFileDiscussionLastComments',
'currentDiscussionComments',
'currentDiscussionLastCommentId',
]),
updateTopsTrigger() {
return utils.serializeObject([
this.styles,
this.currentFileDiscussionLastComments,
this.currentDiscussionComments,
this.currentDiscussionId,
this.isCommenting,
]);
},
updateStickyTrigger() {
return utils.serializeObject([
this.updateTopsTrigger,
this.newCommentText,
]);
},
},
methods: {
...mapMutations('discussion', [
'setCurrentDiscussionId',
]),
updateTops() {
const localSettings = this.$store.getters['data/localSettings'];
const minTop = -2;
let minCommentTop = minTop;
const getTop = (discussion, commentElt1, commentElt2, isCurrent) => {
const firstElt = commentElt1 || commentElt2;
const secondElt = commentElt1 && commentElt2;
const coordinates = localSettings.showEditor
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
let commentTop = minTop;
if (coordinates) {
commentTop = (coordinates.top + coordinates.height) - 80;
}
let top = commentTop;
if (isCurrent) {
top -= firstElt.offsetTop + 2; // 2 for top border
}
if (top < minTop) {
commentTop += minTop - top;
top = minTop;
}
if (commentTop < minCommentTop) {
top += minCommentTop - commentTop;
commentTop = minCommentTop;
}
minCommentTop = commentTop + firstElt.offsetHeight + 60;
if (secondElt) {
minCommentTop += secondElt.offsetHeight;
}
return top;
};
// Get the discussion top coordinates
const tops = {};
const discussions = this.currentFileDiscussions;
Object.keys(discussions)
.sort((id1, id2) => discussions[id1].end - discussions[id2].end)
.forEach((discussionId) => {
const discussion = this.currentFileDiscussions[discussionId];
if (discussion === this.currentDiscussion || discussion === this.newDiscussion) {
tops.current = getTop(
discussion,
this.currentDiscussionLastCommentId
&& this.$el.querySelector(`.comment--${this.currentDiscussionLastCommentId}`),
this.$el.querySelector('.comment--new'),
true);
} else {
tops[discussionId] = getTop(discussion,
this.$el.querySelector(`.comment--discussion-${discussionId}`));
}
});
this.tops = tops;
},
},
mounted() {
this.$watch(
() => this.updateTopsTrigger,
() => this.updateTops(),
{ immediate: true });
const localSettings = this.$store.getters['data/localSettings'];
this.scrollerElt = localSettings.showEditor
? editorSvc.editorElt.parentNode
: editorSvc.previewElt.parentNode;
this.updateSticky = () => {
const commitIfDifferent = (value) => {
if (this.$store.state.discussion.stickyComment !== value) {
this.$store.commit('discussion/setStickyComment', value);
}
};
let height = 0;
let offsetTop = this.tops.current;
const lastCommentElt = this.$el.querySelector(`.comment--${this.currentDiscussionLastCommentId}`);
if (lastCommentElt) {
height += lastCommentElt.clientHeight;
offsetTop += lastCommentElt.offsetTop;
}
const newCommentElt = this.$el.querySelector('.comment--new');
if (newCommentElt) {
height += newCommentElt.clientHeight;
}
const currentDiscussionElt = document.querySelector('.current-discussion__inner');
const minOffsetTop = this.scrollerElt.scrollTop + 10;
const maxOffsetTop = (this.scrollerElt.scrollTop + this.scrollerElt.clientHeight) - height
- currentDiscussionElt.clientHeight - 10;
if (offsetTop > maxOffsetTop || maxOffsetTop < minOffsetTop) {
commitIfDifferent('bottom');
} else if (offsetTop < minOffsetTop) {
commitIfDifferent('top');
} else {
commitIfDifferent(null);
}
};
this.scrollerElt.addEventListener('scroll', this.updateSticky);
this.$watch(
() => this.updateStickyTrigger,
() => this.updateSticky(),
{ immediate: true });
},
destroyed() {
this.scrollerElt.removeEventListener('scroll', this.updateSticky);
},
};
</script>
<style lang="scss">
@import '../common/variables.scss';
.comment-list {
position: absolute;
right: 0;
font-size: 15px;
}
.comment--last,
.comment-list__current-discussion {
position: absolute;
width: 100%;
padding-top: 10px;
}
.comment--last {
opacity: 0.33;
cursor: pointer;
* {
pointer-events: none;
}
&:hover,
&.comment--hover {
opacity: 0.67;
}
}
.comment-list__current-discussion {
border-top: 2px solid;
border-bottom: 2px solid;
.comment-list--top & {
border-bottom-color: transparent;
}
.comment-list--bottom & {
border-top-color: transparent;
}
}
.user-name {
font-weight: 600;
}
.comment {
padding: 5px 10px 10px;
}
.comment__header {
font-size: 0.75em;
padding-bottom: 0.25em;
}
.comment__user-image {
height: 20px;
width: 20px;
border-radius: $border-radius-base;
overflow: hidden;
margin-right: 5px;
.comment:hover & {
display: none;
.sticky-comment & {
display: block;
}
}
.comment--new:hover &,
.comment--last:hover & {
display: block;
}
}
.comment__remove-button {
height: 20px;
width: 20px;
padding: 1px;
color: rgba(0, 0, 0, 0.33);
margin-right: 5px;
display: none;
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.5);
}
.comment:hover & {
display: block;
.sticky-comment & {
display: none;
}
}
.comment--last:hover & {
display: none;
}
}
.comment__created {
opacity: 0.5;
}
.comment__buttons {
padding: 10px 5px 0;
}
.comment__button {
padding: 0 8px;
line-height: 28px;
height: 28px;
}
.comment__text {
position: relative;
&::before {
content: '';
position: absolute;
bottom: -8px;
right: 0;
border-top: 8px solid #fff;
border-left: 8px solid transparent;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
ul,
ol,
dl {
margin: 0.25em 0;
}
pre {
font-variant-ligatures: no-common-ligatures;
white-space: pre-wrap;
word-break: break-word;
word-wrap: break-word;
caret-color: #000;
}
img {
max-width: 100%;
}
.table-wrapper {
max-width: 100%;
overflow: auto;
}
}
.comment__text-inner {
min-height: 37px;
max-height: 200px;
overflow: auto;
padding: 1px 8px;
background-color: #fff;
border: 1px solid transparent;
border-radius: $border-radius-base;
border-bottom-right-radius: 0;
.markdown-highlighting {
padding: 5px 0;
margin: 0;
}
}
</style>

View File

@ -0,0 +1,165 @@
<template>
<div class="current-discussion" :style="{width: constants.gutterWidth + 'px'}">
<sticky-comment v-if="stickyComment === 'bottom'"></sticky-comment>
<div class="current-discussion__inner">
<div class="flex flex--row flex--space-between">
<div class="current-discussion__buttons flex flex--row flex--end">
<button class="current-discussion__button button" v-if="showNext" @click="goToDiscussion(previousDiscussionId)" v-title="'Previous discussion'">
<icon-arrow-left></icon-arrow-left>
</button>
<button class="current-discussion__button current-discussion__button--rotate button" v-if="showNext" @click="goToDiscussion(nextDiscussionId)" v-title="'Next discussion'">
<icon-arrow-left></icon-arrow-left>
</button>
</div>
<div class="current-discussion__buttons flex flex--row flex--end">
<button class="current-discussion__button current-discussion__button--remove button" v-if="showRemove" @click="removeDiscussion" v-title="'Remove discussion'">
<icon-delete></icon-delete>
</button>
<button class="current-discussion__button button" @click="setCurrentDiscussionId()" v-title="'Close discussion'">
<icon-close></icon-close>
</button>
</div>
</div>
<div class="current-discussion__text markdown-highlighting markdown-highlighting--inline">
<span @click="goToDiscussion()" v-html="text"></span>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import editorSvc from '../../services/editorSvc';
import animationSvc from '../../services/animationSvc';
import markdownConversionSvc from '../../services/markdownConversionSvc';
import StickyComment from './StickyComment';
export default {
components: {
StickyComment,
},
computed: {
...mapState('discussion', [
'stickyComment',
'currentDiscussionId',
]),
...mapGetters('discussion', [
'currentDiscussion',
'previousDiscussionId',
'nextDiscussionId',
'currentFileDiscussions',
]),
...mapGetters('layout', [
'constants',
]),
text() {
return markdownConversionSvc.highlight(this.currentDiscussion.text);
},
showNext() {
return this.nextDiscussionId && this.nextDiscussionId !== this.currentDiscussionId;
},
showRemove() {
return this.currentFileDiscussions[this.currentDiscussionId];
},
},
methods: {
...mapMutations('discussion', [
'setCurrentDiscussionId',
]),
goToDiscussion(discussionId = this.currentDiscussionId) {
this.setCurrentDiscussionId(discussionId);
const localSettings = this.$store.getters['data/localSettings'];
const discussion = this.currentFileDiscussions[discussionId];
const coordinates = localSettings.showEditor
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
if (!coordinates) {
this.$store.dispatch('notification/info', "Discussion can't be located in the file.");
} else {
const scrollerElt = localSettings.showEditor
? editorSvc.editorElt.parentNode
: editorSvc.previewElt.parentNode;
let scrollTop = coordinates.top - (scrollerElt.offsetHeight / 2);
const maxScrollTop = scrollerElt.scrollHeight - scrollerElt.offsetHeight;
if (scrollTop < 0) {
scrollTop = 0;
} else if (scrollTop > maxScrollTop) {
scrollTop = maxScrollTop;
}
animationSvc.animate(scrollerElt)
.scrollTop(scrollTop)
.duration(200)
.start();
}
},
removeDiscussion() {
this.$store.dispatch('modal/discussionDeletion')
.then(
() => this.$store.dispatch('discussion/cleanCurrentFile', {
filterDiscussion: this.currentDiscussion,
}),
() => {}); // Cancel
},
},
};
</script>
<style lang="scss">
@import '../common/variables.scss';
.current-discussion {
position: absolute;
right: 0;
bottom: 0;
border-top: 2px solid;
.sticky-comment {
position: relative;
}
}
.current-discussion__inner {
position: relative;
font-size: 16px;
background-color: $info-bg;
max-height: 130px; /* 3 lines max */
overflow: hidden;
}
.current-discussion__buttons {
padding: 4px 4px 0;
}
.current-discussion__button {
width: 30px;
height: 28px;
padding: 2px;
flex: none;
color: rgba(0, 0, 0, 0.5);
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.75);
}
}
.current-discussion__button--remove {
/* Make the trash a bit smaller */
padding: 3px;
}
.current-discussion__button--rotate {
transform: rotate(180deg);
}
.current-discussion__text {
padding: 10px;
span {
padding: 0.2em 0;
background-color: mix(#fff, $selection-highlighting-color, 10%);
cursor: pointer;
}
}
</style>

View File

@ -1,24 +1,17 @@
<template>
<div class="gutter gutter--new-discussion-button" :style="{left: styles.editorGutterLeft + 'px'}">
<a class="new-discussion-button" href="javascript:void(0)" v-if="coordinates" :style="{top: coordinates.top + 'px'}" v-title="'Start a discussion'" @mousedown.stop.prevent @click="createNewDiscussion(selection)">
<icon-message></icon-message>
</a>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import { mapActions } from 'vuex';
import editorSvc from '../../services/editorSvc';
export default {
data: () => ({
coordinates: null,
}),
computed: {
...mapGetters('layout', [
'styles',
]),
},
methods: {
...mapActions('discussion', [
'createNewDiscussion',

View File

@ -0,0 +1,139 @@
<template>
<div class="comment comment--new" @keyup.esc="setIsCommenting(false)">
<div class="comment__header flex flex--row flex--space-between flex--align-center">
<div class="comment__user flex flex--row flex--align-center">
<div class="comment__user-image">
<user-image :user-id="loginToken.sub"></user-image>
</div>
<span class="user-name">{{loginToken.name}}</span>
</div>
</div>
<div class="comment__text">
<div class="comment__text-inner">
<pre class="markdown-highlighting"></pre>
</div>
</div>
<div class="comment__buttons flex flex--row flex--end">
<button class="comment__button button" @click="setIsCommenting(false)">Cancel</button>
<button class="comment__button button" @click="addComment">Ok</button>
</div>
</div>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex';
import Prism from 'prismjs';
import UserImage from '../UserImage';
import cledit from '../../libs/cledit';
import editorSvc from '../../services/editorSvc';
import markdownConversionSvc from '../../services/markdownConversionSvc';
import utils from '../../services/utils';
export default {
components: {
UserImage,
},
computed: mapGetters('data', [
'loginToken',
]),
methods: {
...mapMutations('discussion', [
'setIsCommenting',
]),
addComment() {
const text = this.$store.state.discussion.newCommentText.trim();
if (text.length) {
if (text.length > 2000) {
this.$store.dispatch('notification/error', 'Comment is too long.');
} else {
// Create comment
const discussionId = this.$store.state.discussion.currentDiscussionId;
const comment = {
discussionId,
sub: this.loginToken.sub,
text,
created: Date.now(),
};
const patch = {
comments: {
...this.$store.getters['content/current'].comments,
[utils.uid()]: comment,
},
};
// Create discussion
if (discussionId === this.$store.state.discussion.newDiscussionId) {
patch.discussions = {
...this.$store.getters['content/current'].discussions,
[discussionId]: this.$store.getters['discussion/newDiscussion'],
};
}
this.$store.dispatch('content/patchCurrent', patch);
this.$store.commit('discussion/setNewCommentText');
this.$store.commit('discussion/setIsCommenting');
}
}
},
},
mounted() {
const preElt = this.$el.querySelector('pre.markdown-highlighting');
const scrollerElt = this.$el.querySelector('.comment__text-inner');
const clEditor = cledit(preElt, scrollerElt);
clEditor.init({
sectionHighlighter: section => Prism.highlight(
section.text, editorSvc.prismGrammars[section.data]),
sectionParser: text => markdownConversionSvc.parseSections(
editorSvc.converter, text).sections,
content: this.$store.state.discussion.newCommentText,
selectionStart: this.$store.state.discussion.newCommentSelection.start,
selectionEnd: this.$store.state.discussion.newCommentSelection.end,
getCursorFocusRatio: () => 0.2,
});
this.$nextTick(() => clEditor.focus());
// Save typed content and selection
clEditor.on('contentChanged', value =>
this.$store.commit('discussion/setNewCommentText', value));
clEditor.selectionMgr.on('selectionChanged', (start, end) =>
this.$store.commit('discussion/setNewCommentSelection', {
start, end,
}));
const isSticky = this.$el.parentNode.classList.contains('sticky-comment');
if (isSticky) {
let scrollerMirrorElt;
const getScrollerMirrorElt = () => {
if (!scrollerMirrorElt) {
scrollerMirrorElt = document.querySelector(
'.comment-list .comment--new .comment__text-inner');
}
return scrollerMirrorElt || { scrollTop: 0 };
};
scrollerElt.scrollTop = getScrollerMirrorElt().scrollTop;
scrollerElt.addEventListener('scroll', () => {
getScrollerMirrorElt().scrollTop = scrollerElt.scrollTop;
});
} else {
// Maintain the state with the sticky comment
this.$watch(
() => this.$store.state.discussion.stickyComment === null,
(isVisible) => {
clEditor.toggleEditable(isVisible);
if (isVisible) {
const text = this.$store.state.discussion.newCommentText;
clEditor.setContent(text);
const selection = this.$store.state.discussion.newCommentSelection;
clEditor.selectionMgr.setSelectionStartEnd(selection.start, selection.end);
clEditor.focus();
}
},
{ immediate: true },
);
this.$watch(
() => this.$store.state.discussion.newCommentText,
newCommentText => clEditor.setContent(newCommentText),
);
}
},
};
</script>

View File

@ -0,0 +1,49 @@
<template>
<a class="new-discussion-button" href="javascript:void(0)" v-if="coordinates" :style="{top: coordinates.top + 'px'}" v-title="'Start a discussion'" @mousedown.stop.prevent @click="createNewDiscussion(selection)">
<icon-message></icon-message>
</a>
</template>
<script>
import { mapActions } from 'vuex';
import editorSvc from '../../services/editorSvc';
export default {
data: () => ({
coordinates: null,
}),
methods: {
...mapActions('discussion', [
'createNewDiscussion',
]),
checkSelection() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
let offset;
if (editorSvc.previewSelectionRange) {
this.selection = editorSvc.getTrimmedSelection();
if (this.selection) {
const text = editorSvc.previewTextWithDiffsList;
offset = editorSvc.getPreviewOffset(this.selection.end);
while (offset && text[offset - 1] === '\n') {
offset -= 1;
}
}
}
this.coordinates = offset
? editorSvc.getPreviewOffsetCoordinates(offset)
: null;
}, 25);
},
},
mounted() {
this.$nextTick(() => {
editorSvc.$on('previewSelectionRange', () => this.checkSelection());
this.$watch(
() => this.$store.getters['layout/styles'].previewWidth,
() => this.checkSelection());
this.checkSelection();
});
},
};
</script>

View File

@ -1,71 +0,0 @@
<template>
<div class="gutter gutter--new-discussion-button" :style="{left: styles.previewGutterLeft + 'px'}">
<a class="new-discussion-button" href="javascript:void(0)" v-if="coordinates" :style="{top: coordinates.top + 'px'}" v-title="'Start a discussion'" @mousedown.stop.prevent @click="createNewDiscussion(selection)">
<icon-message></icon-message>
</a>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import cledit from '../../libs/cledit';
import editorSvc from '../../services/editorSvc';
export default {
data: () => ({
coordinates: null,
}),
computed: {
...mapGetters('layout', [
'styles',
]),
},
methods: {
...mapActions('discussion', [
'createNewDiscussion',
]),
checkSelection() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
let offset;
if (editorSvc.previewSelectionRange) {
this.selection = editorSvc.getTrimmedSelection();
if (this.selection) {
const text = editorSvc.previewTextWithDiffsList;
offset = editorSvc.getPreviewOffset(this.selection.end);
while (offset && text[offset - 1] === '\n') {
offset -= 1;
}
}
}
if (!offset) {
this.coordinates = null;
} else {
const start = cledit.Utils.findContainer(editorSvc.previewElt, offset - 1);
const end = cledit.Utils.findContainer(editorSvc.previewElt, offset);
const range = document.createRange();
range.setStart(start.container, start.offsetInContainer);
range.setEnd(end.container, end.offsetInContainer);
const rect = range.getBoundingClientRect();
const contentRect = editorSvc.previewElt.getBoundingClientRect();
this.coordinates = {
top: Math.round((rect.top - contentRect.top) + editorSvc.previewElt.scrollTop),
height: Math.round(rect.height),
left: Math.round((rect.right - contentRect.left) + editorSvc.previewElt.scrollLeft),
};
}
}, 25);
},
},
mounted() {
this.$nextTick(() => {
editorSvc.$on('previewSelectionRange', () => this.checkSelection());
this.$watch(
() => this.$store.getters['layout/styles'].previewWidth,
() => this.checkSelection());
this.checkSelection();
});
},
};
</script>

View File

@ -0,0 +1,45 @@
<template>
<div class="sticky-comment" :style="{width: constants.gutterWidth + 'px', top: top + 'px'}">
<comment v-if="currentDiscussionLastComment" :comment="currentDiscussionLastComment"></comment>
<new-comment v-if="isCommenting"></new-comment>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import Comment from './Comment';
import NewComment from './NewComment';
export default {
components: {
Comment,
NewComment,
},
data: () => ({
top: 0,
}),
computed: {
...mapGetters('layout', [
'constants',
]),
...mapState('discussion', [
'isCommenting',
]),
...mapGetters('discussion', [
'currentDiscussionLastComment',
]),
},
};
</script>
<style lang="scss">
@import '../common/variables.scss';
.sticky-comment {
position: absolute;
right: 0;
font-size: 15px;
padding-top: 10px;
border-bottom: 2px solid;
}
</style>

View File

@ -16,6 +16,8 @@
text-align: left;
padding: 10px;
height: auto;
font-size: 17px;
text-transform: none;
div div {
text-decoration: underline;

View File

@ -76,12 +76,14 @@ export default {
font-size: 2.3em;
margin: 0.75rem 0;
line-height: 1.2;
text-transform: none;
span {
display: inline-block;
font-size: 0.75rem;
opacity: 0.5;
white-space: normal;
line-height: 1.5;
}
.paypal-option__offer {

View File

@ -12,16 +12,16 @@
</select>
</div>
<div class="form-entry__actions flex flex--row flex--end">
<button class="form-entry__button button" @click="create">
<button class="form-entry__button button" @click="create" v-title="'New template'">
<icon-file-plus></icon-file-plus>
</button>
<button class="form-entry__button button" @click="copy">
<button class="form-entry__button button" @click="copy" v-title="'Copy template'">
<icon-file-multiple></icon-file-multiple>
</button>
<button v-if="!isReadOnly" class="form-entry__button button" @click="isEditing = true">
<button v-if="!isReadOnly" class="form-entry__button button" @click="isEditing = true" v-title="'Rename template'">
<icon-pen></icon-pen>
</button>
<button v-if="!isReadOnly" class="form-entry__button button" @click="remove">
<button v-if="!isReadOnly" class="form-entry__button button" @click="remove" v-title="'Remove template'">
<icon-delete></icon-delete>
</button>
</div>

View File

@ -24,17 +24,22 @@ export default {
'config',
]),
showSponsorButton() {
return !this.$store.getters.isSponsor && this.$store.getters['modal/config'].type !== 'sponsor';
const type = this.$store.getters['modal/config'].type;
return !this.$store.getters.isSponsor && type !== 'sponsor' && type !== 'signInForSponsorship';
},
},
methods: {
sponsor() {
Promise.resolve()
.then(() => !this.$store.getters['data/loginToken'] &&
this.$store.dispatch('modal/signInRequired') // If user has to sign in
this.$store.dispatch('modal/signInForSponsorship') // If user has to sign in
.then(() => googleHelper.signin())
.then(() => syncSvc.requestSync()))
.then(() => this.$store.dispatch('modal/open', 'sponsor'))
.then(() => {
if (!this.$store.getters.isSponsor) {
this.$store.dispatch('modal/open', 'sponsor');
}
})
.catch(() => { }); // Cancel
},
},
@ -65,7 +70,7 @@ export default {
color: darken($error-color, 10%);
background-color: transparentize($error-color, 0.85);
border-radius: $border-radius-base;
padding: 1em;
padding: 1em 1.5em;
margin-bottom: 1.2em;
}
</style>

View File

@ -168,8 +168,10 @@ function Highlighter(editor) {
}
this.addTrailingNode()
self.$trigger('highlighted')
if (editor.selectionMgr.hasFocus()) {
editor.selectionMgr.restoreSelection()
editor.selectionMgr.updateCursorCoordinates()
}
}.cl_bind(this))
return sectionList

View File

@ -11,6 +11,7 @@ import sectionUtils from './sectionUtils';
import extensionSvc from './extensionSvc';
import editorSvcDiscussions from './editorSvcDiscussions';
import editorSvcUtils from './editorSvcUtils';
import utils from './utils';
import store from '../store';
const debounce = cledit.Utils.debounce;
@ -77,6 +78,8 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
* Initialize the cledit editor with markdown-it section parser and Prism highlighter
*/
initClEditor() {
this.sectionDescMeasuredList = null;
this.sectionDescWithDiffsList = null;
const options = {
sectionHighlighter: section => Prism.highlight(
section.text, this.prismGrammars[section.data]),
@ -509,7 +512,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
if (content.properties !== lastProperties) {
lastProperties = content.properties;
const options = extensionSvc.getOptions(store.getters['content/currentProperties']);
if (JSON.stringify(options) !== JSON.stringify(this.options)) {
if (utils.serializeObject(options) !== utils.serializeObject(this.options)) {
this.options = options;
this.initPrism();
this.initConverter();
@ -532,7 +535,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
immediate: true,
});
store.watch(() => store.getters['layout/styles'],
store.watch(() => utils.serializeObject(store.getters['layout/styles']),
() => this.measureSectionDimensions(false, true));
this.initHighlighters();

View File

@ -201,7 +201,7 @@ export default {
Object.keys(discussions).forEach((discussionId) => {
const classApplier = oldEditorClassAppliers[discussionId] || new EditorClassApplier(
() => {
const classes = [`discussion-editor-highlighting-${discussionId}`, 'discussion-editor-highlighting'];
const classes = [`discussion-editor-highlighting--${discussionId}`, 'discussion-editor-highlighting'];
if (store.state.discussion.currentDiscussionId === discussionId) {
classes.push('discussion-editor-highlighting--selected');
}
@ -210,7 +210,7 @@ export default {
() => {
const startMarker = discussionMarkers[`${discussionId}:start`];
const endMarker = discussionMarkers[`${discussionId}:end`];
return {
return startMarker && endMarker && {
start: startMarker.offset,
end: endMarker.offset,
};
@ -229,7 +229,7 @@ export default {
Object.keys(discussions).forEach((discussionId) => {
const classApplier = oldPreviewClassAppliers[discussionId] || new PreviewClassApplier(
() => {
const classes = [`discussion-preview-highlighting-${discussionId}`, 'discussion-preview-highlighting'];
const classes = [`discussion-preview-highlighting--${discussionId}`, 'discussion-preview-highlighting'];
if (store.state.discussion.currentDiscussionId === discussionId) {
classes.push('discussion-preview-highlighting--selected');
}
@ -238,7 +238,7 @@ export default {
() => {
const startMarker = discussionMarkers[`${discussionId}:start`];
const endMarker = discussionMarkers[`${discussionId}:end`];
return {
return startMarker && endMarker && {
start: this.getPreviewOffset(startMarker.offset),
end: this.getPreviewOffset(endMarker.offset),
};

View File

@ -1,4 +1,5 @@
import DiffMatchPatch from 'diff-match-patch';
import cledit from '../libs/cledit';
import animationSvc from './animationSvc';
import store from '../store';
@ -105,6 +106,24 @@ export default {
return editorOffset;
},
/**
* Get the coordinates of an offset in the preview
*/
getPreviewOffsetCoordinates(offset) {
const start = cledit.Utils.findContainer(this.previewElt, offset && offset - 1);
const end = cledit.Utils.findContainer(this.previewElt, offset || offset + 1);
const range = document.createRange();
range.setStart(start.container, start.offsetInContainer);
range.setEnd(end.container, end.offsetInContainer);
const rect = range.getBoundingClientRect();
const contentRect = this.previewElt.getBoundingClientRect();
return {
top: Math.round((rect.top - contentRect.top) + this.previewElt.scrollTop),
height: Math.round(rect.height),
left: Math.round((rect.right - contentRect.left) + this.previewElt.scrollLeft),
};
},
/**
* Scroll the preview (or the editor if preview is hidden) to the specified anchor
*/

View File

@ -380,6 +380,7 @@ localDbSvc.sync()
// Wait for the next watch tick
return null;
}
return Promise.resolve()
// Load contentState from DB
.then(() => localDbSvc.loadContentState(currentFile.id))
@ -388,8 +389,15 @@ localDbSvc.sync()
// Load content from DB
.then(() => localDbSvc.loadItem(`${currentFile.id}/content`))
.then(
// Success, set last opened file
() => store.dispatch('data/setLastOpenedId', currentFile.id),
() => {
// Set last opened file
store.dispatch('data/setLastOpenedId', currentFile.id);
// Cancel new discussion
store.commit('discussion/setCurrentDiscussionId');
// Open the gutter if file contains discussions
store.commit('discussion/setCurrentDiscussionId',
store.getters['discussion/nextDiscussionId']);
},
(err) => {
// Failure (content is not available), go back to previous file
const lastOpenedFile = store.getters['file/lastOpened'];

View File

@ -88,6 +88,7 @@ export default {
.then(() => {
const data = utils.parseQueryParams(`${event.data}`.slice(1));
if (data.error || data.state !== state) {
console.error(data); // eslint-disable-line no-console
reject('Could not get required authorization.');
} else {
resolve({

View File

@ -166,10 +166,10 @@ editorSvc.$on('previewText', () => {
});
store.watch(
() => store.getters['layout/styles'],
(styles) => {
isScrollEditor = styles.showEditor;
isScrollPreview = !styles.showEditor;
() => store.getters['layout/styles'].showEditor,
(showEditor) => {
isScrollEditor = showEditor;
isScrollPreview = !showEditor;
skipAnimation = true;
});

View File

@ -116,6 +116,7 @@ export default {
.then((res) => {
store.commit('userInfo/addItem', {
id: res.body.id,
name: res.body.displayName,
imageUrl: res.body.image.url,
});
return res.body;

View File

@ -155,11 +155,19 @@ function syncFile(fileId, syncContext = new SyncContext()) {
};
const isWelcomeFile = () => {
if (store.getters['data/syncDataByItemId'][`${fileId}/content`]) {
// If file has already been synced, keep on syncing
return false;
}
const file = getFile();
const content = getContent();
if (!file || !content) {
return false;
}
const welcomeFileHashes = store.getters['data/localSettings'].welcomeFileHashes;
const hash = content ? utils.hash(content.text) : 0;
return file.name === 'Welcome file' && welcomeFileHashes[hash];
const hash = utils.hash(content.text);
const hasDiscussions = Object.keys(content.discussions).length;
return file.name === 'Welcome file' && welcomeFileHashes[hash] && !hasDiscussions;
};
const syncOneContentLocation = () => {
@ -180,8 +188,8 @@ function syncFile(fileId, syncContext = new SyncContext()) {
// Skip welcome file if not synchronized explicitly
(syncLocations.length > 1 || !isWelcomeFile())
) {
const token = provider.getToken(syncLocation);
result = provider && token && store.dispatch('queue/doWithLocation', {
const token = provider && provider.getToken(syncLocation);
result = token && store.dispatch('queue/doWithLocation', {
location: syncLocation,
promise: provider.downloadContent(token, syncLocation)
.then((serverContent = null) => {

173
src/services/timeSvc.js Normal file
View File

@ -0,0 +1,173 @@
// Credit: https://github.com/github/time-elements/
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const pad = num => `0${num}`.slice(-2);
function strftime(time, formatString) {
const day = time.getDay();
const date = time.getDate();
const month = time.getMonth();
const year = time.getFullYear();
const hour = time.getHours();
const minute = time.getMinutes();
const second = time.getSeconds();
return formatString.replace(/%([%aAbBcdeHIlmMpPSwyYZz])/g, (_arg) => {
let match;
const modifier = _arg[1];
switch (modifier) {
case '%':
default:
return '%';
case 'a':
return weekdays[day].slice(0, 3);
case 'A':
return weekdays[day];
case 'b':
return months[month].slice(0, 3);
case 'B':
return months[month];
case 'c':
return time.toString();
case 'd':
return pad(date);
case 'e':
return date;
case 'H':
return pad(hour);
case 'I':
return pad(strftime(time, '%l'));
case 'l':
return hour === 0 || hour === 12 ? 12 : (hour + 12) % 12;
case 'm':
return pad(month + 1);
case 'M':
return pad(minute);
case 'p':
return hour > 11 ? 'PM' : 'AM';
case 'P':
return hour > 11 ? 'pm' : 'am';
case 'S':
return pad(second);
case 'w':
return day;
case 'y':
return pad(year % 100);
case 'Y':
return year;
case 'Z':
match = time.toString().match(/\((\w+)\)$/);
return match ? match[1] : '';
case 'z':
match = time.toString().match(/\w([+-]\d\d\d\d) /);
return match ? match[1] : '';
}
});
}
let dayFirst = null;
let yearSeparator = null;
// Private: Determine if the day should be formatted before the month name in
// the user's current locale. For example, `9 Jun` for en-GB and `Jun 9`
// for en-US.
//
// Returns true if the day appears before the month.
function isDayFirst() {
if (dayFirst !== null) {
return dayFirst;
}
if (!('Intl' in window)) {
return false;
}
const options = { day: 'numeric', month: 'short' };
const formatter = new window.Intl.DateTimeFormat(undefined, options);
const output = formatter.format(new Date(0));
dayFirst = !!output.match(/^\d/);
return dayFirst;
}
// Private: Determine if the year should be separated from the month and day
// with a comma. For example, `9 Jun 2014` in en-GB and `Jun 9, 2014` in en-US.
//
// Returns true if the date needs a separator.
function isYearSeparator() {
if (yearSeparator !== null) {
return yearSeparator;
}
if (!('Intl' in window)) {
return true;
}
const options = { day: 'numeric', month: 'short', year: 'numeric' };
const formatter = new window.Intl.DateTimeFormat(undefined, options);
const output = formatter.format(new Date(0));
yearSeparator = !!output.match(/\d,/);
return yearSeparator;
}
// Private: Determine if the date occurs in the same year as today's date.
//
// date - The Date to test.
//
// Returns true if it's this year.
function isThisYear(date) {
const now = new Date();
return now.getUTCFullYear() === date.getUTCFullYear();
}
class RelativeTime {
constructor(date) {
this.date = date;
}
toString() {
const ago = this.timeElapsed();
return ago || `on ${this.formatDate()}`;
}
timeElapsed() {
const ms = new Date().getTime() - this.date.getTime();
const sec = Math.round(ms / 1000);
const min = Math.round(sec / 60);
const hr = Math.round(min / 60);
const day = Math.round(hr / 24);
if (ms < 0) {
return 'just now';
} else if (sec < 45) {
return 'just now';
} else if (sec < 90) {
return 'a minute ago';
} else if (min < 45) {
return `${min} minutes ago`;
} else if (min < 90) {
return 'an hour ago';
} else if (hr < 24) {
return `${hr} hours ago`;
} else if (hr < 36) {
return 'a day ago';
} else if (day < 30) {
return `${day} days ago`;
}
return null;
}
formatDate() {
let format = isDayFirst() ? '%e %b' : '%b %e';
if (!isThisYear(this.date)) {
format += isYearSeparator() ? ', %Y' : ' %Y';
}
return strftime(this.date, format);
}
}
export default {
format(time) {
return time && new RelativeTime(new Date(time)).toString();
},
};

28
src/services/userSvc.js Normal file
View File

@ -0,0 +1,28 @@
import googleHelper from '../services/providers/helpers/googleHelper';
import store from '../store';
const promised = {};
export default {
getInfo(userId) {
if (!promised[userId]) {
// Try to find a token with this sub
const token = store.getters['data/googleTokens'][userId];
if (token) {
store.commit('userInfo/addItem', {
id: userId,
name: token.name,
});
}
// Get user info from Google
if (!store.state.offline) {
promised[userId] = true;
googleHelper.getUser(userId)
.catch(() => {
promised[userId] = false;
});
}
}
},
};

View File

@ -64,14 +64,16 @@ module.actions.toggleSidePreview = localSettingsToggler('showSidePreview');
module.actions.toggleStatusBar = localSettingsToggler('showStatusBar');
module.actions.toggleScrollSync = localSettingsToggler('scrollSync');
module.actions.toggleFocusMode = localSettingsToggler('focusMode');
const notEnoughSpace = (rootGetters) => {
const constants = rootGetters['layout/constants'];
const notEnoughSpace = (getters) => {
const constants = getters['layout/constants'];
const showGutter = getters['discussion/currentDiscussion'];
return document.body.clientWidth < constants.editorMinWidth +
constants.explorerWidth +
constants.sideBarWidth +
constants.buttonBarWidth;
constants.buttonBarWidth +
(showGutter ? constants.gutterWidth : 0);
};
module.actions.toggleSideBar = ({ getters, dispatch, rootGetters }, value) => {
module.actions.toggleSideBar = ({ commit, getters, dispatch, rootGetters }, value) => {
// Reset side bar
dispatch('setSideBarPanel');
// Close explorer if not enough space
@ -83,7 +85,7 @@ module.actions.toggleSideBar = ({ getters, dispatch, rootGetters }, value) => {
}
dispatch('patchLocalSettings', patch);
};
module.actions.toggleExplorer = ({ getters, dispatch, rootGetters }, value) => {
module.actions.toggleExplorer = ({ commit, getters, dispatch, rootGetters }, value) => {
// Close side bar if not enough space
const patch = {
showExplorer: value === undefined ? !getters.localSettings.showExplorer : value,

View File

@ -1,51 +1,148 @@
import utils from '../services/utils';
const idShifter = offset => (state, getters) => {
const ids = Object.keys(getters.currentFileDiscussions);
const idx = ids.indexOf(state.currentDiscussionId) + offset + ids.length;
return ids[idx % ids.length];
};
export default {
namespaced: true,
state: {
currentDiscussionId: null,
newDiscussion: null,
newDiscussionId: '',
newDiscussionId: null,
newCommentText: '',
newCommentSelection: { start: 0, end: 0 },
isCommenting: false,
stickyComment: null,
},
mutations: {
setCurrentDiscussionId: (state, value) => {
if (state.currentDiscussionId !== value) {
state.currentDiscussionId = value;
state.isCommenting = false;
}
},
setNewDiscussion: (state, value) => {
state.newDiscussion = value;
state.newDiscussionId = utils.uid();
state.currentDiscussionId = state.newDiscussionId;
state.isCommenting = true;
},
patchNewDiscussion: (state, value) => {
Object.assign(state.newDiscussion, value);
},
setNewCommentText: (state, value) => {
state.newCommentText = value || '';
},
setNewCommentSelection: (state, value) => {
state.newCommentSelection = value;
},
setIsCommenting: (state, value) => {
state.isCommenting = value;
if (!value) {
state.newDiscussionId = null;
}
},
setStickyComment: (state, value) => {
state.stickyComment = value;
},
},
getters: {
newDiscussion: state =>
state.currentDiscussionId === state.newDiscussionId && state.newDiscussion,
currentFileDiscussionLastComments: (state, getters, rootState, rootGetters) => {
const discussions = rootGetters['content/current'].discussions;
const comments = rootGetters['content/current'].comments;
const discussionLastComments = {};
Object.keys(comments).forEach((commentId) => {
const comment = comments[commentId];
if (discussions[comment.discussionId]) {
const lastComment = discussionLastComments[comment.discussionId];
if (!lastComment || lastComment.created < comment.created) {
discussionLastComments[comment.discussionId] = comment;
}
}
});
return discussionLastComments;
},
currentFileDiscussions: (state, getters, rootState, rootGetters) => {
const currentContent = rootGetters['content/current'];
const currentDiscussions = {
...currentContent.discussions,
};
const currentFileDiscussions = {};
const newDiscussion = getters.newDiscussion;
if (newDiscussion) {
currentDiscussions[state.newDiscussionId] = newDiscussion;
currentFileDiscussions[state.newDiscussionId] = newDiscussion;
}
return currentDiscussions;
const discussions = rootGetters['content/current'].discussions;
const discussionLastComments = getters.currentFileDiscussionLastComments;
Object.keys(discussionLastComments)
.sort((id1, id2) =>
discussionLastComments[id2].created - discussionLastComments[id1].created)
.forEach((discussionId) => {
currentFileDiscussions[discussionId] = discussions[discussionId];
});
return currentFileDiscussions;
},
currentDiscussion: (state, getters) =>
getters.currentFileDiscussions[state.currentDiscussionId],
previousDiscussionId: idShifter(-1),
nextDiscussionId: idShifter(1),
currentDiscussionComments: (state, getters, rootState, rootGetters) => {
const comments = {};
if (getters.currentDiscussion) {
const contentComments = rootGetters['content/current'].comments;
Object.keys(contentComments)
.filter(commentId =>
contentComments[commentId].discussionId === state.currentDiscussionId)
.sort((id1, id2) =>
contentComments[id1].created - contentComments[id2].created)
.forEach((commentId) => {
comments[commentId] = contentComments[commentId];
});
}
return comments;
},
currentDiscussionLastCommentId: (state, getters) =>
Object.keys(getters.currentDiscussionComments).pop(),
currentDiscussionLastComment: (state, getters) =>
getters.currentDiscussionComments[getters.currentDiscussionLastCommentId],
},
actions: {
createNewDiscussion({ commit, rootGetters }, selection) {
if (selection) {
let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim();
if (text.length > 250) {
text = `${text.slice(0, 249).trim()}`;
const maxLength = 80;
if (text.length > maxLength) {
text = `${text.slice(0, maxLength - 1).trim()}`;
}
commit('setNewDiscussion', { ...selection, text });
}
},
cleanCurrentFile(
{ getters, rootGetters, commit, dispatch },
{ filterComment, filterDiscussion } = {},
) {
const discussions = rootGetters['content/current'].discussions;
const comments = rootGetters['content/current'].comments;
const patch = {
discussions: {},
comments: {},
};
Object.keys(comments).forEach((commentId) => {
const comment = comments[commentId];
const discussion = discussions[comment.discussionId];
if (discussion && comment !== filterComment && discussion !== filterDiscussion) {
patch.discussions[comment.discussionId] = discussion;
patch.comments[commentId] = comment;
}
});
const nextDiscussionId = getters.nextDiscussionId;
dispatch('content/patchCurrent', patch, { root: true });
if (!getters.currentDiscussion) {
// Keep the gutter open
commit('setCurrentDiscussionId', nextDiscussionId);
}
},
},
};

View File

@ -46,6 +46,7 @@ const store = new Vuex.Store({
ready: false,
offline: false,
lastOfflineCheck: 0,
minuteCounter: 0,
monetizeSponsor: false,
},
getters: {
@ -69,6 +70,9 @@ const store = new Vuex.Store({
updateLastOfflineCheck: (state) => {
state.lastOfflineCheck = Date.now();
},
updateMinuteCounter: (state) => {
state.minuteCounter += 1;
},
setMonetizeSponsor: (state, value) => {
state.monetizeSponsor = value;
},
@ -121,4 +125,8 @@ const store = new Vuex.Store({
plugins: debug ? [createLogger()] : [],
});
setInterval(() => {
store.commit('updateMinuteCounter');
}, 60 * 1000);
export default store;

View File

@ -1,7 +1,5 @@
const minPadding = 20;
const previewButtonWidth = 55;
const editorTopPadding = 10;
const gutterWidth = 250;
const navigationBarEditButtonsWidth = (36 * 14) + 8; // 14 buttons + 1 spacer
const navigationBarLeftButtonWidth = 38 + 4 + 15;
const navigationBarRightButtonWidth = 38 + 8;
@ -15,6 +13,7 @@ const minTitleMaxWidth = 200;
const constants = {
editorMinWidth: 320,
explorerWidth: 250,
gutterWidth: 250,
sideBarWidth: 280,
navigationBarHeight: 44,
buttonBarWidth: 26,
@ -48,9 +47,9 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
}
let doublePanelWidth = styles.innerWidth - constants.buttonBarWidth;
const showGutter = getters['discussion/currentDiscussion'];
const showGutter = !!getters['discussion/currentDiscussion'];
if (showGutter) {
doublePanelWidth -= gutterWidth;
doublePanelWidth -= constants.gutterWidth;
}
if (doublePanelWidth < constants.editorMinWidth) {
doublePanelWidth = constants.editorMinWidth;
@ -87,17 +86,17 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
const panelWidth = Math.floor(doublePanelWidth / 2);
styles.previewWidth = styles.showSidePreview ?
panelWidth :
doublePanelWidth + constants.buttonBarWidth;
let previewRightPadding = Math.max(
doublePanelWidth;
const previewRightPadding = Math.max(
Math.floor((styles.previewWidth - styles.textWidth) / 2), minPadding);
if (!styles.showSidePreview) {
styles.previewWidth += constants.buttonBarWidth;
}
styles.previewGutterWidth = showGutter && !localSettings.showEditor
? gutterWidth
? constants.gutterWidth
: 0;
const previewLeftPadding = previewRightPadding + styles.previewGutterWidth;
styles.previewGutterLeft = previewLeftPadding - minPadding;
if (!styles.showEditor && previewRightPadding < previewButtonWidth) {
previewRightPadding = previewButtonWidth;
}
styles.previewPadding = `${editorTopPadding}px ${previewRightPadding}px ${bottomPadding}px ${previewLeftPadding}px`;
styles.editorWidth = styles.showSidePreview ?
panelWidth :
@ -105,7 +104,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
const editorRightPadding = Math.max(
Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding);
styles.editorGutterWidth = showGutter && localSettings.showEditor
? gutterWidth
? constants.gutterWidth
: 0;
const editorLeftPadding = editorRightPadding + styles.editorGutterWidth;
styles.editorGutterLeft = editorLeftPadding - minPadding;

View File

@ -56,6 +56,16 @@ export default {
resolveText: 'Yes, delete',
rejectText: 'No',
}),
discussionDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to delete a discussion. Are you sure?</p>',
resolveText: 'Yes, delete',
rejectText: 'No',
}),
commentDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to delete a comment. Are you sure?</p>',
resolveText: 'Yes, delete',
rejectText: 'No',
}),
trashDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>Files in the trash are automatically deleted after 7 days of inactivity.</p>',
resolveText: 'Ok',
@ -67,14 +77,15 @@ export default {
}),
providerRedirection: ({ dispatch }, { providerName, onResolve }) => dispatch('open', {
content: `<p>You are about to navigate to the <b>${providerName}</b> authorization page.</p>`,
resolveText: 'Ok, go on!',
resolveText: 'Ok, go on',
rejectText: 'Cancel',
onResolve,
}),
signInRequired: ({ dispatch }) => dispatch('open', {
content: `<p>We have to sign you in with <b>Google</b> in order to activate your sponsorship.</p>
<div class="modal__info"><b>Note:</b> This will backup and sync all your files and settings.</div>`,
resolveText: 'Ok, sign in!',
signInForSponsorship: ({ dispatch }) => dispatch('open', {
type: 'signInForSponsorship',
content: `<p>You have to sign in with <b>Google</b> 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',
}),
sponsorOnly: ({ dispatch }) => dispatch('open', {