Rudimentary text file editing support.
This commit is contained in:
+62
-9
@@ -11,11 +11,27 @@
|
||||
<AboutModal />
|
||||
<AccessDeniedModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||
<BreadCrumb :path="path.pathList" primary />
|
||||
<HeaderMain
|
||||
ref="headerMain"
|
||||
:path="path.pathList"
|
||||
:query="path.query"
|
||||
:editor-mode="path.isEditorPath"
|
||||
/>
|
||||
<BreadCrumb
|
||||
:path="path.breadcrumbPathList"
|
||||
:links="path.breadcrumbLinks"
|
||||
primary
|
||||
/>
|
||||
</header>
|
||||
<main>
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
<main class="transition-wrapper">
|
||||
<Transition
|
||||
:name="routeTransitionName"
|
||||
@after-enter="store.transitionDirection = 'none'"
|
||||
>
|
||||
<div :key="routeViewKey" class="explorer-content">
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||
<SelectionToolbar :path="path.pathList" />
|
||||
@@ -44,20 +60,49 @@ import type { SortOrder } from './utils/docsort'
|
||||
|
||||
interface Path {
|
||||
path: string
|
||||
isEditorPath: boolean
|
||||
pathList: string[]
|
||||
breadcrumbPathList: string[]
|
||||
breadcrumbLinks?: string[]
|
||||
query: string
|
||||
}
|
||||
const store = useMainStore()
|
||||
const path: ComputedRef<Path> = computed(() => {
|
||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||
const pathList = (p[0] ?? '').split('/').filter(value => value !== '')
|
||||
const routePathList = (p[0] ?? '').split('/').filter(value => value !== '')
|
||||
const query = p.slice(1).join('//')
|
||||
const isEditorPath = routePathList[0] === 'edit'
|
||||
const pathList = isEditorPath ? routePathList.slice(1, -1) : routePathList
|
||||
const breadcrumbPathList = isEditorPath
|
||||
? routePathList.slice(1)
|
||||
: routePathList
|
||||
const breadcrumbLinks = isEditorPath
|
||||
? [
|
||||
'/',
|
||||
...routePathList
|
||||
.slice(1, -1)
|
||||
.map((_, index) => `/${routePathList.slice(1, index + 2).join('/')}/`),
|
||||
`/${routePathList.join('/')}`
|
||||
]
|
||||
: undefined
|
||||
return {
|
||||
path: p[0] ?? '',
|
||||
isEditorPath,
|
||||
pathList,
|
||||
breadcrumbPathList,
|
||||
breadcrumbLinks,
|
||||
query
|
||||
}
|
||||
})
|
||||
const routeTransitionName = computed(() => {
|
||||
if (store.transitionDirection === 'forward') return 'slide-forward'
|
||||
if (store.transitionDirection === 'backward') return 'slide-backward'
|
||||
return ''
|
||||
})
|
||||
const routeViewKey = computed(() => {
|
||||
const route = Router.currentRoute.value
|
||||
return route.name === 'editor' ? route.path : String(route.name ?? route.path)
|
||||
})
|
||||
watch(
|
||||
() => path.value.path,
|
||||
() => {
|
||||
@@ -86,7 +131,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const fileExplorer = store.fileExplorer as any
|
||||
if (!fileExplorer) return
|
||||
const c = fileExplorer.isCursor()
|
||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||
const input = ['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)
|
||||
const keyup = event.type === 'keyup'
|
||||
|
||||
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
||||
@@ -142,11 +187,17 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
// Paging/navigation key handling - fall through to bottom
|
||||
}
|
||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||
else if (
|
||||
!path.value.isEditorPath &&
|
||||
!input &&
|
||||
!keyup &&
|
||||
event.key === 'f' &&
|
||||
(event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||
else if (!input && keyup && event.code === 'Slash') {
|
||||
else if (!path.value.isEditorPath && !input && keyup && event.code === 'Slash') {
|
||||
// Record the actual character for display (varies by keyboard layout)
|
||||
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
||||
store.prefs.searchHotkey = event.key
|
||||
@@ -159,7 +210,9 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
store.clearToast()
|
||||
// Keep rename and other non-search inputs isolated from search behavior.
|
||||
if (input && !searchInput) return
|
||||
headerMain.value!.clearSearch(event)
|
||||
if (!path.value.isEditorPath) {
|
||||
headerMain.value!.clearSearch(event)
|
||||
}
|
||||
store.focusBreadcrumb()
|
||||
} else if (!input && keyup && event.key === 'Backspace') {
|
||||
Router.back()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@focus=focusCurrent
|
||||
tabindex=0
|
||||
>
|
||||
<a href="#/"
|
||||
<a :href="`/#${urlAt(0)}`"
|
||||
:ref="el => setLinkRef(0, el)"
|
||||
class="home"
|
||||
:class="{ current: !!isCurrent(0) }"
|
||||
@@ -22,7 +22,7 @@
|
||||
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
||||
</a>
|
||||
<template v-for="(location, index) in longest" :key="index">
|
||||
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
|
||||
<a :href="`/#${urlAt(index + 1)}`"
|
||||
:class="{ current: !!isCurrent(index + 1) }"
|
||||
:aria-current="isCurrent(index + 1)"
|
||||
@click.prevent="navigate(index + 1)"
|
||||
@@ -62,10 +62,17 @@ const setPathTooltipRef = (index: number, el: any) => {
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
links?: Array<string>
|
||||
primary?: boolean
|
||||
}>()
|
||||
|
||||
const longest = ref<Array<string>>([])
|
||||
const longestLinks = ref<Array<string>>(['/'])
|
||||
|
||||
const defaultLinks = (segments: Array<string>) => [
|
||||
'/',
|
||||
...segments.map((_, index) => `/${segments.slice(0, index + 1).join('/')}/`)
|
||||
]
|
||||
|
||||
const isCurrent = (index: number) =>
|
||||
index == props.path.length ? 'location' : undefined
|
||||
@@ -77,16 +84,22 @@ const focusCurrent = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const urlAt = (index: number) => {
|
||||
const explicit = longestLinks.value[index]
|
||||
return explicit ?? (index ? `/${longest.value.slice(0, index).join('/')}/` : '/')
|
||||
}
|
||||
|
||||
const navigate = (index: number) => {
|
||||
const link = links[index]
|
||||
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
||||
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
||||
const url = urlAt(index)
|
||||
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Clicking on current link clears the rest of the path and adds new history
|
||||
if (isCurrent(index)) {
|
||||
longest.value.splice(index)
|
||||
longestLinks.value.splice(index + 1)
|
||||
router.push(u)
|
||||
}
|
||||
// Moving along breadcrumbs doesn't create new history
|
||||
@@ -102,20 +115,27 @@ const move = (dir: number) => {
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const currentLinks = props.links ?? defaultLinks(props.path)
|
||||
const longcut = longest.value.slice(0, props.path.length)
|
||||
const same = longcut.every((value, index) => value === props.path[index])
|
||||
// Navigated out of previous path, reset longest to current
|
||||
if (!same) longest.value = props.path
|
||||
if (!same) {
|
||||
longest.value = props.path
|
||||
longestLinks.value = currentLinks
|
||||
}
|
||||
else if (props.path.length > longcut.length) {
|
||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||
} else {
|
||||
// Prune deleted folders from longest
|
||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||
if (!exists(longest.value.slice(0, i + 1))) {
|
||||
longest.value = longest.value.slice(0, i)
|
||||
longestLinks.value = longestLinks.value.slice(0, i + 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||
}
|
||||
// If needed, focus primary navigation to new location
|
||||
if (props.primary)
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||
{{ doc.name }}
|
||||
</a>
|
||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
|
||||
@@ -49,10 +49,12 @@ import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const router = useRouter()
|
||||
type EditingProp = {
|
||||
rename: (doc: Doc, newName: string) => void
|
||||
exit: () => void
|
||||
@@ -87,7 +89,12 @@ const snap = computed(() => {
|
||||
})
|
||||
|
||||
const onclick = (ev: Event) => {
|
||||
if (m.value!.play()) ev.preventDefault()
|
||||
if (m.value!.play()) {
|
||||
ev.preventDefault()
|
||||
} else if (props.doc.text) {
|
||||
ev.preventDefault()
|
||||
router.push(props.doc.editurl.replace('/#', ''))
|
||||
}
|
||||
store.cursor = props.doc.key
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
<template>
|
||||
<nav class="headermain buttons">
|
||||
<UploadButton :path="props.path" />
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
tooltip="New folder"
|
||||
@click="() => { store.fileExplorer!.newFolder() }"
|
||||
/>
|
||||
<div class="smallgap"></div>
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||
<div class="search-group">
|
||||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
:value="query"
|
||||
@input="updateSearch"
|
||||
@keydown.escape="clearSearch"
|
||||
<template v-if="!props.editorMode">
|
||||
<UploadButton :path="props.path" />
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
tooltip="New folder"
|
||||
@click="() => { store.fileExplorer!.newFolder() }"
|
||||
/>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||
</div>
|
||||
<div v-if="showSortHints" class="sort-hints">
|
||||
<div class="smallgap"></div>
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||
<div class="search-group">
|
||||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
:value="query"
|
||||
@input="updateSearch"
|
||||
@keydown.escape="clearSearch"
|
||||
/>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!props.editorMode && showSortHints" class="sort-hints">
|
||||
<span class="sort-label">Order</span>
|
||||
<span class="keycap">1</span>
|
||||
<span class="keycap">2</span>
|
||||
<span class="keycap">3</span>
|
||||
</div>
|
||||
<SvgButton
|
||||
v-if="props.editorMode"
|
||||
name="disk"
|
||||
tooltip="Save (Ctrl/Cmd+S)"
|
||||
@click="store.editorSave?.()"
|
||||
/>
|
||||
<div class="spacer smallgap"></div>
|
||||
<DiskSpace v-if="store.space.disk" />
|
||||
<SvgButton name="cog" @click="settingsMenu" />
|
||||
@@ -49,6 +57,7 @@ const textInputFocused = ref(false)
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
editorMode?: boolean
|
||||
}>()
|
||||
|
||||
const isInputElement = (el: Element | null): boolean => {
|
||||
|
||||
@@ -89,6 +89,14 @@ export class Doc {
|
||||
get print(): boolean {
|
||||
return (FILE_TYPES.print as readonly string[]).includes(this.ext)
|
||||
}
|
||||
get text(): boolean {
|
||||
return (FILE_TYPES.text as readonly string[]).includes(this.ext)
|
||||
}
|
||||
get editurl(): string {
|
||||
if (!this.text) return ''
|
||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||
return '/#/edit/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
}
|
||||
get complete(): boolean {
|
||||
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import ExplorerView from '@/views/ExplorerView.vue'
|
||||
import TextEditorView from '@/views/TextEditorView.vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
function getPathDepth(path: string): number {
|
||||
@@ -10,6 +11,11 @@ function getPathDepth(path: string): number {
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/edit/:pathMatch(.*)*',
|
||||
name: 'editor',
|
||||
component: TextEditorView
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'explorer',
|
||||
|
||||
@@ -99,6 +99,7 @@ export const useMainStore = defineStore('main', {
|
||||
isLoggedIn: false as boolean
|
||||
},
|
||||
transitionDirection: 'none' as 'forward' | 'backward' | 'none',
|
||||
editorSave: null as null | (() => void),
|
||||
space: {
|
||||
disk: 0,
|
||||
free: 0,
|
||||
@@ -327,6 +328,7 @@ export const useMainStore = defineStore('main', {
|
||||
this.connected = false
|
||||
this.dialog = ''
|
||||
this.cursor = ''
|
||||
this.editorSave = null
|
||||
},
|
||||
async logout() {
|
||||
console.log('Logout')
|
||||
|
||||
@@ -77,7 +77,65 @@ export const FILE_TYPES = {
|
||||
imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||
// Images that require server-side preview (browsers cannot display them natively)
|
||||
image: ['bmp', 'heic', 'heif', 'ico', 'tif', 'tiff'],
|
||||
print: ['epub', 'mobi', 'pdf']
|
||||
print: ['epub', 'mobi', 'pdf'],
|
||||
text: [
|
||||
'txt',
|
||||
'md',
|
||||
'json',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'conf',
|
||||
'config',
|
||||
'cfg',
|
||||
'log',
|
||||
'csv',
|
||||
'tsv',
|
||||
'py',
|
||||
'js',
|
||||
'ts',
|
||||
'jsx',
|
||||
'tsx',
|
||||
'html',
|
||||
'htm',
|
||||
'css',
|
||||
'scss',
|
||||
'sass',
|
||||
'less',
|
||||
'vue',
|
||||
'php',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'java',
|
||||
'c',
|
||||
'cpp',
|
||||
'h',
|
||||
'hpp',
|
||||
'cs',
|
||||
'swift',
|
||||
'kt',
|
||||
'sh',
|
||||
'bash',
|
||||
'zsh',
|
||||
'fish',
|
||||
'ps1',
|
||||
'bat',
|
||||
'cmd',
|
||||
'sql',
|
||||
'lua',
|
||||
'r',
|
||||
'pl',
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
'gitignore',
|
||||
'gitattributes',
|
||||
'env',
|
||||
'diff',
|
||||
'patch'
|
||||
]
|
||||
} as const
|
||||
|
||||
export type FileCategory = keyof typeof FILE_TYPES
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="text-editor">
|
||||
<div class="editor-body">
|
||||
<div v-if="loading" class="status">Loading…</div>
|
||||
<div v-else-if="error" class="status error">{{ error }}</div>
|
||||
<textarea
|
||||
v-else
|
||||
ref="textarea"
|
||||
v-model="content"
|
||||
spellcheck="false"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useMainStore()
|
||||
|
||||
const MAX_SIZE = 1024 * 1024 // 1 MiB
|
||||
|
||||
const filePath = computed(() => decodeURIComponent(route.path.slice(6))) // strip /edit/
|
||||
const filename = computed(() => filePath.value.split('/').pop() || '')
|
||||
|
||||
const filesUrl = computed(() => {
|
||||
return (
|
||||
'/files/' +
|
||||
filePath.value
|
||||
.split('/')
|
||||
.map(part => encodeURIComponent(part))
|
||||
.join('/')
|
||||
)
|
||||
})
|
||||
|
||||
const content = ref('')
|
||||
const original = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const textarea = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const dirty = computed(() => content.value !== original.value)
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
if (!dirty.value) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
const discard = window.confirm('You have unsaved changes. Discard them?')
|
||||
next(discard)
|
||||
})
|
||||
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!dirty.value) return
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
const onKeydown = (ev: KeyboardEvent) => {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
|
||||
ev.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (saving.value || loading.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await apiFetch(filesUrl.value, {
|
||||
method: 'PUT',
|
||||
body: content.value,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.message || data.detail || `${res.status} ${res.statusText}`)
|
||||
}
|
||||
original.value = content.value
|
||||
store.showToast(`Saved ${filename.value}`)
|
||||
} catch (err) {
|
||||
console.error('Save failed', err)
|
||||
store.showToast(err instanceof Error ? err.message : 'Save failed')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
store.editorSave = save
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await fetch(filesUrl.value, { method: 'HEAD' })
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
const size = Number(res.headers.get('content-length') || '0')
|
||||
if (size > MAX_SIZE) {
|
||||
throw new Error(
|
||||
`File is too large to edit (${(size / 1024 / 1024).toFixed(1)} MB)`
|
||||
)
|
||||
}
|
||||
const textRes = await fetch(filesUrl.value)
|
||||
if (!textRes.ok) throw new Error(`${textRes.status} ${textRes.statusText}`)
|
||||
const text = await textRes.text()
|
||||
content.value = text
|
||||
original.value = text
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load file'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (store.editorSave === save) {
|
||||
store.editorSave = null
|
||||
}
|
||||
window.removeEventListener('beforeunload', beforeUnload)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #1a1a1a;
|
||||
color: #ddd;
|
||||
}
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.editor-body textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 1rem;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
background: #1a1a1a;
|
||||
color: #ddd;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.editor-body textarea::selection {
|
||||
background: var(--accent-color, #007bff);
|
||||
color: #000;
|
||||
}
|
||||
.status {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
}
|
||||
.status.error {
|
||||
color: #f55;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user