add copy/cut/paste to cxt menu and tweak position

This commit is contained in:
joshuaboud 2022-06-22 17:07:50 -03:00
parent 685cdeddc3
commit 1847ecc47b
No known key found for this signature in database
GPG Key ID: 17EFB59E2A8BF50E
3 changed files with 336 additions and 187 deletions

View File

@ -16,98 +16,138 @@ If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div
v-if="show"
class="fixed inset-0 bg-transparent"
tabindex="-1"
@click="show = false"
@contextmenu.prevent="show = false"
@keypress="show = false"
></div>
<transition
enter-active-class="origin-top-left transition ease-out duration-100"
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="origin-top-left transition ease-in duration-75"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
@enter="setPosition"
@after-leave="reset"
>
<div
v-if="show"
class="fixed inset-0 bg-transparent"
class="fixed z-20 max-w-sm flex flex-col items-stretch bg-default shadow-lg divide-y divide-default"
tabindex="-1"
@click="show = false"
@contextmenu.prevent="show = false"
@keypress="show = false"
ref="contextMenuRef"
>
<div
class="fixed z-20 max-w-sm flex flex-col items-stretch bg-default shadow-lg divide-y divide-default position-contextmenu">
<div class="flex items-stretch">
<div class="flex items-stretch">
<button
:disabled="!pathHistory?.backAllowed()"
:class="{ 'grow flex items-center justify-center p-2': true, 'hover:bg-red-600/10': pathHistory?.backAllowed() }"
@click="$emit('browserAction', 'back')"
>
<ArrowLeftIcon class="size-icon icon-default" />
</button>
<button
:disabled="!pathHistory?.forwardAllowed()"
:class="{ 'grow flex items-center justify-center p-2': true, 'hover:bg-red-600/10': pathHistory?.forwardAllowed() }"
@click="$emit('browserAction', 'forward')"
>
<ArrowRightIcon class="size-icon icon-default" />
</button>
<button
class="grow hover:bg-red-600/10 flex items-center justify-center p-2"
@click="$emit('browserAction', 'up')"
>
<ArrowUpIcon class="size-icon icon-default" />
</button>
</div>
<div class="flex flex-col items-stretch">
<!-- Non-selection actions -->
<button
class="context-menu-button"
@click="$emit('browserAction', 'createFile', selection[0])"
>
<DocumentAddIcon class="size-icon icon-default" />
<span>New file</span>
</button>
<button
class="context-menu-button"
@click="$emit('browserAction', 'createDirectory', selection[0])"
>
<FolderAddIcon class="size-icon icon-default" />
<span>New directory</span>
</button>
<button
class="context-menu-button"
@click="$emit('browserAction', 'createLink', selection[0])"
>
<LinkIcon class="size-icon icon-default" />
<span>New link</span>
</button>
</div>
<template v-if="selection.length === 0">
<!-- Current directory actions -->
<div
v-if="clipboard.content.length"
class="flex flex-col items-stretch"
>
<button
:disabled="!pathHistory?.backAllowed()"
:class="{ 'grow flex items-center justify-center p-2': true, 'hover:bg-red-600/10': pathHistory?.backAllowed() }"
@click="$emit('browserAction', 'back')"
class="context-menu-button"
@click="$emit('directoryViewAction', 'paste', currentDirEntry)"
>
<ArrowLeftIcon class="size-icon icon-default" />
</button>
<button
:disabled="!pathHistory?.forwardAllowed()"
:class="{ 'grow flex items-center justify-center p-2': true, 'hover:bg-red-600/10': pathHistory?.forwardAllowed() }"
@click="$emit('browserAction', 'forward')"
>
<ArrowRightIcon class="size-icon icon-default" />
</button>
<button
class="grow hover:bg-red-600/10 flex items-center justify-center p-2"
@click="$emit('browserAction', 'up')"
>
<ArrowUpIcon class="size-icon icon-default" />
<ClipboardIcon class="size-icon icon-default" />
<span>
Paste {{ clipboard.content.length }}
item{{ clipboard.content.length > 1 ? 's' : '' }}
here
</span>
</button>
</div>
<div class="flex flex-col items-stretch">
<!-- Non-selection actions -->
<button
class="context-menu-button"
@click="$emit('browserAction', 'createFile', selection[0])"
@click="$emit('browserAction', 'download', currentDirEntry)"
>
<DocumentAddIcon class="size-icon icon-default" />
<span>New file</span>
</button>
<button
class="context-menu-button"
@click="$emit('browserAction', 'createDirectory', selection[0])"
>
<FolderAddIcon class="size-icon icon-default" />
<span>New directory</span>
</button>
<button
class="context-menu-button"
@click="$emit('browserAction', 'createLink', selection[0])"
>
<LinkIcon class="size-icon icon-default" />
<span>New link</span>
<FolderDownloadIcon class="size-icon icon-default" />
<span>Zip and download current directory</span>
</button>
</div>
<template v-if="selection.length === 0">
<!-- Current directory actions -->
<div class="flex flex-col items-stretch">
<button
class="context-menu-button"
@click="$emit('browserAction', 'download', currentDirEntry)"
>
<FolderDownloadIcon class="size-icon icon-default" />
<span>Zip and download directory</span>
</button>
</div>
<div class="flex flex-col items-stretch">
<button
v-if="currentDirEntry?.path !== '/'"
class="context-menu-button"
@click="$emit('browserAction', 'editPermissions', currentDirEntry)"
>
<KeyIcon class="size-icon icon-default" />
<span>Edit permissions</span>
</button>
</div>
</template>
<template v-else-if="selection.length === 1">
<!-- Single entry selection actions -->
<div
v-if="selection[0]?.resolvedType === 'f'"
class="flex flex-col items-stretch"
<div class="flex flex-col items-stretch">
<button
v-if="currentDirEntry?.path !== '/'"
class="context-menu-button"
@click="$emit('browserAction', 'editPermissions', currentDirEntry)"
>
<!-- regular file actions -->
<KeyIcon class="size-icon icon-default" />
<span>Edit permissions of current directory</span>
</button>
</div>
</template>
<template v-else-if="selection.length === 1">
<!-- Single entry selection actions -->
<template v-if="selection[0]?.resolvedType === 'f'">
<!-- regular file actions -->
<div class="flex flex-col items-stretch">
<button
class="context-menu-button"
@click="$emit('directoryViewAction', 'cut', selection[0])"
>
<ScissorsIcon class="size-icon icon-default" />
<span>Cut file</span>
</button>
<button
class="context-menu-button"
@click="$emit('directoryViewAction', 'copy', selection[0])"
>
<ClipboardCopyIcon class="size-icon icon-default" />
<span>Copy file</span>
</button>
</div>
<div class="flex flex-col items-stretch">
<button
class="context-menu-button"
@click="$emit('browserAction', 'edit', selection[0])"
@ -130,11 +170,37 @@ If not, see <https://www.gnu.org/licenses/>.
<span>Zip and download</span>
</button>
</div>
<div
v-else-if="selection[0]?.resolvedType === 'd'"
class="flex flex-col items-stretch"
>
<!-- directory actions -->
</template>
<template v-else-if="selection[0]?.resolvedType === 'd'">
<!-- directory actions -->
<div class="flex flex-col items-stretch">
<button
class="context-menu-button"
@click="$emit('directoryViewAction', 'cut', selection[0])"
>
<ScissorsIcon class="size-icon icon-default" />
<span>Cut directory</span>
</button>
<button
class="context-menu-button"
@click="$emit('directoryViewAction', 'copy', selection[0])"
>
<ClipboardCopyIcon class="size-icon icon-default" />
<span>Copy directory</span>
</button>
<button
v-if="clipboard.content.length"
class="context-menu-button"
@click="$emit('directoryViewAction', 'paste', selection[0])"
>
<ClipboardIcon class="size-icon icon-default" />
<span>
Paste {{ clipboard.content.length }}
item{{ clipboard.content.length > 1 ? 's' : '' }} into directory
</span>
</button>
</div>
<div class="flex flex-col items-stretch">
<button
class="context-menu-button"
@click="$emit('browserAction', 'cd', selection[0])"
@ -150,72 +216,88 @@ If not, see <https://www.gnu.org/licenses/>.
<span>Zip and download directory</span>
</button>
</div>
<div
v-if="selection[0]?.type === 'l'"
class="flex flex-col items-stretch"
>
<!-- link actions -->
<button
class="context-menu-button"
@click="$emit('browserAction', 'editLink', selection[0])"
>
<LinkIcon class="size-icon icon-default" />
<span>Edit link target</span>
</button>
</div>
<div class="flex flex-col items-stretch">
<!-- general actions -->
<button
v-if="selection[0]?.path !== '/'"
class="context-menu-button"
@click="$emit('browserAction', 'editPermissions', selection[0])"
>
<KeyIcon class="size-icon icon-default" />
<span>Edit permissions</span>
</button>
<button
v-if="selection[0]?.path !== '/'"
class="context-menu-button"
@click="$emit('browserAction', 'rename', selection[0])"
>
<PencilIcon class="size-icon icon-default" />
<span>Rename</span>
</button>
<button
class="context-menu-button"
@click="$emit('browserAction', 'delete', selection[0])"
>
<TrashIcon class="size-icon icon-default" />
<span>Delete</span>
</button>
</div>
</template>
<tempalte v-else>
<!-- Multi-entry selection actions -->
<div class="flex flex-col items-stretch">
<button
class="context-menu-button"
@click="$emit('browserAction', 'download', [...selection])"
>
<DownloadIcon class="size-icon-sm icon-default" />
<span>Zip and download {{ selection.length }} items</span>
</button>
<button
class="context-menu-button"
@click="$emit('browserAction', 'delete', [...selection])"
>
<TrashIcon class="size-icon-sm icon-default" />
<span>Delete {{ selection.length }} items</span>
</button>
</div>
</tempalte>
</div>
<div
v-if="selection[0]?.type === 'l'"
class="flex flex-col items-stretch"
>
<!-- link actions -->
<button
class="context-menu-button"
@click="$emit('browserAction', 'editLink', selection[0])"
>
<LinkIcon class="size-icon icon-default" />
<span>Edit link target</span>
</button>
</div>
<div class="flex flex-col items-stretch">
<!-- general actions -->
<button
v-if="selection[0]?.path !== '/'"
class="context-menu-button"
@click="$emit('browserAction', 'editPermissions', selection[0])"
>
<KeyIcon class="size-icon icon-default" />
<span>Edit permissions</span>
</button>
<button
v-if="selection[0]?.path !== '/'"
class="context-menu-button"
@click="$emit('browserAction', 'rename', selection[0])"
>
<PencilIcon class="size-icon icon-default" />
<span>Rename</span>
</button>
<button
class="context-menu-button"
@click="$emit('browserAction', 'delete', selection[0])"
>
<TrashIcon class="size-icon icon-danger" />
<span>Delete</span>
</button>
</div>
</template>
<template v-else>
<!-- Multi-entry selection actions -->
<div class="flex flex-col items-stretch">
<button
class="context-menu-button"
@click="$emit('directoryViewAction', 'cut', [...selection])"
>
<ScissorsIcon class="size-icon icon-default" />
<span>Cut {{ selection.length }} items</span>
</button>
<button
class="context-menu-button"
@click="$emit('directoryViewAction', 'copy', [...selection])"
>
<ClipboardCopyIcon class="size-icon icon-default" />
<span>Copy {{ selection.length }} items</span>
</button>
</div>
<div class="flex flex-col items-stretch">
<button
class="context-menu-button"
@click="$emit('browserAction', 'download', [...selection])"
>
<DownloadIcon class="size-icon-sm icon-default" />
<span>Zip and download {{ selection.length }} items</span>
</button>
<button
class="context-menu-button"
@click="$emit('browserAction', 'delete', [...selection])"
>
<TrashIcon class="size-icon-sm icon-danger" />
<span>Delete {{ selection.length }} items</span>
</button>
</div>
</template>
</div>
</transition>
</template>
<script>
import { inject, ref, computed } from 'vue'
import { inject, ref, computed, nextTick } from 'vue'
import {
ArrowLeftIcon,
ArrowRightIcon,
@ -231,8 +313,11 @@ import {
PencilIcon,
KeyIcon,
DownloadIcon,
ClipboardIcon,
ClipboardCopyIcon,
ScissorsIcon,
} from '@heroicons/vue/solid';
import { pathHistoryInjectionKey } from '../keys';
import { pathHistoryInjectionKey, clipboardInjectionKey } from '../keys';
export default {
props: {
@ -240,13 +325,18 @@ export default {
},
setup(props, { emit }) {
const pathHistory = inject(pathHistoryInjectionKey);
const clipboard = inject(clipboardInjectionKey);
const show = ref();
const event = ref();
const selection = ref();
const currentDirEntry = computed(() => ({
...props.currentPath,
name: `Current directory (${props.currentPath.path.split('/').pop()})`
name: `Current directory (${props.currentPath.path.split('/').pop()})`,
type: 'd',
resolvedType: 'd',
resolvedPath: props.currentPath.path,
}));
const contextMenuRef = ref();
/**
* Open the context menu
@ -260,6 +350,30 @@ export default {
show.value = true;
}
const setPosition = (el, done) => {
// const { width: menuWidth, height: menuHeight } = el.getBoundingClientRect();
const menuWidth = el.scrollWidth;
const menuHeight = el.scrollHeight;
let x = event.value.clientX;
let y = event.value.clientY;
let origin;
if (y + menuHeight > window.innerHeight) {
y -= menuHeight;
origin = 'bottom';
} else {
origin = 'top';
}
if (x + menuWidth > window.innerWidth) {
x -= menuWidth;
origin += ' right';
} else {
origin += ' left';
}
el.style.left = `${x}px`;
el.style.top = `${y}px`;
el.style.transformOrigin = origin;
}
const reset = () => {
event.value = null;
selection.value = [];
@ -268,12 +382,15 @@ export default {
return {
// data
pathHistory,
clipboard,
show,
event,
selection,
currentDirEntry,
contextMenuRef,
// methods
open,
setPosition,
reset,
}
},
@ -292,22 +409,21 @@ export default {
PencilIcon,
KeyIcon,
DownloadIcon,
ClipboardIcon,
ClipboardCopyIcon,
ScissorsIcon,
},
emits: [
'hide',
'browserAction',
'directoryViewAction',
]
}
</script>
<style scoped>
div.position-contextmenu {
top: v-bind("`${event?.clientY ?? 0}px`");
left: v-bind("`${event?.clientX ?? 0}px`");
}
button.context-menu-button {
@apply text-default font-normal pl-1 pr-2 text-sm text-left flex items-center gap-1;
@apply text-default font-normal pl-1 pr-2 py-1 text-sm text-left flex items-center gap-1;
}
button.context-menu-button:hover {

View File

@ -26,7 +26,7 @@ If not, see <https://www.gnu.org/licenses/>.
class="h-full"
@selectRectangle="selectRectangle"
@mouseup.exact="deselectAll"
@contextmenu.prevent="$emit('browserAction', 'contextMenu', $event)"
@contextmenu.prevent="contextMenuCallback"
>
<Table
:key="host + path"
@ -36,6 +36,7 @@ If not, see <https://www.gnu.org/licenses/>.
stickyHeaders
noShrink
noShrinkHeight="h-full"
class="border-x-0"
>
<template #thead>
<tr>
@ -249,6 +250,7 @@ import LoadingSpinner from './LoadingSpinner.vue';
import SortCallbackButton from './SortCallbackButton.vue';
import DirectoryEntryList from './DirectoryEntryList.vue';
import DragSelectArea from './DragSelectArea.vue';
import { commonPath } from '../functions/commonPath';
export default {
props: {
@ -331,7 +333,7 @@ export default {
selectedCount.value = gatherEntries().map(entry => entry.selected = true).length ?? 0;
}
const deselectAll = (event) => {
const deselectAll = () => {
gatherEntries([], false).map(entry => entry.selected = false);
selectedCount.value = 0;
}
@ -353,11 +355,64 @@ export default {
tallySelected();
}
const unCutEntries = () => gatherEntries([], false).map(entry => entry.cut = false);
const clipboardStore = (items, cut = false, append = false) => {
if (!append)
unCutEntries();
const newContent = items.map(entry => {
entry.cut = cut;
return {
uniqueId: entry.uniqueId,
host: entry.host,
path: entry.path,
name: entry.name,
cut,
clipboardRelativePath: entry.path.slice(props.path.length).replace(/^\//, '')
};
});
if (append)
clipboard.content = [...newContent, ...clipboard.content].filter((a, index, arr) => arr.findIndex(b => b.uniqueId === a.uniqueId) === index);
else
clipboard.content = newContent;
const message = append
? `Added ${newContent.length} items to clipboard.\n(${clipboard.content.length} items total)`
: `${cut ? 'Cut' : 'Copied'} ${newContent.length} items to clipboard.`;
notifications.value.constructNotification('Clipboard', message, 'info', 2000);
}
const paste = (destinations) => {
let destination;
if (destinations.length === 1) {
destination = destinations[0];
if (destination.resolvedType !== 'd') {
notifications.value.constructNotification("Paste Failed", 'Cannot paste to non-directory.', 'error');
return;
}
} else if (destinations.length === 0) {
destination = { host: props.host, path: props.path };
} else {
notifications.value.constructNotification("Paste Failed", 'Cannot paste to multiple directories.', 'error');
return;
}
console.log("paste", clipboard.content, destination);
const fullSources = [...clipboard.content];
const { common } = commonPath(fullSources.map(item => item.path));
const groupedByHost = fullSources.reduce((res, item) => {
if (!res[item.host])
res[item.host] = [];
res[item.host].push(item);
return res;
}, {});
for (const host of Object.keys(groupedByHost)) {
console.log(`rsync -avh ${groupedByHost[host].map(a => a.path).join(' ')} ${destination.host}:${destination.path}`);
}
}
/**
* @param {KeyboardEvent} event
*/
const keyHandler = (event) => {
const unCutEntries = () => gatherEntries([], false).map(entry => entry.cut = false);
const keypress = event.key.toLowerCase();
const handleExact = (keypress) => {
switch (keypress) {
@ -379,7 +434,6 @@ export default {
return;
}
event.preventDefault();
event.stopPropagation();
}
const handleCtrl = (keypress) => {
switch (keypress) {
@ -392,51 +446,15 @@ export default {
break;
case 'c':
case 'x':
const isCut = keypress === 'x';
if (!event.shiftKey)
unCutEntries();
const newContent = getSelected().map(entry => {
entry.cut = isCut;
return {
uniqueId: entry.uniqueId,
host: entry.host,
path: entry.path,
name: entry.name,
cut: isCut,
clipboardRelativePath: entry.path.slice(props.path.length).replace(/^\//, '')
};
});
if (event.shiftKey)
clipboard.content = [...newContent, ...clipboard.content].filter((a, index, arr) => arr.findIndex(b => b.uniqueId === a.uniqueId) === index);
else
clipboard.content = newContent;
const message = event.shiftKey
? `Added ${newContent.length} items to clipboard.\n(${clipboard.content.length} items total)`
: `Copied ${newContent.length} items to clipboard.`;
notifications.value.constructNotification('Clipboard', message, 'info', 2000);
clipboardStore(getSelected(), keypress === 'x', event.shiftKey);
break;
case 'v':
const selected = getSelected();
let destination;
if (selected.length === 1) {
destination = selected[0];
if (destination.resolvedType !== 'd') {
notifications.value.constructNotification("Paste Failed", 'Cannot paste to non-directory.', 'error');
break;
}
} else if (selected.length === 0) {
destination = { host: props.host, path: props.path };
} else {
notifications.value.constructNotification("Paste Failed", 'Cannot paste to multiple directories.', 'error');
break;
}
console.log("paste", clipboard.content, destination);
paste(getSelected());
break;
default:
return;
}
event.preventDefault();
event.stopPropagation();
}
const handleShift = (keypress) => {
switch(keypress) {
@ -444,7 +462,6 @@ export default {
return;
}
event.preventDefault();
event.stopPropagation();
}
const handleCtrlShift = (keypress) => {
switch(keypress) {
@ -452,7 +469,6 @@ export default {
return;
}
event.preventDefault();
event.stopPropagation();
}
const handleAny = (keypress) => {
switch(keypress) {
@ -460,7 +476,6 @@ export default {
return;
}
event.preventDefault();
event.stopPropagation();
}
if (event.ctrlKey && event.shiftKey) {
handleCtrlShift(keypress);
@ -488,11 +503,27 @@ export default {
}
}
const contextMenuCallback = (event) => {
if (!(event.ctrlKey || event.shiftKey)) {
deselectAll();
}
emit('browserAction', 'contextMenu', event);
}
const handleAction = (action, ...args) => {
switch (action) {
case 'toggleSelected':
toggleSelected(...args);
break;
case 'copy':
clipboardStore(args?.flat(1) ?? getSelected(), false, false);
break;
case 'cut':
clipboardStore(args?.flat(1) ?? getSelected(), true, false);
break;
case 'paste':
paste(args?.flat(1) ?? getSelected());
break;
default:
console.error('Unknown directoryViewAction:', action, args);
break;
@ -539,6 +570,7 @@ export default {
selectAll,
deselectAll,
selectRectangle,
contextMenuCallback,
handleAction,
tallySelected,
}

View File

@ -192,6 +192,7 @@
<ContextMenu
:currentPath="pathHistory.current() ?? { path: '/', host: 'localhost' }"
@browserAction="handleAction"
@directoryViewAction="(...args) => directoryViewRef?.handleAction(...args)"
ref="contextMenuRef"
/>
<ModalPrompt ref="modalPromptRef" />