fix: 修复 Rust OnlyOffice 附件打开链路
This commit is contained in:
Generated
+34
@@ -174,6 +174,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -581,6 +582,15 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "entities"
|
name = "entities"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -1518,6 +1528,7 @@ dependencies = [
|
|||||||
name = "mnote-web"
|
name = "mnote-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"adapter-onlyoffice",
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"bridge-runtime",
|
"bridge-runtime",
|
||||||
@@ -1539,6 +1550,23 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "next_tuple"
|
name = "next_tuple"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2400,6 +2428,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.9.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ license.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
adapter-onlyoffice = { path = "../adapter-onlyoffice" }
|
||||||
|
axum = { version = "0.8", features = ["multipart", "ws"] }
|
||||||
bridge-runtime = { path = "../bridge-runtime" }
|
bridge-runtime = { path = "../bridge-runtime" }
|
||||||
core-protocol = { path = "../core-protocol" }
|
core-protocol = { path = "../core-protocol" }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
use crate::app::AppState;
|
||||||
|
use crate::context::RequestContext;
|
||||||
|
use crate::error::WebError;
|
||||||
|
use crate::transport::convex::{execute_convex_mutation_by_name, execute_convex_query_by_name};
|
||||||
|
use axum::extract::{Multipart, Query, State};
|
||||||
|
use axum::http::{header, HeaderMap};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::{Extension, Json};
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MediaSignQuery {
|
||||||
|
asset_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileTreeUploadTargetPreflightPayload {
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
target_document_id: Option<String>,
|
||||||
|
target_row_id: Option<String>,
|
||||||
|
focused_row_id: Option<String>,
|
||||||
|
active_document_id: Option<String>,
|
||||||
|
rows: Option<Vec<FileTreeUploadTargetRow>>,
|
||||||
|
document_workspaces: Option<Vec<FileTreeDocumentWorkspace>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct FileTreeUploadTargetRow {
|
||||||
|
row_id: Option<String>,
|
||||||
|
row_kind: Option<String>,
|
||||||
|
document_id: Option<String>,
|
||||||
|
asset_id: Option<String>,
|
||||||
|
asset_document_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileTreeDocumentWorkspace {
|
||||||
|
document_id: Option<String>,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileTreeUploadTargetPlan {
|
||||||
|
workspace_id: String,
|
||||||
|
target_document_id: String,
|
||||||
|
target_mindmap_id: Option<String>,
|
||||||
|
target_sub_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UploadFile {
|
||||||
|
name: String,
|
||||||
|
content_type: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
|
||||||
|
let actor_id = context.auth.actor_id.trim();
|
||||||
|
if !actor_id.is_empty() && actor_id != "anonymous" {
|
||||||
|
return actor_id.to_string();
|
||||||
|
}
|
||||||
|
if let Ok(user) = execute_convex_query_by_name(
|
||||||
|
state.config(),
|
||||||
|
context,
|
||||||
|
"users:currentUser",
|
||||||
|
json!({}),
|
||||||
|
context.workspace.workspace_id.as_deref(),
|
||||||
|
"media_current_user",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
for key in ["_id", "id"] {
|
||||||
|
if let Some(user_id) = user
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
return user_id.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.config().dev_user_id.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_millis() -> u128 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_asset_id() -> String {
|
||||||
|
format!(
|
||||||
|
"asset_{}_{}",
|
||||||
|
now_millis(),
|
||||||
|
UPLOAD_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asset_type(mime: &str) -> &'static str {
|
||||||
|
if mime.starts_with("image/") {
|
||||||
|
"image"
|
||||||
|
} else if mime.starts_with("video/") {
|
||||||
|
"video"
|
||||||
|
} else if mime.starts_with("audio/") {
|
||||||
|
"audio"
|
||||||
|
} else {
|
||||||
|
"file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_upload_multipart(
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<(UploadFile, String, String, Option<String>), WebError> {
|
||||||
|
let mut file: Option<UploadFile> = None;
|
||||||
|
let mut workspace_id = String::new();
|
||||||
|
let mut document_id = String::new();
|
||||||
|
let mut mindmap_id: Option<String> = None;
|
||||||
|
|
||||||
|
while let Some(field) = multipart.next_field().await.map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"media_upload_bad_multipart",
|
||||||
|
format!("上传表单解析失败: {error}"),
|
||||||
|
)
|
||||||
|
})? {
|
||||||
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
|
if name == "file" {
|
||||||
|
let file_name = field
|
||||||
|
.file_name()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("附件")
|
||||||
|
.to_string();
|
||||||
|
let content_type = field
|
||||||
|
.content_type()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
let bytes = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"media_upload_file_read_failed",
|
||||||
|
format!("读取上传文件失败: {error}"),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_vec();
|
||||||
|
file = Some(UploadFile {
|
||||||
|
name: file_name,
|
||||||
|
content_type,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = field.text().await.map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"media_upload_field_read_failed",
|
||||||
|
format!("读取上传字段失败: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match name.as_str() {
|
||||||
|
"workspaceId" => workspace_id = value.trim().to_string(),
|
||||||
|
"documentId" => document_id = value.trim().to_string(),
|
||||||
|
"mindmapId" => {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
mindmap_id = Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let file =
|
||||||
|
file.ok_or_else(|| WebError::bad_request_code("media_upload_file_missing", "缺少 file"))?;
|
||||||
|
if file.bytes.is_empty() || workspace_id.is_empty() || document_id.is_empty() {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"media_upload_required_missing",
|
||||||
|
"缺少必要参数",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((file, workspace_id, document_id, mindmap_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absolute_origin(headers: &HeaderMap) -> String {
|
||||||
|
let proto = headers
|
||||||
|
.get("x-forwarded-proto")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("http");
|
||||||
|
let host = headers
|
||||||
|
.get(header::HOST)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("127.0.0.1:3000");
|
||||||
|
format!("{proto}://{host}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxied_file_url(headers: &HeaderMap, raw: &str) -> String {
|
||||||
|
let encoded = URL_SAFE_NO_PAD.encode(raw.as_bytes());
|
||||||
|
format!(
|
||||||
|
"{}/api/onlyoffice/proxy?u={encoded}",
|
||||||
|
absolute_origin(headers)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_string(value: Option<&String>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(String::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_for_document(
|
||||||
|
document_id: &str,
|
||||||
|
fallback_workspace_id: Option<&str>,
|
||||||
|
document_workspaces: &[FileTreeDocumentWorkspace],
|
||||||
|
) -> Option<String> {
|
||||||
|
for item in document_workspaces {
|
||||||
|
if trim_string(item.document_id.as_ref()).as_deref() == Some(document_id) {
|
||||||
|
if let Some(workspace_id) = trim_string(item.workspace_id.as_ref()) {
|
||||||
|
return Some(workspace_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback_workspace_id
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_for_target_row(
|
||||||
|
target_row_id: Option<&str>,
|
||||||
|
rows: &[FileTreeUploadTargetRow],
|
||||||
|
) -> Option<String> {
|
||||||
|
let row_id = target_row_id?.trim();
|
||||||
|
if row_id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for row in rows {
|
||||||
|
if trim_string(row.row_id.as_ref()).as_deref() != Some(row_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(document_id) = trim_string(row.document_id.as_ref()) {
|
||||||
|
return Some(document_id);
|
||||||
|
}
|
||||||
|
if let Some(document_id) = trim_string(row.asset_document_id.as_ref()) {
|
||||||
|
return Some(document_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?;
|
||||||
|
let user_id = current_user_id(&state, &context).await;
|
||||||
|
let upload_url = execute_convex_mutation_by_name(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
"mediaAssets:generateUploadUrl",
|
||||||
|
json!({ "userId": user_id }),
|
||||||
|
Some(&workspace_id),
|
||||||
|
None,
|
||||||
|
"media_upload_generate_url",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let upload_url = upload_url.as_str().ok_or_else(|| {
|
||||||
|
WebError::bad_gateway_code("media_upload_bad_upload_url", "Convex 未返回上传 URL")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let upload_response = client
|
||||||
|
.post(upload_url)
|
||||||
|
.header(header::CONTENT_TYPE, file.content_type.as_str())
|
||||||
|
.body(file.bytes.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"media_upload_storage_failed",
|
||||||
|
format!("上传到 Convex Files 失败: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let upload_status = upload_response.status();
|
||||||
|
let upload_json: Value = upload_response.json().await.map_err(|error| {
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"media_upload_storage_bad_response",
|
||||||
|
format!("Convex Files 响应解析失败: {error}"),
|
||||||
|
)
|
||||||
|
.with_header("x-upstream-status", upload_status.as_u16().to_string())
|
||||||
|
})?;
|
||||||
|
if !upload_status.is_success() {
|
||||||
|
return Err(WebError::bad_gateway_code(
|
||||||
|
"media_upload_storage_status",
|
||||||
|
format!("上传到 Convex Files 失败: {upload_json}"),
|
||||||
|
)
|
||||||
|
.with_header("x-upstream-status", upload_status.as_u16().to_string()));
|
||||||
|
}
|
||||||
|
let storage_id = upload_json
|
||||||
|
.get("storageId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"media_upload_storage_id_missing",
|
||||||
|
"Convex Files 缺少 storageId",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let target_sub_path = mindmap_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| format!("mindmaps/{value}"));
|
||||||
|
let id = new_asset_id();
|
||||||
|
let kind = asset_type(&file.content_type);
|
||||||
|
let created = execute_convex_mutation_by_name(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
"mediaAssets:createWithStorage",
|
||||||
|
json!({
|
||||||
|
"userId": user_id,
|
||||||
|
"storageId": storage_id,
|
||||||
|
"targetSubPath": target_sub_path,
|
||||||
|
"asset": {
|
||||||
|
"id": id,
|
||||||
|
"workspace_id": workspace_id,
|
||||||
|
"document_id": document_id,
|
||||||
|
"asset_type": kind,
|
||||||
|
"file_name": file.name,
|
||||||
|
"file_size": file.bytes.len(),
|
||||||
|
"mime_type": file.content_type,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Some(&workspace_id),
|
||||||
|
None,
|
||||||
|
"media_upload_create_asset",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let asset_id = created
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
Ok(Json(json!({
|
||||||
|
"asset": created,
|
||||||
|
"mindmapUrl": format!("asset:{asset_id}"),
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn filetree_upload_target_preflight(
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Json(payload): Json<FileTreeUploadTargetPreflightPayload>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let rows = payload.rows.unwrap_or_default();
|
||||||
|
let document_workspaces = payload.document_workspaces.unwrap_or_default();
|
||||||
|
let fallback_workspace_id = trim_string(payload.workspace_id.as_ref());
|
||||||
|
let target_row_document_id = document_for_target_row(
|
||||||
|
trim_string(payload.target_row_id.as_ref()).as_deref(),
|
||||||
|
&rows,
|
||||||
|
);
|
||||||
|
let document_id = trim_string(payload.target_document_id.as_ref())
|
||||||
|
.or(target_row_document_id)
|
||||||
|
.or_else(|| {
|
||||||
|
trim_string(payload.focused_row_id.as_ref())
|
||||||
|
.and_then(|row_id| document_for_target_row(Some(&row_id), &rows))
|
||||||
|
})
|
||||||
|
.or_else(|| trim_string(payload.active_document_id.as_ref()))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"filetree_upload_target_document_missing",
|
||||||
|
"请选择一个目标页面后再上传文件",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let workspace_id = workspace_for_document(
|
||||||
|
&document_id,
|
||||||
|
fallback_workspace_id
|
||||||
|
.as_deref()
|
||||||
|
.or(context.workspace.workspace_id.as_deref()),
|
||||||
|
&document_workspaces,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"filetree_upload_target_workspace_missing",
|
||||||
|
"缺少 workspaceId",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let plan = FileTreeUploadTargetPlan {
|
||||||
|
workspace_id,
|
||||||
|
target_document_id: document_id,
|
||||||
|
target_mindmap_id: None,
|
||||||
|
target_sub_path: None,
|
||||||
|
};
|
||||||
|
Ok(Json(json!({
|
||||||
|
"requestId": context.trace.request_id,
|
||||||
|
"traceId": context.trace.trace_id,
|
||||||
|
"plan": plan,
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sign(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Query(query): Query<MediaSignQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let asset_id = query
|
||||||
|
.asset_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| WebError::bad_request_code("media_sign_asset_missing", "缺少 assetId"))?;
|
||||||
|
let user_id = current_user_id(&state, &context).await;
|
||||||
|
let asset = execute_convex_query_by_name(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
"mediaAssets:getById",
|
||||||
|
json!({ "userId": user_id, "id": asset_id }),
|
||||||
|
None,
|
||||||
|
"media_sign_get_asset",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if asset.is_null() {
|
||||||
|
return Err(WebError::new(
|
||||||
|
axum::http::StatusCode::NOT_FOUND,
|
||||||
|
"media_asset_not_found",
|
||||||
|
"资源不存在",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let refreshed = execute_convex_mutation_by_name(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
"mediaAssets:refreshUrl",
|
||||||
|
json!({ "userId": user_id, "id": asset_id }),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"media_sign_refresh_url",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let signed_url = refreshed
|
||||||
|
.get("signedUrl")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| WebError::bad_gateway_code("media_sign_url_missing", "生成签名链接失败"))?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"signedUrl": proxied_file_url(&headers, signed_url),
|
||||||
|
"asset": {
|
||||||
|
"id": asset.get("id").cloned().unwrap_or(Value::Null),
|
||||||
|
"document_id": asset.get("document_id").cloned().unwrap_or(Value::Null),
|
||||||
|
"workspace_id": asset.get("workspace_id").cloned().unwrap_or(Value::Null),
|
||||||
|
"file_name": asset.get("file_name").cloned().unwrap_or(Value::Null),
|
||||||
|
"mime_type": asset.get("mime_type").cloned().unwrap_or(Value::Null),
|
||||||
|
"file_size": asset.get("file_size").cloned().unwrap_or(Value::Null),
|
||||||
|
"storage_id": asset.get("storage_id").cloned().unwrap_or(Value::Null),
|
||||||
|
"updated_at": asset.get("updated_at").cloned().unwrap_or(Value::Null),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ mod kernel;
|
|||||||
mod local_folder_source;
|
mod local_folder_source;
|
||||||
mod local_folder_events;
|
mod local_folder_events;
|
||||||
mod local_markdown_parser;
|
mod local_markdown_parser;
|
||||||
|
mod media;
|
||||||
mod mindmap_shell;
|
mod mindmap_shell;
|
||||||
|
mod onlyoffice;
|
||||||
mod query_support;
|
mod query_support;
|
||||||
mod search;
|
mod search;
|
||||||
mod session;
|
mod session;
|
||||||
@@ -22,7 +24,7 @@ mod web_shell;
|
|||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{any, get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
@@ -69,6 +71,19 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/auth/mnote-web-token", get(session::session))
|
.route("/api/auth/mnote-web-token", get(session::session))
|
||||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||||
.route("/api/ai-agent/run", post(compat::next_ai_agent_run))
|
.route("/api/ai-agent/run", post(compat::next_ai_agent_run))
|
||||||
|
.route("/onlyoffice", get(onlyoffice::page))
|
||||||
|
.route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy))
|
||||||
|
.route("/cache/{*path}", any(onlyoffice::cache_proxy))
|
||||||
|
.route("/api/onlyoffice/sign", post(onlyoffice::sign))
|
||||||
|
.route("/api/onlyoffice/proxy", get(onlyoffice::proxy))
|
||||||
|
.route("/api/onlyoffice/callback", post(onlyoffice::callback))
|
||||||
|
.route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave))
|
||||||
|
.route("/api/media/upload", post(media::upload))
|
||||||
|
.route("/api/media/sign", get(media::sign))
|
||||||
|
.route(
|
||||||
|
"/api/tree/filetree/upload-target-preflight",
|
||||||
|
post(media::filetree_upload_target_preflight),
|
||||||
|
)
|
||||||
.route("/api/documents/meta", get(documents::meta))
|
.route("/api/documents/meta", get(documents::meta))
|
||||||
.route("/api/documents/content", get(documents::content))
|
.route("/api/documents/content", get(documents::content))
|
||||||
.route("/api/documents/page", get(web_shell::documents_page_compat))
|
.route("/api/documents/page", get(web_shell::documents_page_compat))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
};
|
};
|
||||||
var projectionRefreshTimer = 0;
|
var projectionRefreshTimer = 0;
|
||||||
var activeTreeContextMenu = null;
|
var activeTreeContextMenu = null;
|
||||||
|
var activeEditorAttachmentLink = null;
|
||||||
|
var attachmentActionsHideTimer = 0;
|
||||||
var pageUiState = {
|
var pageUiState = {
|
||||||
pageOptions: null,
|
pageOptions: null,
|
||||||
historySnapshots: [],
|
historySnapshots: [],
|
||||||
@@ -1024,6 +1026,608 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
|
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inferOnlyOfficeFileType(fileName, mimeType) {
|
||||||
|
var name = String(fileName || '').trim().toLowerCase();
|
||||||
|
var mt = String(mimeType || '').trim().toLowerCase();
|
||||||
|
var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : '';
|
||||||
|
if (['doc', 'docx', 'odt', 'rtf'].indexOf(ext) >= 0) return ext;
|
||||||
|
if (['ppt', 'pptx', 'odp'].indexOf(ext) >= 0) return ext;
|
||||||
|
if (['xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return ext;
|
||||||
|
if (ext === 'pdf') return ext;
|
||||||
|
if (mt.indexOf('wordprocessingml') >= 0) return 'docx';
|
||||||
|
if (mt.indexOf('presentationml') >= 0) return 'pptx';
|
||||||
|
if (mt.indexOf('spreadsheetml') >= 0) return 'xlsx';
|
||||||
|
if (mt.indexOf('pdf') >= 0) return 'pdf';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOnlyOfficeOpenUrl(input) {
|
||||||
|
var target = new URL('/onlyoffice', window.location.origin);
|
||||||
|
target.searchParams.set('fileUrl', input.fileUrl || '');
|
||||||
|
target.searchParams.set('fileName', input.fileName || '未命名资源');
|
||||||
|
target.searchParams.set('fileType', input.fileType || 'docx');
|
||||||
|
if (input.assetId) target.searchParams.set('assetId', input.assetId);
|
||||||
|
if (input.documentId) target.searchParams.set('documentId', input.documentId);
|
||||||
|
if (input.userId) target.searchParams.set('userId', input.userId);
|
||||||
|
target.searchParams.set('mode', input.mode || 'edit');
|
||||||
|
return target.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOnlyOfficeOpenPath(input) {
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
params.set('fileUrl', input.fileUrl || '');
|
||||||
|
params.set('fileName', input.fileName || '未命名资源');
|
||||||
|
params.set('fileType', input.fileType || 'docx');
|
||||||
|
if (input.assetId) params.set('assetId', input.assetId);
|
||||||
|
if (input.documentId) params.set('documentId', input.documentId);
|
||||||
|
if (input.userId) params.set('userId', input.userId);
|
||||||
|
params.set('mode', input.mode || 'edit');
|
||||||
|
return '/onlyoffice?' + params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCurrentOnlyOfficeUserId() {
|
||||||
|
try {
|
||||||
|
var response = await fetch('/api/auth/whoami', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
var payload = await response.json().catch(function() { return null; });
|
||||||
|
return String(payload && payload.userId || '').trim();
|
||||||
|
} catch (_error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConvexAssetFromFileTree(detail) {
|
||||||
|
var assetId = String(detail && detail.assetId || '').trim();
|
||||||
|
if (!assetId) return;
|
||||||
|
try {
|
||||||
|
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
var payload = await response.json().catch(function() { return null; });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload && payload.error ? payload.error : '生成签名链接失败');
|
||||||
|
}
|
||||||
|
var asset = payload && payload.asset && typeof payload.asset === 'object' ? payload.asset : {};
|
||||||
|
var fileUrl = String(payload && payload.signedUrl || asset.file_url || asset.signed_url || '').trim();
|
||||||
|
if (!fileUrl) throw new Error('附件链接不可用');
|
||||||
|
var fileName = String(asset.file_name || detail.fileName || '未命名资源').trim() || '未命名资源';
|
||||||
|
var fileType = inferOnlyOfficeFileType(fileName, asset.mime_type);
|
||||||
|
if (fileType) {
|
||||||
|
var userId = await fetchCurrentOnlyOfficeUserId();
|
||||||
|
window.open(buildOnlyOfficeOpenUrl({
|
||||||
|
fileUrl: fileUrl,
|
||||||
|
fileName: fileName,
|
||||||
|
fileType: fileType,
|
||||||
|
assetId: assetId,
|
||||||
|
documentId: String(asset.document_id || detail.documentId || '').trim(),
|
||||||
|
userId: userId,
|
||||||
|
mode: 'edit'
|
||||||
|
}), '_blank', 'noopener,noreferrer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(fileUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error && error.message ? error.message : '打开附件失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('tree.asset.open', function(event) {
|
||||||
|
void openConvexAssetFromFileTree(event.detail || {});
|
||||||
|
});
|
||||||
|
|
||||||
|
function fileTreeRowsForUploadPreflight() {
|
||||||
|
return Array.from(document.querySelectorAll('.tree-row[data-shell-mode="filetree"]')).map(function(row) {
|
||||||
|
return {
|
||||||
|
rowId: row.getAttribute('data-row-id') || '',
|
||||||
|
rowKind: row.getAttribute('data-row-kind') || '',
|
||||||
|
documentId: row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || null,
|
||||||
|
assetId: row.getAttribute('data-asset-id') || null,
|
||||||
|
assetDocumentId: row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || null,
|
||||||
|
assetType: row.querySelector('.tree-kind-badge') ? row.querySelector('.tree-kind-badge').getAttribute('data-kind') : null,
|
||||||
|
storagePath: null
|
||||||
|
};
|
||||||
|
}).filter(function(row) {
|
||||||
|
return row.rowId || row.documentId || row.assetId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTreeDocumentWorkspacesForUploadPreflight(workspaceId) {
|
||||||
|
var seen = new Set();
|
||||||
|
return fileTreeRowsForUploadPreflight().filter(function(row) {
|
||||||
|
if (!row.documentId || seen.has(row.documentId)) return false;
|
||||||
|
seen.add(row.documentId);
|
||||||
|
return true;
|
||||||
|
}).map(function(row) {
|
||||||
|
return { documentId: row.documentId, workspaceId: workspaceId || null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preflightFileTreeUploadTarget(detail) {
|
||||||
|
var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim();
|
||||||
|
var body = {
|
||||||
|
workspaceId: workspaceId || null,
|
||||||
|
targetDocumentId: detail && detail.documentId ? String(detail.documentId) : null,
|
||||||
|
targetRowId: detail && detail.targetRowId ? String(detail.targetRowId) : null,
|
||||||
|
focusedRowId: sidebarFileTreeSelection.focusedRowId || null,
|
||||||
|
activeDocumentId: currentDocumentId() || null,
|
||||||
|
rows: fileTreeRowsForUploadPreflight(),
|
||||||
|
documentWorkspaces: fileTreeDocumentWorkspacesForUploadPreflight(workspaceId)
|
||||||
|
};
|
||||||
|
var response = await fetch('/api/tree/filetree/upload-target-preflight', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
var payload = await response.json().catch(function() { return null; });
|
||||||
|
if (!response.ok || !payload || !payload.plan) {
|
||||||
|
throw new Error(payload && payload.error ? payload.error : '文件树上传目标预检失败');
|
||||||
|
}
|
||||||
|
return payload.plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackFileTreeUploadTarget(detail) {
|
||||||
|
var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim();
|
||||||
|
var documentId = String(detail && detail.documentId || currentDocumentId() || '').trim();
|
||||||
|
if (!workspaceId || !documentId) {
|
||||||
|
throw new Error('请选择一个目标页面后再拖入文件');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
targetDocumentId: documentId,
|
||||||
|
targetMindmapId: null,
|
||||||
|
targetSubPath: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFileTreeUploadTarget(detail) {
|
||||||
|
try {
|
||||||
|
return await preflightFileTreeUploadTarget(detail || {});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[mnote upload] upload target preflight fallback', error);
|
||||||
|
return fallbackFileTreeUploadTarget(detail || {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedAssetTitle(asset) {
|
||||||
|
return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件';
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedAssetUrl(asset) {
|
||||||
|
return String(asset && (asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedAssetType(asset) {
|
||||||
|
return String(asset && (asset.asset_type || asset.assetType || asset.mime_type || '') || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedAssetExtension(asset) {
|
||||||
|
var match = uploadedAssetTitle(asset).toLowerCase().match(/\.([a-z0-9]+)$/);
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentClassForFileName(fileName) {
|
||||||
|
var name = String(fileName || '').trim().toLowerCase();
|
||||||
|
var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : '';
|
||||||
|
if (['doc', 'docx', 'odt', 'rtf'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-word';
|
||||||
|
if (['ppt', 'pptx', 'odp'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-ppt';
|
||||||
|
if (['xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-sheet';
|
||||||
|
if (ext === 'pdf') return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-pdf';
|
||||||
|
return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedAttachmentClass(asset) {
|
||||||
|
return attachmentClassForFileName(uploadedAssetTitle(asset));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOnlyOfficeAssetOpenUrl(asset, userId) {
|
||||||
|
var title = uploadedAssetTitle(asset);
|
||||||
|
var fileType = inferOnlyOfficeFileType(title, asset && asset.mime_type);
|
||||||
|
if (!fileType) return '';
|
||||||
|
var assetId = String(asset && asset.id || '').trim();
|
||||||
|
return buildOnlyOfficeOpenUrl({
|
||||||
|
fileUrl: assetId ? '' : uploadedAssetUrl(asset),
|
||||||
|
fileName: title,
|
||||||
|
fileType: fileType,
|
||||||
|
assetId: assetId,
|
||||||
|
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
|
||||||
|
userId: userId || '',
|
||||||
|
mode: 'edit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedFileSize(asset) {
|
||||||
|
var size = Number(asset && (asset.file_size || asset.fileSize) || 0);
|
||||||
|
if (!Number.isFinite(size) || size <= 0) return '';
|
||||||
|
if (size >= 1024 * 1024) return (size / 1024 / 1024).toFixed(size >= 10 * 1024 * 1024 ? 1 : 2) + ' MB';
|
||||||
|
if (size >= 1024) return (size / 1024).toFixed(size >= 100 * 1024 ? 0 : 2) + ' KB';
|
||||||
|
return String(Math.round(size)) + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachmentMetaCache = Object.create(null);
|
||||||
|
var attachmentMetaPending = Object.create(null);
|
||||||
|
var legacyOfficeAttachmentIndex = null;
|
||||||
|
var legacyOfficeAttachmentIndexPending = null;
|
||||||
|
|
||||||
|
function parseCurrentWorkspaceId() {
|
||||||
|
return (new URLSearchParams(window.location.search).get('workspaceId') || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLegacyOfficeAttachmentIndex() {
|
||||||
|
if (legacyOfficeAttachmentIndex) return legacyOfficeAttachmentIndex;
|
||||||
|
if (legacyOfficeAttachmentIndexPending) return legacyOfficeAttachmentIndexPending;
|
||||||
|
var documentId = currentDocumentId();
|
||||||
|
var workspaceId = parseCurrentWorkspaceId();
|
||||||
|
if (!documentId || !workspaceId) {
|
||||||
|
legacyOfficeAttachmentIndex = Object.create(null);
|
||||||
|
return legacyOfficeAttachmentIndex;
|
||||||
|
}
|
||||||
|
legacyOfficeAttachmentIndexPending = fetch(
|
||||||
|
'/api/tree/projections/file?documentId=' + encodeURIComponent(documentId) + '&workspaceId=' + encodeURIComponent(workspaceId),
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store'
|
||||||
|
}
|
||||||
|
).then(function(response) {
|
||||||
|
return response.json().catch(function() { return null; }).then(function(payload) {
|
||||||
|
var items = payload && payload.ok && payload.result && Array.isArray(payload.result.items)
|
||||||
|
? payload.result.items
|
||||||
|
: [];
|
||||||
|
var index = Object.create(null);
|
||||||
|
items.forEach(function(item) {
|
||||||
|
if (!item || item.rowKind !== 'asset') return;
|
||||||
|
var title = String(item.title || '').trim();
|
||||||
|
if (!title || index[title]) return;
|
||||||
|
var fileType = inferOnlyOfficeFileType(title, '');
|
||||||
|
if (!fileType) return;
|
||||||
|
var rowId = String(item.rowId || '').trim();
|
||||||
|
var assetId = String(item.assetId || '').trim();
|
||||||
|
if (!assetId && rowId.indexOf('asset:') === 0) assetId = rowId.slice('asset:'.length);
|
||||||
|
if (!assetId) return;
|
||||||
|
index[title] = {
|
||||||
|
assetId: assetId,
|
||||||
|
fileName: title,
|
||||||
|
fileType: fileType,
|
||||||
|
documentId: documentId
|
||||||
|
};
|
||||||
|
});
|
||||||
|
legacyOfficeAttachmentIndex = index;
|
||||||
|
return index;
|
||||||
|
});
|
||||||
|
}).catch(function() {
|
||||||
|
var empty = Object.create(null);
|
||||||
|
legacyOfficeAttachmentIndex = empty;
|
||||||
|
return empty;
|
||||||
|
}).finally(function() {
|
||||||
|
legacyOfficeAttachmentIndexPending = null;
|
||||||
|
});
|
||||||
|
return legacyOfficeAttachmentIndexPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function healLegacyOfficeAttachmentParagraphs() {
|
||||||
|
var editor = document.querySelector('.editor-surface .ProseMirror');
|
||||||
|
if (!(editor instanceof HTMLElement)) return;
|
||||||
|
var index = await fetchLegacyOfficeAttachmentIndex();
|
||||||
|
var paragraphs = Array.from(editor.querySelectorAll('p'));
|
||||||
|
paragraphs.forEach(function(paragraph) {
|
||||||
|
if (!(paragraph instanceof HTMLParagraphElement)) return;
|
||||||
|
if (paragraph.querySelector('a, img, video, audio, table, iframe, canvas')) return;
|
||||||
|
if (paragraph.childNodes.length !== 1 || paragraph.firstChild?.nodeType !== Node.TEXT_NODE) return;
|
||||||
|
var fileName = String(paragraph.textContent || '').trim();
|
||||||
|
if (!fileName) return;
|
||||||
|
var detail = index[fileName];
|
||||||
|
if (!detail) return;
|
||||||
|
var link = document.createElement('a');
|
||||||
|
link.textContent = fileName;
|
||||||
|
link.setAttribute('href', buildOnlyOfficeOpenPath({
|
||||||
|
fileUrl: '',
|
||||||
|
fileName: detail.fileName,
|
||||||
|
fileType: detail.fileType,
|
||||||
|
assetId: detail.assetId,
|
||||||
|
documentId: detail.documentId || currentDocumentId() || '',
|
||||||
|
userId: '',
|
||||||
|
mode: 'edit'
|
||||||
|
}));
|
||||||
|
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||||
|
link.setAttribute('data-asset-id', detail.assetId);
|
||||||
|
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
|
||||||
|
if (name) link.classList.add(name);
|
||||||
|
});
|
||||||
|
paragraph.replaceChildren(link);
|
||||||
|
enhanceEditorAttachmentLink(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEditorAttachmentMeta(link, meta) {
|
||||||
|
if (!(link instanceof HTMLAnchorElement) || !meta) return;
|
||||||
|
if (meta.assetId) link.setAttribute('data-asset-id', meta.assetId);
|
||||||
|
if (meta.fileSize) link.setAttribute('data-file-size', meta.fileSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateEditorAttachmentMeta(link) {
|
||||||
|
if (!(link instanceof HTMLAnchorElement)) return;
|
||||||
|
var detail = detailFromEditorAttachmentLink(link);
|
||||||
|
var assetId = String(detail && detail.assetId || '').trim();
|
||||||
|
if (!assetId) return;
|
||||||
|
if (attachmentMetaCache[assetId]) {
|
||||||
|
applyEditorAttachmentMeta(link, attachmentMetaCache[assetId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attachmentMetaPending[assetId]) {
|
||||||
|
try { await attachmentMetaPending[assetId]; } catch (_) {}
|
||||||
|
applyEditorAttachmentMeta(link, attachmentMetaCache[assetId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attachmentMetaPending[assetId] = fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(function(response) {
|
||||||
|
return response.json().catch(function() { return null; }).then(function(payload) {
|
||||||
|
if (!response.ok || !payload) return null;
|
||||||
|
var asset = payload.asset && typeof payload.asset === 'object' ? payload.asset : {};
|
||||||
|
var meta = {
|
||||||
|
assetId: assetId,
|
||||||
|
fileSize: uploadedFileSize(asset)
|
||||||
|
};
|
||||||
|
attachmentMetaCache[assetId] = meta;
|
||||||
|
return meta;
|
||||||
|
});
|
||||||
|
}).catch(function() {
|
||||||
|
return null;
|
||||||
|
}).finally(function() {
|
||||||
|
delete attachmentMetaPending[assetId];
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
var meta = await attachmentMetaPending[assetId];
|
||||||
|
applyEditorAttachmentMeta(link, meta);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealFileTreeRow(row) {
|
||||||
|
if (!(row instanceof HTMLElement)) return;
|
||||||
|
var node = row.closest('.tree-node');
|
||||||
|
while (node && node.parentElement) {
|
||||||
|
if (node.parentElement.classList && node.parentElement.classList.contains('tree-children')) {
|
||||||
|
node.parentElement.classList.remove('tree-children--collapsed');
|
||||||
|
var parentNode = node.parentElement.closest('.tree-node');
|
||||||
|
var parentRow = parentNode ? parentNode.querySelector(':scope > .tree-row') : null;
|
||||||
|
if (parentRow instanceof HTMLElement) {
|
||||||
|
parentRow.setAttribute('aria-expanded', 'true');
|
||||||
|
var toggle = parentRow.querySelector('[data-rust-action="toggle"]');
|
||||||
|
if (toggle) toggle.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.parentElement.closest('.tree-node');
|
||||||
|
}
|
||||||
|
try { row.scrollIntoView({ block: 'nearest' }); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealFileTreeAssetRow(assetId) {
|
||||||
|
if (!assetId) return false;
|
||||||
|
var row = document.querySelector('.tree-row[data-shell-mode="filetree"][data-asset-id="' + cssEscape(assetId) + '"]');
|
||||||
|
if (!(row instanceof HTMLElement)) return false;
|
||||||
|
revealFileTreeRow(row);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendUploadedAssetRow(asset, documentId) {
|
||||||
|
var assetId = String(asset && asset.id || '').trim();
|
||||||
|
if (!assetId) return;
|
||||||
|
if (revealFileTreeAssetRow(assetId)) return;
|
||||||
|
var targetDocumentId = String(documentId || asset.document_id || asset.documentId || currentDocumentId() || '').trim();
|
||||||
|
var parentRow = targetDocumentId
|
||||||
|
? document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + targetDocumentId) + '"]')
|
||||||
|
: null;
|
||||||
|
if (!parentRow) parentRow = document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-kind="document"]');
|
||||||
|
var root = document.querySelector('#sidebar-file-tree-root .tree-root');
|
||||||
|
if (!root && !parentRow) return;
|
||||||
|
var parentLi = parentRow ? parentRow.closest('.tree-node') : null;
|
||||||
|
var children = parentLi ? parentLi.querySelector(':scope > .tree-children') : null;
|
||||||
|
if (parentLi && !children) {
|
||||||
|
children = document.createElement('ul');
|
||||||
|
children.className = 'tree-children';
|
||||||
|
parentLi.appendChild(children);
|
||||||
|
}
|
||||||
|
if (children) {
|
||||||
|
children.classList.remove('tree-children--collapsed');
|
||||||
|
if (parentRow) {
|
||||||
|
parentRow.setAttribute('aria-expanded', 'true');
|
||||||
|
var toggle = parentRow.querySelector('[data-rust-action="toggle"]');
|
||||||
|
if (toggle) toggle.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var container = children || root;
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'tree-node';
|
||||||
|
li.setAttribute('data-node-id', 'asset:' + assetId);
|
||||||
|
var title = uploadedAssetTitle(asset);
|
||||||
|
var iconKind = uploadedAssetType(asset) || 'file';
|
||||||
|
li.innerHTML =
|
||||||
|
'<div class="tree-row" role="treeitem" aria-level="' + (parentRow ? '2' : '1') + '" aria-expanded="false" data-rust-rendered-row="filetree" data-testid="filetree-asset-row" data-row-id="' + escapeHtml('asset:' + assetId) + '" data-row-kind="asset" data-node-id="' + escapeHtml('asset:' + assetId) + '" data-document-id="' + escapeHtml(targetDocumentId) + '" data-doc-id="' + escapeHtml(targetDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-shell-mode="filetree" data-selected="false" data-active="false" draggable="true">' +
|
||||||
|
'<span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span>' +
|
||||||
|
'<button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml('asset:' + assetId) + '" data-document-id="' + escapeHtml(targetDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button>' +
|
||||||
|
'<div class="tree-actions"><button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml('asset:' + assetId) + '" aria-label="更多操作">…</button></div></div>';
|
||||||
|
container.appendChild(li);
|
||||||
|
revealFileTreeRow(li.querySelector('.tree-row'));
|
||||||
|
document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertUploadedAssetIntoEditor(asset) {
|
||||||
|
var editorRoot = document.querySelector('.editor-surface .ProseMirror');
|
||||||
|
var editor = editorRoot && editorRoot.editor;
|
||||||
|
if (!editor || !editor.chain) return false;
|
||||||
|
var title = uploadedAssetTitle(asset);
|
||||||
|
var url = uploadedAssetUrl(asset);
|
||||||
|
var type = uploadedAssetType(asset);
|
||||||
|
var assetId = String(asset && asset.id || '').trim();
|
||||||
|
var sizeLabel = uploadedFileSize(asset);
|
||||||
|
try {
|
||||||
|
if (type === 'image' && url) {
|
||||||
|
return editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true;
|
||||||
|
}
|
||||||
|
var userId = '';
|
||||||
|
var onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
||||||
|
if (onlyOfficeUrl && assetId) {
|
||||||
|
userId = await fetchCurrentOnlyOfficeUserId();
|
||||||
|
onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
||||||
|
}
|
||||||
|
var href = onlyOfficeUrl || url;
|
||||||
|
if (href) {
|
||||||
|
var storedHref = onlyOfficeUrl
|
||||||
|
? buildOnlyOfficeOpenPath({
|
||||||
|
fileUrl: '',
|
||||||
|
fileName: title,
|
||||||
|
fileType: inferOnlyOfficeFileType(title, asset && asset.mime_type) || 'docx',
|
||||||
|
assetId: assetId,
|
||||||
|
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
|
||||||
|
userId: userId || '',
|
||||||
|
mode: 'edit'
|
||||||
|
})
|
||||||
|
: href;
|
||||||
|
var inserted = editor.chain().focus().insertContent({
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: title,
|
||||||
|
marks: [{
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: storedHref,
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer nofollow',
|
||||||
|
class: uploadedAttachmentClass(asset)
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}).run() === true;
|
||||||
|
window.setTimeout(function() {
|
||||||
|
enhanceEditorAttachmentLinks();
|
||||||
|
var selector = assetId
|
||||||
|
? '.editor-surface .ProseMirror a[href*="' + cssEscape(assetId) + '"]'
|
||||||
|
: '.editor-surface .ProseMirror a';
|
||||||
|
var link = document.querySelector(selector);
|
||||||
|
if (link instanceof HTMLElement) {
|
||||||
|
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||||
|
if (assetId) link.setAttribute('data-asset-id', assetId);
|
||||||
|
if (sizeLabel) link.setAttribute('data-file-size', sizeLabel);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[mnote upload] insert uploaded asset failed', error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFileToMediaAsset(file, plan, options) {
|
||||||
|
var form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('workspaceId', plan.workspaceId);
|
||||||
|
form.append('documentId', plan.targetDocumentId);
|
||||||
|
if (plan.targetMindmapId) form.append('mindmapId', plan.targetMindmapId);
|
||||||
|
var response = await fetch('/api/media/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
var payload = await response.json().catch(function() { return null; });
|
||||||
|
if (!response.ok || !payload || !payload.asset) {
|
||||||
|
throw new Error(payload && payload.error ? payload.error : '上传失败');
|
||||||
|
}
|
||||||
|
appendUploadedAssetRow(payload.asset, plan.targetDocumentId);
|
||||||
|
if (options && options.insertIntoEditor) {
|
||||||
|
await insertUploadedAssetIntoEditor(payload.asset);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('wolai:assets-changed', {
|
||||||
|
detail: { docId: plan.targetDocumentId, asset: payload.asset, assetIds: [payload.asset.id] }
|
||||||
|
}));
|
||||||
|
return payload.asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFilesWithResolvedTarget(files, detail, options) {
|
||||||
|
var list = Array.from(files || []).filter(Boolean);
|
||||||
|
if (!list.length) return [];
|
||||||
|
var plan = await resolveFileTreeUploadTarget(detail || {});
|
||||||
|
var uploaded = [];
|
||||||
|
var errors = [];
|
||||||
|
for (var i = 0; i < list.length; i += 1) {
|
||||||
|
try {
|
||||||
|
uploaded.push(await uploadFileToMediaAsset(list[i], plan, options || {}));
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(list[i].name + ': ' + (error && error.message ? error.message : '上传失败'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length) {
|
||||||
|
window.alert('部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : ''));
|
||||||
|
}
|
||||||
|
return uploaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditorUploadFilePicker(detail) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.multiple = detail && detail.multiple !== false;
|
||||||
|
if (detail && detail.accept) input.accept = String(detail.accept);
|
||||||
|
input.style.position = 'fixed';
|
||||||
|
input.style.left = '-9999px';
|
||||||
|
input.style.top = '-9999px';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
var files = Array.from(input.files || []);
|
||||||
|
input.remove();
|
||||||
|
void uploadFilesWithResolvedTarget(files, {
|
||||||
|
workspaceId: resolveWorkspaceId(document.body),
|
||||||
|
documentId: currentDocumentId(),
|
||||||
|
targetRowId: null
|
||||||
|
}, {
|
||||||
|
insertIntoEditor: detail && detail.insertIntoEditor !== false
|
||||||
|
});
|
||||||
|
}, { once: true });
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mnote:editor-upload-request', function(event) {
|
||||||
|
openEditorUploadFilePicker(event.detail || {});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('tree.filetree.external-drop', function(event) {
|
||||||
|
var detail = event.detail || {};
|
||||||
|
void uploadFilesWithResolvedTarget(detail.files || [], detail, {
|
||||||
|
insertIntoEditor: String(detail.documentId || '') === currentDocumentId()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('dragover', function(event) {
|
||||||
|
var editorTarget = closestAction(event.target, '[data-testid="mnote-leptos-tiptap-editor-stage"], .editor-surface .ProseMirror');
|
||||||
|
var hasFiles = event.dataTransfer && Array.prototype.indexOf.call(event.dataTransfer.types || [], 'Files') >= 0;
|
||||||
|
if (!editorTarget || !hasFiles) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('drop', function(event) {
|
||||||
|
var editorTarget = closestAction(event.target, '[data-testid="mnote-leptos-tiptap-editor-stage"], .editor-surface .ProseMirror');
|
||||||
|
var files = event.dataTransfer ? Array.from(event.dataTransfer.files || []) : [];
|
||||||
|
if (!editorTarget || !files.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void uploadFilesWithResolvedTarget(files, {
|
||||||
|
workspaceId: resolveWorkspaceId(document.body),
|
||||||
|
documentId: currentDocumentId(),
|
||||||
|
targetRowId: null
|
||||||
|
}, {
|
||||||
|
insertIntoEditor: true
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
|
||||||
function rowTitle(row) {
|
function rowTitle(row) {
|
||||||
var title = row ? row.querySelector(':scope > .tree-link > .tree-link-title') : null;
|
var title = row ? row.querySelector(':scope > .tree-link > .tree-link-title') : null;
|
||||||
return title && title.textContent ? title.textContent.trim() : '无标题';
|
return title && title.textContent ? title.textContent.trim() : '无标题';
|
||||||
@@ -1109,6 +1713,31 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
|
|
||||||
function handleTreeContextMenuAction(action, detail, trigger) {
|
function handleTreeContextMenuAction(action, detail, trigger) {
|
||||||
closeTreeContextMenu();
|
closeTreeContextMenu();
|
||||||
|
detail = detail || {};
|
||||||
|
if (detail.contextKind === 'attachment') {
|
||||||
|
if (action === 'copy-link') {
|
||||||
|
void copyTreeContextValue(detail.href || '', 'attachment-copy-link');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'download') {
|
||||||
|
openEditorAttachmentDownload(detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'popup-preview') {
|
||||||
|
openEditorAttachmentDetail(detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'right-preview') {
|
||||||
|
dispatchSidebarEvent('tree.attachment.open-right', detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'copy-id') {
|
||||||
|
void copyTreeContextValue(detail.assetId || '', 'attachment-copy-id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatchSidebarEvent('tree.attachment.action', { action: action, attachment: detail });
|
||||||
|
return;
|
||||||
|
}
|
||||||
var documentId = detail.documentId || '';
|
var documentId = detail.documentId || '';
|
||||||
var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body);
|
var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body);
|
||||||
var title = detail.title || '无标题';
|
var title = detail.title || '无标题';
|
||||||
@@ -1221,13 +1850,32 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
|
|
||||||
function openTreeContextMenu(kind, detail, x, y, trigger) {
|
function openTreeContextMenu(kind, detail, x, y, trigger) {
|
||||||
closeTreeContextMenu();
|
closeTreeContextMenu();
|
||||||
|
detail = Object.assign({}, detail || {}, { contextKind: kind });
|
||||||
var menu = document.createElement('div');
|
var menu = document.createElement('div');
|
||||||
menu.className = 'mnote-tree-context-menu';
|
menu.className = 'mnote-tree-context-menu';
|
||||||
menu.setAttribute('role', 'menu');
|
menu.setAttribute('role', 'menu');
|
||||||
menu.setAttribute('data-testid', 'mnote-tree-context-menu');
|
menu.setAttribute('data-testid', 'mnote-tree-context-menu');
|
||||||
menu.setAttribute('data-kind', kind);
|
menu.setAttribute('data-kind', kind);
|
||||||
|
var isAttachment = kind === 'attachment';
|
||||||
var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||||
var items = isAsset ? [
|
var items = isAttachment ? [
|
||||||
|
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' },
|
||||||
|
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true },
|
||||||
|
{ separator: true },
|
||||||
|
{ action: 'copy-link', icon: 'link', label: '复制链接' },
|
||||||
|
{ action: 'move-embed', icon: 'subdirectory_arrow_right', label: '移动/嵌入到...', shortcut: 'Alt+Shift+M/G' },
|
||||||
|
{ action: 'history', icon: 'history', label: '块历史...' },
|
||||||
|
{ separator: true },
|
||||||
|
{ action: 'popup-preview', icon: 'preview', label: '弹窗预览' },
|
||||||
|
{ action: 'right-preview', icon: 'right_panel_open', label: '右侧预览' },
|
||||||
|
{ action: 'download', icon: 'download', label: '下载' },
|
||||||
|
{ action: 'replace-file', icon: 'sync', label: '更换文件' },
|
||||||
|
{ action: 'rename-attachment', icon: 'drive_file_rename_outline', label: '重命名' },
|
||||||
|
{ action: 'comment', icon: 'mode_comment', label: '评论', shortcut: 'Ctrl+Alt+M' },
|
||||||
|
{ action: 'caption', icon: 'notes', label: '添加说明文字' },
|
||||||
|
{ separator: true },
|
||||||
|
{ action: 'color', icon: 'format_paint', label: '颜色' }
|
||||||
|
] : isAsset ? [
|
||||||
{ action: 'open-right', icon: 'open_in_new', label: '在右侧边栏打开', shortcut: 'Alt + O' },
|
{ action: 'open-right', icon: 'open_in_new', label: '在右侧边栏打开', shortcut: 'Alt + O' },
|
||||||
{ action: 'move', icon: 'drive_file_move', label: '移动到...' },
|
{ action: 'move', icon: 'drive_file_move', label: '移动到...' },
|
||||||
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' }
|
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' }
|
||||||
@@ -2244,10 +2892,242 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
else openPageSettingsPopover();
|
else openPageSettingsPopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachmentQueryParams(href) {
|
||||||
|
try {
|
||||||
|
return new URL(String(href || ''), window.location.origin).searchParams;
|
||||||
|
} catch (_) {
|
||||||
|
return new URLSearchParams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnlyOfficeAttachmentHref(href) {
|
||||||
|
try {
|
||||||
|
var url = new URL(String(href || ''), window.location.origin);
|
||||||
|
return url.pathname === '/onlyoffice' && (url.searchParams.has('assetId') || url.searchParams.has('fileName'));
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOnlyOfficeAttachmentHref(href) {
|
||||||
|
try {
|
||||||
|
var url = new URL(String(href || ''), window.location.origin);
|
||||||
|
if (url.pathname !== '/onlyoffice') return String(href || '');
|
||||||
|
return buildOnlyOfficeOpenUrl({
|
||||||
|
fileUrl: url.searchParams.get('fileUrl') || '',
|
||||||
|
fileName: url.searchParams.get('fileName') || '未命名附件',
|
||||||
|
fileType: url.searchParams.get('fileType') || inferOnlyOfficeFileType(url.searchParams.get('fileName') || '', ''),
|
||||||
|
assetId: url.searchParams.get('assetId') || '',
|
||||||
|
documentId: url.searchParams.get('documentId') || currentDocumentId() || '',
|
||||||
|
userId: url.searchParams.get('userId') || '',
|
||||||
|
mode: url.searchParams.get('mode') || 'edit'
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return String(href || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOfficeFileName(fileName) {
|
||||||
|
return Boolean(inferOnlyOfficeFileType(fileName, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailFromEditorAttachmentLink(link) {
|
||||||
|
var rawHref = link instanceof HTMLAnchorElement ? link.href : '';
|
||||||
|
var params = attachmentQueryParams(rawHref);
|
||||||
|
var fileName = params.get('fileName') || (link ? link.textContent : '') || '未命名附件';
|
||||||
|
var fileType = params.get('fileType') || inferOnlyOfficeFileType(fileName, '');
|
||||||
|
var assetId = params.get('assetId') || (link ? link.getAttribute('data-asset-id') : '') || '';
|
||||||
|
var fileUrl = params.get('fileUrl') || '';
|
||||||
|
var href = rawHref;
|
||||||
|
if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) {
|
||||||
|
fileUrl = rawHref;
|
||||||
|
href = buildOnlyOfficeOpenUrl({
|
||||||
|
fileUrl: fileUrl,
|
||||||
|
fileName: fileName,
|
||||||
|
fileType: fileType,
|
||||||
|
assetId: assetId,
|
||||||
|
documentId: currentDocumentId() || '',
|
||||||
|
userId: '',
|
||||||
|
mode: 'edit'
|
||||||
|
});
|
||||||
|
} else if (isOnlyOfficeAttachmentHref(rawHref)) {
|
||||||
|
href = normalizeOnlyOfficeAttachmentHref(rawHref);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
href: href,
|
||||||
|
fileUrl: fileUrl,
|
||||||
|
fileName: fileName,
|
||||||
|
title: fileName,
|
||||||
|
fileType: fileType,
|
||||||
|
assetId: assetId,
|
||||||
|
documentId: params.get('documentId') || currentDocumentId() || '',
|
||||||
|
workspaceId: resolveWorkspaceId(document.body),
|
||||||
|
fileSize: (link ? link.getAttribute('data-file-size') : '') || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceEditorAttachmentLink(link) {
|
||||||
|
if (!(link instanceof HTMLAnchorElement)) return;
|
||||||
|
var href = link.getAttribute('href') || '';
|
||||||
|
var params = attachmentQueryParams(href);
|
||||||
|
var fileName = params.get('fileName') || link.textContent || '';
|
||||||
|
var className = link.getAttribute('class') || '';
|
||||||
|
var shouldEnhance = isOnlyOfficeAttachmentHref(href)
|
||||||
|
|| className.indexOf('mnote-uploaded-attachment-row') >= 0
|
||||||
|
|| isOfficeFileName(fileName);
|
||||||
|
if (!shouldEnhance) return;
|
||||||
|
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||||
|
var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || '';
|
||||||
|
if (assetId) link.setAttribute('data-asset-id', assetId);
|
||||||
|
if (isOnlyOfficeAttachmentHref(href)) {
|
||||||
|
link.setAttribute('href', buildOnlyOfficeOpenPath({
|
||||||
|
fileUrl: params.get('fileUrl') || '',
|
||||||
|
fileName: fileName || '未命名附件',
|
||||||
|
fileType: params.get('fileType') || inferOnlyOfficeFileType(fileName, ''),
|
||||||
|
assetId: assetId,
|
||||||
|
documentId: params.get('documentId') || currentDocumentId() || '',
|
||||||
|
userId: params.get('userId') || '',
|
||||||
|
mode: params.get('mode') || 'edit'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
|
||||||
|
if (name) link.classList.add(name);
|
||||||
|
});
|
||||||
|
link.setAttribute('target', '_blank');
|
||||||
|
link.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||||
|
void hydrateEditorAttachmentMeta(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceEditorAttachmentLinks() {
|
||||||
|
document.querySelectorAll('.editor-surface .ProseMirror a[href]').forEach(enhanceEditorAttachmentLink);
|
||||||
|
void healLegacyOfficeAttachmentParagraphs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAttachmentActions() {
|
||||||
|
var existing = document.querySelector('[data-testid="mnote-attachment-actions"]');
|
||||||
|
if (existing instanceof HTMLElement) return existing;
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'mnote-attachment-actions';
|
||||||
|
actions.setAttribute('data-testid', 'mnote-attachment-actions');
|
||||||
|
actions.hidden = true;
|
||||||
|
actions.innerHTML = '' +
|
||||||
|
'<button type="button" class="mnote-attachment-action" data-testid="mnote-attachment-action-download" data-attachment-action="download" aria-label="下载附件"><span class="material-symbols-outlined" data-icon="download" aria-hidden="true"></span></button>' +
|
||||||
|
'<button type="button" class="mnote-attachment-action" data-testid="mnote-attachment-action-menu" data-attachment-action="menu" aria-label="更多操作"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>';
|
||||||
|
actions.addEventListener('mouseenter', function() {
|
||||||
|
if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
|
||||||
|
});
|
||||||
|
actions.addEventListener('mouseleave', scheduleHideAttachmentActions);
|
||||||
|
document.body.appendChild(actions);
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionAttachmentActions(link) {
|
||||||
|
if (!(link instanceof HTMLElement)) return;
|
||||||
|
var actions = ensureAttachmentActions();
|
||||||
|
var rect = link.getBoundingClientRect();
|
||||||
|
actions.hidden = false;
|
||||||
|
actions.style.left = Math.min(window.innerWidth - 76, Math.max(8, rect.right + 6)) + 'px';
|
||||||
|
actions.style.top = Math.max(8, rect.top + (rect.height - 28) / 2) + 'px';
|
||||||
|
activeEditorAttachmentLink = link;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHideAttachmentActions() {
|
||||||
|
if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
|
||||||
|
attachmentActionsHideTimer = window.setTimeout(function() {
|
||||||
|
var actions = document.querySelector('[data-testid="mnote-attachment-actions"]');
|
||||||
|
if (actions instanceof HTMLElement) actions.hidden = true;
|
||||||
|
activeEditorAttachmentLink = null;
|
||||||
|
}, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditorAttachmentDetail(detail) {
|
||||||
|
if (!detail || !detail.href) return;
|
||||||
|
window.open(detail.href, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditorAttachmentDownload(detail) {
|
||||||
|
if (!detail) return;
|
||||||
|
if (detail.assetId) {
|
||||||
|
try {
|
||||||
|
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(detail.assetId), {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
var payload = await response.json().catch(function() { return null; });
|
||||||
|
var signedUrl = String(payload && payload.signedUrl || '').trim();
|
||||||
|
if (response.ok && signedUrl) {
|
||||||
|
window.open(signedUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
var target = detail.fileUrl || detail.href;
|
||||||
|
if (!target) return;
|
||||||
|
window.open(target, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditorAttachmentMenu(link, trigger) {
|
||||||
|
var detail = detailFromEditorAttachmentLink(link);
|
||||||
|
var rect = trigger && trigger.getBoundingClientRect ? trigger.getBoundingClientRect() : link.getBoundingClientRect();
|
||||||
|
openTreeContextMenu('attachment', detail, rect.right, rect.bottom + 4, trigger || link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditorAttachmentLink(link) {
|
||||||
|
enhanceEditorAttachmentLink(link);
|
||||||
|
openEditorAttachmentDetail(detailFromEditorAttachmentLink(link));
|
||||||
|
}
|
||||||
|
|
||||||
|
enhanceEditorAttachmentLinks();
|
||||||
|
var attachmentObserver = new MutationObserver(function() { enhanceEditorAttachmentLinks(); });
|
||||||
|
attachmentObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
document.addEventListener('mouseover', function(event) {
|
||||||
|
var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||||
|
if (!(link instanceof HTMLAnchorElement)) return;
|
||||||
|
if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
|
||||||
|
enhanceEditorAttachmentLink(link);
|
||||||
|
positionAttachmentActions(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseout', function(event) {
|
||||||
|
var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||||
|
if (!(link instanceof HTMLAnchorElement)) return;
|
||||||
|
var next = event.relatedTarget;
|
||||||
|
var actions = document.querySelector('[data-testid="mnote-attachment-actions"]');
|
||||||
|
if (next && (link.contains(next) || (actions && actions.contains(next)))) return;
|
||||||
|
scheduleHideAttachmentActions();
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
|
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
|
||||||
if (activeTreeContextMenu) closeTreeContextMenu();
|
if (activeTreeContextMenu) closeTreeContextMenu();
|
||||||
|
|
||||||
|
var attachmentAction = closestAction(e.target, '[data-attachment-action]');
|
||||||
|
if (attachmentAction) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var link = activeEditorAttachmentLink;
|
||||||
|
if (!(link instanceof HTMLAnchorElement)) return;
|
||||||
|
var attachmentDetail = detailFromEditorAttachmentLink(link);
|
||||||
|
var attachmentActionName = attachmentAction.getAttribute('data-attachment-action') || '';
|
||||||
|
if (attachmentActionName === 'download') {
|
||||||
|
openEditorAttachmentDownload(attachmentDetail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attachmentActionName === 'menu') {
|
||||||
|
openEditorAttachmentMenu(link, attachmentAction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
|
||||||
|
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
openEditorAttachmentLink(editorAttachmentLink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var historyClose = closestAction(e.target, '[data-page-history-action="close"]');
|
var historyClose = closestAction(e.target, '[data-page-history-action="close"]');
|
||||||
if (historyClose) {
|
if (historyClose) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -3084,6 +3964,13 @@ mod tests {
|
|||||||
assert!(SIDEBAR_TREE_JS.contains("sidebar-file-tree-root"));
|
assert!(SIDEBAR_TREE_JS.contains("sidebar-file-tree-root"));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open"));
|
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open"));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("tree.asset.open"));
|
assert!(SIDEBAR_TREE_JS.contains("tree.asset.open"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("openConvexAssetFromFileTree"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("/api/media/sign?assetId="));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("fetchCurrentOnlyOfficeUserId"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("buildOnlyOfficeOpenUrl"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('userId'"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("window.open(buildOnlyOfficeOpenUrl"));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop"));
|
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop"));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop"));
|
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop"));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY"));
|
assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY"));
|
||||||
|
|||||||
@@ -168,13 +168,20 @@ a:hover {
|
|||||||
.material-symbols-outlined[data-icon="drive_file_move"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 6h6l2 2h8v10H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m13 12 3 3-3 3M8 15h8' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="drive_file_move"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 6h6l2 2h8v10H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m13 12 3 3-3 3M8 15h8' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="delete"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 6h14M9 6V4h6v2M8 6l1 14h6l1-14M10.5 10v6M13.5 10v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="delete"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 6h14M9 6V4h6v2M8 6l1 14h6l1-14M10.5 10v6M13.5 10v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="right_panel_open"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v14H4zM14 5v14M8 12h6M11 9l3 3-3 3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="right_panel_open"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v14H4zM14 5v14M8 12h6M11 9l3 3-3 3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
|
.material-symbols-outlined[data-icon="preview"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Ccircle cx='12' cy='12' r='2.8' fill='none' stroke='black' stroke-width='2'/%3E%3C/svg%3E"); }
|
||||||
|
.material-symbols-outlined[data-icon="download"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 4v10M8.5 11.5 12 15l3.5-3.5M5 19h14' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
|
.material-symbols-outlined[data-icon="notes"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 5h12v14H6zM9 9h6M9 12h6M9 15h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="open_in_new"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 6H5v13h13v-3M12 5h7v7M10 14 19 5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="open_in_new"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 6H5v13h13v-3M12 5h7v7M10 14 19 5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="share"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='18' cy='5' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='6' cy='12' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='18' cy='19' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m8.7 10.7 6.6-4.4M8.7 13.3l6.6 4.4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="share"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='18' cy='5' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='6' cy='12' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='18' cy='19' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m8.7 10.7 6.6-4.4M8.7 13.3l6.6 4.4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||||
|
|
||||||
.material-symbols-outlined[data-icon="comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
|
.material-symbols-outlined[data-icon="mode_comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="person_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='8' r='3.2' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M3.5 20a5.5 5.5 0 0 1 11 0M18 8v6M15 11h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="person_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='8' r='3.2' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M3.5 20a5.5 5.5 0 0 1 11 0M18 8v6M15 11h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="slideshow"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v12H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m10 9 5 2-5 2zM9 21h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="slideshow"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v12H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m10 9 5 2-5 2zM9 21h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="article"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="article"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
|
.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20 6v5h-5M4 18v-5h5M7.5 9A6 6 0 0 1 18 6M16.5 15A6 6 0 0 1 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
|
.material-symbols-outlined[data-icon="drive_file_rename_outline"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m4 17-.5 3.5L7 20l11-11-3-3zM13 8l3 3M5 6h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
|
.material-symbols-outlined[data-icon="format_paint"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 7h10l2 3-2 3H5l-2-3zM9 13v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="link"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.5 14.5 14.5 9.5M10.8 6.2l1.1-1.1a4 4 0 0 1 5.7 5.7l-1.6 1.6M13.2 17.8l-1.1 1.1a4 4 0 0 1-5.7-5.7L8 11.6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="link"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.5 14.5 14.5 9.5M10.8 6.2l1.1-1.1a4 4 0 0 1 5.7 5.7l-1.6 1.6M13.2 17.8l-1.1 1.1a4 4 0 0 1-5.7-5.7L8 11.6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="content_copy"],
|
.material-symbols-outlined[data-icon="content_copy"],
|
||||||
.material-symbols-outlined[data-icon="file_copy"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 8h10v12H8zM6 16H4V4h10v2' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="file_copy"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 8h10v12H8zM6 16H4V4h10v2' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
@@ -1750,6 +1757,75 @@ body {
|
|||||||
background: rgba(27, 28, 28, 0.08);
|
background: rgba(27, 28, 28, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mnote-attachment-actions {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1100;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
box-shadow: 0 6px 18px rgba(27, 28, 28, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-attachment-actions[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-attachment-action {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6D6A65;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-attachment-action:hover {
|
||||||
|
background: #F4F3F3;
|
||||||
|
color: #37352F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-attachment-action .material-symbols-outlined {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 7px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-height: 28px !important;
|
||||||
|
padding: 2px 4px !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
color: #37352f !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::after {
|
||||||
|
content: "" !important;
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row[data-file-size]::after {
|
||||||
|
content: "◔ " attr(data-file-size) !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
margin-left: 4px !important;
|
||||||
|
color: #9ca3af !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-tree .tree-node .tree-children .tree-row {
|
.sidebar-tree .tree-node .tree-children .tree-row {
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,29 @@ pub async fn execute_sidebar_dataset_query(
|
|||||||
execute_convex_query_plan(config, context, plan).await
|
execute_convex_query_plan(config, context, plan).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn execute_convex_query_by_name(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
function_name: &str,
|
||||||
|
args: Value,
|
||||||
|
workspace_id: Option<&str>,
|
||||||
|
error_phase: &'static str,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let plan = RuntimeQueryExecutionPlan {
|
||||||
|
query_name: function_name.to_string(),
|
||||||
|
function_name: function_name.to_string(),
|
||||||
|
workspace_id: workspace_id.map(ToOwned::to_owned),
|
||||||
|
request_id: context.trace.request_id.clone(),
|
||||||
|
trace_id: context.trace.trace_id.clone(),
|
||||||
|
actor_id: context.auth.actor_id.clone(),
|
||||||
|
payload_json: args.to_string(),
|
||||||
|
args_json: args,
|
||||||
|
};
|
||||||
|
execute_convex_query_plan(config, context, &plan)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.with_header("x-error-phase", error_phase))
|
||||||
|
}
|
||||||
|
|
||||||
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||||
let mut args = plan.args_json.clone();
|
let mut args = plan.args_json.clone();
|
||||||
if matches!(
|
if matches!(
|
||||||
|
|||||||
+14
-13
@@ -46,28 +46,29 @@ export interface InitOutput {
|
|||||||
readonly memory: WebAssembly.Memory;
|
readonly memory: WebAssembly.Memory;
|
||||||
readonly mount: (a: any, b: any) => [number, number, number];
|
readonly mount: (a: any, b: any) => [number, number, number];
|
||||||
readonly unmount: (a: number) => [number, number];
|
readonly unmount: (a: number) => [number, number];
|
||||||
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
|
||||||
readonly intounderlyingsink_write: (a: number, b: any) => any;
|
|
||||||
readonly intounderlyingsink_close: (a: number) => any;
|
|
||||||
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
|
||||||
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||||
readonly intounderlyingbytesource_type: (a: number) => number;
|
readonly intounderlyingbytesource_type: (a: number) => number;
|
||||||
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||||
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
|
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||||
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
|
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||||
readonly intounderlyingbytesource_cancel: (a: number) => void;
|
readonly intounderlyingbytesource_cancel: (a: number) => void;
|
||||||
|
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||||
|
readonly intounderlyingsink_write: (a: number, b: any) => any;
|
||||||
|
readonly intounderlyingsink_close: (a: number) => any;
|
||||||
|
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
||||||
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||||
readonly intounderlyingsource_pull: (a: number, b: any) => any;
|
readonly intounderlyingsource_pull: (a: number, b: any) => any;
|
||||||
readonly intounderlyingsource_cancel: (a: number) => void;
|
readonly intounderlyingsource_cancel: (a: number) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
|
readonly wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e: (a: number, b: number, c: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
|
||||||
|
readonly wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
|
||||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
readonly __externref_table_alloc: () => number;
|
readonly __externref_table_alloc: () => number;
|
||||||
|
|||||||
+68
-35
@@ -785,7 +785,7 @@ function __wbg_get_imports() {
|
|||||||
const a = state0.a;
|
const a = state0.a;
|
||||||
state0.a = 0;
|
state0.a = 0;
|
||||||
try {
|
try {
|
||||||
return wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(a, state0.b, arg0, arg1);
|
return wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(a, state0.b, arg0, arg1);
|
||||||
} finally {
|
} finally {
|
||||||
state0.a = a;
|
state0.a = a;
|
||||||
}
|
}
|
||||||
@@ -1014,6 +1014,24 @@ function __wbg_get_imports() {
|
|||||||
const ret = arg0.right;
|
const ret = arg0.right;
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
|
__wbg_run_0b0a622deae25fda: function(arg0, arg1, arg2) {
|
||||||
|
try {
|
||||||
|
var state0 = {a: arg1, b: arg2};
|
||||||
|
var cb0 = () => {
|
||||||
|
const a = state0.a;
|
||||||
|
state0.a = 0;
|
||||||
|
try {
|
||||||
|
return wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(a, state0.b, );
|
||||||
|
} finally {
|
||||||
|
state0.a = a;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ret = arg0.run(cb0);
|
||||||
|
return ret;
|
||||||
|
} finally {
|
||||||
|
state0.a = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
__wbg_scrollHeight_5fe8cbb97ae906d8: function(arg0) {
|
__wbg_scrollHeight_5fe8cbb97ae906d8: function(arg0) {
|
||||||
const ret = arg0.scrollHeight;
|
const ret = arg0.scrollHeight;
|
||||||
return ret;
|
return ret;
|
||||||
@@ -1111,6 +1129,10 @@ function __wbg_get_imports() {
|
|||||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||||
},
|
},
|
||||||
|
__wbg_static_accessor_CREATE_TASK_f3ab6a6954bda493: function() {
|
||||||
|
const ret = typeof console === 'undefined' ? null : console?.createTask;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
},
|
||||||
__wbg_static_accessor_GLOBAL_8cfadc87a297ca02: function() {
|
__wbg_static_accessor_GLOBAL_8cfadc87a297ca02: function() {
|
||||||
const ret = typeof global === 'undefined' ? null : global;
|
const ret = typeof global === 'undefined' ? null : global;
|
||||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
@@ -1188,6 +1210,12 @@ function __wbg_get_imports() {
|
|||||||
const ret = arg0.view;
|
const ret = arg0.view;
|
||||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
},
|
},
|
||||||
|
__wbg_warn_3cc416af27dbdc02: function(arg0) {
|
||||||
|
console.warn(arg0);
|
||||||
|
},
|
||||||
|
__wbg_warn_bd0f407277b102f4: function(arg0, arg1, arg2) {
|
||||||
|
console.warn(arg0, arg1, arg2);
|
||||||
|
},
|
||||||
__wbg_width_9673a519d7bd5a6a: function(arg0) {
|
__wbg_width_9673a519d7bd5a6a: function(arg0) {
|
||||||
const ret = arg0.width;
|
const ret = arg0.width;
|
||||||
return ret;
|
return ret;
|
||||||
@@ -1205,43 +1233,43 @@ function __wbg_get_imports() {
|
|||||||
}
|
}
|
||||||
}, arguments); },
|
}, arguments); },
|
||||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1025, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1440, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
|
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 794, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1721, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1812, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 916, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1638, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1723, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 918, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1637, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 940, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1659, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5);
|
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 976, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1722, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000009: function(arg0) {
|
__wbindgen_cast_0000000000000009: function(arg0) {
|
||||||
@@ -1285,43 +1313,48 @@ function __wbg_get_imports() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1) {
|
function wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1);
|
wasm.wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1) {
|
function wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1);
|
wasm.wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1) {
|
function wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1);
|
wasm.wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2);
|
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1);
|
||||||
|
return ret !== 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2) {
|
||||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2) {
|
||||||
|
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2);
|
||||||
if (ret[1]) {
|
if (ret[1]) {
|
||||||
throw takeFromExternrefTable0(ret[0]);
|
throw takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3) {
|
function wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3);
|
wasm.wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
Vendored
+14
-13
@@ -3,28 +3,29 @@
|
|||||||
export const memory: WebAssembly.Memory;
|
export const memory: WebAssembly.Memory;
|
||||||
export const mount: (a: any, b: any) => [number, number, number];
|
export const mount: (a: any, b: any) => [number, number, number];
|
||||||
export const unmount: (a: number) => [number, number];
|
export const unmount: (a: number) => [number, number];
|
||||||
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
|
||||||
export const intounderlyingsink_write: (a: number, b: any) => any;
|
|
||||||
export const intounderlyingsink_close: (a: number) => any;
|
|
||||||
export const intounderlyingsink_abort: (a: number, b: any) => any;
|
|
||||||
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||||
export const intounderlyingbytesource_type: (a: number) => number;
|
export const intounderlyingbytesource_type: (a: number) => number;
|
||||||
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||||
export const intounderlyingbytesource_start: (a: number, b: any) => void;
|
export const intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||||
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
|
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||||
export const intounderlyingbytesource_cancel: (a: number) => void;
|
export const intounderlyingbytesource_cancel: (a: number) => void;
|
||||||
|
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||||
|
export const intounderlyingsink_write: (a: number, b: any) => any;
|
||||||
|
export const intounderlyingsink_close: (a: number) => any;
|
||||||
|
export const intounderlyingsink_abort: (a: number, b: any) => any;
|
||||||
export const __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
export const __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||||
export const intounderlyingsource_pull: (a: number, b: any) => any;
|
export const intounderlyingsource_pull: (a: number, b: any) => any;
|
||||||
export const intounderlyingsource_cancel: (a: number) => void;
|
export const intounderlyingsource_cancel: (a: number) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
|
export const wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e: (a: number, b: number, c: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
|
export const wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
|
||||||
|
export const wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
|
||||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
export const __externref_table_alloc: () => number;
|
export const __externref_table_alloc: () => number;
|
||||||
|
|||||||
@@ -1819,6 +1819,72 @@ const SPIKE_STYLE: &str = r#"
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
margin: 1px 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #37352f;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before {
|
||||||
|
content: "";
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--attachment-icon-bg, #e9ecef);
|
||||||
|
border: 1px solid var(--attachment-icon-border, rgba(55, 53, 47, 0.14));
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row::after {
|
||||||
|
content: "◉";
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row:hover {
|
||||||
|
background: rgba(55, 53, 47, 0.06);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror a.mnote-uploaded-attachment-word {
|
||||||
|
--attachment-icon-bg: #4f82ff;
|
||||||
|
--attachment-icon-border: #3366d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror a.mnote-uploaded-attachment-ppt {
|
||||||
|
--attachment-icon-bg: #ea581f;
|
||||||
|
--attachment-icon-border: #cf4817;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror a.mnote-uploaded-attachment-sheet {
|
||||||
|
--attachment-icon-bg: #2f9e44;
|
||||||
|
--attachment-icon-border: #25813a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror a.mnote-uploaded-attachment-pdf {
|
||||||
|
--attachment-icon-bg: #ef4444;
|
||||||
|
--attachment-icon-border: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror a.mnote-uploaded-attachment-file {
|
||||||
|
--attachment-icon-bg: #9ca3af;
|
||||||
|
--attachment-icon-border: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-strip {
|
.footer-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1964,6 +2030,7 @@ enum SlashActionKind {
|
|||||||
Divider,
|
Divider,
|
||||||
SimpleTable,
|
SimpleTable,
|
||||||
Image,
|
Image,
|
||||||
|
UploadAttachment,
|
||||||
Toc,
|
Toc,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2013,7 +2080,7 @@ const FOLDED_HEADING_ACTIONS: [FoldedHeadingAction; 4] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const SLASH_ACTIONS: [SlashAction; 20] = [
|
const SLASH_ACTIONS: [SlashAction; 21] = [
|
||||||
SlashAction {
|
SlashAction {
|
||||||
kind: SlashActionKind::AiAssistant,
|
kind: SlashActionKind::AiAssistant,
|
||||||
id: "ai-assistant",
|
id: "ai-assistant",
|
||||||
@@ -2182,9 +2249,18 @@ const SLASH_ACTIONS: [SlashAction; 20] = [
|
|||||||
category: "媒体与附件",
|
category: "媒体与附件",
|
||||||
icon: "▧",
|
icon: "▧",
|
||||||
label: "图片",
|
label: "图片",
|
||||||
description: "插入图片",
|
description: "上传并插入图片",
|
||||||
shortcut: "/tp",
|
shortcut: "/tp",
|
||||||
},
|
},
|
||||||
|
SlashAction {
|
||||||
|
kind: SlashActionKind::UploadAttachment,
|
||||||
|
id: "upload-attachment",
|
||||||
|
category: "媒体与附件",
|
||||||
|
icon: "↥",
|
||||||
|
label: "上传附件",
|
||||||
|
description: "上传 Office、PDF 或其他文件",
|
||||||
|
shortcut: "/fj",
|
||||||
|
},
|
||||||
SlashAction {
|
SlashAction {
|
||||||
kind: SlashActionKind::Toc,
|
kind: SlashActionKind::Toc,
|
||||||
id: "toc",
|
id: "toc",
|
||||||
@@ -4058,7 +4134,9 @@ fn turn_into_block(node: &Value, action: SlashActionKind) -> Value {
|
|||||||
node_with_attrs("codeBlock", Some(attrs), content)
|
node_with_attrs("codeBlock", Some(attrs), content)
|
||||||
}
|
}
|
||||||
SlashActionKind::Divider => node_with_attrs("horizontalRule", None, Vec::new()),
|
SlashActionKind::Divider => node_with_attrs("horizontalRule", None, Vec::new()),
|
||||||
SlashActionKind::SimpleTable | SlashActionKind::Toc => paragraph_node(inline),
|
SlashActionKind::SimpleTable | SlashActionKind::UploadAttachment | SlashActionKind::Toc => {
|
||||||
|
paragraph_node(inline)
|
||||||
|
}
|
||||||
SlashActionKind::Image => {
|
SlashActionKind::Image => {
|
||||||
let mut attrs = Map::new();
|
let mut attrs = Map::new();
|
||||||
attrs.insert("src".to_string(), json!(E24_IMAGE_PLACEHOLDER_SRC));
|
attrs.insert("src".to_string(), json!(E24_IMAGE_PLACEHOLDER_SRC));
|
||||||
@@ -5437,6 +5515,25 @@ fn table_option_checked(document: &Value, action: TableOptionAction) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dispatch_editor_upload_request(kind: &str, accept: &str) -> Result<(), String> {
|
||||||
|
let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?;
|
||||||
|
let detail = json!({
|
||||||
|
"kind": kind,
|
||||||
|
"accept": accept,
|
||||||
|
"multiple": true,
|
||||||
|
"insertIntoEditor": true,
|
||||||
|
});
|
||||||
|
let detail =
|
||||||
|
serde_wasm_bindgen::to_value(&detail).map_err(|err| format!("构造上传请求失败:{err}"))?;
|
||||||
|
let init = CustomEventInit::new();
|
||||||
|
init.set_detail(&detail);
|
||||||
|
let event = CustomEvent::new_with_event_init_dict("mnote:editor-upload-request", &init)
|
||||||
|
.map_err(|_| "构造上传事件失败".to_string())?;
|
||||||
|
win.dispatch_event(&event)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|_| "派发上传事件失败".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn run_slash_action(
|
fn run_slash_action(
|
||||||
editor: TiptapEditorHandle,
|
editor: TiptapEditorHandle,
|
||||||
action: SlashActionKind,
|
action: SlashActionKind,
|
||||||
@@ -5464,11 +5561,14 @@ fn run_slash_action(
|
|||||||
let _ = editor.focus();
|
let _ = editor.focus();
|
||||||
editor.insert_table(4, 3, false)
|
editor.insert_table(4, 3, false)
|
||||||
}
|
}
|
||||||
SlashActionKind::Image => editor.set_image(TiptapImageResource {
|
SlashActionKind::Image => {
|
||||||
src: E24_IMAGE_PLACEHOLDER_SRC.into(),
|
dispatch_editor_upload_request("image", "image/*")?;
|
||||||
alt: Some(E24_IMAGE_PLACEHOLDER_ALT.into()),
|
return Ok("已打开图片上传");
|
||||||
title: Some(E24_IMAGE_PLACEHOLDER_TITLE.into()),
|
}
|
||||||
}),
|
SlashActionKind::UploadAttachment => {
|
||||||
|
dispatch_editor_upload_request("attachment", "")?;
|
||||||
|
return Ok("已打开附件上传");
|
||||||
|
}
|
||||||
SlashActionKind::Toc => editor.insert_toc_node(TiptapTocNodeAttrs {
|
SlashActionKind::Toc => editor.insert_toc_node(TiptapTocNodeAttrs {
|
||||||
top_offset: Some(0),
|
top_offset: Some(0),
|
||||||
max_show_count: Some(20),
|
max_show_count: Some(20),
|
||||||
@@ -5495,7 +5595,8 @@ fn run_slash_action(
|
|||||||
SlashActionKind::CodeBlock => "已切到代码块",
|
SlashActionKind::CodeBlock => "已切到代码块",
|
||||||
SlashActionKind::Divider => "已插入分割线",
|
SlashActionKind::Divider => "已插入分割线",
|
||||||
SlashActionKind::SimpleTable => "已插入简单表格",
|
SlashActionKind::SimpleTable => "已插入简单表格",
|
||||||
SlashActionKind::Image => "已插入图片",
|
SlashActionKind::Image => "已打开图片上传",
|
||||||
|
SlashActionKind::UploadAttachment => "已打开附件上传",
|
||||||
SlashActionKind::Toc => "已插入页面目录",
|
SlashActionKind::Toc => "已插入页面目录",
|
||||||
})
|
})
|
||||||
.map_err(|err| format!("命令执行失败:{err}"))
|
.map_err(|err| format!("命令执行失败:{err}"))
|
||||||
@@ -5844,6 +5945,7 @@ fn top_level_block_matches_action(
|
|||||||
SlashActionKind::Divider => node_type == "horizontalRule",
|
SlashActionKind::Divider => node_type == "horizontalRule",
|
||||||
SlashActionKind::SimpleTable => node_type == "table",
|
SlashActionKind::SimpleTable => node_type == "table",
|
||||||
SlashActionKind::Image => node_type == "image",
|
SlashActionKind::Image => node_type == "image",
|
||||||
|
SlashActionKind::UploadAttachment => false,
|
||||||
SlashActionKind::Toc => node_type == "tocNode",
|
SlashActionKind::Toc => node_type == "tocNode",
|
||||||
SlashActionKind::AiAssistant
|
SlashActionKind::AiAssistant
|
||||||
| SlashActionKind::AiWrite
|
| SlashActionKind::AiWrite
|
||||||
@@ -9052,7 +9154,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
|||||||
<div class="block-drag-submenu" data-testid="block-transform-submenu" data-e30-testid="block-turn-into-menu">
|
<div class="block-drag-submenu" data-testid="block-transform-submenu" data-e30-testid="block-turn-into-menu">
|
||||||
{SLASH_ACTIONS
|
{SLASH_ACTIONS
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|action| !matches!(action.kind, SlashActionKind::Divider | SlashActionKind::SimpleTable | SlashActionKind::Image | SlashActionKind::Toc | SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi))
|
.filter(|action| !matches!(action.kind, SlashActionKind::Divider | SlashActionKind::SimpleTable | SlashActionKind::Image | SlashActionKind::UploadAttachment | SlashActionKind::Toc | SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi))
|
||||||
.map(|action| {
|
.map(|action| {
|
||||||
let kind = action.kind;
|
let kind = action.kind;
|
||||||
let label = action.label;
|
let label = action.label;
|
||||||
|
|||||||
+33
-23
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同时热启动前端、FastAPI,以及按需启用的 Celery。
|
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / Celery。
|
||||||
* 可使用以下环境变量调整行为:
|
* 可使用以下环境变量调整行为:
|
||||||
* - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效
|
* - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效
|
||||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令,默认为 "python -m uvicorn app.main:app --reload --port 8000"
|
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
||||||
|
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
||||||
|
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
|
||||||
* - ENABLE_CELERY:设为 "1" or "true" 时启用默认 Celery worker
|
* - ENABLE_CELERY:设为 "1" or "true" 时启用默认 Celery worker
|
||||||
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
|
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
|
||||||
* - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker pool;Windows 默认 "solo",其他平台默认使用 Celery 自身默认值
|
* - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker pool;Windows 默认 "solo",其他平台默认使用 Celery 自身默认值
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
* - REDIS_URL:仅用于探测 Redis 是否就绪,默认 "redis://localhost:6379/0"
|
* - REDIS_URL:仅用于探测 Redis 是否就绪,默认 "redis://localhost:6379/0"
|
||||||
* - SKIP_CELERY:设为 "1" or "true" 可强制跳过 Celery。
|
* - SKIP_CELERY:设为 "1" or "true" 可强制跳过 Celery。
|
||||||
* - MNOTE_WEB_SKIP_GATEWAY:设为 "1" or "true" 临时恢复旧 Next 3000 入口。
|
* - MNOTE_WEB_SKIP_GATEWAY:设为 "1" or "true" 临时恢复旧 Next 3000 入口。
|
||||||
* - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:设为 "1" or "true" 时才启动 Next legacy upstream。
|
* - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:已废弃;desktop:hot 默认不再启动 Next legacy upstream。
|
||||||
* - NEXT_LEGACY_PORT:显式启用 legacy compat 时的 Next upstream 端口,默认 3100。
|
* - NEXT_LEGACY_PORT:显式启用 legacy compat 时的 Next upstream 端口,默认 3100。
|
||||||
* - SKIP_NEXT_LEGACY:兼容旧环境变量;设为 "1" or "true" 时强制只启动 Rust gateway。
|
* - SKIP_NEXT_LEGACY:兼容旧环境变量;设为 "1" or "true" 时强制只启动 Rust gateway。
|
||||||
*/
|
*/
|
||||||
@@ -72,19 +74,21 @@ function shouldStartCelery(env = process.env) {
|
|||||||
return isEnabledEnv(env.ENABLE_CELERY);
|
return isEnabledEnv(env.ENABLE_CELERY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldStartBackend(env = process.env) {
|
||||||
|
if (isEnabledEnv(env.SKIP_BACKEND)) return false;
|
||||||
|
if (String(env.BACKEND_CMD || "").trim()) return true;
|
||||||
|
return isEnabledEnv(env.ENABLE_BACKEND);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRuntimePlan(env = process.env) {
|
function resolveRuntimePlan(env = process.env) {
|
||||||
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
||||||
const nextLegacyPort = Number(env.NEXT_LEGACY_PORT || 3100);
|
const nextLegacyPort = Number(env.NEXT_LEGACY_PORT || 3100);
|
||||||
const skipGateway = isEnabledEnv(env.MNOTE_WEB_SKIP_GATEWAY);
|
const skipGateway = isEnabledEnv(env.MNOTE_WEB_SKIP_GATEWAY);
|
||||||
const legacyCompatRequested =
|
const skipNextLegacy = !skipGateway;
|
||||||
!skipGateway &&
|
|
||||||
isEnabledEnv(env.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT) &&
|
|
||||||
!isEnabledEnv(env.SKIP_NEXT_LEGACY);
|
|
||||||
const skipNextLegacy = !skipGateway && !legacyCompatRequested;
|
|
||||||
const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000;
|
const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000;
|
||||||
const legacyPort = Number.isFinite(nextLegacyPort) ? Math.floor(nextLegacyPort) : 3100;
|
const legacyPort = Number.isFinite(nextLegacyPort) ? Math.floor(nextLegacyPort) : 3100;
|
||||||
const legacyUrl = `http://127.0.0.1:${legacyPort}`;
|
const legacyUrl = `http://127.0.0.1:${legacyPort}`;
|
||||||
const legacyCompatEnabled = legacyCompatRequested ? "1" : "0";
|
const legacyCompatEnabled = "0";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skipGateway,
|
skipGateway,
|
||||||
@@ -93,17 +97,14 @@ function resolveRuntimePlan(env = process.env) {
|
|||||||
legacyPort,
|
legacyPort,
|
||||||
publicUrl: `http://localhost:${publicPort}`,
|
publicUrl: `http://localhost:${publicPort}`,
|
||||||
legacyUrl,
|
legacyUrl,
|
||||||
frontendTaskName: skipGateway ? "frontend" : skipNextLegacy ? null : "next-legacy",
|
frontendTaskName: skipGateway ? "frontend" : null,
|
||||||
frontendCommand: env.FRONTEND_CMD || `pnpm dev -p ${skipGateway ? publicPort : legacyPort}`,
|
frontendCommand: env.FRONTEND_CMD || `pnpm dev -p ${skipGateway ? publicPort : legacyPort}`,
|
||||||
mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web",
|
mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web",
|
||||||
mnoteWebEnv: skipGateway
|
mnoteWebEnv: skipGateway
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `127.0.0.1:${publicPort}`,
|
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
|
||||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||||
...(skipNextLegacy
|
|
||||||
? {}
|
|
||||||
: { MNOTE_WEB_LEGACY_NEXT_BASE_URL: env.MNOTE_WEB_LEGACY_NEXT_BASE_URL || legacyUrl }),
|
|
||||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled,
|
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -135,13 +136,17 @@ const tasks = [
|
|||||||
cwd: path.join(rootDir, "rust"),
|
cwd: path.join(rootDir, "rust"),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
{
|
...(shouldStartBackend(process.env)
|
||||||
name: "backend",
|
? [
|
||||||
command:
|
{
|
||||||
process.env.BACKEND_CMD ||
|
name: "backend",
|
||||||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
|
command:
|
||||||
cwd: backendDir,
|
process.env.BACKEND_CMD ||
|
||||||
},
|
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
|
||||||
|
cwd: backendDir,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
function findTask(name) {
|
function findTask(name) {
|
||||||
@@ -549,7 +554,7 @@ async function main() {
|
|||||||
if (runtimePlan.frontendTaskName === "next-legacy") {
|
if (runtimePlan.frontendTaskName === "next-legacy") {
|
||||||
logPrefix("next-legacy", `Next legacy upstream:${runtimePlan.legacyUrl}`);
|
logPrefix("next-legacy", `Next legacy upstream:${runtimePlan.legacyUrl}`);
|
||||||
} else {
|
} else {
|
||||||
logPrefix("next-legacy", "默认不启动历史 Next upstream;如需临时兼容请设置 MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT=1。");
|
logPrefix("next-legacy", "desktop:hot 默认不启动历史 Next upstream,3000 由 Rust mnote-web 独占。");
|
||||||
}
|
}
|
||||||
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
|
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
|
||||||
if (gatewayTask) {
|
if (gatewayTask) {
|
||||||
@@ -559,7 +564,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const desiredBackendPort = backendPortFromEnv;
|
const desiredBackendPort = backendPortFromEnv;
|
||||||
if (!process.env.BACKEND_CMD) {
|
if (shouldStartBackend(process.env) && !process.env.BACKEND_CMD) {
|
||||||
const backendPortOk = await ensurePortFree(desiredBackendPort, "backend");
|
const backendPortOk = await ensurePortFree(desiredBackendPort, "backend");
|
||||||
if (!backendPortOk) {
|
if (!backendPortOk) {
|
||||||
console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`);
|
console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`);
|
||||||
@@ -570,6 +575,10 @@ async function main() {
|
|||||||
throw new Error("缺少后端任务配置");
|
throw new Error("缺少后端任务配置");
|
||||||
}
|
}
|
||||||
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
|
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
|
||||||
|
} else if (isEnabledEnv(process.env.SKIP_BACKEND)) {
|
||||||
|
logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。");
|
||||||
|
} else if (!shouldStartBackend(process.env)) {
|
||||||
|
logPrefix("backend", "默认不启动 FastAPI 后端;desktop:hot 保持 3000 单入口。如需启用请设置 ENABLE_BACKEND=1 或 BACKEND_CMD。");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldStartCelery(process.env)) {
|
if (shouldStartCelery(process.env)) {
|
||||||
@@ -625,6 +634,7 @@ module.exports = {
|
|||||||
isPortFree,
|
isPortFree,
|
||||||
resolveRuntimePlan,
|
resolveRuntimePlan,
|
||||||
resolveBackendExecutable,
|
resolveBackendExecutable,
|
||||||
|
shouldStartBackend,
|
||||||
shouldStartCelery,
|
shouldStartCelery,
|
||||||
terminatePid,
|
terminatePid,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
ensurePortFree,
|
ensurePortFree,
|
||||||
isPortFree,
|
isPortFree,
|
||||||
resolveRuntimePlan,
|
resolveRuntimePlan,
|
||||||
|
shouldStartBackend,
|
||||||
shouldStartCelery,
|
shouldStartCelery,
|
||||||
} = require("./desktop-hot.js");
|
} = require("./desktop-hot.js");
|
||||||
|
|
||||||
@@ -128,13 +129,13 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner,不启动 Nex
|
|||||||
assert.equal(plan.frontendTaskName, null);
|
assert.equal(plan.frontendTaskName, null);
|
||||||
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
||||||
assert.deepEqual(plan.mnoteWebEnv, {
|
assert.deepEqual(plan.mnoteWebEnv, {
|
||||||
MNOTE_WEB_BIND: "127.0.0.1:3000",
|
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("显式开启 legacy compat 时才启动 Next legacy upstream", () => {
|
test("显式 legacy compat 也不再启动 Next legacy upstream", () => {
|
||||||
const plan = resolveRuntimePlan({
|
const plan = resolveRuntimePlan({
|
||||||
FRONTEND_PORT: "3000",
|
FRONTEND_PORT: "3000",
|
||||||
NEXT_LEGACY_PORT: "3100",
|
NEXT_LEGACY_PORT: "3100",
|
||||||
@@ -142,15 +143,13 @@ test("显式开启 legacy compat 时才启动 Next legacy upstream", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(plan.skipGateway, false);
|
assert.equal(plan.skipGateway, false);
|
||||||
assert.equal(plan.skipNextLegacy, false);
|
assert.equal(plan.skipNextLegacy, true);
|
||||||
assert.equal(plan.frontendTaskName, "next-legacy");
|
assert.equal(plan.frontendTaskName, null);
|
||||||
assert.equal(plan.frontendCommand, "pnpm dev -p 3100");
|
|
||||||
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
||||||
assert.deepEqual(plan.mnoteWebEnv, {
|
assert.deepEqual(plan.mnoteWebEnv, {
|
||||||
MNOTE_WEB_BIND: "127.0.0.1:3000",
|
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||||
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,7 +179,7 @@ test("SKIP_NEXT_LEGACY 保持 Rust gateway 为 3000 owner,但不启动 Next le
|
|||||||
assert.equal(plan.frontendTaskName, null);
|
assert.equal(plan.frontendTaskName, null);
|
||||||
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
||||||
assert.deepEqual(plan.mnoteWebEnv, {
|
assert.deepEqual(plan.mnoteWebEnv, {
|
||||||
MNOTE_WEB_BIND: "127.0.0.1:3000",
|
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||||
});
|
});
|
||||||
@@ -193,3 +192,11 @@ test("默认跳过 Celery,只有显式开启时才启动", () => {
|
|||||||
assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true);
|
assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true);
|
||||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false);
|
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
|
||||||
|
assert.equal(shouldStartBackend({}), false);
|
||||||
|
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1" }), true);
|
||||||
|
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "true" }), true);
|
||||||
|
assert.equal(shouldStartBackend({ BACKEND_CMD: "custom-backend" }), true);
|
||||||
|
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// 说明:
|
||||||
|
// - 验证 Rust 3000 主入口的 Convex 附件行打开 OnlyOffice 链路。
|
||||||
|
// - 脚本会创建临时页面、上传临时 docx、点击文件树附件行并断言新窗口打开 /onlyoffice。
|
||||||
|
// - 结束后清理临时页面,避免污染长期测试空间。
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
BASE_URL,
|
||||||
|
UI_TIMEOUT_MS,
|
||||||
|
assert,
|
||||||
|
createTempDocument,
|
||||||
|
ensureAuthenticated,
|
||||||
|
openDocument,
|
||||||
|
openFilesystemView,
|
||||||
|
purgeDocument,
|
||||||
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
|
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
const ONLYOFFICE_READY_TIMEOUT_MS = Number(process.env.MNOTE_ONLYOFFICE_READY_TIMEOUT_MS || 120_000);
|
||||||
|
const PROBE_DOCX_PATH =
|
||||||
|
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
|
||||||
|
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
|
||||||
|
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||||
|
const TEST_PASSWORD = "MnoteE2E123!";
|
||||||
|
let authCookieHeader = "";
|
||||||
|
const popupConsoleMessages = [];
|
||||||
|
const popupNetworkFailures = [];
|
||||||
|
|
||||||
|
function parseSetCookie(setCookie, origin) {
|
||||||
|
const [nameValue] = String(setCookie || "").split(";");
|
||||||
|
const separator = nameValue.indexOf("=");
|
||||||
|
if (separator <= 0) return null;
|
||||||
|
return {
|
||||||
|
name: nameValue.slice(0, separator).trim(),
|
||||||
|
value: nameValue.slice(separator + 1).trim(),
|
||||||
|
domain: new URL(origin).hostname,
|
||||||
|
path: "/",
|
||||||
|
httpOnly: /;\s*httponly\b/i.test(setCookie),
|
||||||
|
sameSite: "Lax",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceTestAccountLogin(context) {
|
||||||
|
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
action: "auth:signIn",
|
||||||
|
args: {
|
||||||
|
provider: "password",
|
||||||
|
params: {
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
flow: "signIn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
|
||||||
|
assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`);
|
||||||
|
|
||||||
|
const cookies = response
|
||||||
|
.headersArray()
|
||||||
|
.filter((header) => header.name.toLowerCase() === "set-cookie")
|
||||||
|
.map((header) => parseSetCookie(header.value, BASE_URL))
|
||||||
|
.filter(Boolean);
|
||||||
|
assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie");
|
||||||
|
await context.addCookies(cookies);
|
||||||
|
authCookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadProbeDocx(requestContext, target) {
|
||||||
|
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`);
|
||||||
|
const buffer = fs.readFileSync(PROBE_DOCX_PATH);
|
||||||
|
const response = await requestContext.fetch(`${BASE_URL}/api/media/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: authCookieHeader ? { cookie: authCookieHeader } : undefined,
|
||||||
|
multipart: {
|
||||||
|
file: {
|
||||||
|
name: "task174-onlyoffice-attachment.docx",
|
||||||
|
mimeType: DOCX_MIME,
|
||||||
|
buffer,
|
||||||
|
},
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
documentId: target.documentId,
|
||||||
|
},
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
|
||||||
|
assert(
|
||||||
|
response.ok(),
|
||||||
|
`/api/media/upload 请求失败:${response.status()} ${JSON.stringify(payload)}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
payload && payload.asset && typeof payload.asset.id === "string",
|
||||||
|
`上传结果缺少 asset.id:${JSON.stringify(payload)}`,
|
||||||
|
);
|
||||||
|
return payload.asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAssetRow(page, assetId) {
|
||||||
|
const selector = `[data-testid="filetree-asset-row"][data-asset-id="${assetId}"]`;
|
||||||
|
const row = page.locator(selector).first();
|
||||||
|
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForOnlyOfficeReady(page) {
|
||||||
|
page.on("console", (message) => {
|
||||||
|
popupConsoleMessages.push({
|
||||||
|
type: message.type(),
|
||||||
|
text: message.text().slice(0, 1000),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
page.on("pageerror", (error) => {
|
||||||
|
popupConsoleMessages.push({
|
||||||
|
type: "pageerror",
|
||||||
|
text: error.message.slice(0, 1000),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
page.on("response", (response) => {
|
||||||
|
if (response.status() >= 400) {
|
||||||
|
popupNetworkFailures.push({
|
||||||
|
status: response.status(),
|
||||||
|
url: response.url().slice(0, 1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__MNOTE_ONLYOFFICE_READY__ === true,
|
||||||
|
undefined,
|
||||||
|
{ timeout: ONLYOFFICE_READY_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const root = document.getElementById("onlyoffice-frame");
|
||||||
|
if (root && root.querySelector("iframe,canvas")) return true;
|
||||||
|
return Boolean(document.querySelector("iframe,canvas"));
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: ONLYOFFICE_READY_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const debug = await page.evaluate(() => ({
|
||||||
|
url: window.location.href,
|
||||||
|
title: document.title,
|
||||||
|
readyState: document.readyState,
|
||||||
|
ready: window.__MNOTE_ONLYOFFICE_READY__ === true,
|
||||||
|
debug: window.__MNOTE_ONLYOFFICE_DEBUG__ || null,
|
||||||
|
errors: window.__MNOTE_ONLYOFFICE_ERRLOG__ || [],
|
||||||
|
hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")),
|
||||||
|
bodyText: document.body ? document.body.innerText.slice(0, 800) : "",
|
||||||
|
html: document.body ? document.body.innerHTML.slice(0, 1600) : "",
|
||||||
|
scriptCount: document.scripts.length,
|
||||||
|
frameCount: document.querySelectorAll("iframe,canvas").length,
|
||||||
|
hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")),
|
||||||
|
})).catch((debugError) => ({
|
||||||
|
evaluateError: debugError instanceof Error ? debugError.message : String(debugError),
|
||||||
|
isClosed: page.isClosed(),
|
||||||
|
}));
|
||||||
|
debug.consoleMessages = popupConsoleMessages.slice(-20);
|
||||||
|
debug.networkFailures = popupNetworkFailures.slice(-30);
|
||||||
|
throw new Error(
|
||||||
|
`OnlyOffice ready 超时:${error instanceof Error ? error.message : String(error)} ${JSON.stringify(debug)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOnlyOfficeDebug(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const debug = window.__MNOTE_ONLYOFFICE_DEBUG__ || null;
|
||||||
|
return {
|
||||||
|
debug,
|
||||||
|
docKey: debug && typeof debug.docKey === "string" ? debug.docKey : "",
|
||||||
|
ready: window.__MNOTE_ONLYOFFICE_READY__ === true,
|
||||||
|
frameCount: document.querySelectorAll("iframe,canvas").length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
let target = null;
|
||||||
|
try {
|
||||||
|
await forceTestAccountLogin(context);
|
||||||
|
await ensureAuthenticated(page, context.request);
|
||||||
|
target = await createTempDocument(context.request, null);
|
||||||
|
const asset = await uploadProbeDocx(context.request, target);
|
||||||
|
|
||||||
|
await openDocument(page, target.workspaceId, target.documentId);
|
||||||
|
await openFilesystemView(page);
|
||||||
|
const row = await waitForAssetRow(page, asset.id);
|
||||||
|
|
||||||
|
const [popup] = await Promise.all([
|
||||||
|
page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }),
|
||||||
|
row.locator('[data-rust-action="open"]').first().click({ timeout: UI_TIMEOUT_MS }),
|
||||||
|
]);
|
||||||
|
await popup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||||
|
|
||||||
|
const opened = new URL(popup.url());
|
||||||
|
assert(opened.pathname === "/onlyoffice", `附件应在新窗口打开 /onlyoffice,实际为:${popup.url()}`);
|
||||||
|
assert(opened.searchParams.get("assetId") === asset.id, "OnlyOffice URL 缺少正确 assetId");
|
||||||
|
assert(
|
||||||
|
opened.searchParams.get("documentId") === target.documentId,
|
||||||
|
"OnlyOffice URL 缺少正确 documentId",
|
||||||
|
);
|
||||||
|
assert(opened.searchParams.get("fileType") === "docx", "OnlyOffice URL fileType 应为 docx");
|
||||||
|
assert(opened.searchParams.get("mode") === "edit", "OnlyOffice URL mode 应为 edit");
|
||||||
|
await waitForOnlyOfficeReady(popup);
|
||||||
|
const firstDebug = await readOnlyOfficeDebug(popup);
|
||||||
|
assert(firstDebug.ready, "OnlyOffice ready flag 应为 true");
|
||||||
|
assert(firstDebug.frameCount > 0, "OnlyOffice 应创建 iframe/canvas");
|
||||||
|
assert(
|
||||||
|
/^[0-9A-Za-z_.=-]{1,128}$/.test(firstDebug.docKey),
|
||||||
|
`OnlyOffice document.key 不符合安全字符集或长度:${firstDebug.docKey}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
firstDebug.docKey === asset.id || firstDebug.docKey.startsWith(`${asset.id}_`),
|
||||||
|
`OnlyOffice document.key 应绑定 assetId,实际为:${firstDebug.docKey}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reopened = await context.newPage();
|
||||||
|
await reopened.goto(popup.url(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await waitForOnlyOfficeReady(reopened);
|
||||||
|
const secondDebug = await readOnlyOfficeDebug(reopened);
|
||||||
|
assert(
|
||||||
|
secondDebug.docKey === firstDebug.docKey,
|
||||||
|
`同一附件重复打开 document.key 应稳定:first=${firstDebug.docKey} second=${secondDebug.docKey}`,
|
||||||
|
);
|
||||||
|
await reopened.close().catch(() => undefined);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
documentId: target.documentId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
assetId: asset.id,
|
||||||
|
openedUrl: popup.url(),
|
||||||
|
docKey: firstDebug.docKey,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (target && target.documentId) {
|
||||||
|
await purgeDocument(context.request, target.documentId).catch((error) => {
|
||||||
|
console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
BASE_URL,
|
||||||
|
UI_TIMEOUT_MS,
|
||||||
|
assert,
|
||||||
|
createTempDocument,
|
||||||
|
ensureAuthenticated,
|
||||||
|
openDocument,
|
||||||
|
openFilesystemView,
|
||||||
|
purgeDocument,
|
||||||
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
|
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
const PNG_MIME = "image/png";
|
||||||
|
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||||
|
const TEST_PASSWORD = "MnoteE2E123!";
|
||||||
|
const PROBE_DOCX_PATH =
|
||||||
|
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
|
||||||
|
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
|
||||||
|
const SCREENSHOT_DIR =
|
||||||
|
process.env.MNOTE_UPLOAD_ENTRY_SCREENSHOT_DIR ||
|
||||||
|
"/mnt/Data1T/mnote/tmp/wolai-editor-parity/task175-rust-upload-entry";
|
||||||
|
|
||||||
|
function ensureProbeDocx() {
|
||||||
|
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少 Office 探测文件:${PROBE_DOCX_PATH}`);
|
||||||
|
return {
|
||||||
|
name: "task175-slash-attachment.docx",
|
||||||
|
mimeType: DOCX_MIME,
|
||||||
|
buffer: fs.readFileSync(PROBE_DOCX_PATH),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tinyPngBuffer() {
|
||||||
|
return Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSetCookie(setCookie, origin) {
|
||||||
|
const [nameValue] = String(setCookie || "").split(";");
|
||||||
|
const separator = nameValue.indexOf("=");
|
||||||
|
if (separator <= 0) return null;
|
||||||
|
return {
|
||||||
|
name: nameValue.slice(0, separator).trim(),
|
||||||
|
value: nameValue.slice(separator + 1).trim(),
|
||||||
|
domain: new URL(origin).hostname,
|
||||||
|
path: "/",
|
||||||
|
httpOnly: /;\s*httponly\b/i.test(setCookie),
|
||||||
|
sameSite: "Lax",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceTestAccountLogin(context) {
|
||||||
|
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
action: "auth:signIn",
|
||||||
|
args: {
|
||||||
|
provider: "password",
|
||||||
|
params: {
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
flow: "signIn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
|
||||||
|
assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`);
|
||||||
|
const cookies = response
|
||||||
|
.headersArray()
|
||||||
|
.filter((header) => header.name.toLowerCase() === "set-cookie")
|
||||||
|
.map((header) => parseSetCookie(header.value, BASE_URL))
|
||||||
|
.filter(Boolean);
|
||||||
|
assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie");
|
||||||
|
await context.addCookies(cookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRuntimeEditor(page) {
|
||||||
|
const editor = page
|
||||||
|
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||||
|
.first();
|
||||||
|
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForUploadResponse(page, fileName, action) {
|
||||||
|
const response = await page.waitForResponse(
|
||||||
|
async (candidate) => {
|
||||||
|
if (!candidate.url().includes("/api/media/upload") || candidate.request().method() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const payload = await candidate.json().catch(() => null);
|
||||||
|
return Boolean(payload?.asset?.id && (!fileName || payload.asset.file_name === fileName));
|
||||||
|
},
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
assert(response.ok(), `${action} 上传失败:${response.status()} ${JSON.stringify(payload)}`);
|
||||||
|
assert(payload?.asset?.id, `${action} 上传响应缺少 asset.id:${JSON.stringify(payload)}`);
|
||||||
|
return payload.asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAssetRow(page, assetId, action) {
|
||||||
|
try {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(targetAssetId) =>
|
||||||
|
Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).some(
|
||||||
|
(row) =>
|
||||||
|
row instanceof HTMLElement &&
|
||||||
|
window.getComputedStyle(row).display !== "none" &&
|
||||||
|
window.getComputedStyle(row).visibility !== "hidden" &&
|
||||||
|
row.getClientRects().length > 0,
|
||||||
|
),
|
||||||
|
assetId,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const debug = await page.evaluate((targetAssetId) => ({
|
||||||
|
mode: document.documentElement.getAttribute("data-mnote-sidebar-tree-mode"),
|
||||||
|
lastUpload: document.documentElement.getAttribute("data-mnote-last-upload-asset-id"),
|
||||||
|
targetCount: document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`).length,
|
||||||
|
assets: Array.from(document.querySelectorAll('[data-testid="filetree-asset-row"]')).slice(-12).map((row) => ({
|
||||||
|
id: row.getAttribute("data-asset-id"),
|
||||||
|
visible: row instanceof HTMLElement && window.getComputedStyle(row).display !== "none" && window.getComputedStyle(row).visibility !== "hidden" && row.getClientRects().length > 0,
|
||||||
|
text: row.textContent,
|
||||||
|
})),
|
||||||
|
body: document.body.innerText.slice(0, 1200),
|
||||||
|
}), assetId).catch((debugError) => ({ debugError: String(debugError) }));
|
||||||
|
throw new Error(`${action} 上传后的文件树附件行不可见:${assetId} ${JSON.stringify(debug)}`);
|
||||||
|
}
|
||||||
|
const title = await page.evaluate((targetAssetId) => {
|
||||||
|
const row = Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate instanceof HTMLElement &&
|
||||||
|
window.getComputedStyle(candidate).display !== "none" &&
|
||||||
|
window.getComputedStyle(candidate).visibility !== "hidden" &&
|
||||||
|
candidate.getClientRects().length > 0,
|
||||||
|
);
|
||||||
|
return row?.querySelector(".tree-link-title")?.textContent?.trim() || "";
|
||||||
|
}, assetId);
|
||||||
|
assert(title.length > 0, `${action} 上传后的文件树附件行缺少标题`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForEditorOfficeAttachment(page, assetId, fileName, action) {
|
||||||
|
const attachment = page
|
||||||
|
.locator(`.editor-surface .ProseMirror a[data-mnote-attachment-link="true"][href*="/onlyoffice"][href*="${assetId}"]`)
|
||||||
|
.first();
|
||||||
|
await attachment.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
const text = (await attachment.innerText()).trim();
|
||||||
|
assert(text.includes(fileName), `${action} 正文附件标题不正确:${text}`);
|
||||||
|
const href = await attachment.getAttribute("href");
|
||||||
|
assert(href && href.includes("/onlyoffice?"), `${action} 正文 Office 附件 href 应指向 /onlyoffice,实际:${href}`);
|
||||||
|
assert(
|
||||||
|
href && href.startsWith("/onlyoffice?"),
|
||||||
|
`${action} 正文 Office 附件 href 应保存为相对 /onlyoffice 链接,实际:${href}`,
|
||||||
|
);
|
||||||
|
assert(href.includes(`assetId=${encodeURIComponent(assetId)}`), `${action} 正文 Office 附件 href 缺少 assetId:${href}`);
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertAttachmentActions(page, attachment, action) {
|
||||||
|
await attachment.hover({ timeout: UI_TIMEOUT_MS });
|
||||||
|
const actions = page.locator('[data-testid="mnote-attachment-actions"]').first();
|
||||||
|
await actions.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await actions.locator('[data-testid="mnote-attachment-action-menu"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
const menu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="attachment"]').first();
|
||||||
|
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
const text = await menu.innerText();
|
||||||
|
for (const label of ["拷贝副本", "删除", "复制链接", "弹窗预览", "右侧预览", "下载", "更换文件", "重命名", "添加说明文字"]) {
|
||||||
|
assert(text.includes(label), `${action} 附件三点菜单缺少“${label}”:${text}`);
|
||||||
|
}
|
||||||
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function screenshot(page, name) {
|
||||||
|
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(SCREENSHOT_DIR, `${name}.png`),
|
||||||
|
fullPage: false,
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadViaSlash(page, itemTestId, filePayload, action) {
|
||||||
|
const editor = await waitForRuntimeEditor(page);
|
||||||
|
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.keyboard.type("/");
|
||||||
|
const slash = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
|
||||||
|
await slash.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
const slashText = await slash.innerText();
|
||||||
|
assert(slashText.includes("媒体与附件"), `slash 菜单缺少媒体与附件分组:${slashText}`);
|
||||||
|
|
||||||
|
const item = page.locator(`[data-testid="${itemTestId}"]`).first();
|
||||||
|
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await item.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await screenshot(page, `slash-${itemTestId}`);
|
||||||
|
|
||||||
|
const [fileChooser] = await Promise.all([
|
||||||
|
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
|
||||||
|
item.click({ timeout: UI_TIMEOUT_MS }),
|
||||||
|
]);
|
||||||
|
const uploadResponse = waitForUploadResponse(page, filePayload.name, action);
|
||||||
|
await fileChooser.setFiles(filePayload);
|
||||||
|
const asset = await uploadResponse;
|
||||||
|
await waitForAssetRow(page, asset.id, action);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchFileDrop(page, selector, filePayload, action) {
|
||||||
|
const uploadPromise = waitForUploadResponse(page, filePayload.name, action);
|
||||||
|
await page.evaluate(
|
||||||
|
({ selector, fileName, mimeType, bytes }) => {
|
||||||
|
const target = Array.from(document.querySelectorAll(selector)).find(
|
||||||
|
(candidate) => candidate instanceof HTMLElement && candidate.getClientRects().length > 0,
|
||||||
|
) || document.querySelector(selector);
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
throw new Error(`拖放目标不存在:${selector}`);
|
||||||
|
}
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
|
||||||
|
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
||||||
|
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector,
|
||||||
|
fileName: filePayload.name,
|
||||||
|
mimeType: filePayload.mimeType,
|
||||||
|
bytes: Array.from(filePayload.buffer),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const asset = await uploadPromise;
|
||||||
|
await waitForAssetRow(page, asset.id, action);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const networkNotes = [];
|
||||||
|
page.on("response", async (response) => {
|
||||||
|
if (!/\/api\/(media\/upload|tree\/filetree\/upload-target-preflight)/.test(response.url())) return;
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
networkNotes.push({
|
||||||
|
status: response.status(),
|
||||||
|
url: response.url(),
|
||||||
|
body: text.slice(0, 1000),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
page.on("pageerror", (error) => {
|
||||||
|
networkNotes.push({ type: "pageerror", message: error.message });
|
||||||
|
});
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (["error", "warning"].includes(message.type())) {
|
||||||
|
networkNotes.push({ type: message.type(), message: message.text().slice(0, 1000) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on("dialog", async (dialog) => {
|
||||||
|
networkNotes.push({ type: "dialog", message: dialog.message() });
|
||||||
|
await dialog.dismiss().catch(() => undefined);
|
||||||
|
});
|
||||||
|
let target = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await forceTestAccountLogin(context);
|
||||||
|
await ensureAuthenticated(page, context.request);
|
||||||
|
target = await createTempDocument(context.request, null);
|
||||||
|
await openDocument(page, target.workspaceId, target.documentId);
|
||||||
|
await openFilesystemView(page);
|
||||||
|
|
||||||
|
const slashAttachment = ensureProbeDocx();
|
||||||
|
const slashImage = {
|
||||||
|
name: "task175-slash-image.png",
|
||||||
|
mimeType: PNG_MIME,
|
||||||
|
buffer: tinyPngBuffer(),
|
||||||
|
};
|
||||||
|
const treeDropAttachment = {
|
||||||
|
name: "task175-filetree-drop.docx",
|
||||||
|
mimeType: DOCX_MIME,
|
||||||
|
buffer: slashAttachment.buffer,
|
||||||
|
};
|
||||||
|
const editorDropImage = {
|
||||||
|
name: "task175-editor-drop.png",
|
||||||
|
mimeType: PNG_MIME,
|
||||||
|
buffer: tinyPngBuffer(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachmentAsset = await uploadViaSlash(
|
||||||
|
page,
|
||||||
|
"slash-item-upload-attachment",
|
||||||
|
slashAttachment,
|
||||||
|
"slash 上传附件",
|
||||||
|
);
|
||||||
|
const editorAttachment = await waitForEditorOfficeAttachment(
|
||||||
|
page,
|
||||||
|
attachmentAsset.id,
|
||||||
|
slashAttachment.name,
|
||||||
|
"slash 上传附件",
|
||||||
|
);
|
||||||
|
await assertAttachmentActions(page, editorAttachment, "slash 上传附件");
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await waitForRuntimeEditor(page);
|
||||||
|
await waitForEditorOfficeAttachment(page, attachmentAsset.id, slashAttachment.name, "刷新后正文附件");
|
||||||
|
await openFilesystemView(page);
|
||||||
|
const imageAsset = await uploadViaSlash(page, "slash-item-image", slashImage, "slash 上传图片");
|
||||||
|
|
||||||
|
const docRowSelector = `[data-testid="filetree-doc-row"][data-document-id="${target.documentId}"]`;
|
||||||
|
const droppedAttachment = await dispatchFileDrop(page, docRowSelector, treeDropAttachment, "文件树拖入附件");
|
||||||
|
|
||||||
|
const editorStageSelector = '[data-testid="mnote-leptos-tiptap-editor-stage"]';
|
||||||
|
const droppedImage = await dispatchFileDrop(page, editorStageSelector, editorDropImage, "主编辑区拖入图片");
|
||||||
|
await page.waitForFunction(
|
||||||
|
(fileName) => {
|
||||||
|
const images = Array.from(document.querySelectorAll(".editor-surface .ProseMirror img[src]"));
|
||||||
|
return images.some((image) => image.getAttribute("alt") === fileName || image.getAttribute("title") === fileName);
|
||||||
|
},
|
||||||
|
editorDropImage.name,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
documentId: target.documentId,
|
||||||
|
uploadedAssetIds: [
|
||||||
|
attachmentAsset.id,
|
||||||
|
imageAsset.id,
|
||||||
|
droppedAttachment.id,
|
||||||
|
droppedImage.id,
|
||||||
|
],
|
||||||
|
screenshotDir: SCREENSHOT_DIR,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (networkNotes.length) {
|
||||||
|
console.error(`上传入口调试信息:${JSON.stringify(networkNotes.slice(-20), null, 2)}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (target?.documentId) {
|
||||||
|
await purgeDocument(context.request, target.documentId).catch((error) => {
|
||||||
|
console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import Script from "next/script";
|
|
||||||
import OnlyOfficeClientPage from "./OnlyOfficeClientPage";
|
import OnlyOfficeClientPage from "./OnlyOfficeClientPage";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -105,9 +104,8 @@ export default function OnlyOfficePage() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Script
|
<script
|
||||||
id="mnote-onlyoffice-earlypatch"
|
id="mnote-onlyoffice-earlypatch"
|
||||||
strategy="beforeInteractive"
|
|
||||||
dangerouslySetInnerHTML={{ __html: ONLYOFFICE_EARLY_PATCH }}
|
dangerouslySetInnerHTML={{ __html: ONLYOFFICE_EARLY_PATCH }}
|
||||||
/>
|
/>
|
||||||
<OnlyOfficeClientPage />
|
<OnlyOfficeClientPage />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildOnlyOfficeAssetOpenUrl,
|
||||||
buildOnlyOfficeCallbackUrl,
|
buildOnlyOfficeCallbackUrl,
|
||||||
buildOnlyOfficeForcesaveUrl,
|
buildOnlyOfficeForcesaveUrl,
|
||||||
buildOnlyOfficeOpenFileId,
|
buildOnlyOfficeOpenFileId,
|
||||||
docTypeFromExt,
|
docTypeFromExt,
|
||||||
|
inferOnlyOfficeFileType,
|
||||||
resolveOnlyOfficeDocumentUrl,
|
resolveOnlyOfficeDocumentUrl,
|
||||||
} from "@/lib/onlyoffice/client-session";
|
} from "@/lib/onlyoffice/client-session";
|
||||||
|
|
||||||
@@ -15,6 +17,29 @@ describe("onlyoffice client session helpers", () => {
|
|||||||
expect(docTypeFromExt("pdf")).toBe("pdf");
|
expect(docTypeFromExt("pdf")).toBe("pdf");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("inferOnlyOfficeFileType detects Office assets from file name and MIME", () => {
|
||||||
|
expect(inferOnlyOfficeFileType("demo.pptx", null)).toBe("pptx");
|
||||||
|
expect(inferOnlyOfficeFileType("demo", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe("docx");
|
||||||
|
expect(inferOnlyOfficeFileType("demo", "application/pdf")).toBe("pdf");
|
||||||
|
expect(inferOnlyOfficeFileType("demo.png", "image/png")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildOnlyOfficeAssetOpenUrl keeps attachment identity for callback writeback", () => {
|
||||||
|
const result = buildOnlyOfficeAssetOpenUrl({
|
||||||
|
origin: "https://mnote.example.com",
|
||||||
|
fileUrl: "https://storage.example.com/demo.pptx?token=abc",
|
||||||
|
fileName: "2023自我介绍PPT_李爱波0831.pptx",
|
||||||
|
fileType: "pptx",
|
||||||
|
assetId: "asset_ppt_1",
|
||||||
|
documentId: "doc_1",
|
||||||
|
mode: "edit",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
"https://mnote.example.com/onlyoffice?fileUrl=https%3A%2F%2Fstorage.example.com%2Fdemo.pptx%3Ftoken%3Dabc&fileName=2023%E8%87%AA%E6%88%91%E4%BB%8B%E7%BB%8DPPT_%E6%9D%8E%E7%88%B1%E6%B3%A20831.pptx&fileType=pptx&assetId=asset_ppt_1&documentId=doc_1&mode=edit",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("resolveOnlyOfficeDocumentUrl wraps signed url with proxy", () => {
|
it("resolveOnlyOfficeDocumentUrl wraps signed url with proxy", () => {
|
||||||
const result = resolveOnlyOfficeDocumentUrl({
|
const result = resolveOnlyOfficeDocumentUrl({
|
||||||
effectiveFileUrl: "https://public.example.com/storage/v1/object/sign/documents/a.docx?token=abc",
|
effectiveFileUrl: "https://public.example.com/storage/v1/object/sign/documents/a.docx?token=abc",
|
||||||
|
|||||||
@@ -30,6 +30,40 @@ export const docTypeFromExt = (ext: string) => {
|
|||||||
return "word";
|
return "word";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const inferOnlyOfficeFileType = (fileName: string | null | undefined, mimeType: string | null | undefined) => {
|
||||||
|
const name = (fileName ?? "").trim().toLowerCase();
|
||||||
|
const mt = (mimeType ?? "").trim().toLowerCase();
|
||||||
|
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||||
|
if (["doc", "docx", "odt", "rtf"].includes(ext)) return ext;
|
||||||
|
if (["ppt", "pptx", "odp"].includes(ext)) return ext;
|
||||||
|
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return ext;
|
||||||
|
if (ext === "pdf") return ext;
|
||||||
|
if (mt.includes("wordprocessingml")) return "docx";
|
||||||
|
if (mt.includes("presentationml")) return "pptx";
|
||||||
|
if (mt.includes("spreadsheetml")) return "xlsx";
|
||||||
|
if (mt.includes("pdf")) return "pdf";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildOnlyOfficeAssetOpenUrl(input: {
|
||||||
|
origin: string;
|
||||||
|
fileUrl: string;
|
||||||
|
fileName: string;
|
||||||
|
fileType: string;
|
||||||
|
assetId?: string | null;
|
||||||
|
documentId?: string | null;
|
||||||
|
mode?: "view" | "edit";
|
||||||
|
}): string {
|
||||||
|
const target = new URL("/onlyoffice", input.origin);
|
||||||
|
target.searchParams.set("fileUrl", input.fileUrl);
|
||||||
|
target.searchParams.set("fileName", input.fileName);
|
||||||
|
target.searchParams.set("fileType", input.fileType);
|
||||||
|
if (input.assetId) target.searchParams.set("assetId", input.assetId);
|
||||||
|
if (input.documentId) target.searchParams.set("documentId", input.documentId);
|
||||||
|
target.searchParams.set("mode", input.mode ?? "edit");
|
||||||
|
return target.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveOnlyOfficeDocumentUrl(input: {
|
export function resolveOnlyOfficeDocumentUrl(input: {
|
||||||
effectiveFileUrl: string;
|
effectiveFileUrl: string;
|
||||||
proxyOrigin: string;
|
proxyOrigin: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user