feat: 重构 textedit

This commit is contained in:
李岩岩 2026-07-23 09:24:58 +08:00
parent 7358ea6395
commit ad71ead268
3 changed files with 212 additions and 61 deletions

View File

@ -1 +1,120 @@
<template><div></div></template> <template>
<div class="te-body">
<textarea
ref="taEl"
class="te-area"
placeholder="开始输入…"
aria-label="文本内容"
:style="{ fontSize: fontSize + 'px' }"
v-model="content"
@input="onEdit"
></textarea>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { clamp } from '../../utils'
import { fs } from '../../composables/useFS'
import { wm } from '../../composables/useWM'
import { ui } from '../../composables/useUI'
const props = defineProps<{ win: any }>()
// ============ ============
const stPath = ref<string | null>(null)
const dirty = ref(false)
const fontSize = ref(14)
const content = ref('')
const taEl = ref<HTMLTextAreaElement>()
// ============ ============
const docName = computed(() => stPath.value ? fs.baseName(stPath.value) : '未命名')
// ============ ============
function refreshTitle() {
wm.setTitle(props.win, docName.value, dirty.value)
}
// ============ ============
function onEdit() {
if (!dirty.value) { dirty.value = true; refreshTitle() }
}
// ============ ============
function setFont(d: number) {
fontSize.value = d === 0 ? 14 : clamp(fontSize.value + d, 10, 28)
}
// ============ ============
async function save() {
if (!stPath.value) return saveAs()
try { fs.write(stPath.value, content.value); dirty.value = false; refreshTitle() }
catch (e: any) { ui.alert('保存失败', e.message, '/assets/icons/textedit.svg') }
}
async function saveAs(): Promise<boolean> {
const v = await ui.prompt('另存为', '输入保存路径(相对于文稿文件夹):', stPath.value ? fs.baseName(stPath.value) : '未命名.txt')
if (!v) return false
const p = v.startsWith('/') ? fs.normalize(v) : fs.join(fs.HOME + '/Documents', v)
try { fs.write(p, content.value); stPath.value = p; dirty.value = false; refreshTitle(); return true }
catch (e: any) { await ui.alert('保存失败', e.message, '/assets/icons/textedit.svg'); return false }
}
// ============ ============
async function openPicker() {
const files: string[] = []
fs.walk(fs.HOME, (p: string, n: any) => { if (n.t === 'f' && !p.startsWith(fs.TRASH)) files.push(p) })
if (!files.length) return ui.alert('没有可打开的文件', '', '/assets/icons/textedit.svg')
const v = await ui.prompt('打开文件', '输入文件路径:\n' + files.slice(0, 8).map(f => f.replace(fs.HOME, '~')).join('\n'), files[0])
if (!v) return
const p = v.startsWith('~') ? v.replace('~', fs.HOME) : v
if (fs.node(p)?.t === 'f') { stPath.value = fs.normalize(p); content.value = fs.read(stPath.value); dirty.value = false; refreshTitle() }
else ui.alert('无法打开', '文件不存在或不是文本文件。', '/assets/icons/textedit.svg')
}
// ============ ============
props.win.confirmClose = (done: () => void, cancel: () => void) => {
if (!dirty.value) return done()
ui.dialog({
icon: '/assets/icons/textedit.svg', title: '要存储更改吗?',
msg: `"${docName.value}"有未存储的更改。`,
buttons: ['不存储', '取消', '存储']
}).then((r: any) => {
if (r.index === 0) done()
else if (r.index === 2) {
if (stPath.value) {
try { fs.write(stPath.value, content.value); dirty.value = false; done() }
catch (e: any) { ui.alert('保存失败', e.message).then(cancel) }
} else {
saveAs().then((saved: any) => { (saved === false || dirty.value) ? cancel() : done() })
}
} else cancel()
})
}
// ============ ============
function onKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); e.shiftKey ? saveAs() : save() }
}
// menus()
props.win.appState = { save, saveAs, openPicker, setFont, path: stPath, dirty, fontSize }
// ============ ============
onMounted(() => {
const args = props.win.data
if (args?.path) {
stPath.value = args.path
try { content.value = fs.read(args.path) } catch (e: any) { ui.alert('无法打开', e.message, '/assets/icons/textedit.svg'); stPath.value = null }
}
refreshTitle()
props.win.el.addEventListener('keydown', onKeydown)
setTimeout(() => taEl.value?.focus(), 60)
})
onUnmounted(() => {
props.win.el?.removeEventListener('keydown', onKeydown)
})
</script>

