Skip to content

Commit 8dc2412

Browse files
committed
2 parents fe89be8 + c41723f commit 8dc2412

File tree

24 files changed

+805
-349
lines changed

24 files changed

+805
-349
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Project Graph 背景信息
2+
3+
## 项目概述
4+
5+
Project Graph 是一个图形化思维桌面工具和知识管理系统,支持节点连接、图形渲染和自动布局等功能。
6+
7+
## 技术架构
8+
9+
- 前端:React + TypeScript
10+
- 后端:Tauri (Rust)
11+
- 构建工具:Vite + pnpm (monorepo)
12+
13+
## 核心功能模块
14+
15+
1. **图形渲染引擎**:基于Canvas 2D的实体渲染系统
16+
2. **控制服务**:鼠标/键盘交互控制、快捷键管理
17+
3. **数据服务**:文件加载、自动保存、协作引擎
18+
4. **反馈系统**:视觉特效和声音反馈
19+
20+
## 开发辅助
21+
22+
- 项目采用严格的代码规范和类型检查
23+
- 包含完善的文档系统(docs/)和测试用例
24+
- 支持插件系统扩展功能

.fitten/rules/开发注意.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# 开发注意
2+
3+
开发mac适配windows快捷键部分时,不能用control键,用command键。
4+
UI上增加内容时,要使用i18n翻译适配英文和繁体中文。
5+
如果要增加rust侧的函数,一定要保证函数的健壮性,不能让他抛出错误

app/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
},
1818
"dependencies": {
1919
"@generouted/react-router": "^1.19.9",
20+
"@headlessui/react": "^2.2.4",
2021
"@marsidev/react-turnstile": "^1.1.0",
22+
"@msgpack/msgpack": "^3.1.2",
2123
"@octokit/rest": "^21.0.2",
2224
"@tauri-apps/api": "^2.1.1",
2325
"@tauri-apps/plugin-cli": "~2.2.0",
@@ -29,6 +31,7 @@
2931
"@tauri-apps/plugin-shell": "2.2.0",
3032
"@tauri-apps/plugin-store": "^2.2.0",
3133
"@tauri-apps/plugin-updater": "~2",
34+
"@tauri-apps/plugin-window-state": ">=2",
3235
"bcrypt": "^5.1.1",
3336
"decimal.js": "^10.5.0",
3437
"driver.js": "^1.3.1",

app/src-tauri/Cargo.lock

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ tauri-plugin-process = "2"
3030
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
3131
tauri-plugin-cli = "2"
3232
tauri-plugin-updater = "2"
33+
tauri-plugin-window-state = "2"

app/src-tauri/capabilities/desktop.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"identifier": "desktop-capability",
33
"platforms": ["macOS", "windows", "linux"],
44
"windows": ["main"],
5-
"permissions": ["cli:default", "updater:default"]
5+
"permissions": ["cli:default", "updater:default", "window-state:default"]
66
}

app/src-tauri/src/lib.rs

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ use base64::engine::general_purpose;
66
use base64::Engine;
77

88
use tauri::Manager;
9-
use tauri::Runtime;
10-
use tauri::Url;
119

