You've already forked AstralRinth
forked from didirus/AstralRinth
Files UX Sprint (#3019)
* chore: dedupe lockfile Signed-off-by: Evan Song <theevansong@gmail.com> * fix: incorrect spacing between editing and browsing state Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve files image viewer toolbar Signed-off-by: Evan Song <theevansong@gmail.com> * chore: image viewer cursor affordance Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean imports Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add tooltips Signed-off-by: Evan Song <theevansong@gmail.com> * chore: use black background Signed-off-by: Evan Song <theevansong@gmail.com> * feat: show scale factor, handle large images, consolidate state Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add types to fs operations Signed-off-by: Evan Song <theevansong@gmail.com> * feat: add date create sorting option Signed-off-by: Evan Song <theevansong@gmail.com> * fix: match name of folder creation modal Signed-off-by: Evan Song <theevansong@gmail.com> * fix: add it here too Signed-off-by: Evan Song <theevansong@gmail.com> * feat: add creation date to file item, file manager header Signed-off-by: Evan Song <theevansong@gmail.com> * chore: a11y Signed-off-by: Evan Song <theevansong@gmail.com> * fix: ensure move item modal always has leading slash Signed-off-by: Evan Song <theevansong@gmail.com> * fix: correct move input placeholder Signed-off-by: Evan Song <theevansong@gmail.com> * chore: correct design disparity Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add better affordance on active file item state Signed-off-by: Evan Song <theevansong@gmail.com> * fix: correct instances where we dont sentence case Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * chore: notify that server restarted on saveandrestart Signed-off-by: Evan Song <theevansong@gmail.com> * fix: consolidate error state in file manager Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust sizing Signed-off-by: Evan Song <theevansong@gmail.com> * feat: drag and drop file items to move them Signed-off-by: Evan Song <theevansong@gmail.com> * feat: enable ability to drag folders too Signed-off-by: Evan Song <theevansong@gmail.com> * feat: better file movement toasts Signed-off-by: Evan Song <theevansong@gmail.com> * just say u hate me Signed-off-by: Evan Song <theevansong@gmail.com> * feat: uploading indicator for file uploads Signed-off-by: Evan Song <theevansong@gmail.com> * chore: cleaner file item ghost when dragging Signed-off-by: Evan Song <theevansong@gmail.com> * fix: enforce max length and truncate on ghost Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve item rename toast Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve item create toast Signed-off-by: Evan Song <theevansong@gmail.com> * feat: undo and redo stack Signed-off-by: Evan Song <theevansong@gmail.com> * fix: confusing behavior where folders were not sorted alphabetically Signed-off-by: Evan Song <theevansong@gmail.com> * feat: find and replace in file editor Signed-off-by: Evan Song <theevansong@gmail.com> * feat: correctly set language mode of file editor Signed-off-by: Evan Song <theevansong@gmail.com> * chore: slop Signed-off-by: Evan Song <theevansong@gmail.com> * chore: actually handle case with multiple dots in file name before setting language Signed-off-by: Evan Song <theevansong@gmail.com> * fix: match move icons in file context/threedot Signed-off-by: Evan Song <theevansong@gmail.com> * feat: upload indicator Signed-off-by: Evan Song <theevansong@gmail.com> * chore: dedupe lockfile again Signed-off-by: Evan Song <theevansong@gmail.com> * lockfile Signed-off-by: Evan Song <theevansong@gmail.com> * fix: file undefinedness Signed-off-by: Evan Song <theevansong@gmail.com> * checkpoint Signed-off-by: Evan Song <theevansong@gmail.com> * checkpoint Signed-off-by: Evan Song <theevansong@gmail.com> * checkpoint Signed-off-by: Evan Song <theevansong@gmail.com> * remove shitty animation logic Signed-off-by: Evan Song <theevansong@gmail.com> * feat: file upload queuer Signed-off-by: Evan Song <theevansong@gmail.com> * chore: only allow editable files to have active affordance Signed-off-by: Evan Song <theevansong@gmail.com> * fix: properly throw pyrofetcherror when rename fails Signed-off-by: Evan Song <theevansong@gmail.com> * feat: cancel file uploads Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> --------- Signed-off-by: Evan Song <theevansong@gmail.com>
This commit is contained in:
@@ -1,159 +1,178 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center bg-bg-raised">
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
|
||||
<div
|
||||
ref="container"
|
||||
class="relative w-full flex-grow overflow-hidden bg-bg-raised"
|
||||
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-b-2xl bg-black active:cursor-grabbing"
|
||||
@mousedown="startPan"
|
||||
@mousemove="pan"
|
||||
@mouseup="endPan"
|
||||
@mouseleave="endPan"
|
||||
@mousemove="handlePan"
|
||||
@mouseup="stopPan"
|
||||
@mouseleave="stopPan"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<UiServersPyroLoading v-if="loading" />
|
||||
<div v-if="error" class="flex h-full w-full flex-col items-center justify-center gap-8">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-12"
|
||||
>
|
||||
<path d="M4 13c3.5-2 8-2 10 2a5.5 5.5 0 0 1 8 5" />
|
||||
<path
|
||||
d="M5.15 17.89c5.52-1.52 8.65-6.89 7-12C11.55 4 11.5 2 13 2c3.22 0 5 5.5 5 8 0 6.5-4.2 12-10.49 12C5.11 22 2 22 2 20c0-1.5 1.14-1.55 3.15-2.11Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="m-0">Invalid or empty image file.</p>
|
||||
<UiServersPyroLoading v-if="state.isLoading" />
|
||||
<div
|
||||
v-if="state.hasError"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||
>
|
||||
<UiServersIconsPanelErrorIcon />
|
||||
<p class="m-0">{{ state.errorMessage || "Invalid or empty image file." }}</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="!loading && !error"
|
||||
ref="image"
|
||||
:src="imageUrl"
|
||||
v-show="isReady"
|
||||
ref="imageRef"
|
||||
:src="imageObjectUrl"
|
||||
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
|
||||
:style="{
|
||||
transform: `translate(-50%, -50%) scale(${scale}) translate(${translateX}px, ${translateY}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.3s ease-out',
|
||||
}"
|
||||
:style="imageStyle"
|
||||
alt="Viewed image"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!error"
|
||||
class="absolute bottom-0 mb-2 flex w-fit justify-center space-x-4 rounded-xl bg-bg p-2"
|
||||
v-if="!state.hasError"
|
||||
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
|
||||
>
|
||||
<Button icon-only transparent @click="zoomIn">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="resetZoom">
|
||||
<HomeIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="zoomOut">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
|
||||
<button v-tooltip="'Zoom in'">
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
|
||||
<button v-tooltip="'Zoom out'">
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" @click="reset">
|
||||
<button>
|
||||
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
|
||||
<span class="ml-4 text-sm text-blue">Reset</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { HomeIcon, ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
||||
import { Button } from "@modrinth/ui";
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps({
|
||||
imageBlob: {
|
||||
type: Blob,
|
||||
required: true,
|
||||
},
|
||||
const ZOOM_MIN = 0.1;
|
||||
const ZOOM_MAX = 5;
|
||||
const ZOOM_IN_FACTOR = 1.2;
|
||||
const ZOOM_OUT_FACTOR = 0.8;
|
||||
const INITIAL_SCALE = 0.5;
|
||||
const MAX_IMAGE_DIMENSION = 4096;
|
||||
|
||||
const props = defineProps<{
|
||||
imageBlob: Blob;
|
||||
}>();
|
||||
|
||||
const state = ref({
|
||||
scale: INITIAL_SCALE,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
isPanning: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
errorMessage: "",
|
||||
});
|
||||
|
||||
const container = ref(null);
|
||||
const image = ref(null);
|
||||
const scale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const isPanning = ref(false);
|
||||
const startX = ref(0);
|
||||
const startY = ref(0);
|
||||
const imageUrl = ref("");
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const imageRef = ref<HTMLImageElement | null>(null);
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
const imageObjectUrl = ref("");
|
||||
const rafId = ref(0);
|
||||
|
||||
const createImageUrl = (blob) => {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value);
|
||||
const isReady = computed(() => !state.value.isLoading && !state.value.hasError);
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
|
||||
transition: state.value.isPanning ? "none" : "transform 0.3s ease-out",
|
||||
}));
|
||||
|
||||
const validateImageDimensions = (img: HTMLImageElement): boolean => {
|
||||
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
|
||||
state.value.hasError = true;
|
||||
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`;
|
||||
return false;
|
||||
}
|
||||
imageUrl.value = URL.createObjectURL(blob);
|
||||
return true;
|
||||
};
|
||||
|
||||
const updateImageUrl = (blob: Blob) => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
|
||||
imageObjectUrl.value = URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
|
||||
state.value.isLoading = false;
|
||||
return;
|
||||
}
|
||||
state.value.isLoading = false;
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
state.value.isLoading = false;
|
||||
state.value.hasError = true;
|
||||
state.value.errorMessage = "Failed to load image";
|
||||
};
|
||||
|
||||
const zoom = (factor: number) => {
|
||||
const newScale = state.value.scale * factor;
|
||||
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX));
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
state.value.scale = INITIAL_SCALE;
|
||||
state.value.translateX = 0;
|
||||
state.value.translateY = 0;
|
||||
};
|
||||
|
||||
const startPan = (e: MouseEvent) => {
|
||||
state.value.isPanning = true;
|
||||
state.value.startX = e.clientX - state.value.translateX;
|
||||
state.value.startY = e.clientY - state.value.translateY;
|
||||
};
|
||||
|
||||
const handlePan = (e: MouseEvent) => {
|
||||
if (!state.value.isPanning) return;
|
||||
cancelAnimationFrame(rafId.value);
|
||||
rafId.value = requestAnimationFrame(() => {
|
||||
state.value.translateX = e.clientX - state.value.startX;
|
||||
state.value.translateY = e.clientY - state.value.startY;
|
||||
});
|
||||
};
|
||||
|
||||
const stopPan = () => {
|
||||
state.value.isPanning = false;
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
const delta = e.deltaY * -0.001;
|
||||
const factor = 1 + delta;
|
||||
zoom(factor);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.imageBlob,
|
||||
(newBlob) => {
|
||||
if (newBlob) {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
createImageUrl(newBlob);
|
||||
}
|
||||
if (!newBlob) return;
|
||||
state.value.isLoading = true;
|
||||
state.value.hasError = false;
|
||||
updateImageUrl(newBlob);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) {
|
||||
createImageUrl(props.imageBlob);
|
||||
}
|
||||
if (props.imageBlob) updateImageUrl(props.imageBlob);
|
||||
});
|
||||
|
||||
const onImageLoad = () => {
|
||||
loading.value = false;
|
||||
resetZoom();
|
||||
};
|
||||
|
||||
const onImageError = () => {
|
||||
loading.value = false;
|
||||
error.value = true;
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
scale.value = Math.min(scale.value * 1.2, 5);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
scale.value = Math.max(scale.value / 1.2, 0.1);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
scale.value = 0.5;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
};
|
||||
|
||||
const startPan = (e) => {
|
||||
isPanning.value = true;
|
||||
startX.value = e.clientX - translateX.value;
|
||||
startY.value = e.clientY - translateY.value;
|
||||
};
|
||||
|
||||
const pan = (e) => {
|
||||
if (isPanning.value) {
|
||||
translateX.value = e.clientX - startX.value;
|
||||
translateY.value = e.clientY - startY.value;
|
||||
}
|
||||
};
|
||||
|
||||
const endPan = () => {
|
||||
isPanning.value = false;
|
||||
};
|
||||
|
||||
const handleWheel = (e) => {
|
||||
const delta = (e.deltaY * -0.01) / 10;
|
||||
const newScale = Math.max(0.1, Math.min(scale.value + delta, 5));
|
||||
|
||||
scale.value = newScale;
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
|
||||
cancelAnimationFrame(rafId.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user