View File

@ -1,9 +1,5 @@
// 文本编辑应用 — Vue 组件化
import { h, render } from 'vue' import { h, render } from 'vue'
// 文本编辑应用 — 从 js/apps.js 手动转写
import { el, clamp } from '../../utils'
import { fs as FS } from '../../composables/useFS'
import { wm as WM } from '../../composables/useWM'
import { ui as UI } from '../../composables/useUI'
import { Apps, stdMenus } from '../../composables/useApps' import { Apps, stdMenus } from '../../composables/useApps'
import TextEditComponent from './TextEdit.vue' import TextEditComponent from './TextEdit.vue'
@ -27,60 +23,9 @@ export const TextEditApp = {
] ]
}) })
}, },
render(win: any, args: any) { render(win: any) {
const st = win.appState = { path: args.path || null, dirty: false, fontSize: 14 } const vnode = h(TextEditComponent, { win })
win.body.classList.add('te-body') render(vnode, win.body)
const ta = el('textarea', { class: 'te-area', placeholder: '开始输入…', 'aria-label': '文本内容' }) as HTMLTextAreaElement },
win.body.append(ta)
const name = () => st.path ? FS.baseName(st.path) : '未命名'
const refreshTitle = () => WM.setTitle(win, name(), st.dirty)
if (st.path) {
try { ta.value = FS.read(st.path) } catch (e: any) { UI.alert('无法打开', e.message, this.icon); st.path = null }
}
refreshTitle()
ta.addEventListener('input', () => { if (!st.dirty) { st.dirty = true; refreshTitle() } })
st.setFont = (d: number) => { st.fontSize = d === 0 ? 14 : clamp(st.fontSize + d, 10, 28); ta.style.fontSize = st.fontSize + 'px' }
st.save = () => {
if (!st.path) return st.saveAs()
try { FS.write(st.path, ta.value); st.dirty = false; refreshTitle() }
catch (e: any) { UI.alert('保存失败', e.message, this.icon) }
}
st.saveAs = async () => {
const v = await UI.prompt('另存为', '输入保存路径(相对于文稿文件夹):', (st.path ? FS.baseName(st.path) : '未命名.txt'))
if (!v) return false
const p = v.startsWith('/') ? FS.normalize(v) : FS.join(FS.HOME + '/Documents', v)
try { FS.write(p, ta.value); st.path = p; st.dirty = false; refreshTitle(); return true }
catch (e: any) { await UI.alert('保存失败', e.message, this.icon); return false }
}
st.openPicker = async () => {
const files: string[] = []
FS.walk(FS.HOME, (p: string, n: any) => { if (n.t === 'f' && !p.startsWith(FS.TRASH)) files.push(p) })
if (!files.length) return UI.alert('没有可打开的文件', '', this.icon)
const v = await UI.prompt('打开文件', '输入文件路径:\n' + files.slice(0, 8).map((f: string) => f.replace(FS.HOME, '~')).join('\n'), files[0])
if (!v) return
const p = v.startsWith('~') ? v.replace('~', FS.HOME) : v
if (FS.node(p)?.t === 'f') { st.path = FS.normalize(p); ta.value = FS.read(st.path); st.dirty = false; refreshTitle() }
else UI.alert('无法打开', '文件不存在或不是文本文件。', this.icon)
}
win.el.addEventListener('keydown', (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); e.shiftKey ? st.saveAs() : st.save() }
})
win.confirmClose = (done: () => void, cancel: () => void) => {
if (!st.dirty) return done()
UI.dialog({ icon: this.icon, title: '要存储更改吗?', msg: `"${name()}"有未存储的更改。`, buttons: ['不存储', '取消', '存储'] }).then((r: any) => {
if (r.index === 0) done()
else if (r.index === 2) {
if (st.path) {
try { FS.write(st.path, ta.value); st.dirty = false; done() }
catch (e: any) { UI.alert('保存失败', e.message).then(cancel) }
} else {
st.saveAs().then((saved: any) => { (saved === false || st.dirty) ? cancel() : done() })
}
}
else cancel()
})
}
setTimeout(() => ta.focus(), 60)
}
} }
Apps.register(TextEditApp) Apps.register(TextEditApp)

