nicer filemanager and layout

This commit is contained in:
2024-11-09 17:10:24 +00:00
parent 08489ed67b
commit eb6abbb97d
16 changed files with 671 additions and 52 deletions

View File

@@ -25,6 +25,8 @@
--color-fuchsia: #d946ef;
--color-pink: #ec4899;
--color-rose: #f43f5e;
--color-app-primary: var(--color-indigo);
}
.theme {

View File

@@ -0,0 +1,65 @@
<template>
<div class="app-layout theme">
<div class="app-layout__aside bg-theme-500 contrast " style="grid-area: topAside;">
<FilesAside>
<template v-slot:handle>
<IconButton name="Files" >
<Directory />
</IconButton>
</template>
</FilesAside>
<IconButton name="Settings">
<Cog />
</IconButton>
</div>
<div class="app-layout__aside bg-theme-500 contrast" style="grid-area: menu;">
</div>
<div class="app-layout__overview bg-theme-800 contrast" style="grid-area: overview;">
<slot name="overview"></slot>
</div>
<div class="app-layout__main" style="grid-area: main;">
This is main
</div>
</div>
</template>
<script lang="ts" setup>
import FilesAside from '../Files/FilesAside.vue';
import Cog from '../Icons/Cog.vue';
import Directory from '../Icons/Directory.vue';
import IconButton from './IconButton.vue';
</script>
<style>
.app-layout {
display: grid;
grid-template-columns: 1fr 64px;
grid-template-rows: max-content 1fr;
grid-template-areas:
"overview topAside"
"main menu";
height: 100dvh;
--color-theme: var(--color-app-primary);
}
.app-layout__aside {
padding: 12px;
overflow:hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.app-layout__overview {
padding: 1rem;
}
.app-layout__main {
padding: 1rem;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<button class="icon-button contrast" @mouseover="onMouseOver" @mouseleave="onMouseLeave">
<div class="icon-button__content">
<slot />
</div>
<div class="icon-button__popover" popover ref="popover">{{ name }}</div>
</button>
</template>
<script setup lang="ts">
import {ref} from 'vue';
const props = defineProps<{
name: string
}>()
const popover = ref<HTMLDivElement>()
function onMouseOver() {
popover.value?.showPopover();
}
function onMouseLeave() {
popover.value?.hidePopover();
}
</script>
<style>
.icon-button {
anchor-scope: --icon-button;
position: relative;
cursor: pointer;
font-size: 1rem;
color: var(--color-theme);
background: var(--color-contrast-500);
border: none;
background: white;
border-radius: 4px;
padding:0;
}
.icon-button__content {
padding: 4px;
text-align: center;
anchor-name: --icon-button;
}
.icon-button > * {
display: inline-block;
width: 32px;
height: 32px;
}
.icon-button__popover {
color: var(--color-theme);
background: white;
border: none;
pointer-events: none;
opacity: 0;
position: absolute;
inset:initial;
margin: 0;
padding: 4px 8px;
width: auto;
height: auto;
position-anchor: --icon-button;
top: anchor(--icon-button top);
right: calc(anchor(--icon-button left) + 8px);
border-radius: 4px 4px 4px 4px;
transition: opacity 0.2s;
}
.icon-button__popover:popover-open {
opacity: 1;
}
.icon-button__popover::backdrop {
pointer-events: none;
display: none;
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div
@drop="onDrop"
@dragover.prevent="onDragOver"
@dragleave="onDragLeave"
@dragenter="onDragEnter"
>
<slot :isDragOver="isDragOver"></slot>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import {VirtualFile} from '../../model/FileSystem/types';
const isDragOver = ref(false);
const emit = defineEmits<{
onDrop: [VirtualFile],
}>();
const props = defineProps<{
accept?: string,
}>();
const slot = defineSlots<{
default(props: {isDragOver: boolean}): any
}>()
function onDragEnter(event: DragEvent) {
console.log('drag enter');
isDragOver.value = true;
}
function onDragLeave(event: DragEvent) {
console.log('drag leave');
isDragOver.value = false;
}
function onDragOver(event: DragEvent) {
event.preventDefault();
}
function parseMeta(event: DragEvent): VirtualFile | null {
try {
const file = event.dataTransfer?.files?.[0];
if (file) {
return {
type: 'file',
source: 'drop',
path: [] as string[],
name: file.name,
mimeType: file.type
} as const;
} else if (event.dataTransfer?.getData('application/json')) {
return JSON.parse(event.dataTransfer?.getData('application/json'));
}
} catch (e) {
}
return null;
}
function onDrop(event: DragEvent) {
event.preventDefault();
isDragOver.value = false;
const drop = parseMeta(event);
console.log('drop', drop);
}
</script>

View File

@@ -1,5 +1,7 @@
<template>
<div>
<div class="file-manager">
<div class="file-manager__toolbar">
<input class="file-manager__search" type="search" placeholder="search" v-model="search">
<button
@click="buildIndex"
:disabled="buildingIndex"
@@ -7,7 +9,8 @@
<template v-if="buildingIndex">building {{ indexCounter }}</template>
<template v-else>Build Index</template>
</button>
<input type="search" placeholder="search" v-model="search">
</div>
<div class="file-manager__files">
<ul
v-if="searchResults.length > 0"
class="file-manager-dir"
@@ -17,6 +20,7 @@
:key="file.name"
:file="file"
@openFile="$emit('openFile', $event)"
@filePreview="onFilePreview"
/>
</ul>
<ul
@@ -29,9 +33,14 @@
:fs="fs"
:dir="dir"
@openFile="$emit('openFile', $event)"
@filePreview="onFilePreview"
/>
</ul>
</div>
<div class="file-manager__preview">
<FilePreview :file="filePreview" />
</div>
</div>
</template>
<script setup lang="ts">
import {inject, ref, watch} from 'vue';
@@ -39,16 +48,25 @@ import {MountedFsSymbol} from '../../model/injectionSymbols';
import FileManagerDir from './FileManagerDir.vue';
import {VirtualFile} from '../../model/FileSystem/types';
import FileManagerFile from './FileManagerFile.vue';
import FilePreview from './FilePreview.vue';
const fs = inject(MountedFsSymbol)!;
const emit = defineEmits<{
'openFile': [value: VirtualFile]
'filePreview': [value: VirtualFile]
}>();
const search = ref('');
let searchAbort: AbortController;
const searchResults = ref<VirtualFile[]>([]);
const filePreview = ref<VirtualFile>();
function onFilePreview(file: VirtualFile) {
filePreview.value = file;
emit('filePreview', file)
}
watch(search, async (value) => {
searchResults.value = [];
if (value === '') {
@@ -76,15 +94,46 @@ async function buildIndex() {
</script>
<style>
.file-manager {
font-family: monospace;
display: grid;
height: 100%;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
gap: 0.5rem;
padding: 0.5rem 0.25rem;
}
.file-manager__search {
flex: 1;
border-radius: 4px;
border:none;
padding: 2px 8px;
}
.file-manager__toolbar {
padding: 4px;
display: flex;
flex-direction: row;
grid-gap: 4px;
}
.file-manager__preview {
max-height: 25vh;
transition: max-height 200ms;
transition-behavior: allow-discrete;
}
.file-manager__preview:hover {
max-height: initial;
}
.file-manager-dir {
list-style-type: none;
padding-left: 1rem;
}
.file-manager__files {
overflow: auto;
}
.file-manager-dir__label {
cursor: pointer;

View File

@@ -14,11 +14,13 @@
v-if="file.type === 'directory'"
:dir="file"
@openFile="$emit('openFile', $event)"
@filePreview="$emit('filePreview', $event)"
/>
<FileManagerFile
v-else
:file="file"
@openFile="$emit('openFile', $event)"
@filePreview="$emit('filePreview', $event)"
/>
</template>
</ul>
@@ -37,6 +39,7 @@ const isCollapsed = ref(true);
const emit = defineEmits<{
'openFile': [value: VirtualFile]
'filePreview': [value: VirtualFile]
}>();
const content = ref<(VirtualFile | VirtualDirectory)[]>([]);

View File

@@ -4,6 +4,7 @@
draggable="true"
class="file-manager-file__wrapper"
@click="$emit('openFile', file)"
@mouseover="$emit('filePreview', file)"
@dragstart="onDragStart"
>
{{ file.name }}
@@ -30,7 +31,8 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
'openFile': [value: VirtualFile]
'openFile': [value: VirtualFile],
'filePreview': [value: VirtualFile]
}>();
const withMeta = computed(() => {

View File

@@ -0,0 +1,40 @@
<template>
<div v-if="currentPreviewUrl" class="file-preview">
<img :src="currentPreviewUrl" alt="preview" class="file-preview__image" />
</div>
</template>
<script setup lang="ts">
import {inject, ref, watch} from 'vue';
import {VirtualFile} from '../../model/FileSystem/types';
import {MountedFsSymbol} from '../../model/injectionSymbols';
const props=defineProps<{
file?:VirtualFile
}>()
const fs = inject(MountedFsSymbol)!;
const currentPreviewUrl = ref<string | null>(null);
watch(()=> props.file, async (file)=>{
if (currentPreviewUrl.value) {
URL.revokeObjectURL(currentPreviewUrl.value);
}
if (!file) {
currentPreviewUrl.value = null;
return;
}
const blob = await fs.download(file);
currentPreviewUrl.value = URL.createObjectURL(blob);
})
</script>
<style>
.file-preview__image {
max-width: 100%;
}
</style>

View File

@@ -2,13 +2,15 @@
<template>
<div class="files">
<div @click="openAside" style="cursor: pointer;">Files</div>
<div @click="openAside" style="cursor: pointer;">
<slot name="handle">Files</slot>
</div>
<div
popover
ref="popover"
class="files__aside"
:class="{ 'files__aside--dragging': dragging }"
>
<div
class="files__aside-content"
@@ -17,7 +19,7 @@
@dragleave="onDragLeave"
@dragover.prevent
>
<FileManager />
<FileManager @filePreview="loadFilePreview" />
</div>
</div>
</div>
@@ -25,13 +27,22 @@
<script setup lang="ts">
import {onUnmounted, ref} from 'vue';
import FileManager from '../FileManager/FileManager.vue';
import FilePreview from '../FileManager/FilePreview.vue';
import {VirtualFile} from '../../model/FileSystem/types';
const popover = ref<HTMLDivElement>()
const content = ref<HTMLDivElement>()
const filePreview = ref<VirtualFile>();
function openAside(){
popover.value?.showPopover();
}
function loadFilePreview(file: VirtualFile){
filePreview.value = file;
}
const dragging = ref(false);
function onDragStart(){
@@ -48,6 +59,7 @@ function onDragLeave(event) {
function onDragEnd(){
console.log('dragend');
dragging.value = false;
showPreview();
}
window.addEventListener('dragend', onDragEnd, {passive: true});
@@ -60,17 +72,19 @@ onUnmounted(() => {
.files__aside {
--animation-duration: 200ms;
display: flex;
flex-direction: column;
position: fixed;
inset: auto;
height: auto;
left: 0;
top: 10px;
bottom: 10px;
right: 0;
top: 0;
bottom: 0;
width: 500px;
max-width: 100vw;
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
transform: translateX(-100%);
transform: translateX(100%);
border: none;
transition: all var(--animation-duration) allow-discrete;
}
@@ -78,8 +92,7 @@ onUnmounted(() => {
.files__aside-content {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overflow: hidden;
}
.files__aside:popover-open {
@@ -89,12 +102,20 @@ onUnmounted(() => {
@starting-style {
.files__aside:popover-open {
transform: translateX(-100%);
transform: translateX(100%);
}
}
.files__aside--dragging:popover-open {
transform: translateX(-25%);
transform: translateX(25%);
}
.files__file-preview {
position: fixed;
left: 0;
top: 0;
width: 100px;
height: 100px;
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
</svg>
</template>

View File

@@ -0,0 +1,121 @@
<template>
<Dropzone
v-slot="{isDragOver}"
class="map-control"
:class="{
'map-control--over': isDragOver
}"
@onDrop="onDrop"
@mousedown="onMouseDown"
>
<div ref="rendererWrapper">
<MapRenderer
ref="renderer"
:layers="layers"
:width="3840"
:height="2160"
:offsetX="offsetX"
:offsetY="offsetY"
:dpi="300"
/>
</div>
</Dropzone>
<button @click="onLoad">load</button>
</template>
<script setup lang="ts">
import {inject, onMounted, ref} from 'vue';
import Dropzone from '../Dropzone/Dropzone.vue';
import MapRenderer from './MapRenderer.vue';
import {MountedFsSymbol} from '../../model/injectionSymbols';
import {MapDrawable, MapImage} from './helpers';
import {VirtualFile} from '../../model/FileSystem/types';
const fs = inject(MountedFsSymbol)!;
const layers = ref<MapDrawable[]>([])
const renderer = ref<MapRenderer>();
const rendererWrapper = ref<HTMLDivElement>();
const scale = ref(1);
const offsetX = ref(0);
const offsetY = ref(0);
async function onDrop(file: VirtualFile) {
const data = await fs.download(file);
console.log('file', file, data);
}
const mode = ref<'drag' | 'draw'>('drag');
async function onLoad() {
const newLayers = [];
const map: VirtualFile = {
"type": "file",
"source": "Maps",
"name": "G_AbandonedMineEntrance_EscapedDragon_Night.jpg",
"mimeType": "image/jpeg",
"path": [
"Abandoned Mine Entrance [33x21] - $5 Rewards",
"Gridded",
"G_AbandonedMineEntrance_EscapedDragon_Night.jpg"
],
"map": {
"width": 33,
"height": 21,
"valid": true,
"animated": false
}
}
const mapFile = await fs.download(map);
const mapLayer = await MapImage.fromImageFile(mapFile, {
gameDimensions: {w: 33, h: 21},
});
newLayers.push(mapLayer);
layers.value = newLayers;
}
const dragging = ref(false);
function onMouseMove(event: MouseEvent) {
const mouseScale = rendererWrapper.value!.clientWidth / 3840;
console.log(renderer.value)
if (dragging.value) {
offsetX.value += event.movementX / mouseScale;
offsetY.value += event.movementY / mouseScale;
}
}
function onMouseUp(event: MouseEvent) {
console.log('mouse up', event);
dragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
function onMouseDown(event: MouseEvent) {
console.log('mouse down', event);
dragging.value = true;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
onMounted(() => {
onLoad();
});
</script>
<style lang="css">
.map-control {
display: inline-block;
border: 1px solid black;
background: white;
position: relative;
}
.map-control--over:before {
content: 'Drop here';
position: absolute;
inset: 10px;
border: 3px solid black;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<canvas
ref="map" style="max-width: 100%;"
:width="props.width"
:height="props.height"
></canvas>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue';
import {MapContext, MapDrawable} from './helpers';
const map = ref<HTMLCanvasElement>();
const props = defineProps<{
width: number,
height: number,
offsetX: number,
offsetY: number,
dpi: number,
layers: MapDrawable[]
}>()
const emit = defineEmits<{
mousedown: [MouseEvent]
}>()
const mapContext = computed(() => {
return {
virtualHeight: props.height,
virtualWidth: props.width,
offsetX: props.offsetX,
offsetY: props.offsetY,
scale: props.dpi,
dpi: props.dpi,
}
})
function redraw() {
const ctx = {
ctx: map.value!.getContext('2d')!,
...mapContext.value,
};
for (const layer of props.layers) {
layer.draw(ctx);
}
}
watch(() => mapContext.value, redraw);
watch(() => props.layers, redraw);
onMounted(redraw);
</script>

View File

@@ -0,0 +1,98 @@
async function loadImage(src: string | File): Promise<HTMLImageElement> {
if (src instanceof File) {
console.log('Creating object URL');
src = URL.createObjectURL(src);
}
try {
return await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = src;
});
} finally {
URL.revokeObjectURL(src);
}
}
/**
* number of 1 inch squares in the map
*/
interface GameDimensions {
w: number | undefined;
h: number | undefined;
}
interface MapImageOptions {
gameDimensions: GameDimensions;
}
export class MapImage implements MapDrawable {
file: HTMLCanvasElement;
gameDimensions: GameDimensions;
constructor(file: HTMLCanvasElement, {gameDimensions}: MapImageOptions) {
this.file = file;
this.gameDimensions = gameDimensions;
if (!gameDimensions.w && !gameDimensions.h) {
gameDimensions.w = 40;
}
if (!gameDimensions.h) {
gameDimensions.h = gameDimensions.w! * (file.height / file.width);
}
if (!gameDimensions.w) {
gameDimensions.w = gameDimensions.h! * (file.width / file.height);
}
}
static async fromImageFile(file: File, options: MapImageOptions) {
const canvas = document.createElement('canvas');
const img = await loadImage(file);
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.log('Failed to get 2d context');
throw new Error('Failed to get 2d context');
}
ctx.drawImage(img, 0, 0);
return new MapImage(canvas, options);
}
draw(mapContext: MapContext) {
const {ctx, dpi, virtualWidth, virtualHeight, offsetX, offsetY} = mapContext;
const naturalWidth = this.file.width;
const naturalHeight = this.file.height;
const w = this.gameDimensions.w!;
const h = this.gameDimensions.h!;
const imageDpi = naturalWidth / w;
const dpiScale = dpi / imageDpi;
const targetWidth = w * imageDpi * dpiScale;
const targetHeight = h * imageDpi * dpiScale;
const basicOffsetX = (virtualWidth - targetWidth) / 2;
const basicOffsetY = (virtualHeight - targetHeight) / 2;
ctx.drawImage(
this.file,
0, 0, naturalWidth, naturalHeight,
basicOffsetX + offsetX, basicOffsetY + offsetY, w * imageDpi * dpiScale, h * imageDpi * dpiScale,
);
}
}
export interface MapContext {
ctx: CanvasRenderingContext2D;
dpi: number;
virtualWidth: number;
virtualHeight: number;
scale: number;
offsetX: number;
offsetY: number;
}
export interface MapDrawable {
draw(ctx: MapContext): void;
}

View File

@@ -0,0 +1,4 @@
export interface MapLayer {
file: HTMLCanvasElement;
name: string;
}

View File

@@ -121,8 +121,6 @@ async function onDrop(event: DragEvent) {
await loadFile(meta, file);
}
}
console.log('files', event.dataTransfer?.files);
console.log('drop', event.dataTransfer?.getData('application/json'));
}

View File

@@ -1,6 +1,6 @@
<template>
<div>
<AppLayout>
<div style="background-color: white; display: flex; flex-direction: row;gap: 0.5rem;padding: 0.5rem;margin-bottom:0.5rem; color: black">
<FilesAside />
<!-- <div style="max-width: 32px; height: 32px; overflow: hidden;">
@@ -9,13 +9,15 @@
<button @click="openMapWindow">Open map window</button>
<button @click="onStreamImage">Stream image</button> -->
</div>
<FilesAside />
<template v-slot:overview>
<ScreenManager />
</template>
<Tabs
:tabs="[
{label: 'Sounds', key: 'sounds'},
{label: 'Settings', key: 'settings'},
{label: 'Devices', key: 'devices'},
{label: 'Map', key: 'map'},
]"
>
<template v-slot:settings>
@@ -40,8 +42,12 @@
<Soundboard />
</div>
</template>
<template v-slot:map>
<MapControl />
</template>
</Tabs>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import FileManager from '../../components/FileManager/FileManager.vue';
@@ -54,6 +60,8 @@ import ScreenManager from '../../components/ScreenManager/ScreenManager.vue';
import RemoteDevices from '../../components/RemoteDevices/RemoteDevices.vue';
import Soundboard from '../../components/Audio/Soundboard.vue';
import FilesAside from '../../components/Files/FilesAside.vue';
import MapControl from '../../components/Map/MapControl.vue';
import AppLayout from '../../components/Common/AppLayout.vue';
const openedFile = ref<FileWithMeta>()
const sender = inject(SenderSymbol);