basic text editor
This commit is contained in:
@@ -214,6 +214,7 @@ impl FileSystem {
|
|||||||
let mut file = fs::OpenOptions::new()
|
let mut file = fs::OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.create(true)
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
.open(&file_path)
|
.open(&file_path)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const fileInputElement = ref<HTMLInputElement>(
|
|||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const dropZoneRef = ref<HTMLElement>();
|
const dropZoneRef = ref<HTMLElement>();
|
||||||
|
|
||||||
const dropZone = useDropZone(dropZoneRef, {
|
useDropZone(dropZoneRef, {
|
||||||
onDrop,
|
onDrop,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const warrenStore = useWarrenStore();
|
import { useImageViewer } from '~/stores/viewers';
|
||||||
|
|
||||||
|
const imageViewer = useImageViewer();
|
||||||
|
|
||||||
function onOpenUpdate(state: boolean) {
|
function onOpenUpdate(state: boolean) {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
warrenStore.imageViewer.src = null;
|
imageViewer.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog :open="imageViewer.src != null" @update:open="onOpenUpdate">
|
||||||
:open="warrenStore.imageViewer.src != null"
|
|
||||||
@update:open="onOpenUpdate"
|
|
||||||
>
|
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<slot />
|
<slot />
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -20,9 +19,9 @@ function onOpenUpdate(state: boolean) {
|
|||||||
class="w-full overflow-hidden p-0 sm:!max-h-[90vh] sm:!max-w-[90vw]"
|
class="w-full overflow-hidden p-0 sm:!max-h-[90vh] sm:!max-w-[90vw]"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="warrenStore.imageViewer.src"
|
v-if="imageViewer.src"
|
||||||
class="h-full w-full overflow-hidden !object-contain"
|
class="h-full w-full overflow-hidden !object-contain"
|
||||||
:src="warrenStore.imageViewer.src"
|
:src="imageViewer.src"
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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">
|
<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 { Separator } from '@/components/ui/separator';
|
||||||
import CreateDirectoryDialog from '~/components/actions/CreateDirectoryDialog.vue';
|
import CreateDirectoryDialog from '~/components/actions/CreateDirectoryDialog.vue';
|
||||||
import UploadDialog from '~/components/actions/UploadDialog.vue';
|
import UploadDialog from '~/components/actions/UploadDialog.vue';
|
||||||
@@ -18,7 +20,10 @@ await useAsyncData('warrens', async () => {
|
|||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<SelectionRect />
|
<SelectionRect />
|
||||||
<ActionsShareDialog />
|
<ActionsShareDialog />
|
||||||
|
|
||||||
|
<TextEditor />
|
||||||
<ImageViewer />
|
<ImageViewer />
|
||||||
|
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset class="flex flex-col-reverse md:flex-col">
|
<SidebarInset class="flex flex-col-reverse md:flex-col">
|
||||||
<header
|
<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>
|
<template>
|
||||||
<main class="flex h-full w-full items-center justify-center">
|
<main class="flex h-full w-full items-center justify-center">
|
||||||
<SelectionRect />
|
<SelectionRect />
|
||||||
|
|
||||||
|
<TextEditor />
|
||||||
<ImageViewer />
|
<ImageViewer />
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -245,20 +245,11 @@ export async function uploadToWarren(
|
|||||||
try {
|
try {
|
||||||
await promise;
|
await promise;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Upload', {
|
|
||||||
id: 'UPLOAD_FILE_TOAST',
|
|
||||||
description: `Failed to upload`,
|
|
||||||
});
|
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshNuxtData('current-directory');
|
await refreshNuxtData('current-directory');
|
||||||
|
|
||||||
toast.success('Upload', {
|
|
||||||
id: 'UPLOAD_FILE_TOAST',
|
|
||||||
description: `Successfully uploaded ${files.length} file${files.length !== 1 ? 's' : ''}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { fetchShareFile, getShare, listShareFiles } from '~/lib/api/shares';
|
import { fetchShareFile, getShare, listShareFiles } from '~/lib/api/shares';
|
||||||
import type { DirectoryEntry } from '~/shared/types';
|
import type { DirectoryEntry } from '~/shared/types';
|
||||||
import type { Share } from '~/shared/types/shares';
|
import type { Share } from '~/shared/types/shares';
|
||||||
|
import { useImageViewer, useTextEditor } from '~/stores/viewers';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'share',
|
layout: 'share',
|
||||||
@@ -10,6 +11,9 @@ definePageMeta({
|
|||||||
const warrenStore = useWarrenStore();
|
const warrenStore = useWarrenStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
const imageViewer = useImageViewer();
|
||||||
|
const textEditor = useTextEditor();
|
||||||
|
|
||||||
const entries = computed(() =>
|
const entries = computed(() =>
|
||||||
warrenStore.current != null && warrenStore.current.dir != null
|
warrenStore.current != null && warrenStore.current.dir != null
|
||||||
? warrenStore.current.dir.entries
|
? warrenStore.current.dir.entries
|
||||||
@@ -127,8 +131,29 @@ async function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const url = URL.createObjectURL(result.data);
|
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 RenameEntryDialog from '~/components/actions/RenameEntryDialog.vue';
|
||||||
import { fetchFile, getWarrenDirectory, warrenRm } from '~/lib/api/warrens';
|
import { fetchFile, getWarrenDirectory, warrenRm } from '~/lib/api/warrens';
|
||||||
import type { DirectoryEntry } from '~/shared/types';
|
import type { DirectoryEntry } from '~/shared/types';
|
||||||
|
import { useImageViewer, useTextEditor } from '~/stores/viewers';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const warrenStore = useWarrenStore();
|
const warrenStore = useWarrenStore();
|
||||||
|
|
||||||
|
const imageViewer = useImageViewer();
|
||||||
|
const textEditor = useTextEditor();
|
||||||
|
|
||||||
const loadingIndicator = useLoadingIndicator();
|
const loadingIndicator = useLoadingIndicator();
|
||||||
const uploadStore = useUploadStore();
|
const uploadStore = useUploadStore();
|
||||||
const warrenPath = computed(() => useWarrenPath());
|
const warrenPath = computed(() => useWarrenPath());
|
||||||
@@ -105,10 +110,32 @@ async function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
|
|||||||
warrenStore.current.path,
|
warrenStore.current.path,
|
||||||
entry.name
|
entry.name
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const url = URL.createObjectURL(result.data);
|
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', {
|
export const useWarrenStore = defineStore('warrens', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
warrens: {} as Record<string, WarrenData>,
|
warrens: {} as Record<string, WarrenData>,
|
||||||
imageViewer: {
|
|
||||||
src: null as string | null,
|
|
||||||
},
|
|
||||||
current: null as {
|
current: null as {
|
||||||
warrenId: string;
|
warrenId: string;
|
||||||
path: 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