Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ target/

# Tauri autogenerated files
packages/app/src-tauri/plugins/*/permissions/autogenerated/
packages/app/src-tauri/plugins/*/permissions/schemas/
packages/app/src-tauri/plugins/*/permissions/schemas/

.serena
27 changes: 27 additions & 0 deletions packages/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ futures-util = "0.3"
tauri-plugin-os = "2"
tauri-plugin-shell = "2"
tauri-plugin-updater = "2"
fontdb = "0.11"

[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"
Expand Down
57 changes: 57 additions & 0 deletions packages/app/src-tauri/src/core/fonts/commands.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_shell::ShellExt;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemFontInfo {
pub family: String,
pub is_monospace: bool,
pub sources: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FontInfo {
pub name: String,
Expand Down Expand Up @@ -177,3 +185,52 @@ pub async fn upload_font_data(

Ok(result)
}

fn map_font_source(source: &fontdb::Source) -> Option<String> {
#[allow(unreachable_patterns)]
match source {
fontdb::Source::File(path) => Some(path.to_string_lossy().to_string()),
fontdb::Source::SharedFile(path, _) => Some(path.to_string_lossy().to_string()),
_ => None,
}
}

#[tauri::command]
pub async fn list_system_fonts() -> Result<Vec<SystemFontInfo>, String> {
tauri::async_runtime::spawn_blocking(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();

let mut families: BTreeMap<String, SystemFontInfo> = BTreeMap::new();

for face in db.faces() {
let is_monospace = face.monospaced;
let source_path = map_font_source(&face.source);

for (name, _) in &face.families {
let entry = families.entry(name.to_string()).or_insert_with(|| SystemFontInfo {
family: name.to_string(),
is_monospace,
sources: Vec::new(),
});

if is_monospace {
entry.is_monospace = true;
}

if let Some(path) = &source_path {
if !entry.sources.contains(path) {
entry.sources.push(path.clone());
}
}
}
}

let mut fonts: Vec<SystemFontInfo> = families.into_values().collect();
fonts.sort_by(|a, b| a.family.to_lowercase().cmp(&b.family.to_lowercase()));

Ok(fonts)
})
.await
.map_err(|e| format!("获取系统字体失败: {e}"))?
}
3 changes: 2 additions & 1 deletion packages/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use crate::core::{
update_reading_session,
},
database,
fonts::commands::{upload_and_convert_font, upload_font_data},
fonts::commands::{list_system_fonts, upload_and_convert_font, upload_font_data},
llama::commands::{
delete_local_model, download_llama_server, download_model_file,
ensure_llamacpp_directories, get_app_data_dir, get_llamacpp_backend_path, greet,
Expand Down Expand Up @@ -165,6 +165,7 @@ pub fn run() {
// fonts
upload_and_convert_font,
upload_font_data,
list_system_fonts,
// llama
greet,
get_app_data_dir,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/components/notepad/hooks/use-notepad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const useNotepad = ({ bookId }: UseNotepadProps = {}) => {
const transformNoteForDisplay = useCallback(
(note: Note) => ({
id: note.id,
preview: note.content || "",
preview: note.content || note.title || "",
createdAt: new Date(note.createdAt),
}),
[],
Expand Down
103 changes: 97 additions & 6 deletions packages/app/src/components/notepad/note-detail-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,78 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { QuoteBlock } from "@/components/ui/quote-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { useNotepad } from "./hooks";
import type { Note } from "@/types/note";
import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react";

interface NoteDetailDialogProps {
note: Note | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onNoteUpdated?: (note: Note) => void;
}

export function NoteDetailDialog({ note, open, onOpenChange }: NoteDetailDialogProps) {
export function NoteDetailDialog({
note,
open,
onOpenChange,
onNoteUpdated,
}: NoteDetailDialogProps) {
const { handleUpdateNote } = useNotepad({ bookId: note?.bookId });
const [noteValue, setNoteValue] = useState(note?.content ?? "");
const [saving, setSaving] = useState(false);

useEffect(() => {
if (!note || !open) return;
setNoteValue(note.content ?? "");
}, [note, open]);

const hasChanges = useMemo(() => {
if (!note) return false;
const originalNote = note.content ?? "";
return noteValue !== originalNote;
}, [note, noteValue]);

const handleSave = async () => {
if (!note || !hasChanges) return;
try {
setSaving(true);
const updatedNote = await handleUpdateNote({
id: note.id,
content: noteValue.trim().length > 0 ? noteValue.trim() : null,
});

if (updatedNote) {
onNoteUpdated?.(updatedNote);
}
onOpenChange(false);
} catch (error) {
console.error("更新笔记失败:", error);
} finally {
setSaving(false);
}
};

if (!note) return null;

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{note.title || "无标题"}</DialogTitle>
<DialogTitle>笔记详情</DialogTitle>
</DialogHeader>
<DialogDescription>
<div className="mt-2 flex flex-col gap-1 text-mutedx text-sm">
<div className="mt-2 flex flex-col gap-1 text-muted-foreground text-sm">
<div className="flex items-center gap-1">
<div>{dayjs(note.createdAt).format("YYYY-MM-DD HH:mm:ss")}</div>
</div>
Expand All @@ -33,10 +86,48 @@ export function NoteDetailDialog({ note, open, onOpenChange }: NoteDetailDialogP
</div>
</DialogDescription>
<ScrollArea className="max-h-[60vh] min-h-[200px] px-4 py-2">
<div className="whitespace-pre-wrap break-words text-neutral-900 dark:text-neutral-300">
{note.content || "暂无内容"}
<div className="flex flex-col gap-4 pr-2">
{note.title && (
<div className="space-y-2">
<span className="font-medium text-neutral-600 text-sm dark:text-neutral-300">
引用内容
</span>
<QuoteBlock className="bg-neutral-200/60 px-3 py-2 text-neutral-700 dark:bg-neutral-800/70 dark:text-neutral-200">
{note.title}
</QuoteBlock>
</div>
)}
<div className="space-y-2">
<span className="font-medium text-neutral-600 text-sm dark:text-neutral-300">
我的想法
</span>
<Textarea
id="note-content"
value={noteValue}
onChange={(event) => setNoteValue(event.target.value)}
placeholder="写下你的思考、问题或感受"
className="min-h-28"
/>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
</Button>
<Button
type="button"
onClick={handleSave}
disabled={!hasChanges || saving}
>
{saving ? "保存中..." : "保存修改"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
Expand Down
48 changes: 37 additions & 11 deletions packages/app/src/components/notepad/note-item.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { QuoteBlock } from "@/components/ui/quote-block";
import type { Note } from "@/types/note";
import { Menu } from "@tauri-apps/api/menu";
import { LogicalPosition } from "@tauri-apps/api/window";
import { ask } from "@tauri-apps/plugin-dialog";
import dayjs from "dayjs";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNotepad } from "./hooks";
import { NoteDetailDialog } from "./note-detail-dialog";

Expand All @@ -14,10 +15,15 @@ interface NoteItemProps {
export const NoteItem = ({ note }: NoteItemProps) => {
const { handleDeleteNote } = useNotepad();
const [showDetail, setShowDetail] = useState(false);
const [currentNote, setCurrentNote] = useState(note);

useEffect(() => {
setCurrentNote(note);
}, [note]);

const handleNativeDelete = useCallback(async () => {
try {
const preview = note.content || "";
const preview = currentNote.content || "";
const confirmed = await ask(
`确定要删除这条笔记吗?\n\n"${preview.length > 50 ? `${preview.substring(0, 50)}...` : preview}"\n\n此操作无法撤销。`,
{
Expand All @@ -27,12 +33,12 @@ export const NoteItem = ({ note }: NoteItemProps) => {
);

if (confirmed) {
await handleDeleteNote(note.id);
await handleDeleteNote(currentNote.id);
}
} catch (error) {
console.error("删除笔记失败:", error);
}
}, [note, handleDeleteNote]);
}, [currentNote, handleDeleteNote]);

const handleClick = useCallback(() => {
setShowDetail(true);
Expand Down Expand Up @@ -72,19 +78,39 @@ export const NoteItem = ({ note }: NoteItemProps) => {
onContextMenu={handleMenuClick}
>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<p className="line-clamp-3 select-auto text-neutral-700 text-sm dark:text-neutral-200">
{note.content || "暂无内容"}
</p>
<div className="min-w-0 flex-1 space-y-2">
{currentNote.title && (
<QuoteBlock
className="bg-neutral-200/60 px-3 py-2 text-neutral-700 dark:bg-neutral-800/80 dark:text-neutral-200"
contentClassName="line-clamp-3"
>
{currentNote.title}
</QuoteBlock>
)}

{currentNote.content && (
<p className="line-clamp-3 select-auto text-neutral-700 text-sm leading-5 dark:text-neutral-200">
{currentNote.content}
</p>
)}

{!currentNote.title && !currentNote.content && (
<p className="select-auto text-neutral-500 text-sm">暂无内容</p>
)}

<div className="mt-1 text-neutral-800 text-xs dark:text-neutral-500">
{dayjs(note.createdAt).format("YYYY-MM-DD HH:mm:ss")}
<div className="text-neutral-800 text-xs dark:text-neutral-500">
{dayjs(currentNote.createdAt).format("YYYY-MM-DD HH:mm:ss")}
</div>
</div>
</div>
</div>

<NoteDetailDialog note={note} open={showDetail} onOpenChange={setShowDetail} />
<NoteDetailDialog
note={currentNote}
open={showDetail}
onOpenChange={setShowDetail}
onNoteUpdated={setCurrentNote}
/>
</>
);
};
Loading