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:
Evan Song
2024-12-16 16:13:31 -07:00
committed by GitHub
parent 9aa70359a8
commit fee8d6c34e
15 changed files with 1394 additions and 2539 deletions

View File

@@ -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>