New discussion gutter
This commit is contained in:
parent
8767adc505
commit
fcff116d92
@ -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,
|
||||
|
@ -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'));
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
20
src/components/UserName.vue
Normal file
20
src/components/UserName.vue
Normal 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>
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
|
79
src/components/gutters/Comment.vue
Normal file
79
src/components/gutters/Comment.vue
Normal 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>
|
360
src/components/gutters/CommentList.vue
Normal file
360
src/components/gutters/CommentList.vue
Normal 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>
|
165
src/components/gutters/CurrentDiscussion.vue
Normal file
165
src/components/gutters/CurrentDiscussion.vue
Normal 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>
|
@ -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',
|
139
src/components/gutters/NewComment.vue
Normal file
139
src/components/gutters/NewComment.vue
Normal 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>
|
49
src/components/gutters/PreviewNewDiscussionButton.vue
Normal file
49
src/components/gutters/PreviewNewDiscussionButton.vue
Normal 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>
|
@ -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>
|
||||
|
45
src/components/gutters/StickyComment.vue
Normal file
45
src/components/gutters/StickyComment.vue
Normal 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>
|
@ -16,6 +16,8 @@
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
font-size: 17px;
|
||||
text-transform: none;
|
||||
|
||||
div div {
|
||||
text-decoration: underline;
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
@ -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),
|
||||
};
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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'];
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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
173
src/services/timeSvc.js
Normal 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
28
src/services/userSvc.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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', {
|
||||
|
Loading…
Reference in New Issue
Block a user