basic text editor

This commit is contained in:
2025-09-06 01:21:13 +02:00
parent 73d3b2a27f
commit a4c2c039d2
13 changed files with 315 additions and 24 deletions

View File

@@ -214,6 +214,7 @@ impl FileSystem {
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&file_path)
.await?;

View File

@@ -35,7 +35,7 @@ const fileInputElement = ref<HTMLInputElement>(
const uploading = ref(false);
const dropZoneRef = ref<HTMLElement>();
const dropZone = useDropZone(dropZoneRef, {
useDropZone(dropZoneRef, {
onDrop,
multiple: true,
});

View File

@@ -1,18 +1,17 @@
<script setup lang="ts">
const warrenStore = useWarrenStore();
import { useImageViewer } from '~/stores/viewers';
const imageViewer = useImageViewer();
function onOpenUpdate(state: boolean) {
if (!state) {
warrenStore.imageViewer.src = null;
imageViewer.close();
}
}
</script>
<template>
<Dialog
:open="warrenStore.imageViewer.src != null"
@update:open="onOpenUpdate"
>
<Dialog :open="imageViewer.src != null" @update:open="onOpenUpdate">
<DialogTrigger>
<slot />
</DialogTrigger>
@@ -20,9 +19,9 @@ function onOpenUpdate(state: boolean) {
class="w-full overflow-hidden p-0 sm:!max-h-[90vh] sm:!max-w-[90vw]"
>
<img
v-if="warrenStore.imageViewer.src"
v-if="imageViewer.src"
class="h-full w-full overflow-hidden !object-contain"
:src="warrenStore.imageViewer.src"
:src="imageViewer.src"
/>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { toast } from 'vue-sonner';
import { useTextEditor } from '~/stores/viewers';
const editor = useTextEditor();
const textarea = ref<HTMLTextAreaElement>(
null as unknown as HTMLTextAreaElement
);
const currentLine = ref<number>(0);
const totalLines = computed(
() => editor.data?.editedContent.split('\n').length ?? 0
);
function onOpenUpdate(state: boolean) {
if (!state) {
editor.close();
}
}
async function saveFile() {
if (editor.data == null) {
return;
}
const name = editor.data.entry.name;
const success = await editor.save();
if (success) {
toast.success('Save', {
id: 'TEXT_EDITOR_SAVE_TOAST',
description: `Successfully saved ${name}`,
});
} else {
toast.error('Save', {
id: 'TEXT_EDITOR_SAVE_TOAST',
description: `Failed to save ${name}`,
});
}
}
function onEditorKeyDown(e: KeyboardEvent) {
if (editor.saving) {
e.preventDefault();
return;
}
requestAnimationFrame(updateCurrentLine);
if (!e.ctrlKey || e.key !== 's') {
return;
}
e.preventDefault();
saveFile();
}
function updateCurrentLine() {
if (editor.data == null) {
return;
}
currentLine.value = editor.data.editedContent
.substring(0, textarea.value.selectionStart)
.split('\n').length;
}
function onEditorClick() {
requestAnimationFrame(updateCurrentLine);
}
onMounted(() => {
requestAnimationFrame(updateCurrentLine);
});
</script>
<template>
<Dialog :open="editor.data != null" @update:open="onOpenUpdate">
<DialogContent
v-if="editor.data"
class="flex h-full max-h-[min(98vh,700px)] w-full !max-w-[min(98vw,1000px)] flex-col"
>
<DialogHeader>
<DialogTitle>{{ editor.data.entry.name }}</DialogTitle>
</DialogHeader>
<div v-if="editor.data.content == null">
<span class="text-muted-foreground animate-pulse"
>Loading content...</span
>
</div>
<div v-else class="flex h-full w-full flex-col gap-2">
<div
class="flex h-fit w-full flex-row items-center justify-end gap-1"
>
<Button
title="Reset"
variant="destructive"
size="icon"
:disabled="
editor.data.content === editor.data.editedContent ||
editor.saving
"
@click="() => editor.discardEdits()"
>
<Icon name="lucide:bomb" />
</Button>
<Separator orientation="vertical" class="mx-1" />
<Button
title="Save"
size="icon"
:disabled="
editor.saving ||
editor.data.content === editor.data.editedContent
"
@click="saveFile"
>
<Icon name="lucide:save" />
</Button>
</div>
<textarea
id="editor"
ref="textarea"
v-model="editor.data.editedContent"
class="h-full w-full resize-none overflow-auto rounded-md border p-4 whitespace-pre focus:outline-0"
@keydown="onEditorKeyDown"
@click="onEditorClick"
></textarea>
<div class="flex w-full flex-row justify-end">
<p class="text-muted-foreground">
Line {{ currentLine }} / {{ totalLines }}
</p>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<style>
#editor {
scrollbar-width: 0 !important;
}
#editor::-webkit-scrollbar {
display: none !important;
}
</style>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import TextEditor from '~/components/viewers/TextEditor.vue';
import ImageViewer from '@/components/viewers/ImageViewer.vue';
import { Separator } from '@/components/ui/separator';
import CreateDirectoryDialog from '~/components/actions/CreateDirectoryDialog.vue';
import UploadDialog from '~/components/actions/UploadDialog.vue';
@@ -18,7 +20,10 @@ await useAsyncData('warrens', async () => {
<SidebarProvider>
<SelectionRect />
<ActionsShareDialog />
<TextEditor />
<ImageViewer />
<AppSidebar />
<SidebarInset class="flex flex-col-reverse md:flex-col">
<header

View File

@@ -1,9 +1,15 @@
<script lang="ts" setup></script>
<script lang="ts" setup>
import TextEditor from '~/components/viewers/TextEditor.vue';
import ImageViewer from '@/components/viewers/ImageViewer.vue';
</script>
<template>
<main class="flex h-full w-full items-center justify-center">
<SelectionRect />
<TextEditor />
<ImageViewer />
<slot />
</main>
</template>

View File

@@ -245,20 +245,11 @@ export async function uploadToWarren(
try {
await promise;
} catch {
toast.error('Upload', {
id: 'UPLOAD_FILE_TOAST',
description: `Failed to upload`,
});
return { success: false };
}
await refreshNuxtData('current-directory');
toast.success('Upload', {
id: 'UPLOAD_FILE_TOAST',
description: `Successfully uploaded ${files.length} file${files.length !== 1 ? 's' : ''}`,
});
return { success: true };
}

View File

@@ -2,6 +2,7 @@
import { fetchShareFile, getShare, listShareFiles } from '~/lib/api/shares';
import type { DirectoryEntry } from '~/shared/types';
import type { Share } from '~/shared/types/shares';
import { useImageViewer, useTextEditor } from '~/stores/viewers';
definePageMeta({
layout: 'share',
@@ -10,6 +11,9 @@ definePageMeta({
const warrenStore = useWarrenStore();
const route = useRoute();
const imageViewer = useImageViewer();
const textEditor = useTextEditor();
const entries = computed(() =>
warrenStore.current != null && warrenStore.current.dir != null
? warrenStore.current.dir.entries
@@ -127,8 +131,29 @@ async function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
if (result.success) {
const url = URL.createObjectURL(result.data);
warrenStore.imageViewer.src = url;
imageViewer.open(url);
}
return;
}
if (entry.mimeType.startsWith('text/')) {
const result = await fetchShareFile(
share!.data.id,
entryPath,
password.value.length > 0 ? password.value : null
);
if (result.success) {
textEditor.open(
warrenStore.current.warrenId,
warrenStore.current.path,
entry,
result.data
);
}
return;
}
}

View File

@@ -5,12 +5,17 @@ import DirectoryListContextMenu from '~/components/DirectoryListContextMenu.vue'
import RenameEntryDialog from '~/components/actions/RenameEntryDialog.vue';
import { fetchFile, getWarrenDirectory, warrenRm } from '~/lib/api/warrens';
import type { DirectoryEntry } from '~/shared/types';
import { useImageViewer, useTextEditor } from '~/stores/viewers';
definePageMeta({
middleware: ['authenticated'],
});
const warrenStore = useWarrenStore();
const imageViewer = useImageViewer();
const textEditor = useTextEditor();
const loadingIndicator = useLoadingIndicator();
const uploadStore = useUploadStore();
const warrenPath = computed(() => useWarrenPath());
@@ -105,10 +110,32 @@ async function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
warrenStore.current.path,
entry.name
);
if (result.success) {
const url = URL.createObjectURL(result.data);
warrenStore.imageViewer.src = url;
imageViewer.open(url);
}
return;
}
if (entry.mimeType.startsWith('text/')) {
const result = await fetchFile(
warrenStore.current.warrenId,
warrenStore.current.path,
entry.name
);
if (result.success) {
textEditor.open(
warrenStore.current.warrenId,
warrenStore.current.path,
entry,
result.data
);
}
return;
}
}

View File

@@ -6,9 +6,6 @@ import { getParentPath } from '~/utils/files';
export const useWarrenStore = defineStore('warrens', {
state: () => ({
warrens: {} as Record<string, WarrenData>,
imageViewer: {
src: null as string | null,
},
current: null as {
warrenId: string;
path: string;

View File

@@ -0,0 +1,13 @@
export const useImageViewer = defineStore('image-viewer', {
state: () => ({
src: null as string | null,
}),
actions: {
open(src: string) {
this.src = src;
},
close() {
this.src = null;
},
},
});

View File

@@ -0,0 +1,4 @@
import { useImageViewer } from './image';
import { useTextEditor } from './text';
export { useImageViewer, useTextEditor };

View File

@@ -0,0 +1,71 @@
import { uploadToWarren } from '~/lib/api/warrens';
import type { DirectoryEntry } from '~/shared/types';
export const useTextEditor = defineStore('text-editor', {
state: () => ({
data: null as {
warrenId: string;
parentPath: string;
entry: DirectoryEntry;
content: string;
editedContent: string;
} | null,
saving: false as boolean,
}),
actions: {
async open(
warrenId: string,
parentPath: string,
entry: DirectoryEntry,
content: Blob
) {
const contentString = await content.text();
this.data = {
warrenId,
parentPath,
entry,
content: contentString,
editedContent: contentString,
};
},
async save(): Promise<boolean> {
if (this.saving || this.data == null) {
return false;
}
this.saving = true;
const dt = new DataTransfer();
dt.items.add(
new File([this.data.editedContent], this.data.entry.name)
);
const result = await uploadToWarren(
this.data.warrenId,
this.data.parentPath,
dt.files,
undefined
);
this.$patch({
data: {
content: this.data.editedContent,
},
saving: false,
});
return result.success;
},
discardEdits() {
if (this.data == null) {
return;
}
this.data.editedContent = this.data.content;
},
close() {
this.data = null;
},
},
});