✨ nicer filemanager and layout
This commit is contained in:
@@ -25,6 +25,8 @@
|
||||
--color-fuchsia: #d946ef;
|
||||
--color-pink: #ec4899;
|
||||
--color-rose: #f43f5e;
|
||||
|
||||
--color-app-primary: var(--color-indigo);
|
||||
}
|
||||
|
||||
.theme {
|
||||
|
||||
65
src/components/Common/AppLayout.vue
Normal file
65
src/components/Common/AppLayout.vue
Normal 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>
|
||||
80
src/components/Common/IconButton.vue
Normal file
80
src/components/Common/IconButton.vue
Normal 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>
|
||||
71
src/components/Dropzone/Dropzone.vue
Normal file
71
src/components/Dropzone/Dropzone.vue
Normal 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>
|
||||
@@ -1,36 +1,45 @@
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
@click="buildIndex"
|
||||
:disabled="buildingIndex"
|
||||
>
|
||||
<template v-if="buildingIndex">building {{ indexCounter }}</template>
|
||||
<template v-else>Build Index</template>
|
||||
</button>
|
||||
<input type="search" placeholder="search" v-model="search">
|
||||
<ul
|
||||
v-if="searchResults.length > 0"
|
||||
class="file-manager-dir"
|
||||
>
|
||||
<FileManagerFile
|
||||
v-for="file in searchResults"
|
||||
:key="file.name"
|
||||
:file="file"
|
||||
@openFile="$emit('openFile', $event)"
|
||||
/>
|
||||
</ul>
|
||||
<ul
|
||||
v-else
|
||||
class="file-manager-dir"
|
||||
>
|
||||
<FileManagerDir
|
||||
v-for="dir in fs.root"
|
||||
:key="dir.name"
|
||||
:fs="fs"
|
||||
:dir="dir"
|
||||
@openFile="$emit('openFile', $event)"
|
||||
/>
|
||||
</ul>
|
||||
<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"
|
||||
>
|
||||
<template v-if="buildingIndex">building {{ indexCounter }}</template>
|
||||
<template v-else>Build Index</template>
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-manager__files">
|
||||
<ul
|
||||
v-if="searchResults.length > 0"
|
||||
class="file-manager-dir"
|
||||
>
|
||||
<FileManagerFile
|
||||
v-for="file in searchResults"
|
||||
:key="file.name"
|
||||
:file="file"
|
||||
@openFile="$emit('openFile', $event)"
|
||||
@filePreview="onFilePreview"
|
||||
/>
|
||||
</ul>
|
||||
<ul
|
||||
v-else
|
||||
class="file-manager-dir"
|
||||
>
|
||||
<FileManagerDir
|
||||
v-for="dir in fs.root"
|
||||
:key="dir.name"
|
||||
: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">
|
||||
@@ -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;
|
||||
|
||||
@@ -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)[]>([]);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
40
src/components/FileManager/FilePreview.vue
Normal file
40
src/components/FileManager/FilePreview.vue
Normal 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>
|
||||
@@ -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>
|
||||
5
src/components/Icons/Directory.vue
Normal file
5
src/components/Icons/Directory.vue
Normal 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>
|
||||
121
src/components/Map/MapControl.vue
Normal file
121
src/components/Map/MapControl.vue
Normal 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>
|
||||
52
src/components/Map/MapRenderer.vue
Normal file
52
src/components/Map/MapRenderer.vue
Normal 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>
|
||||
98
src/components/Map/helpers.ts
Normal file
98
src/components/Map/helpers.ts
Normal 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;
|
||||
}
|
||||
4
src/components/Map/types.ts
Normal file
4
src/components/Map/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface MapLayer {
|
||||
file: HTMLCanvasElement;
|
||||
name: string;
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
<ScreenManager />
|
||||
<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);
|
||||
|
||||
Reference in New Issue
Block a user