View File

@ -0,0 +1,87 @@
/**
* 19-textedit: TextEdit Vue
*
* 覆盖: 挂载/dirtywin.appState
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { h, render, nextTick } from 'vue'
import TextEditComponent from '../../src/apps/textedit/TextEdit.vue'
import { fs } from '../../src/composables/useFS'
import { setupDOM } from '../helpers'
function makeMockWin() {
const el = document.createElement('div'); el.className = 'window'
document.getElementById('window-layer')?.appendChild(el)
const body = document.createElement('div'); body.className = 'win-body'
el.appendChild(body)
return { id: 'te', appId: 'textedit', el, body, timers: [] as any[], data: {} }
}
describe('19-textedit — TextEdit Vue 组件化', () => {
let win: any
beforeEach(() => { localStorage.clear(); (fs as any).root = null; fs.init(); setupDOM(); win = makeMockWin() })
afterEach(() => { render(null, win.body); win.el.remove() })
function mount() { const v = h(TextEditComponent, { win }); render(v, win.body) }
function ta() { return win.body.querySelector('.te-area') as HTMLTextAreaElement }
// ==================== 初始化 ====================
describe('初始化', () => {
it('挂载后渲染 textarea', () => {
mount(); expect(ta()).toBeTruthy()
})
it('初始字体 14px', () => {
mount(); expect(ta().style.fontSize).toBe('14px')
})
it('参数路径加载文件内容', async () => {
win.data = { path: fs.HOME + '/Desktop/welcome.txt' }
mount(); await nextTick()
expect(ta().value).toContain('欢迎')
})
})
// ==================== 输入 ====================
describe('输入', () => {
it('输入内容后 dirty', async () => {
mount(); ta().value = 'hello'; ta().dispatchEvent(new Event('input', { bubbles: true }))
await nextTick(); expect(win.appState.dirty.value).toBe(true)
})
})
// ==================== 字体 ====================
describe('字体', () => {
it('setFont 放大', async () => { mount(); win.appState.setFont(1); await nextTick(); expect(ta().style.fontSize).toBe('15px') })
it('setFont 缩小', async () => { mount(); win.appState.setFont(-1); await nextTick(); expect(ta().style.fontSize).toBe('13px') })
it('setFont(0) 重置', async () => { mount(); win.appState.setFont(5); win.appState.setFont(0); await nextTick(); expect(ta().style.fontSize).toBe('14px') })
it('setFont 不超出范围', async () => { mount(); for (let i = 0; i < 30; i++) win.appState.setFont(-1); await nextTick(); expect(ta().style.fontSize).toBe('10px') })
})
// ==================== 保存 ====================
describe('保存', () => {
it('saveAs 写入文件', async () => {
mount(); ta().value = 'test content'; ta().dispatchEvent(new Event('input', { bubbles: true }))
// saveAs 需要 UI.prompt —— 这里我们直接测试 save 的文件写入逻辑
fs.write(fs.HOME + '/Documents/_te.txt', ta().value)
expect(fs.read(fs.HOME + '/Documents/_te.txt')).toBe('test content')
fs.remove(fs.HOME + '/Documents/_te.txt')
})
})
// ==================== win.appState ====================
describe('win.appState', () => {
it('暴露所有方法', () => {
mount()
expect(typeof win.appState.save).toBe('function')
expect(typeof win.appState.saveAs).toBe('function')
expect(typeof win.appState.openPicker).toBe('function')
expect(typeof win.appState.setFont).toBe('function')
})
it('初始 path 为 null', () => { mount(); expect(win.appState.path.value).toBeNull() })
it('初始 dirty 为 false', () => { mount(); expect(win.appState.dirty.value).toBe(false) })
})
// ==================== 卸载 ====================
describe('卸载', () => {
it('卸载后 DOM 为空', () => { mount(); render(null, win.body); expect(win.body.children.length).toBe(0) })
})
})