Explorer context menu

This commit is contained in:
Benoit Schweblin 2018-03-09 21:18:20 +00:00
parent 4b5cd7aef7
commit b06a6a37eb
10 changed files with 292 additions and 83 deletions

View File

@ -4,6 +4,7 @@
<layout v-else></layout>
<modal v-if="showModal"></modal>
<notification></notification>
<context-menu></context-menu>
</div>
</template>
@ -12,6 +13,7 @@ import Vue from 'vue';
import Layout from './Layout';
import Modal from './Modal';
import Notification from './Notification';
import ContextMenu from './ContextMenu';
import SplashScreen from './SplashScreen';
import syncSvc from '../services/syncSvc';
import networkSvc from '../services/networkSvc';
@ -71,6 +73,7 @@ export default {
Layout,
Modal,
Notification,
ContextMenu,
SplashScreen,
},
data: () => ({

View File

@ -0,0 +1,80 @@
<template>
<div class="context-menu" v-if="items.length" @click="close()" @contextmenu.prevent="close()">
<div class="context-menu__inner flex flex--column" :style="{ left: coordinates.left + 'px', top: coordinates.top + 'px' }" @click.stop>
<div v-for="(item, idx) in items" :key="idx">
<div class="context-menu__separator" v-if="item.type === 'separator'"></div>
<div class="context-menu__item context-menu__item--disabled" v-else-if="item.disabled">{{item.name}}</div>
<a class="context-menu__item" href="javascript:void(0)" v-else @click.stop="close(item)">{{item.name}}</a>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
computed: {
...mapState('contextMenu', [
'coordinates',
'items',
'resolve',
]),
},
methods: {
close(item) {
if (item) {
this.resolve(item);
}
this.$store.dispatch('contextMenu/close');
},
},
};
</script>
<style lang="scss">
.context-menu {
position: absolute;
width: 100%;
height: 100%;
font-size: 14px;
line-height: 18px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
user-select: none;
}
$padding: 5px;
.context-menu__inner {
position: absolute;
background-color: #ebebeb;
border-radius: $padding;
padding: $padding 0;
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.16), 0 3px 10px 1px rgba(0, 0, 0, 0.12);
}
.context-menu__item {
display: block;
color: #333;
text-decoration: none;
padding: 0 25px;
}
a.context-menu__item {
&:active,
&:focus,
&:hover {
background-color: #338dfc;
color: #fff;
}
}
.context-menu__item--disabled {
color: #aaa;
}
.context-menu__separator {
border-top: 2px solid #dcdcdd;
margin: $padding 0;
}
</style>

View File