1210
#[derive(Debug, Serialize, Deserialize)]
1311
struct FolderEntry {
@@ -17,14 +15,13 @@ struct FolderEntry {
1715
#[serde(skip_serializing_if = "Option::is_none")]
1816
children: Option<Vec<FolderEntry>>,
1917
}
20-
#[cfg(desktop)]
21-
use tauri_plugin_updater::UpdaterExt;
2218

2319
/// 递归读取文件夹结构,返回嵌套的文件夹结构
2420
#[tauri::command]
2521
fn read_folder_structure(path: String) -> FolderEntry {
2622
let path_buf = std::path::PathBuf::from(&path);
27-
let name = path_buf.file_name()
23+
let name = path_buf
24+
.file_name()
2825
.and_then(|n| n.to_str())
2926
.unwrap_or("")
3027
.to_string();
@@ -36,7 +33,7 @@ fn read_folder_structure(path: String) -> FolderEntry {
3633
let path = entry.path();
3734
let child_name = entry.file_name().to_string_lossy().to_string();
3835
let path_str = path.to_string_lossy().to_string();
39-
36+
4037
if path.is_file() {
4138
children.push(FolderEntry {
4239
name: child_name,
@@ -50,7 +47,7 @@ fn read_folder_structure(path: String) -> FolderEntry {
5047
}
5148
}
5249
}
53-
50+
5451
FolderEntry {
5552
name,
5653
is_file: false,
@@ -88,20 +85,23 @@ fn read_folder(path: String) -> Vec<String> {
8885
/// 如果文件夹不存在,返回空列表
8986
/// fileExts: 要读取的文件扩展名列表,例如:[".txt", ".md"]
9087
#[tauri::command]
91-
fn read_folder_recursive(path: String, fileExts: Vec<String>) -> Vec<String> {
88+
fn read_folder_recursive(path: String, file_exts: Vec<String>) -> Vec<String> {
9289
let mut files = Vec::new();
9390
if let Ok(entries) = std::fs::read_dir(path) {
9491
for entry in entries {
9592
if let Ok(entry) = entry {
9693
let path = entry.path();
9794
if path.is_file() {
9895
if let Some(file_name) = path.to_str() {
99-
if fileExts.iter().any(|ext| file_name.ends_with(ext)) {
96+
if file_exts.iter().any(|ext| file_name.ends_with(ext)) {
10097
files.push(file_name.to_string());
10198
}
10299
}
103100
} else if path.is_dir() {
104-
let mut sub_files = read_folder_recursive(path.to_str().unwrap().to_string(), fileExts.clone());
101+
let mut sub_files = read_folder_recursive(
102+
path.to_str().unwrap().to_string(),
103+
file_exts.clone(),
104+
);
105105
files.append(&mut sub_files);
106106
}
107107
}
@@ -110,15 +110,13 @@ fn read_folder_recursive(path: String, fileExts: Vec<String>) -> Vec<String> {
110110
files
111111
}
112112

113-
114113
/// 删除文件
115114
#[tauri::command]
116115
fn delete_file(path: String) -> Result<(), String> {
117116
std::fs::remove_file(path).map_err(|e| e.to_string())?;
118117
Ok(())
119118
}
120119

121-
122120
/// 读取文件,返回字符串
123121
#[tauri::command]
124122
fn read_text_file(path: String) -> String {
@@ -180,26 +178,6 @@ fn exit(code: i32) {
180178
std::process::exit(code);
181179
}
182180

183-
#[cfg(desktop)]
184-
#[tauri::command]
185-
async fn set_update_channel<R: Runtime>(
186-
app: tauri::AppHandle<R>,
187-
channel: String,
188-
) -> Result<(), tauri_plugin_updater::Error> {
189-
println!("Setting update channel to {}", channel);
190-
app.updater_builder()
191-
.endpoints(vec![Url::parse(
192-
format!(
193-
"https://github.com/LiRenTech/project-graph/releases/{channel}/download/latest.json"
194-
)
195-
.as_str(),
196-
)?])?
197-
.build()?
198-
.check()
199-
.await?;
200-
Ok(())
201-
}
202-
203181
#[cfg_attr(mobile, tauri::mobile_entry_point)]
204182
pub fn run() {
205183
// 在 Linux 上禁用 DMA-BUF 渲染器
@@ -226,6 +204,8 @@ pub fn run() {
226204
{
227205
app.handle().plugin(tauri_plugin_cli::init())?;
228206
app.handle().plugin(tauri_plugin_process::init())?;
207+
app.handle()
208+
.plugin(tauri_plugin_window_state::Builder::new().build())?;
229209
app.handle()
230210
.plugin(tauri_plugin_updater::Builder::new().build())?;
231211
}
@@ -244,9 +224,7 @@ pub fn run() {
244224
write_file_base64,
245225
write_stdout,
246226
write_stderr,
247-
exit,
248-
#[cfg(desktop)]
249-
set_update_channel
227+
exit
250228
])
251229
.run(tauri::generate_context!())
252230
.expect("error while running tauri application");

app/src/core/dataStruct/shape/Rectangle.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Line } from "./Line";
21
import { Renderer } from "../../render/canvas2d/renderer";
32
import { Camera } from "../../stage/Camera";
43
import { Vector } from "../Vector";
4+
import { Line } from "./Line";
55
import { Shape } from "./Shape";
66

77
export class Rectangle extends Shape {
@@ -382,6 +382,10 @@ export class Rectangle extends Shape {
382382
}
383383
}
384384

385+
public translate(offset: Vector): Rectangle {
386+
return new Rectangle(this.location.add(offset), this.size);
387+
}
388+
385389
public transformWorld2View(): Rectangle {
386390
return new Rectangle(
387391
Renderer.transformWorld2View(this.location),

app/src/core/render/domElement/inputElement.tsx

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,17 @@ export namespace InputElement {
9090
document.body.removeEventListener("mousedown", onOutsideClick);
9191
removeElement();
9292
});
93+
let isComposing = false;
94+
inputElement.addEventListener("compositionstart", () => {
95+
isComposing = true;
96+
});
97+
inputElement.addEventListener("compositionend", () => {
98+
isComposing = false;
99+
});
100+
93101
inputElement.addEventListener("keydown", (event) => {
94102
event.stopPropagation();
95-
if (event.key === "Enter") {
103+
if (event.key === "Enter" && !isComposing) {
96104
resolve(inputElement.value);
97105
onChange(inputElement.value);
98106
document.body.removeEventListener("mousedown", onOutsideClick);
@@ -197,7 +205,19 @@ export namespace InputElement {
197205
document.body.removeEventListener("mousedown", onOutsideClick);
198206
removeElement();
199207
});
208+
let isComposing = false;
209+
textareaElement.addEventListener("compositionstart", () => {
210+
isComposing = true;
211+
});
212+
textareaElement.addEventListener("compositionend", () => {
213+
// 防止此事件早于enter键按下触发(Mac的bug)
214+
setTimeout(() => {
215+
isComposing = false;
216+
}, 100);
217+
});
218+
200219
textareaElement.addEventListener("keydown", (event) => {
220+
console.log(event.key, "keydown");
201221
event.stopPropagation();
202222
if (event.key === "Tab") {
203223
// 防止tab切换到其他按钮
@@ -238,17 +258,20 @@ export namespace InputElement {
238258

239259
if (event.key === "Enter") {
240260
event.preventDefault();
241-
const enterKeyDetail = getEnterKey(event);
242-
if (textNodeExitEditMode === enterKeyDetail) {
243-
// 用户想退出编辑
244-
exitEditMode();
245-
addSuccessEffect();
246-
} else if (textNodeContentLineBreak === enterKeyDetail) {
247-
// 用户想换行
248-
breakLine();
249-
} else {
250-
// 用户可能记错了快捷键
251-
addFailEffect();
261+
// 使用event.isComposing和自定义isComposing双重检查
262+
if (!(event.isComposing || isComposing)) {
263+
const enterKeyDetail = getEnterKey(event);
264+
if (textNodeExitEditMode === enterKeyDetail) {
265+
// 用户想退出编辑
266+
exitEditMode();
267+
addSuccessEffect();
268+
} else if (textNodeContentLineBreak === enterKeyDetail) {
269+
// 用户想换行
270+
breakLine();
271+
} else {
272+
// 用户可能记错了快捷键
273+
addFailEffect();
274+
}
252275
}
253276
}
254277
});

app/src/core/service/SubWindow.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { atom, useAtomValue } from "jotai";
2+
import { store } from "../../state";
3+
import { Rectangle } from "../dataStruct/shape/Rectangle";
4+
import { Vector } from "../dataStruct/Vector";
5+
6+
export namespace SubWindow {
7+
export enum IdEnum {}
8+
export interface Window {
9+
/**
10+
* 唯一的id,不能重复,如果创建了已经存在的id,会聚焦到已存在的窗口
11+
* 可以是负数,可以不连续
12+
*/
13+
id: number;
14+
title: string;
15+
children: React.ReactNode;
16+
rect: Rectangle;
17+
maximized: boolean;
18+
minimized: boolean;
19+
opacity: number;
20+
focused: boolean;
21+
zIndex: number;
22+
}
23+
const subWindowsAtom = atom<Window[]>([]);
24+
export const use = () => useAtomValue(subWindowsAtom);
25+
function getMaxZIndex() {
26+
return store.get(subWindowsAtom).reduce((maxZIndex, window) => Math.max(maxZIndex, window.zIndex), 0);
27+
}
28+
export function create(options: Partial<Window>): Window {
29+
if (options.id && store.get(subWindowsAtom).some((window) => window.id === options.id)) {
30+
// 如果已经存在的id,聚焦到已存在的窗口
31+
focus(options.id);
32+
return store.get(subWindowsAtom).find((window) => window.id === options.id)!;
33+
}
34+
const win: Window = {
35+
id: store.get(subWindowsAtom).reduce((maxId, window) => Math.max(maxId, window.id), 0) + 1,
36+
title: "",
37+
children: <></>,
38+
rect: new Rectangle(Vector.getZero(), Vector.same(100)),
39+
maximized: false,
40+
minimized: false,
41+
opacity: 1,
42+
focused: false,
43+
zIndex: getMaxZIndex() + 1,
44+
...options,
45+
};
46+
store.set(subWindowsAtom, [...store.get(subWindowsAtom), win]);
47+
return win;
48+
}
49+
export function update(id: number, options: Partial<Omit<Window, "id">>) {
50+
store.set(
51+
subWindowsAtom,
52+
store.get(subWindowsAtom).map((window) => (window.id === id ? { ...window, ...options } : window)),
53+
);
54+
}
55+
export function close(id: number) {
56+
store.set(
57+
subWindowsAtom,
58+
store.get(subWindowsAtom).filter((window) => window.id !== id),
59+
);
60+
}
61+
export function focus(id: number) {
62+
update(id, { focused: true, zIndex: getMaxZIndex() + 1 });
63+
}
64+
}

0 commit comments

Comments
 (0)