basic text editor
This commit is contained in:
@@ -214,6 +214,7 @@ impl FileSystem {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&file_path)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
152
frontend/components/viewers/TextEditor.vue
Normal file
152
frontend/components/viewers/TextEditor.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
13
frontend/stores/viewers/image.ts
Normal file
13
frontend/stores/viewers/image.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
4
frontend/stores/viewers/index.ts
Normal file
4
frontend/stores/viewers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useImageViewer } from './image';
|
||||
import { useTextEditor } from './text';
|
||||
|
||||
export { useImageViewer, useTextEditor };
|
||||
71
frontend/stores/viewers/text.ts
Normal file
71
frontend/stores/viewers/text.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user