@ -8,7 +8,7 @@
<button class="side-title__button button" @click="newItem(true)" v-title="'New folder'">
<icon-folder-plus></icon-folder-plus>
</button>
<button class="side-title__button button" @click="deleteItem()" v-title="'Remove'">
<button class="side-title__button button" @click="deleteItem()" v-title="'Delete'">
<icon-delete></icon-delete>
</button>
<button class="side-title__button button" @click="editItem()" v-title="'Rename'">
@ -39,63 +39,21 @@ export default {
]),
...mapGetters('explorer', [
'rootNode',
'selectedNode',
]),
},
methods: {
...mapActions('data', [
'toggleExplorer',
]),
newItem(isFolder) {
let parentId = this.$store.getters['explorer/selectedNodeFolder'].item.id;
if (parentId === 'trash') {
parentId = null;
}
this.$store.dispatch('explorer/openNode', parentId);
this.$store.commit('explorer/setNewItem', {
type: isFolder ? 'folder' : 'file',
parentId,
});
},
...mapActions('explorer', [
'newItem',
'deleteItem',
]),
editItem() {
const selectedNode = this.$store.getters['explorer/selectedNode'];
if (selectedNode.item.id !== 'trash') {
this.$store.commit('explorer/setEditingId', selectedNode.item.id);
}
},
deleteItem() {
const selectedNode = this.$store.getters['explorer/selectedNode'];
if (!selectedNode.isNil) {
if (selectedNode.item.id === 'trash' || selectedNode.item.parentId === 'trash') {
this.$store.dispatch('modal/trashDeletion');
return;
}
this.$store.dispatch(selectedNode.isFolder
? 'modal/folderDeletion'
: 'modal/fileDeletion',
selectedNode.item)
.then(() => {
if (selectedNode === this.$store.getters['explorer/selectedNode']) {
if (selectedNode.isFolder) {
const recursiveMoveToTrash = (folderNode) => {
folderNode.folders.forEach(recursiveMoveToTrash);
folderNode.files.forEach((fileNode) => {
this.$store.commit('file/patchItem', {
id: fileNode.item.id,
parentId: 'trash',
});
});
this.$store.commit('folder/deleteItem', folderNode.item.id);
};
recursiveMoveToTrash(selectedNode);
} else {
this.$store.commit('file/patchItem', {
id: selectedNode.item.id,
parentId: 'trash',
});
this.$store.commit('file/setCurrentId', this.$store.getters['data/lastOpenedIds'][1]);
}
}
});
const node = this.selectedNode;
if (!node.isTrash) {
this.$store.commit('explorer/setEditingId', node.item.id);
}
},
},

View File

@ -1,9 +1,9 @@
<template>
<div class="explorer-node" :class="{'explorer-node--selected': isSelected, 'explorer-node--open': isOpen, 'explorer-node--drag-target': isDragTargetFolder}" @dragover.prevent @dragenter.stop="setDragTarget(node.item.id)" @dragleave.stop="isDragTarget && setDragTargetId()" @drop.prevent.stop="onDrop">
<div class="explorer-node" :class="{'explorer-node--selected': isSelected, 'explorer-node--open': isOpen, 'explorer-node--drag-target': isDragTargetFolder}" @dragover.prevent @dragenter.stop="setDragTarget(node.item.id)" @dragleave.stop="isDragTarget && setDragTargetId()" @drop.prevent.stop="onDrop" @contextmenu="onContextMenu">
<div class="explorer-node__item-editor" v-if="isEditing" :class="['explorer-node__item-editor--' + node.item.type]" :style="{paddingLeft: leftPadding}" draggable="true" @dragstart.stop.prevent>
<input type="text" class="text-input" v-focus @blur="submitEdit()" @keyup.enter="submitEdit()" @keyup.esc="submitEdit(true)" v-model="editingNodeName">
</div>
<div class="explorer-node__item" v-else :class="['explorer-node__item--' + node.item.type]" :style="{paddingLeft: leftPadding}" @click="select(node.item.id)" draggable="true" @dragstart.stop="setDragSourceId" @dragend.stop="setDragTargetId()">
<div class="explorer-node__item" v-else :class="['explorer-node__item--' + node.item.type]" :style="{paddingLeft: leftPadding}" @click="select()" draggable="true" @dragstart.stop="setDragSourceId" @dragend.stop="setDragTargetId()">
{{node.item.name}}
<icon-provider class="explorer-node__location" v-for="location in node.locations" :key="location.id" :provider-id="location.providerId"></icon-provider>
</div>
@ -73,23 +73,30 @@ export default {
methods: {
...mapMutations('explorer', [
'setDragTargetId',
'setEditingId',
]),
...mapActions('explorer', [
'setDragTarget',
'newItem',
'deleteItem',
]),
select(id) {
select(id = this.node.item.id, doOpen = true) {
const node = this.$store.getters['explorer/nodeMap'][id];
if (node) {
if (!node) {
return false;
}
this.$store.commit('explorer/setSelectedId', id);
if (doOpen) {
// Prevent from freezing the UI while loading the file
setTimeout(() => {
if (node.isFolder) {
this.$store.commit('explorer/toggleOpenNode', id);
} else {
// Prevent from freezing the UI while loading the file
setTimeout(() => {
this.$store.commit('file/setCurrentId', id);
}
}, 10);
}
}
return true;
},
submitNewChild(cancel) {
const newChildNode = this.$store.state.explorer.newChildNode;
@ -119,7 +126,7 @@ export default {
name: utils.sanitizeName(value),
});
}
this.$store.commit('explorer/setEditingId', null);
this.setEditingId(null);
},
setDragSourceId(evt) {
if (this.node.noDrag) {
@ -147,6 +154,38 @@ export default {
}
}
},
onContextMenu(evt) {
if (this.select(undefined, false)) {
evt.preventDefault();
evt.stopPropagation();
this.$store.dispatch('contextMenu/open', {
coordinates: {
left: evt.clientX,
top: evt.clientY,
},
items: [{
name: 'New file',
disabled: !this.node.isFolder || this.node.isTrash,
perform: () => this.newItem(false),
}, {
name: 'New folder',
disabled: !this.node.isFolder || this.node.isTrash,
perform: () => this.newItem(true),
}, {
type: 'separator',
}, {
name: 'Rename',
disabled: this.node.isTrash,
perform: () => this.setEditingId(this.node.item.id),
}, {
name: 'Delete',
disabled: this.node.isTrash || this.node.item.parentId === 'trash',
perform: () => this.deleteItem(),
}],
})
.then(item => item.perform());
}
},
},
};
</script>
@ -159,6 +198,7 @@ $item-font-size: 14px;
}
.explorer-node__item {
position: relative;
cursor: pointer;
font-size: $item-font-size;
overflow: hidden;

View File

@ -226,6 +226,7 @@ img {
position: fixed;
display: none;
width: 250px;
height: 100%;
top: 0;
left: 0;
overflow-x: hidden;

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

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
<path d="M 9.5,3C 13.0899,3 16,5.91015 16,9.5C 16,11.1149 15.411,12.5923 14.4362,13.7291L 14.7071,14L 15.5,14L 20.5,19L 19,20.5L 14,15.5L 14,14.7071L 13.7291,14.4362C 12.5923,15.411 11.1149,16 9.5,16C 5.91015,16 3,13.0899 3,9.5C 3,5.91015 5.91015,3 9.5,3 Z M 9.5,5.00001C 7.01472,5.00001 5,7.01473 5,9.50001C 5,11.9853 7.01472,14 9.5,14C 11.9853,14 14,11.9853 14,9.50001C 14,7.01473 11.9853,5.00001 9.5,5.00001 Z "/>
</svg>
</template>

View File

@ -48,6 +48,7 @@ import ContentSave from './ContentSave';
import Message from './Message';
import History from './History';
import Database from './Database';
import Magnify from './Magnify';
Vue.component('iconProvider', Provider);
Vue.component('iconFormatBold', FormatBold);
@ -98,3 +99,4 @@ Vue.component('iconContentSave', ContentSave);
Vue.component('iconMessage', Message);
Vue.component('iconHistory', History);
Vue.component('iconDatabase', Database);
Vue.component('iconMagnify', Magnify);

53
src/store/contextMenu.js Normal file
View File

@ -0,0 +1,53 @@
const setter = propertyName => (state, value) => {
state[propertyName] = value;
};
export default {
namespaced: true,
state: {
coordinates: {
left: 0,
top: 0,
},
items: [],
resolve: () => {},
},
mutations: {
setCoordinates: setter('coordinates'),
setItems: setter('items'),
setResolve: setter('resolve'),
},
actions: {
open({ commit, rootState }, { coordinates, items }) {
commit('setItems', items);
// Place the context menu outside the screen
commit('setCoordinates', { top: 0, left: -9999 });
// Let the UI refresh itself
setTimeout(() => {
// Take the size of the context menu and place it
const elt = document.querySelector('.context-menu__inner');
const height = elt.offsetHeight;
if (coordinates.top + height > rootState.layout.bodyHeight) {
coordinates.top -= height;
}
if (coordinates.top < 0) {
coordinates.top = 0;
}
const width = elt.offsetWidth;
if (coordinates.left + width > rootState.layout.bodyWidth) {
coordinates.left -= width;
}
if (coordinates.left < 0) {
coordinates.left = 0;
}
commit('setCoordinates', coordinates);
}, 1);
return new Promise(resolve => commit('setResolve', resolve));
},
close({ commit }) {
commit('setItems', []);
commit('setResolve', () => {});
},
},
};

View File

@ -72,6 +72,7 @@ export default {
const trashFolderNode = new Node(emptyFolder(), [], true);
trashFolderNode.item.id = 'trash';
trashFolderNode.item.name = 'Trash';
trashFolderNode.isTrash = true;
trashFolderNode.noDrag = true;
const nodeMap = {
trash: trashFolderNode,
@ -79,18 +80,20 @@ export default {
rootGetters['folder/items'].forEach((item) => {
nodeMap[item.id] = new Node(item, [], true);
});
const syncLocationsByFileId = rootGetters['syncLocation/groupedByFileId'];
const publishLocationsByFileId = rootGetters['publishLocation/groupedByFileId'];
rootGetters['file/items'].forEach((item) => {
const locations = [
...rootGetters['syncLocation/groupedByFileId'][item.id] || [],
...rootGetters['publishLocation/groupedByFileId'][item.id] || [],
...syncLocationsByFileId[item.id] || [],
...publishLocationsByFileId[item.id] || [],
];
nodeMap[item.id] = new Node(item, locations);
});
const rootNode = new Node(emptyFolder(), [], true, true);
Object.entries(nodeMap).forEach(([id, node]) => {
Object.entries(nodeMap).forEach(([, node]) => {
let parentNode = nodeMap[node.item.parentId];
if (!parentNode || !parentNode.isFolder) {
if (id === 'trash') {
if (node.isTrash) {
return;
}
parentNode = rootNode;
@ -105,7 +108,7 @@ export default {
if (trashFolderNode.files.length) {
rootNode.folders.unshift(trashFolderNode);
}
// Add a fake file at the end of the root folder to always allow drag and drop into it.
// Add a fake file at the end of the root folder to allow drag and drop into it.
rootNode.files.push(fakeFileNode);
return {
nodeMap,
@ -164,5 +167,67 @@ export default {
commit('setDragTargetId', id);
dispatch('openDragTarget');
},
newItem({ getters, commit, dispatch }, isFolder) {
let parentId = getters.selectedNodeFolder.item.id;
if (parentId === 'trash') {
parentId = null;
}
dispatch('openNode', parentId);
commit('setNewItem', {
type: isFolder ? 'folder' : 'file',
parentId,
});
},
deleteItem({ rootState, getters, rootGetters, commit, dispatch }) {
const selectedNode = getters.selectedNode;
if (selectedNode.isNil) {
return Promise.resolve();
}
if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') {
return dispatch('modal/trashDeletion', null, { root: true });
}
return dispatch(selectedNode.isFolder
? 'modal/folderDeletion'
: 'modal/fileDeletion',
selectedNode.item,
{ root: true },
)
.then(() => {
if (selectedNode === getters.selectedNode) {
const currentFileId = rootGetters['file/current'].id;
let doClose = selectedNode.item.id === currentFileId;
if (selectedNode.isFolder) {
const recursiveMoveToTrash = (folderNode) => {
folderNode.folders.forEach(recursiveMoveToTrash);
folderNode.files.forEach((fileNode) => {
commit('file/patchItem', {
id: fileNode.item.id,
parentId: 'trash',
}, { root: true });
doClose = doClose || fileNode.item.id === currentFileId;
});
commit('folder/deleteItem', folderNode.item.id, { root: true });
};
recursiveMoveToTrash(selectedNode);
} else {
commit('file/patchItem', {
id: selectedNode.item.id,
parentId: 'trash',
}, { root: true });
}
if (doClose) {
// Close the current file by opening the last opened, not deleted one
rootGetters['data/lastOpenedIds'].some((id) => {
const file = rootState.file.itemMap[id];
if (file.parentId === 'trash') {
return false;
}
commit('file/setCurrentId', id, { root: true });
return true;
});
}
}
}, () => {}); // Cancel
},
},
};

View File

@ -2,21 +2,22 @@
import Vue from 'vue';
import Vuex from 'vuex';
import utils from '../services/utils';
import contentState from './contentState';
import syncedContent from './syncedContent';
import content from './content';
import contentState from './contentState';
import contextMenu from './contextMenu';
import data from './data';
import discussion from './discussion';
import explorer from './explorer';
import file from './file';
import findReplace from './findReplace';
import folder from './folder';
import publishLocation from './publishLocation';
import syncLocation from './syncLocation';
import data from './data';
import discussion from './discussion';
import layout from './layout';
import explorer from './explorer';
import modal from './modal';
import notification from './notification';
import publishLocation from './publishLocation';
import queue from './queue';
import syncedContent from './syncedContent';
import syncLocation from './syncLocation';
import userInfo from './userInfo';
import workspace from './workspace';
@ -26,21 +27,22 @@ const debug = NODE_ENV !== 'production';
const store = new Vuex.Store({
modules: {
contentState,
syncedContent,
content,
contentState,
contextMenu,
data,
discussion,
explorer,
file,
findReplace,
folder,
publishLocation,
syncLocation,
data,
layout,
explorer,
modal,
notification,
publishLocation,
queue,
syncedContent,
syncLocation,
userInfo,
workspace,
},