Explorer context menu
This commit is contained in:
parent
4b5cd7aef7
commit
b06a6a37eb
@ -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: () => ({
|
||||
|
80
src/components/ContextMenu.vue
Normal file
80
src/components/ContextMenu.vue
Normal 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>
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -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) {
|
||||
this.$store.commit('explorer/setSelectedId', id);
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
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;
|
||||
|
@ -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
5
src/icons/Magnify.vue
Normal 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>
|
@ -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
53
src/store/contextMenu.js
Normal 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', () => {});
|
||||
},
|
||||
},
|
||||
};
|
@ -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
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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,
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user