字玩FontPlayer开发笔记8 Tauri2文件系统
字玩FontPlayer是笔者开源的一款字体设计工具,使用Vue3 + ElementUI开发,源代码:
github: https://github.com/HiToysMaker/fontplayer
gitee: https://gitee.com/toysmaker/fontplayer
笔记
字玩目前是用Electron进行桌面端应用打包,但是性能体验不太好,一直想替换成Tauri。Tauri的功能和Electron类似,都可以把前端代码打包生成桌面端(比如Windows和Mac)应用。Tauri只使用系统提供的WebView,不像Electron一样内置Chromium和Node.js,性能体验更佳。
近几天开始着手将Electron替换成Tauri,前两天完成了系统原生菜单的基本设置,今天将菜单功能实装。笔者项目中菜单功能最常用的就是文件存储和读取,所以今天主要学习了文件系统的内容。Tauri2提供了js端可调用的plugin,可以方便前端轻松实现文件操作。
权限配置
文件操作需要进行权限配置,Tauri2去掉了tauri.conf.json中的allowList一项,变成在src-tauri/capabilities/default.json中进行权限设置。
具体权限对应的选项在文档中Permission Table一栏中有详述:https://tauri.app/plugin/file-system/
笔者的配置:
src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
{
"identifier": "fs:scope",
"allow": [
{
"path": "$APPDATA"
},
{
"path": "$APPDATA/**"
}
]
},
"core:default",
"fs:read-files",
"fs:write-files",
"fs:allow-appdata-read-recursive",
"fs:allow-appdata-write-recursive",
"fs:default",
"dialog:default"
]
}
文件选择对话框
文件选择对话框的插件和文件操作的插件是分开的,首先安装对话框插件:
npm run tauri add dialog
打开文件选择窗口:
import { open } from '@tauri-apps/plugin-dialog'
const file = await open({
multiple: false,
directory: false,
})
打开文件存储窗口:
import { save } from '@tauri-apps/plugin-dialog'
const path = await save({
defaultPath: 'untitled',
filters: [
{
name: 'My Filter',
extensions: ['png', 'jpeg'],
},
],
});
文件操作
文件操作封装在fs插件中,插件分别提供对纯文本读写和二进制读写的方法。
安装插件:
npm run tauri add fs
读取纯文本:
import { readTextFile, BaseDirectory } from '@tauri-apps/plugin-fs'
const configToml = await readTextFile('config.toml', {
baseDir: BaseDirectory.AppConfig,
})
读取二进制文本:
const icon = await readFile('icon.png', {
baseDir: BaseDirectory.Resources,
})
写入纯文本:
import { writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs'
const contents = JSON.stringify({ notifications: true });
await writeTextFile('config.json', contents, {
baseDir: BaseDirectory.AppConfig,
});
写入二进制:
import { writeFile, BaseDirectory } from '@tauri-apps/plugin-fs'
const contents = new Uint8Array();
await writeFile('config', contents, {
baseDir: BaseDirectory.AppConfig,
});
涉及文件操作的具体逻辑实现
保存文本文件
const nativeSaveText = async (data, filename, formats) => {
const path = await save({
defaultPath: filename,
filters: [
{
name: 'Filter',
extensions: formats,
},
],
})
if (path) {
await writeTextFile(path, data)
}
}
保存二进制文件
const nativeSaveBinary = async (data, filename, formats) => {
const path = await save({
defaultPath: filename,
filters: [
{
name: 'Filter',
extensions: formats,
},
],
})
if (path) {
await writeFile(path, data)
}
}
打开文本文件
const nativeImportTextFile = async (formats) => {
const path = await open({
filters: [
{
name: 'Filter',
extensions: formats,
},
],
})
let data = null
let name = 'untitled'
if (path) {
data = await readTextFile(path)
name = path.split('/').pop().split('.')[0]
}
return {
data,
name,
}
}
打开二进制文件
const nativeImportFile = async (formats) => {
const path = await open({
filters: [
{
name: 'Filter',
extensions: formats,
},
],
})
let uint8Array = null
let name = 'untitled'
if (path) {
uint8Array = await readFile(path)
name = path.split('/').pop().split('.')[0]
}
return {
uint8Array,
name,
}
}
打开工程
const openFile_tauri = async (rawdata) => {
if (files.value && files.value.length) {
tips.value = '目前字玩仅支持同时编辑一个工程,请关闭当前工程再打开新工程。注意,关闭工程前请保存工程以避免数据丢失。'
tipsDialogVisible.value = true
} else {
const { data } = await nativeImportTextFile(['json'])
await _openFile_electron(data)
}
}
保存工程
const saveFile_tauri = async () => {
setSaveDialogVisible(true)
}
另存为工程
const saveAs_tauri = async () => {
setSaveDialogVisible(true)
}
导入字体库
const importFont_tauri = async () => {
if (files.value && files.value.length) {
tips.value = '目前字玩仅支持同时编辑一个工程,请关闭当前工程再导入字体。注意,关闭工程前请保存工程以避免数据丢失。'
tipsDialogVisible.value = true
} else {
const options = await nativeImportFile(['otf', 'ttf'])
await _importFont_tauri(options)
}
}
导入字形
const importGlyphs_tauri = async () => {
const { data: rawdata } = await nativeImportTextFile(['json'])
if (!rawdata) return
const data = JSON.parse(rawdata)
const plainGlyphs = data.glyphs
if (data.constants) {
for (let n = 0; n < data.constants.length; n++) {
if (!constantsMap.getByUUID(data.constants[n].uuid)) {
constants.value.push(data.constants[n])
}
}
}
if (data.constantGlyphMap) {
const keys = Object.keys(data.constantGlyphMap)
for (let n = 0; n < keys.length; n++) {
constantGlyphMap.set(keys[n], data.constantGlyphMap[keys[n]])
}
}
const _glyphs = plainGlyphs.map((plainGlyph) => instanceGlyph(plainGlyph))
_glyphs.map((glyph) => {
addGlyph(glyph, editStatus.value)
addGlyphTemplate(glyph, editStatus.value)
})
if (editStatus.value === Status.GlyphList) {
emitter.emit('renderGlyphPreviewCanvas')
} else if (editStatus.value === Status.StrokeGlyphList) {
emitter.emit('renderStrokeGlyphPreviewCanvas')
} else if (editStatus.value === Status.RadicalGlyphList) {
emitter.emit('renderRadicalGlyphPreviewCanvas')
} else if (editStatus.value === Status.CompGlyphList) {
emitter.emit('renderCompGlyphPreviewCanvas')
}
}
导入SVG
const importSVG_tauri = async () => {
const { data: rawdata } = await nativeImportTextFile(['svg'])
if (!rawdata) return
const svgEl: HTMLElement = parseStrToSvg(rawdata).childNodes[0] as HTMLElement
const components = parseSvgToComponents(svgEl as HTMLElement)
components.forEach((component: IComponent) => {
addComponentForCurrentCharacterFile(component)
})
}
识别图片
const importPic_tauri = async () => {
const options = await nativeImportFile(['jpg', 'png', 'jpeg'])
const { name, uint8Array } = options
let binary = ''
uint8Array.forEach((byte) => {
binary += String.fromCharCode(byte);
})
const base64str = btoa(binary)
const type = name.split('.')[1] === 'png' ? 'imge/png' : 'image/jpeg'
const dataUrl = `data:${type};base64,${base64str}`
total.value = 0
loaded.value = 0
loading.value = true
const img = document.createElement('img')
img.onload = () => {
setTimeout(() => {
thumbnail(dataUrl, img, 1000)
setEditStatus(Status.Pic)
loading.value = false
}, 100)
}
img.src = dataUrl
}
导出字体库
const exportFont_tauri = async (options: CreateFontOptions) => {
const font = createFont(options)
const buffer = toArrayBuffer(font) as ArrayBuffer
const filename = `${selectedFile.value.name}.otf`
nativeSaveBinary(buffer, filename, ['otf'])
}
导出字形
const exportGlyphs_tauri = async () => {
if (editStatus.value === Status.GlyphList) {
const _glyphs = glyphs.value.map((glyph: ICustomGlyph) => {
return plainGlyph(glyph)
})
const data = JSON.stringify({
glyphs: _glyphs,
constants: constants.value,
constantGlyphMap: mapToObject(constantGlyphMap),
version: 1.0,
})
await nativeSaveText(data, `glyphs.json`, ['json'])
} else if (editStatus.value === Status.StrokeGlyphList) {
const _glyphs = stroke_glyphs.value.map((glyph: ICustomGlyph) => {
return plainGlyph(glyph)
})
const data = JSON.stringify({
glyphs: _glyphs,
constants: constants.value,
constantGlyphMap: mapToObject(constantGlyphMap),
version: 1.0,
})
await nativeSaveText(data, `stroke_glyphs.json`, ['json'])
} else if (editStatus.value === Status.RadicalGlyphList) {
const _glyphs = radical_glyphs.value.map((glyph: ICustomGlyph) => {
return plainGlyph(glyph)
})
const data = JSON.stringify({
glyphs: _glyphs,
constants: constants.value,
constantGlyphMap: mapToObject(constantGlyphMap),
version: 1.0,
})
await nativeSaveText(data, `radical_glyphs.json`, ['json'])
} else if (editStatus.value === Status.CompGlyphList) {
const _glyphs = comp_glyphs.value.map((glyph: ICustomGlyph) => {
return plainGlyph(glyph)
})
const data = JSON.stringify({
glyphs: _glyphs,
constants: constants.value,
constantGlyphMap: mapToObject(constantGlyphMap),
version: 1.0,
})
nativeSaveText(data, `comp_glyphs.json`, ['json'])
} else {
const _glyphs = glyphs.value.map((glyph: ICustomGlyph) => {
return plainGlyph(glyph)
})
const data = JSON.stringify({
glyphs: _glyphs,
constants: constants.value,
constantGlyphMap: mapToObject(constantGlyphMap),
version: 1.0,
})
await nativeSaveText(data, `glyphs.json`, ['json'])
}
}
导出JPEG图片
const exportJPEG_tauri = async () => {
// 导出JPEG
const _canvas = canvas.value as HTMLCanvasElement
const data = _canvas.toDataURL('image/jpeg')
const buffer = base64ToArrayBuffer(data)
const fileName = `${editCharacterFile.value.character.text}.jpg`
nativeSaveBinary(buffer, fileName, ['jpg', 'jpeg'])
}
导出PNG图片
const exportPNG_tauri = async () => {
// 导出PNG
const _canvas = canvas.value as HTMLCanvasElement
render(_canvas, false)
const data = _canvas.toDataURL('image/png')
const buffer = base64ToArrayBuffer(data)
const fileName = `${editCharacterFile.value.character.text}.png`
nativeSaveBinary(buffer, fileName, ['png'])
render(_canvas, true)
}
导出SVG
const exportSVG_tauri = async () => {
// 导出SVG
if (editStatus.value !== Status.Edit && editStatus.value !== Status.Glyph ) return
const components = editStatus.value === Status.Edit ? orderedListWithItemsForCurrentCharacterFile.value : orderedListWithItemsForCurrentGlyph.value
const data = componentsToSvg(components, selectedFile.value.width, selectedFile.value.height)
const fileName = `${editCharacterFile.value.character.text}.svg`
nativeSaveText(data, fileName, ['svg'])
}
前端与Rust端通信
实现点击菜单按钮事件需要前后端通信,菜单由Rust生成并监听事件,但具体事件逻辑由前端实现。
Rust端代码,声明每个菜单按钮的事件函数,函数中逻辑比较简单,就是发送相应消息给前端,让前端知道目前要做的操作,具体逻辑在前端实现。
#[tauri::command]
fn create_file(app: AppHandle) {
app.emit("create-file", ()).unwrap();
}
#[tauri::command]
fn open_file(app: AppHandle) {
app.emit("open-file", ()).unwrap();
}
#[tauri::command]
fn save_file(app: AppHandle) {
app.emit("save-file", ()).unwrap();
}
#[tauri::command]
fn save_as(app: AppHandle) {
app.emit("save-as", ()).unwrap();
}
#[tauri::command]
fn undo(app: AppHandle) {
app.emit("undo", ()).unwrap();
}
#[tauri::command]
fn redo(app: AppHandle) {
app.emit("redo", ()).unwrap();
}
#[tauri::command]
fn cut(app: AppHandle) {
app.emit("cut", ()).unwrap();
}
#[tauri::command]
fn copy(app: AppHandle) {
app.emit("copy", ()).unwrap();
}
#[tauri::command]
fn paste(app: AppHandle) {
app.emit("paste", ()).unwrap();
}
#[tauri::command]
fn del(app: AppHandle) {
app.emit("delete", ()).unwrap();
}
#[tauri::command]
fn import_font_file(app: AppHandle) {
app.emit("import-font-file", ()).unwrap();
}
#[tauri::command]
fn import_templates_file(app: AppHandle) {
app.emit("import-templates-file", ()).unwrap();
}
#[tauri::command]
fn import_glyphs(app: AppHandle) {
app.emit("import-glyphs", ()).unwrap();
}
#[tauri::command]
fn import_pic(app: AppHandle) {
app.emit("import-pic", ()).unwrap();
}
#[tauri::command]
fn import_svg(app: AppHandle) {
app.emit("import-svg", ()).unwrap();
}
#[tauri::command]
fn export_font_file(app: AppHandle) {
app.emit("export-font-file", ()).unwrap();
}
#[tauri::command]
fn export_glyphs(app: AppHandle) {
app.emit("export-glyphs", ()).unwrap();
}
#[tauri::command]
fn export_jpeg(app: AppHandle) {
app.emit("export-jpeg", ()).unwrap();
}
#[tauri::command]
fn export_png(app: AppHandle) {
app.emit("export-png", ()).unwrap();
}
#[tauri::command]
fn export_svg(app: AppHandle) {
app.emit("export-svg", ()).unwrap();
}
#[tauri::command]
fn add_character(app: AppHandle) {
app.emit("add-character", ()).unwrap();
}
#[tauri::command]
fn add_icon(app: AppHandle) {
app.emit("add-icon", ()).unwrap();
}
#[tauri::command]
fn font_settings(app: AppHandle) {
app.emit("font-settings", ()).unwrap();
}
#[tauri::command]
fn preference_settings(app: AppHandle) {
app.emit("preference-settings", ()).unwrap();
}
#[tauri::command]
fn language_settings(app: AppHandle) {
app.emit("language-settings", ()).unwrap();
}
#[tauri::command]
fn import_template1(app: AppHandle) {
app.emit("template-1", ()).unwrap();
}
#[tauri::command]
fn remove_overlap(app: AppHandle) {
app.emit("remove_overlap", ()).unwrap();
}
Rust端代码,监听事件:
app.on_menu_event(move |app, event| {
if event.id() == "create-file" {
create_file(app.app_handle().clone())
} else if event.id() == "open-file" {
open_file(app.app_handle().clone())
} else if event.id() == "save-file" {
save_file(app.app_handle().clone())
} else if event.id() == "save-as" {
save_as(app.app_handle().clone())
} else if event.id() == "undo" {
undo(app.app_handle().clone())
} else if event.id() == "redo" {
redo(app.app_handle().clone())
} else if event.id() == "cut" {
cut(app.app_handle().clone())
} else if event.id() == "copy" {
copy(app.app_handle().clone())
} else if event.id() == "paste" {
paste(app.app_handle().clone())
} else if event.id() == "delete" {
del(app.app_handle().clone())
} else if event.id() == "import-font-file" {
import_font_file(app.app_handle().clone())
} else if event.id() == "import-templates-file" {
import_templates_file(app.app_handle().clone())
} else if event.id() == "import-glyphs" {
import_glyphs(app.app_handle().clone())
} else if event.id() == "import-pic" {
import_pic(app.app_handle().clone())
} else if event.id() == "import-svg" {
import_svg(app.app_handle().clone())
} else if event.id() == "export-font-file" {
export_font_file(app.app_handle().clone())
} else if event.id() == "export-glyphs" {
export_glyphs(app.app_handle().clone())
} else if event.id() == "export-jpeg" {
export_jpeg(app.app_handle().clone())
} else if event.id() == "export-png" {
export_png(app.app_handle().clone())
} else if event.id() == "export-svg" {
export_svg(app.app_handle().clone())
} else if event.id() == "add-character" {
add_character(app.app_handle().clone())
} else if event.id() == "add-icon" {
add_icon(app.app_handle().clone())
} else if event.id() == "font-settings" {
font_settings(app.app_handle().clone())
} else if event.id() == "preference-settings" {
preference_settings(app.app_handle().clone())
} else if event.id() == "language-settings" {
language_settings(app.app_handle().clone())
} else if event.id() == "template-1" {
import_template1(app.app_handle().clone())
} else if event.id() == "remove_overlap" {
remove_overlap(app.app_handle().clone())
}
});
前端代码,监听消息:
const initTauri = () => {
const keys = Object.keys(tauri_handlers)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
listen(key, (event) => {
tauri_handlers[key]()
})
}
}