feat(frontend): add text editor flow and create-file UX

- replace textarea editor with CodeMirror and syntax highlighting\n- integrate editor mode header (save button, hide unused controls)\n- fix breadcrumb/editor navigation behavior and transitions\n- add Create File action with ghost-rename flow and auto-open in editor\n- refine create-file icon shape and plus cutout alignment
This commit is contained in:
2026-05-06 02:17:17 +00:00
parent 07daf372e8
commit 72b3c0d8ce
7 changed files with 172 additions and 43 deletions
+3
View File
@@ -18,8 +18,11 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@codemirror/language-data": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@imengyu/vue3-context-menu": "^1.5.3", "@imengyu/vue3-context-menu": "^1.5.3",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"codemirror": "^6.0.2",
"esbuild": "^0.27.2", "esbuild": "^0.27.2",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lodash-es": "^4.17.23", "lodash-es": "^4.17.23",
+3 -1
View File
@@ -131,7 +131,9 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = store.fileExplorer as any const fileExplorer = store.fileExplorer as any
if (!fileExplorer) return if (!fileExplorer) return
const c = fileExplorer.isCursor() const c = fileExplorer.isCursor()
const input = ['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName) const target = event.target as HTMLElement
const input =
['INPUT', 'TEXTAREA'].includes(target.tagName) || !!target.closest('.cm-editor')
const keyup = event.type === 'keyup' const keyup = event.type === 'keyup'
// Always clear repeat timer on arrow keyup, even if focus moved to input // Always clear repeat timer on arrow keyup, even if focus moved to input
+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
<path fill-rule="evenodd" d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM22.75 18.55c0 .2625-.175.4375-.4375.4375h-4.55v4.55c0 .2625-.175.4375-.4375.4375h-2.45c-.2625 0-.4375-.175-.4375-.4375v-4.55h-4.55c-.2625 0-.4375-.175-.4375-.4375V16.1c0-.2625.175-.4375.4375-.4375h4.55v-4.55c0-.2625.175-.4375.4375-.4375h2.45c.2625 0 .4375.175.4375.4375v4.55h4.55c.2625 0 .4375.175.4375.4375v2.45z" />
</svg>

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 451 B

+37 -8
View File
@@ -12,10 +12,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="editing?.key === 'new'" class="folder"> <tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
<td class="selection"></td> <td class="selection"></td>
<td class="name"> <td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" /> <FileRenameInput :doc="editing" :rename="createItem" :exit="() => {editing = null}" />
</td> </td>
<FileModified :doc=editing :now=nowkey /> <FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing /> <FileSize :doc=editing />
@@ -206,8 +206,20 @@ const rename = async (doc: Doc, newName: string) => {
} }
} }
defineExpose({ defineExpose({
newFile() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New File.txt',
dir: false,
mtime: now,
size: 0,
allocated: 0
})
store.cursor = editing.value.key
},
newFolder() { newFolder() {
console.log('New folder')
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({ editing.value = new Doc({
loc: loc.value, loc: loc.value,
@@ -350,19 +362,36 @@ onUnmounted(() => {
keyboardFollowScroll.cancel() keyboardFollowScroll.cancel()
clearInterval(modifiedTimer) clearInterval(modifiedTimer)
}) })
const mkdir = async (doc: Doc, name: string) => { const editRoute = (path: string) =>
'/edit/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const createItem = async (doc: Doc, name: string) => {
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc) store.addGhost(doc)
editing.value = null editing.value = null
const path = doc.loc ? `${doc.loc}/${name}` : name const path = doc.loc ? `${doc.loc}/${name}` : name
try { try {
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' }) const res = doc.dir
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
: await apiFetch(filesUrl(path), {
method: 'PUT',
body: '',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) throw new Error(await parseErrorMessage(res)) if (!res.ok) throw new Error(await parseErrorMessage(res))
router.push(doc.urlrouter) if (doc.dir) {
router.push(doc.urlrouter)
} else {
router.push(editRoute(path))
}
} catch (err) { } catch (err) {
console.error('Mkdir failed', err) console.error('Create failed', err)
store.showToast(err instanceof Error ? err.message : 'Mkdir failed') store.showToast(err instanceof Error ? err.message : 'Create failed')
} }
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
+36 -6
View File
@@ -1,6 +1,6 @@
<template> <template>
<div v-if="props.documents.length || editing" class="gallery" ref="gallery"> <div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" /> <GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: createItem, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key> <template v-for="(doc, index) in documents" :key=doc.key>
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/> <BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<GalleryFigure <GalleryFigure
@@ -260,6 +260,19 @@ const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
} }
defineExpose({ defineExpose({
newFile() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New File.txt',
dir: false,
mtime: now,
size: 0,
allocated: 0
})
store.cursor = editing.value.key
},
newFolder() { newFolder() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({ editing.value = new Doc({
@@ -420,19 +433,36 @@ onUnmounted(() => {
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server) // Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
watch(() => props.documents, seedFromDocs) watch(() => props.documents, seedFromDocs)
const mkdir = async (doc: Doc, name: string) => { const editRoute = (path: string) =>
'/edit/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const createItem = async (doc: Doc, name: string) => {
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc) store.addGhost(doc)
editing.value = null editing.value = null
const path = doc.loc ? `${doc.loc}/${name}` : name const path = doc.loc ? `${doc.loc}/${name}` : name
try { try {
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' }) const res = doc.dir
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
: await apiFetch(filesUrl(path), {
method: 'PUT',
body: '',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) throw new Error(await parseErrorMessage(res)) if (!res.ok) throw new Error(await parseErrorMessage(res))
router.push(doc.urlrouter) if (doc.dir) {
router.push(doc.urlrouter)
} else {
router.push(editRoute(path))
}
} catch (err) { } catch (err) {
console.error('Mkdir failed', err) console.error('Create failed', err)
store.showToast(err instanceof Error ? err.message : 'Mkdir failed') store.showToast(err instanceof Error ? err.message : 'Create failed')
} }
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
+5
View File
@@ -2,6 +2,11 @@
<nav class="headermain buttons"> <nav class="headermain buttons">
<template v-if="!props.editorMode"> <template v-if="!props.editorMode">
<UploadButton :path="props.path" /> <UploadButton :path="props.path" />
<SvgButton
name="create-file"
tooltip="New file"
@click="() => { store.fileExplorer!.newFile() }"
/>
<SvgButton <SvgButton
name="create-folder" name="create-folder"
tooltip="New folder" tooltip="New folder"
+85 -27
View File
@@ -3,21 +3,22 @@
<div class="editor-body"> <div class="editor-body">
<div v-if="loading" class="status">Loading</div> <div v-if="loading" class="status">Loading</div>
<div v-else-if="error" class="status error">{{ error }}</div> <div v-else-if="error" class="status error">{{ error }}</div>
<textarea <div v-else ref="editorHost" class="editor-host"></div>
v-else
ref="textarea"
v-model="content"
spellcheck="false"
@keydown="onKeydown"
/>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { indentWithTab } from '@codemirror/commands'
import { LanguageDescription } from '@codemirror/language'
import { languages } from '@codemirror/language-data'
import { Compartment, EditorState } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView, keymap } from '@codemirror/view'
import { basicSetup } from 'codemirror'
import { apiFetch } from '@/repositories/Client' import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute } from 'vue-router' import { onBeforeRouteLeave, useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
@@ -43,7 +44,9 @@ const original = ref('')
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const error = ref('') const error = ref('')
const textarea = ref<HTMLTextAreaElement | null>(null) const editorHost = ref<HTMLDivElement | null>(null)
let editorView: EditorView | null = null
const languageCompartment = new Compartment()
const dirty = computed(() => content.value !== original.value) const dirty = computed(() => content.value !== original.value)
@@ -62,13 +65,46 @@ const beforeUnload = (event: BeforeUnloadEvent) => {
event.returnValue = '' event.returnValue = ''
} }
const onKeydown = (ev: KeyboardEvent) => { const detectLanguage = async () => {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') { const language = LanguageDescription.matchFilename(languages, filename.value)
ev.preventDefault() if (!language) return []
save() try {
return [await language.load()]
} catch {
return []
} }
} }
const initEditor = async (text: string) => {
if (!editorHost.value) return
const languageExtensions = await detectLanguage()
const state = EditorState.create({
doc: text,
extensions: [
basicSetup,
oneDark,
languageCompartment.of(languageExtensions),
EditorView.updateListener.of(update => {
if (update.docChanged) {
content.value = update.state.doc.toString()
}
}),
keymap.of([
{
key: 'Mod-s',
run: () => {
void save()
return true
}
},
indentWithTab
])
]
})
editorView = new EditorView({ state, parent: editorHost.value })
editorView.focus()
}
const save = async () => { const save = async () => {
if (saving.value || loading.value) return if (saving.value || loading.value) return
saving.value = true saving.value = true
@@ -111,10 +147,14 @@ onMounted(async () => {
const text = await textRes.text() const text = await textRes.text()
content.value = text content.value = text
original.value = text original.value = text
loading.value = false
await nextTick()
await initEditor(text)
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load file' error.value = err instanceof Error ? err.message : 'Failed to load file'
} finally { }
loading.value = false finally {
if (loading.value) loading.value = false
} }
}) })
@@ -122,6 +162,8 @@ onUnmounted(() => {
if (store.editorSave === save) { if (store.editorSave === save) {
store.editorSave = null store.editorSave = null
} }
editorView?.destroy()
editorView = null
window.removeEventListener('beforeunload', beforeUnload) window.removeEventListener('beforeunload', beforeUnload)
}) })
</script> </script>
@@ -133,6 +175,7 @@ onUnmounted(() => {
height: 100%; height: 100%;
background: #1a1a1a; background: #1a1a1a;
color: #ddd; color: #ddd;
text-align: left;
} }
.editor-body { .editor-body {
flex: 1; flex: 1;
@@ -140,25 +183,40 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.editor-body textarea { .editor-host {
flex: 1; flex: 1;
width: 100%; min-height: 0;
resize: none; }
.editor-host :deep(.cm-editor) {
flex: 1;
height: 100%;
border: none; border: none;
outline: none; outline: none;
padding: 1rem; }
.editor-host :deep(.cm-scroller) {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
background: #1a1a1a; text-align: left;
color: #ddd;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
} }
.editor-body textarea::selection { .editor-host :deep(.cm-content) {
background: var(--accent-color, #007bff); padding: 1rem;
color: #000; text-align: left;
}
.editor-host :deep(.cm-selectionBackground) {
background: var(--soft-color, #146) !important;
}
.editor-host :deep(.cm-focused .cm-selectionBackground) {
background: var(--soft-color, #146) !important;
}
.editor-host :deep(.cm-content ::selection) {
background: var(--soft-color, #146);
}
.editor-host :deep(.cm-content, .cm-gutter) {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
.editor-host :deep(.cm-line, .cm-gutters, .cm-gutterElement) {
text-align: left;
} }
.status { .status {
padding: 2rem; padding: 2rem;