feat: add main editor resource tabs
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# 5-15 主编辑区 Resource Tab 与 VSCode 打开目标对齐 v1
|
||||
|
||||
> 状态:done
|
||||
>
|
||||
> 日期:2026-05-20
|
||||
|
||||
## 目标
|
||||
|
||||
把当前资源打开行为从“资源点击直接 `window.open` 或单独右侧 pane”收口为 VSCode 风格的打开目标模型:
|
||||
|
||||
- `active-tab`:在主编辑区打开或激活 resource tab。
|
||||
- `side`:保留当前右侧 pane 打开能力。
|
||||
- `new-window`:显式在新浏览器窗口打开。
|
||||
|
||||
本轮重点实现 `active-tab`,并保持已有右侧 pane 与新窗口路径不退化。
|
||||
|
||||
## VSCode 对齐口径
|
||||
|
||||
VSCode Explorer 不按文件类型散落处理打开行为,而是把资源交给 editor service:
|
||||
|
||||
- 普通打开进入当前 editor group。
|
||||
- `Open to the Side` 进入 side group。
|
||||
- 同一资源应激活已有 editor tab,而不是无限重复打开。
|
||||
- side group 与主 editor group 可以同时持有各自 tab。
|
||||
|
||||
MNote 对应关系:
|
||||
|
||||
- 主编辑区 tab host 对应 VSCode active editor group。
|
||||
- 当前 secondary document pane 对应 VSCode side group。
|
||||
- 浏览器新窗口只作为显式外部打开目标。
|
||||
|
||||
## 资源编辑规则
|
||||
|
||||
`.md`、`.markdown`、`.txt`、常见代码文件在主编辑区 resource tab 中继续使用 tiptap runtime 打开。
|
||||
|
||||
关键约束:
|
||||
|
||||
- 页面正文 tiptap 保存到 Page Aggregate body / 页面 Markdown。
|
||||
- 资源 tiptap 保存到对应 resource 文件本身。
|
||||
- resource tab 必须使用独立 object identity 与独立 session key。
|
||||
- resource tab 保存不得调用 `page.body.save` 或 `/api/page-body/write`。
|
||||
- 上传的 `.md` 资源不能因为用 tiptap 打开而重新变成 File Tree / Page Tree 页面。
|
||||
|
||||
## 最小实现范围
|
||||
|
||||
本轮只做浏览器内主编辑区 resource tab host 的最小闭环:
|
||||
|
||||
- 在文档 workspace 顶部增加 tab strip。
|
||||
- 默认页面正文是一个固定 page tab。
|
||||
- 点击本地上传或文件树资源时打开 resource tab。
|
||||
- Office tab 内嵌 `/onlyoffice?...`。
|
||||
- PDF / 图片使用 iframe/img 预览。
|
||||
- `.md` / text / code 使用 tiptap runtime 编辑,并保存回本地资源文件。
|
||||
- 右键菜单保留“在右侧边栏打开”和“在新窗口打开”。
|
||||
|
||||
Convex 远端资源本轮仅保留新窗口 fallback;主闭环优先覆盖 local-first 资源。
|
||||
|
||||
## 详细 Checklist
|
||||
|
||||
- [x] 定义前端 open target:`active-tab`、`side`、`new-window`。
|
||||
- [x] 为本地资源生成稳定 `objectIdentity`:`resource:file:<rootUri>:<relativePath>`。
|
||||
- [x] 新增本地资源读写 API:
|
||||
- [x] `GET /api/local-folder/resource/read?rootUri=&path=`
|
||||
- [x] `POST /api/local-folder/resource/write`
|
||||
- [x] 读写均复用 root escape 校验。
|
||||
- [x] 写入返回 `fileVersion` / `conflictDetectionKey`。
|
||||
- [x] 主编辑区新增 resource tab strip:
|
||||
- [x] page tab 默认存在。
|
||||
- [x] resource tab 重复打开时激活已有 tab。
|
||||
- [x] 关闭 resource tab 后回到 page tab。
|
||||
- [x] 打开 resource tab 不关闭 secondary pane。
|
||||
- [x] tiptap resource editor:
|
||||
- [x] `.md` / `.markdown` / `.txt` / code 资源挂载 tiptap。
|
||||
- [x] session key 与页面正文 session key 隔离。
|
||||
- [x] 保存写 `/api/local-folder/resource/write`。
|
||||
- [x] 保存 payload 中保留 `tiptapDocument`,服务端转 Markdown / text 后写回文件。
|
||||
- [x] Office / PDF / 图片 resource tab:
|
||||
- [x] Office 使用 `/onlyoffice?...` iframe,主编辑区默认 `mode=view`。
|
||||
- [x] PDF 使用 iframe。
|
||||
- [x] 图片使用 img。
|
||||
- [x] 不触发页面正文保存。
|
||||
- [x] 菜单与默认行为:
|
||||
- [x] 文件树资源普通点击默认 `active-tab`。
|
||||
- [x] 附件普通点击默认 `active-tab`。
|
||||
- [x] “在右侧边栏打开”继续走 `side`。
|
||||
- [x] “在新窗口打开”继续走 `new-window`,Office 保留 `mode=edit`。
|
||||
- [x] Smoke:
|
||||
- [x] 上传 `.md` 后点击资源,在主编辑区出现 resource tab 和 tiptap。
|
||||
- [x] 编辑 `.md` resource tab 后文件内容写回资源文件,不写页面正文。
|
||||
- [x] 上传 Office 后点击资源,在主编辑区出现 Office resource tab。
|
||||
- [x] 显式新窗口打开仍打开 `/onlyoffice` 或文件 URL。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不在本轮实现多 tab 持久化恢复。
|
||||
- 不在本轮实现 Convex 远端资源的主编辑区写回。
|
||||
- 不在本轮引入完整代码编辑器或语法高亮。
|
||||
- 不改变 Resource Tree / File Tree / Page Tree 的真源关系。
|
||||
@@ -0,0 +1,136 @@
|
||||
# Main Editor Resource Tabs Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 MNote 主编辑区增加 VSCode 风格 resource tab,使本地 `.md` / text / code 资源可用 tiptap 编辑,Office/PDF/图片可在主编辑区 tab 打开,并保留右侧 pane 与新窗口能力。
|
||||
|
||||
**Architecture:** 以 `resource input + open target` 为边界,前端新增主编辑区 tab host,local-first 资源读写走新的 `/api/local-folder/resource/*` API。页面正文 session 与资源 session 使用不同 object identity 和保存 endpoint,避免资源编辑污染 Page Aggregate body。
|
||||
|
||||
**Tech Stack:** Rust Axum (`mnote-web` routes), Leptos SSR shell, existing `leptos-tiptap` WASM runtime, Playwright smoke scripts.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 本地资源读写 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `rust/crates/mnote-web/src/routes/mod.rs`
|
||||
- Modify: `rust/crates/mnote-web/src/routes/local_folder_source.rs`
|
||||
|
||||
- [ ] **Step 1: 增加 API 路由**
|
||||
|
||||
在 `routes/mod.rs` 的 local-folder route 区域添加:
|
||||
|
||||
```rust
|
||||
.route(
|
||||
"/api/local-folder/resource/read",
|
||||
get(local_folder_source::read_local_resource),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/resource/write",
|
||||
post(local_folder_source::write_local_resource),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现读写 handler**
|
||||
|
||||
在 `local_folder_source.rs` 复用 `resolve_local_file_open_path`,读取 raw text 或写入 UTF-8 内容。写入请求字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"rootUri": "file:///tmp/workspace",
|
||||
"path": "Page.assets/note.md",
|
||||
"contentFormat": "tiptapDocument",
|
||||
"tiptapDocument": { "type": "doc", "content": [] },
|
||||
"expectedFileVersion": "sha256:..."
|
||||
}
|
||||
```
|
||||
|
||||
保存 `.md` 使用现有 editor-blocks -> markdown 能力;保存 `.txt` / code 使用 plain text。
|
||||
|
||||
- [ ] **Step 3: 增加单测**
|
||||
|
||||
新增测试覆盖:
|
||||
|
||||
- root escape 被拒绝。
|
||||
- `.md` 读写返回 `text/markdown` 和 `fileVersion`。
|
||||
- 写 `.md` 后不修改页面正文 Markdown。
|
||||
|
||||
### Task 2: 主编辑区 Resource Tab Host
|
||||
|
||||
**Files:**
|
||||
- Modify: `rust/crates/mnote-web/src/ssr/pages/document.rs`
|
||||
- Modify: `rust/crates/mnote-web/src/ssr/styles.rs`
|
||||
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||
|
||||
- [ ] **Step 1: SSR 增加 tab strip 与 resource host**
|
||||
|
||||
在 `DocumentPage` 的 `.document-workspace` 内添加主编辑区 tab strip 和 resource pane 容器。默认 page tab 对应 primary document pane。
|
||||
|
||||
- [ ] **Step 2: web shell 新增 openResourceInActiveTab**
|
||||
|
||||
新增运行时函数:
|
||||
|
||||
```js
|
||||
window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity,
|
||||
title,
|
||||
kind,
|
||||
rootUri,
|
||||
path,
|
||||
href,
|
||||
officeUrl
|
||||
});
|
||||
```
|
||||
|
||||
同一 `objectIdentity` 已存在时只激活 tab。
|
||||
|
||||
- [ ] **Step 3: 挂载 tiptap resource editor**
|
||||
|
||||
`.md` / `.txt` / code resource tab 创建独立 root,读取 `/api/local-folder/resource/read`,转为 tiptap document 后挂载。change 事件写回 `/api/local-folder/resource/write`。
|
||||
|
||||
### Task 3: 打开路径收口
|
||||
|
||||
**Files:**
|
||||
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||
|
||||
- [ ] **Step 1: 文件树资源普通点击默认 active-tab**
|
||||
|
||||
修改 `openConvexAssetFromFileTree(detail)`:本地资源优先调用 `openResourceInActiveTab`,失败时 fallback 到旧 `window.open`。
|
||||
|
||||
- [ ] **Step 2: 附件点击默认 active-tab**
|
||||
|
||||
修改 `openEditorAttachmentDetail(detail)`:本地附件优先打开 resource tab,显式下载/新窗口保持旧行为。
|
||||
|
||||
- [ ] **Step 3: 菜单补齐 new-window**
|
||||
|
||||
为文件树资源和附件菜单增加“在新窗口打开”,动作显式调用旧 `window.open` 路径。
|
||||
|
||||
### Task 4: Smoke 与回归
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/task174-rust-onlyoffice-attachment-open-smoke.js`
|
||||
- Modify: `scripts/task456-resource-object-shell-sync-smoke.js`
|
||||
- Create: `scripts/task457-main-editor-resource-tab-smoke.js`
|
||||
|
||||
- [ ] **Step 1: 新增 resource tab smoke**
|
||||
|
||||
覆盖上传 `.md`、点击打开主编辑区 tab、编辑保存回资源文件、页面正文不变。
|
||||
|
||||
- [ ] **Step 2: 更新 Office smoke**
|
||||
|
||||
默认点击断言主编辑区 Office resource tab;显式新窗口动作继续断言 `/onlyoffice`。
|
||||
|
||||
- [ ] **Step 3: 运行验证**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
cargo test -p mnote-web -- --test-threads=1
|
||||
node scripts/task457-main-editor-resource-tab-smoke.js
|
||||
node scripts/task174-rust-onlyoffice-attachment-open-smoke.js
|
||||
node scripts/task456-resource-object-shell-sync-smoke.js
|
||||
codegraph sync .
|
||||
```
|
||||
|
||||
预期:全部通过。
|
||||
@@ -206,6 +206,23 @@ struct LocalAssetUploadFields {
|
||||
kind: String,
|
||||
}
|
||||
|
||||
fn local_folder_ok_response(
|
||||
context: &RequestContext,
|
||||
result: Value,
|
||||
) -> (StatusCode, HeaderMap, Json<Value>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
HeaderMap::new(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"owner": "mnote-web",
|
||||
"result": result,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalFileOpenQuery {
|
||||
@@ -215,6 +232,30 @@ pub struct LocalFileOpenQuery {
|
||||
pub download: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalResourceReadQuery {
|
||||
pub root_uri: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalResourceWriteRequest {
|
||||
#[serde(default, alias = "root_uri")]
|
||||
pub root_uri: String,
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
#[serde(default, alias = "expected_file_version")]
|
||||
pub expected_file_version: Option<String>,
|
||||
#[serde(default, alias = "content_format")]
|
||||
pub content_format: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Value,
|
||||
#[serde(default, alias = "tiptap_document")]
|
||||
pub tiptap_document: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalAccessValidateRootRequest {
|
||||
@@ -2337,6 +2378,143 @@ pub async fn open_local_file(
|
||||
Ok((StatusCode::OK, headers, bytes))
|
||||
}
|
||||
|
||||
pub async fn read_local_resource(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalResourceReadQuery>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root = parse_file_root_uri(&query.root_uri)?
|
||||
.canonicalize()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_unavailable",
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let target = resolve_local_file_open_path(&query.root_uri, &query.path)?;
|
||||
let text = fs::read_to_string(&target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_resource_read_failed",
|
||||
format!("无法读取本地资源文件 {}: {error}", target.display()),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("资源")
|
||||
.to_string();
|
||||
let content = if is_markdown_file(&file_name) {
|
||||
crate::routes::local_markdown_parser::markdown_to_blocks(&text)
|
||||
} else {
|
||||
text_to_editor_blocks(&text, &file_name)
|
||||
};
|
||||
let file_version = local_resource_conflict_detection_key(&root, &target)?;
|
||||
Ok(local_folder_ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"rootUri": query.root_uri,
|
||||
"path": query.path,
|
||||
"fileName": file_name,
|
||||
"contentType": content_type_for_path(&target).to_str().unwrap_or("application/octet-stream"),
|
||||
"text": text,
|
||||
"content": content,
|
||||
"contentFormat": "editorBlocks",
|
||||
"fileVersion": file_version,
|
||||
"conflictDetectionKey": file_version,
|
||||
"sourceKind": "local_folder",
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn write_local_resource(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<LocalResourceWriteRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let root_uri = request.root_uri.trim();
|
||||
let path = request.path.trim();
|
||||
if root_uri.is_empty() || path.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_resource_write_required_missing",
|
||||
"缺少 rootUri 或 path",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root = parse_file_root_uri(root_uri)?
|
||||
.canonicalize()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_unavailable",
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let target = resolve_local_file_open_path(root_uri, path)?;
|
||||
let current_key = local_resource_conflict_detection_key(&root, &target)?;
|
||||
if let Some(expected_key) = request
|
||||
.expected_file_version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if expected_key != current_key {
|
||||
return Err(WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_resource_external_change",
|
||||
"本地资源文件已被外部修改,请刷新后再保存",
|
||||
)
|
||||
.with_details(json!({
|
||||
"conflict": {
|
||||
"code": "local_resource_external_change",
|
||||
"rootUri": root_uri,
|
||||
"path": path,
|
||||
"currentDiskVersion": current_key,
|
||||
"editorBaseVersion": expected_key,
|
||||
}
|
||||
}))
|
||||
.with_context(&context));
|
||||
}
|
||||
}
|
||||
let content = local_resource_write_editor_blocks(&request);
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("资源")
|
||||
.to_string();
|
||||
let next_text = if is_markdown_file(&file_name) {
|
||||
editor_blocks_to_markdown_for_file(&content, &root, &target)
|
||||
} else {
|
||||
editor_blocks_to_plain_text(&content)
|
||||
};
|
||||
fs::write(&target, next_text).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_resource_write_failed",
|
||||
format!("无法保存本地资源文件 {}: {error}", target.display()),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let next_key = local_resource_conflict_detection_key(&root, &target)?;
|
||||
Ok(local_folder_ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"ok": true,
|
||||
"rootUri": root_uri,
|
||||
"path": path,
|
||||
"fileName": file_name,
|
||||
"fileVersion": next_key,
|
||||
"conflictDetectionKey": next_key,
|
||||
"contentFormat": request.content_format.unwrap_or_else(|| "editorBlocks".into()),
|
||||
"executedCommand": "resource.file.write",
|
||||
"canonicalCommand": "resource.file.write",
|
||||
"sourceKind": "local_folder",
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
@@ -5630,6 +5808,167 @@ fn editor_blocks_to_markdown_for_file(
|
||||
editor_blocks_to_markdown_with_rewrite(content, Some((root, markdown_path)))
|
||||
}
|
||||
|
||||
fn local_resource_write_editor_blocks(request: &LocalResourceWriteRequest) -> Value {
|
||||
let format = request
|
||||
.content_format
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let source = if request.content.is_null() {
|
||||
request.tiptap_document.as_ref().unwrap_or(&Value::Null)
|
||||
} else {
|
||||
&request.content
|
||||
};
|
||||
if format == "tiptapdocument" || is_tiptap_document(source) {
|
||||
tiptap_document_to_editor_blocks(source)
|
||||
} else {
|
||||
source.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tiptap_document(value: &Value) -> bool {
|
||||
value.get("type").and_then(Value::as_str) == Some("doc")
|
||||
&& value.get("content").and_then(Value::as_array).is_some()
|
||||
}
|
||||
|
||||
fn tiptap_document_to_editor_blocks(value: &Value) -> Value {
|
||||
let nodes = value
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let blocks = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, node)| {
|
||||
let node_type = node
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("paragraph");
|
||||
let content = node.get("content").cloned().unwrap_or_else(|| json!([]));
|
||||
let text = extract_block_text(&content);
|
||||
let block_type = match node_type {
|
||||
"heading" => "heading",
|
||||
"codeBlock" | "code_block" | "code" => "codeBlock",
|
||||
"blockquote" => "quote",
|
||||
"bulletListItem" | "listItem" => "bulletListItem",
|
||||
"orderedListItem" | "numberedListItem" => "numberedListItem",
|
||||
_ => "paragraph",
|
||||
};
|
||||
let mut block = json!({
|
||||
"id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(&format!("{node_type}:{text}"))),
|
||||
"type": block_type,
|
||||
"content": content,
|
||||
});
|
||||
if node_type == "heading" {
|
||||
let level = node
|
||||
.get("attrs")
|
||||
.and_then(|attrs| attrs.get("level"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 6);
|
||||
block["props"] = json!({ "level": level });
|
||||
}
|
||||
block
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Value::Array(blocks)
|
||||
}
|
||||
|
||||
fn text_to_editor_blocks(text: &str, file_name: &str) -> Value {
|
||||
if is_code_like_file(file_name) {
|
||||
return json!([{
|
||||
"id": "resource-block-1",
|
||||
"type": "code_block",
|
||||
"content": text,
|
||||
"props": {
|
||||
"language": file_extension(file_name).unwrap_or_default()
|
||||
}
|
||||
}]);
|
||||
}
|
||||
let blocks = text
|
||||
.split('\n')
|
||||
.enumerate()
|
||||
.map(|(index, line)| {
|
||||
json!({
|
||||
"id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(line)),
|
||||
"type": "paragraph",
|
||||
"content": line
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Value::Array(blocks)
|
||||
}
|
||||
|
||||
fn editor_blocks_to_plain_text(content: &Value) -> String {
|
||||
let blocks = if let Some(array) = content.as_array() {
|
||||
array.clone()
|
||||
} else {
|
||||
content
|
||||
.get("blocks")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let text = blocks
|
||||
.iter()
|
||||
.map(extract_block_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if text.ends_with('\n') {
|
||||
text
|
||||
} else {
|
||||
format!("{text}\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn file_extension(file_name: &str) -> Option<String> {
|
||||
Path::new(file_name)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn is_code_like_file(file_name: &str) -> bool {
|
||||
matches!(
|
||||
file_extension(file_name).as_deref(),
|
||||
Some(
|
||||
"rs" | "ts"
|
||||
| "tsx"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "json"
|
||||
| "css"
|
||||
| "scss"
|
||||
| "html"
|
||||
| "xml"
|
||||
| "py"
|
||||
| "go"
|
||||
| "java"
|
||||
| "kt"
|
||||
| "swift"
|
||||
| "c"
|
||||
| "h"
|
||||
| "cpp"
|
||||
| "hpp"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "sql"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn stable_hash_hex(value: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
value.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
fn editor_blocks_to_markdown_with_rewrite(
|
||||
content: &Value,
|
||||
local_file_context: Option<(&Path, &Path)>,
|
||||
@@ -6201,6 +6540,34 @@ fn local_markdown_conflict_detection_key(
|
||||
))
|
||||
}
|
||||
|
||||
fn local_resource_conflict_detection_key(root: &Path, target: &Path) -> Result<String, WebError> {
|
||||
let content = fs::read(target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_resource_read_failed",
|
||||
format!("无法读取本地资源文件 {}: {error}", target.display()),
|
||||
)
|
||||
})?;
|
||||
let meta = fs::metadata(target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_resource_stat_failed",
|
||||
format!("无法读取本地资源文件状态 {}: {error}", target.display()),
|
||||
)
|
||||
})?;
|
||||
let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
|
||||
let relative_path = target
|
||||
.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|path| path.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_else(|| target.to_string_lossy().to_string());
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
let content_hash = hasher.finish();
|
||||
Ok(format!(
|
||||
"local-resource:{relative_path}:{modified_ms}:{}:{content_hash:016x}",
|
||||
meta.len()
|
||||
))
|
||||
}
|
||||
|
||||
fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Value {
|
||||
let outline = content
|
||||
.as_array()
|
||||
@@ -6267,19 +6634,20 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
|
||||
mod tests {
|
||||
use super::{
|
||||
add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base,
|
||||
create_local_access_grant, create_share_grant, encode_local_id_segment,
|
||||
ensure_local_path_read_access, ensure_local_workspace_access_for_actor,
|
||||
ensure_local_workspace_read_access_for_actor, execute_local_tree_command,
|
||||
get_local_access_policy, get_share_grants, initialize_local_page_id,
|
||||
initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision, local_workspace_id,
|
||||
create_local_access_grant, create_share_grant, editor_blocks_to_markdown_for_file,
|
||||
encode_local_id_segment, ensure_local_path_read_access,
|
||||
ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor,
|
||||
execute_local_tree_command, get_local_access_policy, get_share_grants,
|
||||
initialize_local_page_id, initialize_local_workspace_for_actor,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_resource_write_editor_blocks, local_workspace_id,
|
||||
open_local_file, record_shared_cache, record_sync_pending_change,
|
||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
||||
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
||||
write_local_markdown_page_body, write_local_mindmap_data, write_sync_conflict_report,
|
||||
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
|
||||
LocalShareGrantRequest, LocalUploadFile, SharedCacheRecordRequest,
|
||||
SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile,
|
||||
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
};
|
||||
use crate::context::RequestContext;
|
||||
use axum::extract::{Extension, Path as AxumPath, Query};
|
||||
@@ -7734,6 +8102,65 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_resource_markdown_write_targets_asset_not_page_body() {
|
||||
let root = temp_root("mnote-local-resource-md-write");
|
||||
init_workspace(&root);
|
||||
std::fs::write(root.join("Page.md"), "# Page\n\n正文\n").expect("write page");
|
||||
std::fs::create_dir_all(root.join("Page.assets")).expect("create assets");
|
||||
let asset_path = root.join("Page.assets").join("note.md");
|
||||
std::fs::write(&asset_path, "# Asset\n\n旧内容\n").expect("write asset");
|
||||
let blocks = serde_json::json!([
|
||||
{"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Asset"}]},
|
||||
{"type":"paragraph","content":[{"type":"text","text":"新内容"}]}
|
||||
]);
|
||||
|
||||
let next = editor_blocks_to_markdown_for_file(&blocks, &root, &asset_path);
|
||||
std::fs::write(&asset_path, next).expect("write asset next");
|
||||
|
||||
let asset_text = std::fs::read_to_string(&asset_path).expect("read asset");
|
||||
let page_text = std::fs::read_to_string(root.join("Page.md")).expect("read page");
|
||||
assert!(asset_text.contains("新内容"));
|
||||
assert!(page_text.contains("正文"));
|
||||
assert!(!page_text.contains("新内容"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_resource_write_accepts_tiptap_document_payload() {
|
||||
let request = LocalResourceWriteRequest {
|
||||
root_uri: "file:///tmp/mnote".into(),
|
||||
path: "Page.assets/note.md".into(),
|
||||
content_format: Some("tiptapDocument".into()),
|
||||
tiptap_document: Some(serde_json::json!({
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": { "level": 2 },
|
||||
"content": [{ "type": "text", "text": "资源标题" }]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "资源正文" }]
|
||||
}
|
||||
]
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let blocks = local_resource_write_editor_blocks(&request);
|
||||
let markdown = editor_blocks_to_markdown_for_file(
|
||||
&blocks,
|
||||
std::path::Path::new("/tmp"),
|
||||
std::path::Path::new("/tmp/note.md"),
|
||||
);
|
||||
|
||||
assert!(markdown.contains("## 资源标题"));
|
||||
assert!(markdown.contains("资源正文"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tree_command_delete_folder_moves_directory_to_trash() {
|
||||
let root = temp_root("mnote-local-delete-folder");
|
||||
|
||||
@@ -237,6 +237,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/local-folder/files/open",
|
||||
get(local_folder_source::open_local_file),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/resource/read",
|
||||
get(local_folder_source::read_local_resource),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/resource/write",
|
||||
post(local_folder_source::write_local_resource),
|
||||
)
|
||||
.route(
|
||||
"/api/tree/runtime/reduce",
|
||||
post(tree::reduce_tree_shell_runtime),
|
||||
|
||||
@@ -1391,6 +1391,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const localFolderEventRegistry = new Map();
|
||||
const paneViewRegistry = new Map();
|
||||
const mindmapPaneViewRegistry = new Map();
|
||||
const resourceTabRegistry = new Map();
|
||||
let nextViewId = 1;
|
||||
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
|
||||
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
|
||||
@@ -1675,11 +1676,32 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return nextAggregate;
|
||||
};
|
||||
|
||||
const fetchLatestResourceSnapshot = async (session) => {
|
||||
const url = new URL('/api/local-folder/resource/read', window.location.origin);
|
||||
url.searchParams.set('rootUri', session.rootUri || '');
|
||||
url.searchParams.set('path', session.resourcePath || '');
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) throw new Error('resource_conflict_latest_fetch_failed_' + response.status);
|
||||
const nextResource = payload.result;
|
||||
if (!nextResource || typeof nextResource !== 'object') throw new Error('resource_conflict_latest_missing_snapshot');
|
||||
return nextResource;
|
||||
};
|
||||
|
||||
const aggregatePlainText = (aggregate) => {
|
||||
const body = aggregate?.body || {};
|
||||
return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim();
|
||||
};
|
||||
|
||||
const resourceSnapshotPlainText = (snapshot) => (
|
||||
String(snapshot?.text || flattenText(toTiptapDocument(snapshot?.content, '')) || '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
);
|
||||
|
||||
const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => {
|
||||
if (!session || session.sourceKind !== 'local_folder') return false;
|
||||
if (!sessionHasRecentExternalSignal(session)) return false;
|
||||
@@ -1746,6 +1768,29 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
setSessionStatus(session, 'synced-external-change');
|
||||
};
|
||||
|
||||
const applyResourceSnapshotToSession = (session, nextResource, source) => {
|
||||
const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim();
|
||||
const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || '');
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
session.lastPersistedSerialized = nextSerialized;
|
||||
if (nextConflictKey) {
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey;
|
||||
session.fileVersion = nextConflictKey;
|
||||
}
|
||||
session.dirty = false;
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.lastUserInputAt = 0;
|
||||
clearSessionConflictSurface(session);
|
||||
sessionViews(session).forEach((view) => {
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-conflict-resolved');
|
||||
});
|
||||
setSessionStatus(session, 'synced-external-change');
|
||||
};
|
||||
|
||||
const openConflictDiffPanel = async (session, panel) => {
|
||||
const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]');
|
||||
if (!(diffPanel instanceof HTMLElement)) return;
|
||||
@@ -1756,14 +1801,19 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
loading.textContent = '正在读取磁盘版本...';
|
||||
diffPanel.appendChild(loading);
|
||||
try {
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
const latest = session.sessionKind === 'resource'
|
||||
? await fetchLatestResourceSnapshot(session)
|
||||
: await fetchLatestSessionAggregate(session);
|
||||
const latestText = session.sessionKind === 'resource'
|
||||
? resourceSnapshotPlainText(latest)
|
||||
: aggregatePlainText(latest);
|
||||
diffPanel.replaceChildren();
|
||||
const current = document.createElement('pre');
|
||||
current.setAttribute('data-testid', 'mnote-conflict-current-text');
|
||||
current.textContent = sessionPlainText(session) || '(当前编辑器为空)';
|
||||
const disk = document.createElement('pre');
|
||||
disk.setAttribute('data-testid', 'mnote-conflict-disk-text');
|
||||
disk.textContent = aggregatePlainText(latest) || '(磁盘版本为空)';
|
||||
disk.textContent = latestText || '(磁盘版本为空)';
|
||||
const currentTitle = document.createElement('h3');
|
||||
currentTitle.textContent = '当前编辑器版本';
|
||||
const diskTitle = document.createElement('h3');
|
||||
@@ -1776,7 +1826,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
mergeTitle.textContent = '合并结果';
|
||||
const mergeText = document.createElement('textarea');
|
||||
mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text');
|
||||
mergeText.value = sessionPlainText(session) || aggregatePlainText(latest) || '';
|
||||
mergeText.value = sessionPlainText(session) || latestText || '';
|
||||
const mergeActions = document.createElement('div');
|
||||
mergeActions.className = 'mnote-conflict-actions';
|
||||
const useCurrent = document.createElement('button');
|
||||
@@ -1800,7 +1850,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
mergeText.value = sessionPlainText(session) || '';
|
||||
});
|
||||
useDisk.addEventListener('click', () => {
|
||||
mergeText.value = aggregatePlainText(latest) || '';
|
||||
mergeText.value = latestText || '';
|
||||
});
|
||||
saveMerge.addEventListener('click', () => {
|
||||
writeMergedConflictResult(session, panel, mergeText.value).catch((error) => {
|
||||
@@ -1816,13 +1866,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const acceptDiskVersion = async (session) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...');
|
||||
if (session.sessionKind === 'resource') {
|
||||
const latestResource = await fetchLatestResourceSnapshot(session);
|
||||
applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk');
|
||||
return;
|
||||
}
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk');
|
||||
};
|
||||
|
||||
const keepCurrentEditorVersion = async (session) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...');
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
const latest = session.sessionKind === 'resource'
|
||||
? await fetchLatestResourceSnapshot(session)
|
||||
: await fetchLatestSessionAggregate(session);
|
||||
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
|
||||
if (hydrateView) {
|
||||
const liveText = normalizePlainText(currentEditorText(hydrateView));
|
||||
@@ -1834,7 +1891,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
}
|
||||
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
|
||||
}
|
||||
const nextKey = conflictDetectionKeyFromBody(latest.body || {});
|
||||
const nextKey = session.sessionKind === 'resource'
|
||||
? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim()
|
||||
: conflictDetectionKeyFromBody(latest.body || {});
|
||||
if (nextKey) {
|
||||
session.conflictDetectionKey = nextKey;
|
||||
session.lastExternalConflictDetectionKey = nextKey;
|
||||
@@ -1849,8 +1908,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const writeMergedConflictResult = async (session, panel, mergedText) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...');
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
const nextKey = conflictDetectionKeyFromBody(latest.body || {});
|
||||
const latest = session.sessionKind === 'resource'
|
||||
? await fetchLatestResourceSnapshot(session)
|
||||
: await fetchLatestSessionAggregate(session);
|
||||
const nextKey = session.sessionKind === 'resource'
|
||||
? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim()
|
||||
: conflictDetectionKeyFromBody(latest.body || {});
|
||||
if (nextKey) {
|
||||
session.conflictDetectionKey = nextKey;
|
||||
session.lastExternalConflictDetectionKey = nextKey;
|
||||
@@ -1882,7 +1945,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
text.textContent = message || externalConflictMessage;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'mnote-conflict-meta';
|
||||
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`;
|
||||
const fileLabel = session.sessionKind === 'resource'
|
||||
? `${session.rootUri || ''}/${session.resourcePath || session.documentId}`
|
||||
: (session.rootUri || session.documentId);
|
||||
meta.textContent = `文件:${fileLabel} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'mnote-conflict-actions';
|
||||
const acceptDisk = document.createElement('button');
|
||||
@@ -1969,21 +2035,33 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
|
||||
const content = legacyBlocksFromEditorDocument(editorDocument);
|
||||
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
|
||||
const savePayload = {
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap',
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument: session.currentTiptapDocument,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
};
|
||||
if (session.sourceKind !== 'local_folder') {
|
||||
const savePayload = session.sessionKind === 'resource'
|
||||
? {
|
||||
rootUri: session.rootUri,
|
||||
path: session.resourcePath,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap-resource-tab',
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument: session.currentTiptapDocument,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
}
|
||||
: {
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap',
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument: session.currentTiptapDocument,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
};
|
||||
if (session.sourceKind !== 'local_folder' && session.sessionKind !== 'resource') {
|
||||
savePayload.conflictDetectionKey = session.conflictDetectionKey;
|
||||
}
|
||||
const response = await fetch(saveEndpoint, {
|
||||
@@ -2021,7 +2099,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
syncSessionMetaToViews(session);
|
||||
session.lastPersistedSerialized = serialized;
|
||||
session.saving = false;
|
||||
if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
|
||||
if (session.sessionKind !== 'resource' && typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
|
||||
const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0];
|
||||
if (primaryView) {
|
||||
const plainText = sessionPlainText(session);
|
||||
@@ -2887,6 +2965,252 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return view;
|
||||
};
|
||||
|
||||
const resourceTabHostNodes = () => ({
|
||||
strip: document.querySelector('[data-mnote-main-tab-strip]'),
|
||||
pageTab: document.querySelector('[data-mnote-main-tab="page"]'),
|
||||
pagePanel: document.querySelector('[data-mnote-page-tab-panel]'),
|
||||
host: document.querySelector('[data-mnote-resource-tab-host]'),
|
||||
panelRoot: document.querySelector('[data-mnote-resource-tab-panel-root]'),
|
||||
});
|
||||
|
||||
const resourceIconForKind = (kind) => {
|
||||
if (kind === 'office') return 'article';
|
||||
if (kind === 'pdf') return 'picture_as_pdf';
|
||||
if (kind === 'image') return 'image';
|
||||
if (kind === 'markdown') return 'notes';
|
||||
if (kind === 'code') return 'code';
|
||||
return 'draft';
|
||||
};
|
||||
|
||||
const currentWebShellWorkspaceId = () => {
|
||||
try {
|
||||
return currentUrl().searchParams.get('workspaceId') || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeResourceTabKind = (input) => {
|
||||
const kind = String(input?.kind || '').trim().toLowerCase();
|
||||
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
||||
if (kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind;
|
||||
if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office';
|
||||
if (/\.pdf$/i.test(title)) return 'pdf';
|
||||
if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image';
|
||||
if (/\.(md|markdown)$/i.test(title)) return 'markdown';
|
||||
if (/\.(txt|log)$/i.test(title)) return 'text';
|
||||
if (/\.(rs|ts|tsx|js|jsx|json|css|scss|html|xml|py|go|java|kt|swift|c|h|cpp|hpp|sh|bash|zsh|toml|yaml|yml|sql)$/i.test(title)) return 'code';
|
||||
return 'file';
|
||||
};
|
||||
|
||||
const activateMainEditorTab = (objectIdentity) => {
|
||||
const nodes = resourceTabHostNodes();
|
||||
const activeResource = String(objectIdentity || '').trim();
|
||||
if (nodes.pageTab instanceof HTMLElement) {
|
||||
const activePage = !activeResource;
|
||||
nodes.pageTab.classList.toggle('is-active', activePage);
|
||||
nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false');
|
||||
}
|
||||
if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource);
|
||||
if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource;
|
||||
resourceTabRegistry.forEach((entry, key) => {
|
||||
const active = key === activeResource;
|
||||
if (entry.tab instanceof HTMLElement) {
|
||||
entry.tab.classList.toggle('is-active', active);
|
||||
entry.tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
}
|
||||
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
|
||||
});
|
||||
};
|
||||
|
||||
const closeResourceTab = (objectIdentity) => {
|
||||
const key = String(objectIdentity || '').trim();
|
||||
const entry = resourceTabRegistry.get(key);
|
||||
if (!entry) return;
|
||||
if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
|
||||
if (entry.tab instanceof HTMLElement) entry.tab.remove();
|
||||
if (entry.panel instanceof HTMLElement) entry.panel.remove();
|
||||
resourceTabRegistry.delete(key);
|
||||
activateMainEditorTab('');
|
||||
};
|
||||
|
||||
const createResourceTabDom = (input) => {
|
||||
const nodes = resourceTabHostNodes();
|
||||
if (!(nodes.strip instanceof HTMLElement) || !(nodes.panelRoot instanceof HTMLElement)) return null;
|
||||
const objectIdentity = String(input.objectIdentity || '').trim();
|
||||
const title = String(input.title || input.fileName || input.path || '资源').trim() || '资源';
|
||||
const kind = normalizeResourceTabKind(input);
|
||||
const tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 'mnote-main-tab';
|
||||
tab.setAttribute('role', 'tab');
|
||||
tab.setAttribute('data-mnote-main-tab', objectIdentity);
|
||||
tab.setAttribute('data-mnote-tab-kind', kind);
|
||||
tab.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">' + resourceIconForKind(kind) + '</span><span class="mnote-main-tab-title"></span><span class="mnote-main-tab-close" role="button" aria-label="关闭标签页">×</span>';
|
||||
const titleNode = tab.querySelector('.mnote-main-tab-title');
|
||||
if (titleNode) titleNode.textContent = title;
|
||||
tab.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && target.closest('.mnote-main-tab-close')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeResourceTab(objectIdentity);
|
||||
return;
|
||||
}
|
||||
activateMainEditorTab(objectIdentity);
|
||||
});
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'mnote-resource-tab-panel';
|
||||
panel.setAttribute('data-mnote-resource-tab-panel', objectIdentity);
|
||||
panel.setAttribute('data-resource-kind', kind);
|
||||
panel.hidden = true;
|
||||
nodes.strip.append(tab);
|
||||
nodes.panelRoot.append(panel);
|
||||
return { objectIdentity, title, kind, tab, panel, view: null, session: null };
|
||||
};
|
||||
|
||||
const localResourceReadUrl = (rootUri, path) => {
|
||||
const url = new URL('/api/local-folder/resource/read', window.location.origin);
|
||||
url.searchParams.set('rootUri', rootUri || '');
|
||||
url.searchParams.set('path', path || '');
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const createResourceSession = (entry, input, readResult) => {
|
||||
const tiptapDocument = toTiptapDocument(readResult?.content, readResult?.text || '');
|
||||
const conflictDetectionKey = String(readResult?.fileVersion || readResult?.conflictDetectionKey || '').trim();
|
||||
const session = {
|
||||
key: `resource:${input.rootUri || ''}:${input.path || entry.objectIdentity}`,
|
||||
sessionKind: 'resource',
|
||||
documentId: entry.objectIdentity,
|
||||
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: String(input.rootUri || ''),
|
||||
resourcePath: String(input.path || ''),
|
||||
saveEndpoint: '/api/local-folder/resource/write',
|
||||
pageAggregateScriptId: '',
|
||||
latestAggregate: null,
|
||||
title: entry.title,
|
||||
currentTiptapDocument: tiptapDocument,
|
||||
currentSerialized: JSON.stringify(tiptapDocument),
|
||||
lastPersistedSerialized: JSON.stringify(tiptapDocument),
|
||||
revision: null,
|
||||
conflictDetectionKey,
|
||||
fileVersion: conflictDetectionKey,
|
||||
lastExternalConflictDetectionKey: conflictDetectionKey,
|
||||
readOnly: false,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
hasExternalConflict: false,
|
||||
externalChangePending: false,
|
||||
externalRefreshSource: '',
|
||||
lastExternalChangeSignalAt: 0,
|
||||
lastSelfSaveSignalAt: 0,
|
||||
lastExternalWriteSource: '',
|
||||
lastExternalWriteRunId: '',
|
||||
lastUserInputAt: 0,
|
||||
saveTimer: 0,
|
||||
externalRefreshTimer: 0,
|
||||
releaseTimer: 0,
|
||||
views: new Map(),
|
||||
localFolderChannel: null,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
};
|
||||
documentSessionRegistry.set(session.key, session);
|
||||
return session;
|
||||
};
|
||||
|
||||
const openTiptapResourceTab = async (entry, input) => {
|
||||
const runtime = await loadRuntime();
|
||||
entry.panel.innerHTML = '<main class="document-shell mnote-resource-tab-text-shell" data-editor-host="leptos_tiptap_resource"><div class="mnote-resource-tab-editor-root" data-testid="mnote-leptos-tiptap-island-editor-root" data-editor-host-kind="leptos_tiptap_resource" data-runtime-editor-status="booting" data-pane-role="resource"></div><div class="sr-only" data-editor-host-observability="rust-web-resource-tab" data-pane-role="resource"></div></main>';
|
||||
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const observability = entry.panel.querySelector('[data-editor-host-observability]');
|
||||
if (!(root instanceof HTMLElement)) throw new Error('resource_tab_root_missing');
|
||||
const response = await fetch(localResourceReadUrl(input.rootUri, input.path), { cache: 'no-store', headers: { accept: 'application/json' } });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) throw new Error(payload?.error?.message || `resource_read_failed_${response.status}`);
|
||||
const readResult = payload.result || {};
|
||||
const runtimeDescriptor = {
|
||||
paneRole: 'resource',
|
||||
root,
|
||||
observability,
|
||||
aggregate: { layout: { pageOptions: {} } },
|
||||
bootstrap: {
|
||||
documentId: entry.objectIdentity,
|
||||
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: String(input.rootUri || ''),
|
||||
saveEndpoint: '/api/local-folder/resource/write',
|
||||
},
|
||||
};
|
||||
const session = createResourceSession(entry, input, readResult);
|
||||
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
|
||||
const mountId = runtime.mount(root, {
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
title: session.title,
|
||||
content: session.currentTiptapDocument,
|
||||
revision: session.revision,
|
||||
conflictDetectionKey: session.conflictDetectionKey,
|
||||
readOnly: false,
|
||||
editable: true,
|
||||
pageOptions: {},
|
||||
});
|
||||
view.mountId = mountId;
|
||||
root.setAttribute('data-runtime-mount-id', String(mountId));
|
||||
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_resource');
|
||||
setStatus(runtimeDescriptor, 'mounting-editor');
|
||||
entry.view = view;
|
||||
entry.session = session;
|
||||
};
|
||||
|
||||
const openPassiveResourceTab = (entry, input) => {
|
||||
const href = String(input.officeUrl || input.href || '').trim();
|
||||
if (entry.kind === 'image') {
|
||||
entry.panel.innerHTML = '<img class="mnote-resource-tab-image" alt="">';
|
||||
const img = entry.panel.querySelector('img');
|
||||
if (img instanceof HTMLImageElement) {
|
||||
img.src = href;
|
||||
img.alt = entry.title;
|
||||
}
|
||||
return;
|
||||
}
|
||||
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
|
||||
const frame = entry.panel.querySelector('iframe');
|
||||
if (frame instanceof HTMLIFrameElement) {
|
||||
frame.title = entry.title;
|
||||
frame.src = href;
|
||||
}
|
||||
};
|
||||
|
||||
const openResourceInActiveTab = async (input = {}) => {
|
||||
const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim();
|
||||
if (!objectIdentity) return false;
|
||||
const existing = resourceTabRegistry.get(objectIdentity);
|
||||
if (existing) {
|
||||
activateMainEditorTab(objectIdentity);
|
||||
return true;
|
||||
}
|
||||
const entry = createResourceTabDom({ ...input, objectIdentity });
|
||||
if (!entry) return false;
|
||||
resourceTabRegistry.set(objectIdentity, entry);
|
||||
activateMainEditorTab(objectIdentity);
|
||||
try {
|
||||
if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
|
||||
await openTiptapResourceTab(entry, input);
|
||||
} else {
|
||||
openPassiveResourceTab(entry, input);
|
||||
}
|
||||
activateMainEditorTab(objectIdentity);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('mnote resource tab 打开失败', error);
|
||||
entry.panel.innerHTML = '<div class="mnote-resource-tab-text-shell" data-resource-tab-error="true">资源打开失败</div>';
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const mountPane = async (runtimeDescriptor) => {
|
||||
const runtime = await loadRuntime();
|
||||
const session = getOrCreateDocumentSession(runtimeDescriptor);
|
||||
@@ -2948,6 +3272,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (url instanceof URL) replaceUrlState(url);
|
||||
return true;
|
||||
},
|
||||
openResourceInActiveTab: openResourceInActiveTab,
|
||||
closeSecondaryDocument: ({ url } = {}) => {
|
||||
closeSecondaryPane(url instanceof URL ? url : currentUrl());
|
||||
return true;
|
||||
@@ -3624,7 +3949,12 @@ mod tests {
|
||||
assert!(html.contains("data-document-pane=\"true\""));
|
||||
assert!(html.contains("data-pane-role=\"primary\""));
|
||||
assert!(html.contains("data-document-pane-resizer=\"true\""));
|
||||
assert!(html.contains("data-mnote-main-tab-strip"));
|
||||
assert!(html.contains("data-mnote-resource-tab-host"));
|
||||
assert!(html.contains("openPrimaryMindmap"));
|
||||
assert!(html.contains("openResourceInActiveTab"));
|
||||
assert!(html.contains("/api/local-folder/resource/read"));
|
||||
assert!(html.contains("/api/local-folder/resource/write"));
|
||||
assert!(html.contains("replacePrimaryPaneMindmap"));
|
||||
assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
|
||||
assert!(html.contains("data-mnote-object-identity"));
|
||||
|
||||
@@ -297,7 +297,34 @@ pub fn DocumentPage(
|
||||
data-testid="mnote-document-workspace"
|
||||
data-has-secondary-pane={secondary_visible.to_string()}
|
||||
>
|
||||
<DocumentPane model={primary_model} />
|
||||
<section class="document-main-editor-group" data-testid="mnote-main-editor-tab-host">
|
||||
<div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" role="tablist" aria-label="主编辑区标签页">
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-main-tab is-active"
|
||||
data-mnote-main-tab="page"
|
||||
data-mnote-tab-kind="page"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">"description"</span>
|
||||
<span class="mnote-main-tab-title">{title.clone()}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mnote-main-tab-panels">
|
||||
<div data-mnote-page-tab-panel="true">
|
||||
<DocumentPane model={primary_model} />
|
||||
</div>
|
||||
<section
|
||||
class="mnote-resource-tab-host"
|
||||
data-mnote-resource-tab-host="true"
|
||||
data-testid="mnote-resource-tab-host"
|
||||
hidden=true
|
||||
>
|
||||
<div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
class="document-pane-resizer"
|
||||
data-testid="mnote-secondary-pane-resizer"
|
||||
|
||||
@@ -2062,7 +2062,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return '/onlyoffice?' + params.toString();
|
||||
}
|
||||
|
||||
function buildLocalOnlyOfficeOpenUrl(relativePath, fileName, documentId, assetId) {
|
||||
function buildLocalOnlyOfficeOpenUrl(relativePath, fileName, documentId, assetId, mode) {
|
||||
var fileType = inferOnlyOfficeFileType(fileName || relativePath || '', '');
|
||||
if (!fileType) return '';
|
||||
var fileUrl = buildLocalFileOpenUrl(relativePath, false);
|
||||
@@ -2074,7 +2074,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: assetId || ('local-file:' + relativePath),
|
||||
documentId: documentId || currentDocumentId() || '',
|
||||
userId: '',
|
||||
mode: 'edit'
|
||||
mode: mode || 'edit'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2150,6 +2150,29 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function openLocalResourceInActiveTab(input) {
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab !== 'function') return false;
|
||||
var relativePath = String(input && input.path || '').trim();
|
||||
var rootUri = String(input && input.rootUri || currentRootUri() || '').trim();
|
||||
if (!relativePath || !rootUri) return false;
|
||||
var title = String(input && input.title || '').trim() || relativePath.split('/').pop() || relativePath;
|
||||
var kind = String(input && input.kind || fileTreeIconKindForFileName(title) || '').trim();
|
||||
var objectIdentity = 'resource:file:' + rootUri + ':' + relativePath;
|
||||
return await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: objectIdentity,
|
||||
assetId: String(input && input.assetId || '').trim() || 'local-file:' + relativePath,
|
||||
title: title,
|
||||
fileName: title,
|
||||
kind: kind,
|
||||
rootUri: rootUri,
|
||||
path: relativePath,
|
||||
href: String(input && input.href || buildLocalFileOpenUrl(relativePath, false) || '').trim(),
|
||||
officeUrl: String(input && input.officeUrl || '').trim(),
|
||||
documentId: String(input && input.documentId || currentDocumentId() || '').trim(),
|
||||
workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim()
|
||||
});
|
||||
}
|
||||
|
||||
function readFileTreeObjectIdentity(row) {
|
||||
if (!row) return null;
|
||||
var raw = row.getAttribute('data-object-identity') || '';
|
||||
@@ -2179,6 +2202,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
async function openConvexAssetFromFileTree(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
if (!assetId) return;
|
||||
var forceNewWindow = String(detail && detail.openTarget || '').trim() === 'new-window';
|
||||
var localFilePath = localFilePathFromAssetId(assetId);
|
||||
if (localFilePath) {
|
||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||
@@ -2188,13 +2212,33 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim());
|
||||
return;
|
||||
}
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId);
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, forceNewWindow ? 'edit' : 'view');
|
||||
if (localOfficeUrl) {
|
||||
if (!forceNewWindow && await openLocalResourceInActiveTab({
|
||||
path: localFilePath,
|
||||
title: localFileName,
|
||||
kind: 'office',
|
||||
assetId: assetId,
|
||||
documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(),
|
||||
workspaceId: String(detail.workspaceId || '').trim(),
|
||||
officeUrl: localOfficeUrl
|
||||
})) return;
|
||||
window.open(localOfficeUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
||||
if (localFileUrl) window.open(localFileUrl, '_blank', 'noopener,noreferrer');
|
||||
if (localFileUrl) {
|
||||
if (!forceNewWindow && await openLocalResourceInActiveTab({
|
||||
path: localFilePath,
|
||||
title: localFileName,
|
||||
kind: fileTreeIconKindForFileName(localFileName),
|
||||
assetId: assetId,
|
||||
documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(),
|
||||
workspaceId: String(detail.workspaceId || '').trim(),
|
||||
href: localFileUrl
|
||||
})) return;
|
||||
window.open(localFileUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
return;
|
||||
}
|
||||
var documentId = String(detail && detail.documentId || '').trim();
|
||||
@@ -3319,6 +3363,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
openEditorAttachmentDetail(detail);
|
||||
return;
|
||||
}
|
||||
if (action === 'new-window') {
|
||||
openEditorAttachmentNewWindow(detail);
|
||||
return;
|
||||
}
|
||||
if (action === 'right-preview') {
|
||||
dispatchSidebarEvent('tree.attachment.open-right', detail);
|
||||
return;
|
||||
@@ -3334,6 +3382,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body);
|
||||
var title = detail.title || '无标题';
|
||||
var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||
if (isAsset && action === 'new-window') {
|
||||
void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' });
|
||||
return;
|
||||
}
|
||||
if (action === 'open-right') {
|
||||
dispatchSidebarEvent('tree.page.open-right', detail);
|
||||
return;
|
||||
@@ -3502,6 +3554,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ separator: true },
|
||||
{ action: 'popup-preview', icon: 'preview', label: '弹窗预览' },
|
||||
{ action: 'right-preview', icon: 'right_panel_open', label: '右侧预览' },
|
||||
{ action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' },
|
||||
{ action: 'download', icon: 'download', label: '下载' },
|
||||
{ action: 'replace-file', icon: 'sync', label: '更换文件' },
|
||||
{ action: 'rename-attachment', icon: 'drive_file_rename_outline', label: '重命名' },
|
||||
@@ -3511,6 +3564,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'color', icon: 'format_paint', label: '颜色' }
|
||||
] : isAsset ? [
|
||||
{ action: 'open-right', icon: 'open_in_new', label: '在右侧边栏打开', shortcut: 'Alt + O' },
|
||||
{ action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true },
|
||||
@@ -7218,6 +7272,24 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function openEditorAttachmentDetail(detail) {
|
||||
if (!detail || !detail.href) return;
|
||||
var localFilePath = localFilePathFromAssetId(detail.assetId);
|
||||
if (localFilePath) {
|
||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'view');
|
||||
void openLocalResourceInActiveTab({
|
||||
path: localFilePath,
|
||||
title: detail.fileName || localFileName,
|
||||
kind: localOfficeUrl ? 'office' : fileTreeIconKindForFileName(detail.fileName || localFileName),
|
||||
assetId: detail.assetId,
|
||||
documentId: detail.documentId || currentDocumentId() || '',
|
||||
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
|
||||
href: buildLocalFileOpenUrl(localFilePath, false),
|
||||
officeUrl: localOfficeUrl
|
||||
}).then(function(opened) {
|
||||
if (!opened) window.open(localOfficeUrl || buildLocalFileOpenUrl(localFilePath, false) || detail.href, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isPdfAttachmentFileName(detail.fileName)) {
|
||||
void openPdfEditorAttachment(detail);
|
||||
return;
|
||||
@@ -7229,6 +7301,19 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
window.open(detail.href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
function openEditorAttachmentNewWindow(detail) {
|
||||
if (!detail) return;
|
||||
var localFilePath = localFilePathFromAssetId(detail.assetId);
|
||||
if (localFilePath) {
|
||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit');
|
||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
||||
window.open(localOfficeUrl || localFileUrl || detail.href, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
window.open(detail.href || detail.fileUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
async function resolveEditorAttachmentUrl(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
var localFilePath = localFilePathFromAssetId(assetId);
|
||||
|
||||
@@ -2372,6 +2372,118 @@ body {
|
||||
grid-template-columns: minmax(0, 1fr) var(--mnote-secondary-pane-resizer-width) var(--mnote-secondary-pane-width);
|
||||
}
|
||||
|
||||
.document-main-editor-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-main-tab-strip {
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
border-bottom: 1px solid #E9E9E8;
|
||||
background: #FAFAF9;
|
||||
padding: 0 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.mnote-main-tab {
|
||||
height: 34px;
|
||||
max-width: 240px;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: transparent;
|
||||
color: #6B6862;
|
||||
padding: 0 9px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mnote-main-tab:hover {
|
||||
background: #F4F3F3;
|
||||
color: #1B1C1C;
|
||||
}
|
||||
|
||||
.mnote-main-tab.is-active {
|
||||
background: #FFF;
|
||||
color: #1B1C1C;
|
||||
box-shadow: inset 0 1px 0 #E9E9E8, inset 1px 0 0 #E9E9E8, inset -1px 0 0 #E9E9E8;
|
||||
}
|
||||
|
||||
.mnote-main-tab .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mnote-main-tab-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-main-tab-close {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-main-tab-close:hover {
|
||||
background: rgba(55, 53, 47, 0.12);
|
||||
}
|
||||
|
||||
.mnote-main-tab-panels {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-host[hidden],
|
||||
.mnote-resource-tab-panel[hidden],
|
||||
[data-mnote-page-tab-panel][hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-panel {
|
||||
min-height: calc(100vh - 76px);
|
||||
}
|
||||
|
||||
.mnote-resource-tab-frame,
|
||||
.mnote-resource-tab-image,
|
||||
.mnote-resource-tab-text-shell {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 80px);
|
||||
border: 0;
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-image {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-text-shell {
|
||||
padding: 34px 48px;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-editor-root {
|
||||
min-height: calc(100vh - 112px);
|
||||
}
|
||||
|
||||
.document-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath, secondaryRelativePath = "") {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
if (secondaryRelativePath) {
|
||||
url.searchParams.set("secondaryDocumentId", localMdDocumentId(secondaryRelativePath));
|
||||
url.searchParams.set("secondarySourceKind", "local_folder");
|
||||
url.searchParams.set("secondaryRootUri", fileUrl(root));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: `local-ws:${ownerId}:task457`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function quickLogin(page) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await quickLoginButton.count()) {
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLocalAsset(page, root, documentId, fileName, mimeType, bytes, kind) {
|
||||
return await page.evaluate(
|
||||
async ({ rootUri, documentId, fileName, mimeType, bytes, kind }) => {
|
||||
const form = new FormData();
|
||||
form.append("rootUri", rootUri);
|
||||
form.append("documentId", documentId);
|
||||
form.append("kind", kind);
|
||||
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
|
||||
const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(`upload_failed_${response.status}:${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload.asset;
|
||||
},
|
||||
{ rootUri: fileUrl(root), documentId, fileName, mimeType, bytes: Array.from(bytes), kind },
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task457-resource-tabs-"));
|
||||
const relativePath = "README.md";
|
||||
const documentId = localMdDocumentId(relativePath);
|
||||
const sideRelativePath = "Side.md";
|
||||
writeWorkspaceManifest(root, "user_real");
|
||||
fs.writeFileSync(path.join(root, relativePath), "# Page\n\n页面正文保持不变\n", "utf8");
|
||||
fs.writeFileSync(path.join(root, sideRelativePath), "# Side\n\n右侧栏保持打开\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1360, height: 900 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await page.goto(documentUrl(root, relativePath, sideRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="secondary"][data-pane-visible="true"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const asset = await uploadLocalAsset(
|
||||
page,
|
||||
root,
|
||||
documentId,
|
||||
"resource-note.md",
|
||||
"text/markdown",
|
||||
Buffer.from("# Resource Note\n\n旧资源正文\n", "utf8"),
|
||||
"attachment",
|
||||
);
|
||||
const officeAsset = await uploadLocalAsset(
|
||||
page,
|
||||
root,
|
||||
documentId,
|
||||
"resource-office.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
Buffer.from("task457 office probe", "utf8"),
|
||||
"attachment",
|
||||
);
|
||||
await page.goto(documentUrl(root, relativePath, sideRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.evaluate(({ asset, documentId }) => {
|
||||
window.dispatchEvent(new CustomEvent("tree.asset.open", {
|
||||
detail: {
|
||||
assetId: asset.id,
|
||||
documentId,
|
||||
title: asset.file_name || "resource-note.md",
|
||||
assetType: asset.asset_type || "attachment",
|
||||
},
|
||||
}));
|
||||
}, { asset, documentId });
|
||||
await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="secondary"][data-pane-visible="true"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const editor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End");
|
||||
await page.keyboard.type("\n新增资源正文", { delay: 5 });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-runtime-editor-status="saved"]'),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const assetPath = path.join(root, "README", "resource-note.md");
|
||||
const pageMarkdown = fs.readFileSync(path.join(root, relativePath), "utf8");
|
||||
const assetMarkdown = fs.readFileSync(assetPath, "utf8");
|
||||
assert(assetMarkdown.includes("新增资源正文"), assetMarkdown);
|
||||
assert(pageMarkdown.includes("页面正文保持不变"), pageMarkdown);
|
||||
assert(!pageMarkdown.includes("新增资源正文"), pageMarkdown);
|
||||
|
||||
await page.evaluate(({ officeAsset, documentId }) => {
|
||||
window.dispatchEvent(new CustomEvent("tree.asset.open", {
|
||||
detail: {
|
||||
assetId: officeAsset.id,
|
||||
documentId,
|
||||
title: officeAsset.file_name || "resource-office.docx",
|
||||
assetType: officeAsset.asset_type || "attachment",
|
||||
},
|
||||
}));
|
||||
}, { officeAsset, documentId });
|
||||
await page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="office"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const officeFrameSrc = await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().getAttribute("src", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(officeFrameSrc, "Office resource tab 应创建 iframe");
|
||||
const officeFrameUrl = new URL(officeFrameSrc, BASE_URL);
|
||||
assert(officeFrameUrl.pathname === "/onlyoffice", `Office resource tab iframe 应指向 /onlyoffice,实际为 ${officeFrameSrc}`);
|
||||
assert(officeFrameUrl.searchParams.get("assetId") === officeAsset.id, "Office iframe URL 应携带当前资源 assetId");
|
||||
assert(officeFrameUrl.searchParams.get("mode") === "view", "Office resource tab iframe 应使用 view 模式");
|
||||
|
||||
const [officePopup] = await Promise.all([
|
||||
page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }),
|
||||
page.evaluate(({ officeAsset, documentId }) => {
|
||||
window.dispatchEvent(new CustomEvent("tree.asset.open", {
|
||||
detail: {
|
||||
assetId: officeAsset.id,
|
||||
documentId,
|
||||
title: officeAsset.file_name || "resource-office.docx",
|
||||
assetType: officeAsset.asset_type || "attachment",
|
||||
openTarget: "new-window",
|
||||
},
|
||||
}));
|
||||
}, { officeAsset, documentId }),
|
||||
]);
|
||||
await officePopup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
const officePopupUrl = new URL(officePopup.url());
|
||||
assert(officePopupUrl.pathname === "/onlyoffice", `显式 new-window 应打开 /onlyoffice,实际为 ${officePopup.url()}`);
|
||||
assert(officePopupUrl.searchParams.get("assetId") === officeAsset.id, "new-window URL 应携带当前资源 assetId");
|
||||
assert(officePopupUrl.searchParams.get("mode") === "edit", "显式 new-window 应保留 edit 模式");
|
||||
await officePopup.close().catch(() => undefined);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, root, assetPath, assetId: asset.id, officeAssetId: officeAsset.id }, null, 2));
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user