feat: restore Wolai workspace navigation and assets

This commit is contained in:
Agent Board
2026-07-15 23:12:15 +08:00
parent 31a160fcc5
commit 6f9c7d3b58
48 changed files with 6734 additions and 517 deletions
@@ -67,8 +67,8 @@ export interface InitOutput {
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__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45: (a: number, b: number) => void;
@@ -33,11 +33,12 @@ import { register_text_style } from './snippets/leptos-tiptap-c355e6ec24c3df4c/s
import { register_toc_node } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_toc_node.js';
import { register_underline } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js';
import { register_youtube } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js';
import { find_mnote_block_anchor, write_mnote_text_to_clipboard } from './snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js';
import { download_mnote_image_url, find_mnote_block_anchor, open_mnote_page_block_href, write_mnote_text_to_clipboard } from './snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js';
import * as import1 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import2 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import3 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
import * as import3 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import4 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
import * as import5 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
export class IntoUnderlyingByteSource {
@@ -314,8 +315,13 @@ function __wbg_get_imports() {
const ret = arg0.anchorNode;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_assign_d4fed0f8abb71719: function() { return handleError(function (arg0, arg1, arg2) {
arg0.assign(getStringFromWasm0(arg1, arg2));
__wbg_appendChild_e95c8b3b936d250a: function() { return handleError(function (arg0, arg1) {
const ret = arg0.appendChild(arg1);
return ret;
}, arguments); },
__wbg_attachShadow_a3865128b06993cf: function() { return handleError(function (arg0, arg1) {
const ret = arg0.attachShadow(arg1);
return ret;
}, arguments); },
__wbg_blur_583010b6b4026c5d: function() { return handleError(function (arg0) {
arg0.blur();
@@ -482,6 +488,10 @@ function __wbg_get_imports() {
const ret = arg0.done;
return ret;
},
__wbg_download_mnote_image_url_f3cfdff8b9b428d4: function(arg0, arg1, arg2, arg3) {
const ret = download_mnote_image_url(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
return ret;
},
__wbg_elementFromPoint_69f15053cea2ece4: function(arg0, arg1, arg2) {
const ret = arg0.elementFromPoint(arg1, arg2);
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
@@ -738,6 +748,10 @@ function __wbg_get_imports() {
const ret = arg0.item(arg1 >>> 0);
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_item_aa5667bdc8374f43: function(arg0, arg1) {
const ret = arg0.item(arg1 >>> 0);
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_iterator_013bc09ec998c2a7: function() {
const ret = Symbol.iterator;
return ret;
@@ -769,6 +783,10 @@ function __wbg_get_imports() {
const ret = arg0.length;
return ret;
},
__wbg_length_c157a50ce35f2f72: function(arg0) {
const ret = arg0.length;
return ret;
},
__wbg_localStorage_f5f66b1ffd2486bc: function() { return handleError(function (arg0) {
const ret = arg0.localStorage;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
@@ -847,6 +865,10 @@ function __wbg_get_imports() {
const ret = arg0.ok;
return ret;
},
__wbg_open_mnote_page_block_href_a512603dfc2e4bad: function(arg0, arg1) {
const ret = open_mnote_page_block_href(getStringFromWasm0(arg0, arg1));
return ret;
},
__wbg_origin_1f038926109a2a37: function() { return handleError(function (arg0, arg1) {
const ret = arg1.origin;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
@@ -882,6 +904,10 @@ function __wbg_get_imports() {
__wbg_prototypesetcall_a6b02eb00b0f4ce2: function(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
},
__wbg_querySelectorAll_e9e3fbd41310476e: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.querySelectorAll(getStringFromWasm0(arg1, arg2));
return ret;
}, arguments); },
__wbg_querySelector_12b6c7cdf26a3483: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.querySelector(getStringFromWasm0(arg1, arg2));
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
@@ -1006,6 +1032,10 @@ function __wbg_get_imports() {
__wbg_removeAttribute_c75ac657c944b3f1: function() { return handleError(function (arg0, arg1, arg2) {
arg0.removeAttribute(getStringFromWasm0(arg1, arg2));
}, arguments); },
__wbg_removeChild_172df530ec85cc8a: function() { return handleError(function (arg0, arg1) {
const ret = arg0.removeChild(arg1);
return ret;
}, arguments); },
__wbg_removeEventListener_0069d57d090a1674: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) {
arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3, arg4 !== 0);
}, arguments); },
@@ -1113,6 +1143,9 @@ function __wbg_get_imports() {
__wbg_set_method_c9f1f985f6b6c427: function(arg0, arg1, arg2) {
arg0.method = getStringFromWasm0(arg1, arg2);
},
__wbg_set_mode_43ec0c73d11c9dba: function(arg0, arg1) {
arg0.mode = __wbindgen_enum_ShadowRootMode[arg1];
},
__wbg_set_mode_5e08d503428c06b9: function(arg0, arg1) {
arg0.mode = __wbindgen_enum_RequestMode[arg1];
},
@@ -1232,37 +1265,37 @@ function __wbg_get_imports() {
}
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1139, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1191, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 993, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1062, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1209, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1261, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1139, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1209, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 701, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 800, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1108, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1178, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1142, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5);
return ret;
},
@@ -1301,8 +1334,9 @@ function __wbg_get_imports() {
"./mnote-leptos-tiptap-spike-island_bg.js": import0,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import1,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import2,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import3,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import3,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import4,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import5,
};
}
@@ -1314,14 +1348,14 @@ function wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5(arg0, arg
wasm.wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3(arg0, arg1, arg2);
}
@@ -1346,6 +1380,9 @@ const __wbindgen_enum_ReadableStreamType = ["bytes"];
const __wbindgen_enum_RequestMode = ["same-origin", "no-cors", "cors", "navigate"];
const __wbindgen_enum_ShadowRootMode = ["open", "closed"];
const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr >>> 0, 1));
@@ -20,8 +20,8 @@ export const intounderlyingsource_pull: (a: number, b: any) => any;
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__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45: (a: number, b: number) => void;
@@ -74,6 +74,71 @@ export function download_mnote_selected_image() {
}
}
export function download_mnote_image_url(src, filenameHint) {
try {
if (!src) return false;
const url = new URL(String(src), window.location.href);
if (!(url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'data:')) return false;
const link = document.createElement('a');
link.href = url.href;
link.download = mnoteImageFilename({ getAttribute: (name) => name === 'title' || name === 'alt' ? String(filenameHint || '') : '' }, url.href);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
return true;
} catch (error) {
console.warn('mnote image preview download failed', error);
return false;
}
}
export function request_mnote_image_preview_fullscreen() {
try {
const target = document.querySelector('[data-testid="image-preview-stage"]')
|| document.querySelector('[data-testid="image-preview-dialog"]');
const request = target?.requestFullscreen || target?.webkitRequestFullscreen || target?.mozRequestFullScreen || target?.msRequestFullscreen;
if (!target || typeof request !== 'function') return false;
const result = request.call(target);
if (result?.catch) result.catch(() => {});
return true;
} catch (error) {
console.warn('mnote image preview fullscreen failed', error);
return false;
}
}
export function open_mnote_page_block_href(href) {
try {
const target = new URL(String(href || ''), window.location.href);
if (target.origin === window.location.origin && target.pathname.startsWith('/documents/')) {
const encodedId = target.pathname.slice('/documents/'.length).split('/')[0] || '';
const documentId = decodeURIComponent(encodedId);
const runtime = window.__mnoteDocumentPaneRuntime;
if (documentId && runtime && typeof runtime.openPrimaryDocument === 'function') {
const result = runtime.openPrimaryDocument({
documentId,
workspaceId: target.searchParams.get('workspaceId') || '',
sourceKind: target.searchParams.get('sourceKind') || '',
rootUri: target.searchParams.get('rootUri') || '',
url: target,
pageBlockNavigation: true,
});
if (result && typeof result.catch === 'function') {
result.catch((error) => {
console.warn('mnote page block pane navigation failed', error);
});
}
return true;
}
}
return false;
} catch (error) {
console.warn('mnote page block navigation failed', error);
return false;
}
}
export function bump_mnote_e27_ai_bridge_request_count() {
try {
const key = '__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__';
@@ -586,6 +586,12 @@ struct TreeCommandResult {
parent_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct WorkspaceSourceContext {
source_kind: Option<String>,
root_uri: Option<String>,
}
fn dispatch_tree_local_command(body: &Value, result: &Value) {
let Some(win) = window() else {
return;
@@ -607,15 +613,16 @@ async fn create_child_page_via_tree_command(
parent_id: String,
workspace_id: Option<String>,
title: String,
source_kind: Option<String>,
root_uri: Option<String>,
) -> Result<TreeCommandResult, String> {
let body = json!({
"action": "create",
"workspaceId": workspace_id,
"parentId": parent_id,
"title": title,
"accessScope": "private",
"content": [],
});
let body = child_page_tree_command_body(
parent_id.clone(),
workspace_id,
title,
source_kind,
root_uri,
);
let body_string =
serde_json::to_string(&body).map_err(|err| format!("序列化创建子页面请求失败:{err}"))?;
@@ -706,17 +713,38 @@ fn block_plain_text_from_index(
Ok(normalized_page_block_title(&collect_plain_text(block)))
}
fn page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, title: &str) -> Value {
let href = if page_id.trim().is_empty() {
"#".to_string()
} else if let Some(workspace_id) = workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("/documents/{page_id}?workspaceId={workspace_id}")
} else {
format!("/documents/{page_id}")
};
fn child_page_tree_command_body(
parent_id: String,
workspace_id: Option<String>,
title: String,
source_kind: Option<String>,
root_uri: Option<String>,
) -> Value {
let mut body = json!({
"action": "create",
"workspaceId": workspace_id,
"parentId": parent_id,
"title": title,
"accessScope": "private",
"content": [],
});
if let Some(source_kind) = trimmed_optional(source_kind) {
body["sourceKind"] = Value::String(source_kind);
}
if let Some(root_uri) = trimmed_optional(root_uri) {
body["rootUri"] = Value::String(root_uri);
}
body
}
fn page_reference_paragraph_node(
page_id: &str,
workspace_id: Option<&str>,
title: &str,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Value {
let href = page_reference_href(page_id, workspace_id, source_kind, root_uri);
paragraph_node(vec![json!({
"type": "text",
"text": normalized_page_block_title(title),
@@ -732,12 +760,139 @@ fn page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, titl
})])
}
fn page_reference_href(
page_id: &str,
workspace_id: Option<&str>,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> String {
if page_id.trim().is_empty() {
"#".to_string()
} else if has_local_source_context(source_kind, root_uri) {
let mut params = Vec::new();
if let Some(source_kind) = trimmed_str(source_kind) {
params.push(format!(
"sourceKind={}",
percent_encode_query_component(source_kind)
));
}
if let Some(root_uri) = trimmed_str(root_uri) {
params.push(format!(
"rootUri={}",
percent_encode_query_component(root_uri)
));
}
params.push("treeView=filetree".to_string());
format!("/documents/{page_id}?{}", params.join("&"))
} else if let Some(workspace_id) = workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("/documents/{page_id}?workspaceId={workspace_id}")
} else {
format!("/documents/{page_id}")
}
}
fn current_workspace_source_context() -> WorkspaceSourceContext {
WorkspaceSourceContext {
source_kind: current_query_param_decoded("sourceKind")
.or_else(|| body_attribute("data-mnote-source-kind")),
root_uri: current_query_param_decoded("rootUri")
.or_else(|| body_attribute("data-mnote-root-uri")),
}
}
fn current_query_param_decoded(key: &str) -> Option<String> {
let search = window()?.location().search().ok()?;
query_param_from_search(&search, key).and_then(|value| percent_decode_query_component(&value))
}
fn query_param_from_search(search: &str, key: &str) -> Option<String> {
let query = search.trim_start_matches('?');
for pair in query.split('&') {
if pair.is_empty() {
continue;
}
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
if percent_decode_query_component(name).as_deref() == Some(key) {
return Some(value.to_string());
}
}
None
}
fn body_attribute(name: &str) -> Option<String> {
window()?
.document()?
.body()?
.get_attribute(name)
.and_then(|value| trimmed_optional(Some(value)))
}
fn has_local_source_context(source_kind: Option<&str>, root_uri: Option<&str>) -> bool {
trimmed_str(source_kind) == Some("local_folder") || trimmed_str(root_uri).is_some()
}
fn trimmed_optional(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn trimmed_str(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn percent_encode_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
let character = *byte as char;
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '~') {
encoded.push(character);
} else {
encoded.push('%');
encoded.push_str(&format!("{byte:02X}"));
}
}
encoded
}
fn percent_decode_query_component(value: &str) -> Option<String> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' => {
if index + 2 >= bytes.len() {
return None;
}
let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok()?;
decoded.push(u8::from_str_radix(hex, 16).ok()?);
index += 3;
}
b'+' => {
decoded.push(b' ');
index += 1;
}
byte => {
decoded.push(byte);
index += 1;
}
}
}
String::from_utf8(decoded).ok()
}
fn replace_block_with_page_reference(
editor: TiptapEditorHandle,
block_index: usize,
page_id: &str,
workspace_id: Option<&str>,
title: &str,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<(), String> {
let mut document = editor
.get_json()
@@ -746,7 +901,8 @@ fn replace_block_with_page_reference(
if block_index >= content.len() {
return Err(format!("找不到第 {} 个块", block_index + 1));
}
content[block_index] = page_reference_paragraph_node(page_id, workspace_id, title);
content[block_index] =
page_reference_paragraph_node(page_id, workspace_id, title, source_kind, root_uri);
let next_payload = document
.get("content")
.and_then(Value::as_array)
@@ -787,12 +943,15 @@ pub(crate) fn run_block_turn_into_page_action(
return;
};
let workspace_id_value = workspace_id.get_untracked();
let source_context = current_workspace_source_context();
set_command_feedback.set(format!("正在创建子页面:{page_title}"));
spawn_local(async move {
match create_child_page_via_tree_command(
parent_document_id.clone(),
workspace_id_value.clone(),
page_title.clone(),
source_context.source_kind.clone(),
source_context.root_uri.clone(),
)
.await
{
@@ -812,6 +971,8 @@ pub(crate) fn run_block_turn_into_page_action(
child_page_id,
effective_workspace_id,
effective_title,
source_context.source_kind.as_deref(),
source_context.root_uri.as_deref(),
) {
Ok(()) => {
set_block_menu_open.set(false);
@@ -840,6 +1001,46 @@ pub(crate) fn run_block_turn_into_page_action(
});
}
#[cfg(test)]
mod tests {
use super::{child_page_tree_command_body, page_reference_paragraph_node};
#[test]
fn child_page_tree_command_body_preserves_local_source_context() {
let body = child_page_tree_command_body(
"local-md:Parent~2FParent.md".to_string(),
Some("local:_tmp_mnote".to_string()),
"子页面".to_string(),
Some("local_folder".to_string()),
Some("file:///tmp/mnote".to_string()),
);
assert_eq!(body["action"], "create");
assert_eq!(body["sourceKind"], "local_folder");
assert_eq!(body["rootUri"], "file:///tmp/mnote");
assert_eq!(body["parentId"], "local-md:Parent~2FParent.md");
assert_eq!(body["title"], "子页面");
}
#[test]
fn page_reference_node_for_local_folder_keeps_openable_source_params() {
let node = page_reference_paragraph_node(
"local-md:Parent~2FChild~2FChild.md",
Some("local:_tmp_mnote"),
"Child",
Some("local_folder"),
Some("file:///tmp/mnote"),
);
let href = node["content"][0]["marks"][0]["attrs"]["href"]
.as_str()
.expect("href");
assert!(href.starts_with("/documents/local-md:Parent~2FChild~2FChild.md?"));
assert!(href.contains("sourceKind=local_folder"));
assert!(href.contains("rootUri=file%3A%2F%2F%2Ftmp%2Fmnote"));
}
}
/// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。
/// 返回 true 表示树发生了变更。
pub(crate) fn apply_block_delta_to_json(content: &mut Value, operations: &[Value]) -> bool {
@@ -1768,6 +1768,160 @@ pub(crate) const SPIKE_STYLE: &str = r#"
box-shadow: 0 0 0 5px rgba(255, 71, 71, 0.12);
}
.image-preview-dialog {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: rgba(0, 0, 0, 0.88);
color: #fff;
}
.image-preview-counter {
position: absolute;
top: 12px;
left: 14px;
font-size: 16px;
line-height: 1;
color: rgba(255, 255, 255, 0.9);
z-index: 2;
}
.image-preview-close {
position: absolute;
top: 4px;
right: 4px;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: rgba(0, 0, 0, 0.86);
color: #fff;
font-size: 28px;
line-height: 1;
cursor: pointer;
z-index: 3;
}
.image-preview-close:hover,
.image-preview-close:focus-visible {
background: rgba(32, 32, 32, 0.96);
outline: none;
}
.image-preview-stage {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 34px 74px 70px;
box-sizing: border-box;
overflow: hidden;
cursor: grab;
user-select: none;
touch-action: none;
}
.image-preview-stage:active {
cursor: grabbing;
}
.image-preview-image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
background: #fff;
transform-origin: center center;
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.24);
user-select: none;
will-change: transform;
pointer-events: none;
}
.image-preview-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 46px;
height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 38px;
line-height: 1;
cursor: pointer;
z-index: 2;
}
.image-preview-nav-prev {
left: 0;
}
.image-preview-nav-next {
right: 0;
}
.image-preview-nav:hover:not(:disabled),
.image-preview-nav:focus-visible:not(:disabled) {
background: rgba(0, 0, 0, 0.74);
outline: none;
}
.image-preview-nav:disabled {
opacity: 0.36;
cursor: not-allowed;
}
.image-preview-toolbar {
position: absolute;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 2px;
padding: 6px 9px;
border-radius: 4px;
background: rgba(112, 112, 112, 0.9);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
z-index: 3;
}
.image-preview-tool {
min-width: 32px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 3px;
background: transparent;
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.image-preview-tool-wide {
min-width: 46px;
}
.image-preview-tool:hover,
.image-preview-tool:focus-visible {
background: rgba(255, 255, 255, 0.18);
outline: none;
}
.editor-surface .ProseMirror .tiptap-table-of-contents-node {
display: block;
margin: 12px 0;
@@ -2255,42 +2409,44 @@ pub(crate) const SPIKE_STYLE: &str = r#"
}
.editor-surface .ProseMirror p:has(> a.mnote-page-block-link) {
width: 100%;
margin: 0 0 8px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 71, 71, 0.12);
margin: 1px 0 4px;
padding: 0;
background: transparent;
}
.editor-surface .ProseMirror a.mnote-page-block-link {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 7px;
max-width: 100%;
padding: 1px 4px;
min-height: 28px;
padding: 1px 3px;
border-radius: 4px;
color: #37352f;
font-weight: 600;
line-height: 1.45;
text-decoration: none;
text-decoration: underline;
text-decoration-color: rgba(55, 53, 47, 0.42);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
cursor: pointer;
}
.editor-surface .ProseMirror a.mnote-page-block-link::before {
content: "";
width: 14px;
height: 14px;
width: 16px;
height: 16px;
flex: 0 0 auto;
border: 1.4px solid #c8c5c0;
border-radius: 3px;
background: linear-gradient(180deg, #ffffff 0%, #f6f4f1 100%);
box-shadow: inset 0 -2px 0 rgba(55, 53, 47, 0.08);
border: 0;
border-radius: 0;
background: center / 16px 16px no-repeat url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.25 2.25h5.25L12.75 5.5v8.25h-8.5V2.25Z' stroke='%2337352f' stroke-width='1.25' stroke-linejoin='round'/%3E%3Cpath d='M9.5 2.25V5.5h3.25' stroke='%2337352f' stroke-width='1.25' stroke-linejoin='round'/%3E%3Cpath d='M6.25 8h4M6.25 10.25h3' stroke='%2337352f' stroke-width='1.1' stroke-linecap='round'/%3E%3C/svg%3E");
opacity: 0.86;
}
.editor-surface .ProseMirror a.mnote-page-block-link:hover {
color: #37352f;
background: rgba(55, 53, 47, 0.08);
text-decoration: none;
text-decoration-color: rgba(55, 53, 47, 0.68);
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row {
+472 -13
View File
@@ -22,12 +22,12 @@ use editor_runtime::block_dnd::{
drop_indicator_from_target as drop_indicator_from_target_runtime, element_within_handle_shell,
hovered_block_from_target as hovered_block_from_target_runtime,
};
use editor_runtime::block_hover_state::{
BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState,
};
use editor_runtime::block_handle_menu_view::{
block_handle_menu_view, BlockHandleMenuSignals, BlockHandleMenuViewProps,
};
use editor_runtime::block_hover_state::{
BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState,
};
use editor_runtime::block_menu_overlay;
use editor_runtime::block_transform::{
apply_block_delta_to_json, apply_highlight_color, apply_image_align, apply_text_align,
@@ -51,13 +51,13 @@ use editor_runtime::dom_selection::{
};
use editor_runtime::editor_focus::{active_editor_stage, body_has_focus, schedule_editor_focus};
use editor_runtime::mindmap_node_view::{mount_mindmap_shell_impl, unmount_mindmap_shell_impl};
#[cfg(test)]
use editor_runtime::mount::resolve_runtime_delivery_mode;
use editor_runtime::mount::{
initial_content, mount_app_into, runtime_delivery_mode, runtime_editor_instance_id,
runtime_mount_context, set_runtime_mount_context, set_runtime_mount_options, MountOptions,
RuntimeDeliveryMode, RuntimeStandaloneObject,
};
#[cfg(test)]
use editor_runtime::mount::resolve_runtime_delivery_mode;
use editor_runtime::overlays::{
clamp_overlay_anchor, close_editor_floating_overlays_if_escape, image_element_from_target,
image_toolbar_anchor_from_image, open_block_menu_overlay, open_image_toolbar_overlay,
@@ -73,8 +73,8 @@ use editor_runtime::persistence::{
persisted_document_identity, PersistedDocumentIdentity,
};
use editor_runtime::runtime_bridge::{
dispatch_change_event_to_target, dispatch_ready_event_to_target,
dispatch_runtime_event, dispatch_selection_event_to_target, dispatch_state_event_to_target,
dispatch_change_event_to_target, dispatch_ready_event_to_target, dispatch_runtime_event,
dispatch_selection_event_to_target, dispatch_state_event_to_target,
dispatch_status_event_to_target, register_runtime_listener, take_unmount_handle,
};
use editor_runtime::slash_actions::{SlashActionKind, SLASH_ACTIONS};
@@ -170,6 +170,71 @@ export function download_mnote_selected_image() {
}
}
export function download_mnote_image_url(src, filenameHint) {
try {
if (!src) return false;
const url = new URL(String(src), window.location.href);
if (!(url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'data:')) return false;
const link = document.createElement('a');
link.href = url.href;
link.download = mnoteImageFilename({ getAttribute: (name) => name === 'title' || name === 'alt' ? String(filenameHint || '') : '' }, url.href);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
return true;
} catch (error) {
console.warn('mnote image preview download failed', error);
return false;
}
}
export function request_mnote_image_preview_fullscreen() {
try {
const target = document.querySelector('[data-testid="image-preview-stage"]')
|| document.querySelector('[data-testid="image-preview-dialog"]');
const request = target?.requestFullscreen || target?.webkitRequestFullscreen || target?.mozRequestFullScreen || target?.msRequestFullscreen;
if (!target || typeof request !== 'function') return false;
const result = request.call(target);
if (result?.catch) result.catch(() => {});
return true;
} catch (error) {
console.warn('mnote image preview fullscreen failed', error);
return false;
}
}
export function open_mnote_page_block_href(href) {
try {
const target = new URL(String(href || ''), window.location.href);
if (target.origin === window.location.origin && target.pathname.startsWith('/documents/')) {
const encodedId = target.pathname.slice('/documents/'.length).split('/')[0] || '';
const documentId = decodeURIComponent(encodedId);
const runtime = window.__mnoteDocumentPaneRuntime;
if (documentId && runtime && typeof runtime.openPrimaryDocument === 'function') {
const result = runtime.openPrimaryDocument({
documentId,
workspaceId: target.searchParams.get('workspaceId') || '',
sourceKind: target.searchParams.get('sourceKind') || '',
rootUri: target.searchParams.get('rootUri') || '',
url: target,
pageBlockNavigation: true,
});
if (result && typeof result.catch === 'function') {
result.catch((error) => {
console.warn('mnote page block pane navigation failed', error);
});
}
return true;
}
}
return false;
} catch (error) {
console.warn('mnote page block navigation failed', error);
return false;
}
}
export function bump_mnote_e27_ai_bridge_request_count() {
try {
const key = '__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__';
@@ -199,6 +264,9 @@ extern "C" {
fn find_mnote_block_anchor(block_id: &str) -> Option<Element>;
fn select_mnote_image_node(image: &Element) -> bool;
fn download_mnote_selected_image() -> bool;
fn download_mnote_image_url(src: &str, filename_hint: &str) -> bool;
fn request_mnote_image_preview_fullscreen() -> bool;
fn open_mnote_page_block_href(href: &str) -> bool;
fn bump_mnote_e27_ai_bridge_request_count() -> u32;
#[wasm_bindgen(catch)]
async fn post_mnote_document_save(body: String) -> Result<JsValue, JsValue>;
@@ -218,6 +286,99 @@ struct ToolbarAlignOption {
alignment: TiptapTextAlign,
}
#[derive(Clone, Debug, PartialEq)]
struct ImagePreviewState {
src: String,
alt: String,
title: String,
index: usize,
total: usize,
zoom: f64,
rotation: i32,
offset_x: f64,
offset_y: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ImagePreviewPanState {
start_x: i32,
start_y: i32,
origin_offset_x: f64,
origin_offset_y: f64,
}
impl ImagePreviewState {
fn filename_hint(&self) -> String {
let title = self.title.trim();
if !title.is_empty() {
return title.to_string();
}
let alt = self.alt.trim();
if !alt.is_empty() {
return alt.to_string();
}
"image".to_string()
}
}
fn image_preview_state_from_element(
image: &Element,
index: usize,
total: usize,
) -> Option<ImagePreviewState> {
let src = image.get_attribute("src")?;
Some(ImagePreviewState {
src,
alt: image.get_attribute("alt").unwrap_or_default(),
title: image.get_attribute("title").unwrap_or_default(),
index,
total,
zoom: 1.0,
rotation: 0,
offset_x: 0.0,
offset_y: 0.0,
})
}
fn image_preview_state_from_image(image: &Element) -> Option<ImagePreviewState> {
let document = window()?.document()?;
let images = document
.query_selector_all(&format!("{EDITOR_ROOT_SELECTOR} img[src]"))
.ok()?;
let total = images.length() as usize;
if total == 0 {
return None;
}
let image_node: &web_sys::Node = image.as_ref();
let mut index = 0_usize;
for current in 0..images.length() {
if images
.item(current)
.map(|node| node.is_same_node(Some(image_node)))
.unwrap_or(false)
{
index = current as usize;
break;
}
}
image_preview_state_from_element(image, index, total)
}
fn image_preview_state_by_index(index: usize) -> Option<ImagePreviewState> {
let document = window()?.document()?;
let images = document
.query_selector_all(&format!("{EDITOR_ROOT_SELECTOR} img[src]"))
.ok()?;
let total = images.length() as usize;
if total == 0 || index >= total {
return None;
}
let element = images.item(index as u32)?.dyn_into::<Element>().ok()?;
image_preview_state_from_element(&element, index, total)
}
const TOOLBAR_TEXT_COLORS: [ToolbarColorOption; 10] = [
ToolbarColorOption {
id: "default",
@@ -1166,6 +1327,8 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let (floating_toolbar_anchor, set_floating_toolbar_anchor) =
signal(None::<FloatingToolbarAnchor>);
let (image_toolbar_anchor, set_image_toolbar_anchor) = signal(None::<ImageToolbarAnchor>);
let (image_preview_state, set_image_preview_state) = signal(None::<ImagePreviewState>);
let (image_preview_pan, set_image_preview_pan) = signal(None::<ImagePreviewPanState>);
let (locked_toolbar_anchor, set_locked_toolbar_anchor) = signal(None::<FloatingToolbarAnchor>);
let (slash_open, set_slash_open) = signal(false);
let (slash_index, set_slash_index) = signal(0_usize);
@@ -1806,6 +1969,10 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let set_floating_toolbar_anchor = set_floating_toolbar_anchor;
let image_toolbar_anchor = image_toolbar_anchor;
let set_image_toolbar_anchor = set_image_toolbar_anchor;
let image_preview_state = image_preview_state;
let set_image_preview_state = set_image_preview_state;
let image_preview_pan = image_preview_pan;
let set_image_preview_pan = set_image_preview_pan;
let table_toolbar_open = table_toolbar_open;
let set_table_toolbar_open = set_table_toolbar_open;
let table_options_open = table_options_open;
@@ -1876,6 +2043,15 @@ fn App(mount_options: MountOptions) -> impl IntoView {
drop(keydown_capture_closure);
});
let keydown_handle = window_event_listener(ev::keydown, move |event| {
if image_preview_state.try_get_untracked().flatten().is_some()
&& event.key() == "Escape"
{
event.prevent_default();
let _ = set_image_preview_state.try_set(None);
let _ = set_command_feedback.try_set("已关闭图片预览".to_string());
return;
}
let focused = active_editor_stage();
if !focused {
if body_has_focus() && (event.ctrl_key() || event.meta_key()) && !event.alt_key() {
@@ -2143,7 +2319,36 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}
}
});
on_cleanup(move || drop(keydown_handle));
let preview_mousemove_handle =
window_event_listener(ev::mousemove, move |event: MouseEvent| {
let Some(pan) = image_preview_pan.try_get_untracked().flatten() else {
return;
};
if image_preview_state.try_get_untracked().flatten().is_none() {
let _ = set_image_preview_pan.try_set(None);
return;
}
event.prevent_default();
let offset_x = pan.origin_offset_x + f64::from(event.client_x() - pan.start_x);
let offset_y = pan.origin_offset_y + f64::from(event.client_y() - pan.start_y);
let _ = set_image_preview_state.try_update(|state| {
if let Some(state) = state.as_mut() {
state.offset_x = offset_x;
state.offset_y = offset_y;
}
});
});
let preview_mouseup_handle = window_event_listener(ev::mouseup, move |event: MouseEvent| {
if event.button() == 1 || image_preview_pan.try_get_untracked().flatten().is_some() {
event.prevent_default();
let _ = set_image_preview_pan.try_set(None);
}
});
on_cleanup(move || {
drop(keydown_handle);
drop(preview_mousemove_handle);
drop(preview_mouseup_handle);
});
}
{
@@ -2191,10 +2396,9 @@ fn App(mount_options: MountOptions) -> impl IntoView {
else {
return;
};
event.prevent_default();
event.stop_propagation();
if let Some(win) = window() {
let _ = win.location().assign(&href);
if open_mnote_page_block_href(&href) {
event.prevent_default();
event.stop_propagation();
}
});
on_cleanup(move || drop(click_handle));
@@ -2410,7 +2614,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}
}
if let Some(image) = image_element_from_target(target.clone()) {
if let Some(image) = image_element_from_target(target.clone()) {
let _ = select_mnote_image_node(&image);
set_editor_focused.set(true);
open_image_toolbar_overlay(
@@ -2447,6 +2651,26 @@ fn App(mount_options: MountOptions) -> impl IntoView {
set_turn_into_open.set(false);
set_hovered_block.set(hovered_block_from_target(target));
}
on:dblclick=move |event: MouseEvent| {
let Some(target) = event.target() else {
return;
};
let Some(image) = image_element_from_target(target) else {
return;
};
event.prevent_default();
event.stop_propagation();
let _ = select_mnote_image_node(&image);
set_editor_focused.set(true);
set_image_toolbar_anchor.set(None);
set_table_toolbar_open.set(false);
set_table_options_open.set(false);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_hovered_block.set(None);
set_image_preview_state.set(image_preview_state_from_image(&image));
set_command_feedback.set("图片预览已打开".to_string());
}
on:mousemove=move |event: MouseEvent| {
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
return;
@@ -2614,6 +2838,241 @@ fn App(mount_options: MountOptions) -> impl IntoView {
workspace_id,
set_command_feedback,
)}
{move || {
let Some(preview) = image_preview_state.get() else {
return ().into_any();
};
let preview_index = preview.index;
let preview_total = preview.total;
let preview_src = preview.src.clone();
let preview_alt = preview.alt.clone();
let preview_title = preview.title.clone();
let filename_hint = preview.filename_hint();
let has_prev = preview_index > 0;
let has_next = preview_index + 1 < preview_total;
let image_style = format!(
"transform:translate({}px, {}px) scale({}) rotate({}deg);",
preview.offset_x, preview.offset_y, preview.zoom, preview.rotation
);
view! {
<leptos::portal::Portal>
<div
class="image-preview-dialog"
data-testid="image-preview-dialog"
role="dialog"
aria-modal="true"
aria-label="图片预览"
data-preview-index=preview_index.to_string()
data-preview-total=preview_total.to_string()
data-preview-zoom=preview.zoom.to_string()
data-preview-rotation=preview.rotation.to_string()
data-preview-offset-x=preview.offset_x.to_string()
data-preview-offset-y=preview.offset_y.to_string()
on:click=move |event: MouseEvent| {
let Some(target) = event.target().and_then(target_element) else {
return;
};
if target
.get_attribute("data-testid")
.as_deref()
== Some("image-preview-dialog")
{
set_image_preview_state.set(None);
set_image_preview_pan.set(None);
set_command_feedback.set("已关闭图片预览".to_string());
}
}
>
<div class="image-preview-counter">
{format!("{}/{}", preview_index + 1, preview_total.max(1))}
</div>
<button
type="button"
class="image-preview-close"
data-testid="image-preview-close"
title="关闭"
aria-label="关闭图片预览"
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_image_preview_state.set(None);
set_image_preview_pan.set(None);
set_command_feedback.set("已关闭图片预览".to_string());
}
>"X"</button>
<button
type="button"
class="image-preview-nav image-preview-nav-prev"
data-testid="image-preview-prev"
title="上一张"
aria-label="上一张图片"
disabled=!has_prev
on:click=move |event: MouseEvent| {
event.stop_propagation();
if has_prev {
if let Some(next) = image_preview_state_by_index(preview_index - 1) {
set_image_preview_state.set(Some(next));
}
}
}
>""</button>
<button
type="button"
class="image-preview-nav image-preview-nav-next"
data-testid="image-preview-next"
title="下一张"
aria-label="下一张图片"
disabled=!has_next
on:click=move |event: MouseEvent| {
event.stop_propagation();
if has_next {
if let Some(next) = image_preview_state_by_index(preview_index + 1) {
set_image_preview_state.set(Some(next));
}
}
}
>""</button>
<div
class="image-preview-stage"
data-testid="image-preview-stage"
on:wheel=move |event: WheelEvent| {
event.prevent_default();
event.stop_propagation();
let delta = if event.delta_y() < 0.0 { 0.15 } else { -0.15 };
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.zoom = (state.zoom + delta).clamp(0.25, 4.0);
}
});
}
on:mousedown=move |event: MouseEvent| {
if event.button() != 1 {
return;
}
event.prevent_default();
event.stop_propagation();
let Some(state) = image_preview_state.get_untracked() else {
return;
};
set_image_preview_pan.set(Some(ImagePreviewPanState {
start_x: event.client_x(),
start_y: event.client_y(),
origin_offset_x: state.offset_x,
origin_offset_y: state.offset_y,
}));
}
>
<img
class="image-preview-image"
data-testid="image-preview-image"
src=preview_src.clone()
alt=preview_alt.clone()
title=preview_title.clone()
style=image_style.clone()
/>
</div>
<div
class="image-preview-toolbar"
data-testid="image-preview-toolbar"
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-zoom-in"
title="放大"
aria-label="放大图片"
on:click=move |_| {
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.zoom = (state.zoom + 0.25).min(4.0);
}
});
}
>"+"</button>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-zoom-out"
title="缩小"
aria-label="缩小图片"
on:click=move |_| {
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.zoom = (state.zoom - 0.25).max(0.25);
}
});
}
>"-"</button>
<button
type="button"
class="image-preview-tool image-preview-tool-wide"
data-testid="image-preview-one-to-one"
title="1:1"
aria-label="按原始比例查看"
on:click=move |_| {
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.zoom = 1.0;
state.rotation = 0;
state.offset_x = 0.0;
state.offset_y = 0.0;
}
});
}
>"1:1"</button>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-rotate"
title="旋转"
aria-label="旋转图片"
on:click=move |_| {
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.rotation = (state.rotation + 90) % 360;
}
});
}
>"R"</button>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-download"
title="下载"
aria-label="下载图片"
on:click={
let preview_src = preview_src.clone();
let filename_hint = filename_hint.clone();
move |_| {
if download_mnote_image_url(&preview_src, &filename_hint) {
set_command_feedback.set("已开始下载图片".to_string());
} else {
set_command_feedback.set("图片下载失败".to_string());
}
}
}
>"D"</button>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-fullscreen"
title="全屏"
aria-label="全屏查看图片"
on:click=move |_| {
if request_mnote_image_preview_fullscreen() {
set_command_feedback.set("已请求全屏查看图片".to_string());
} else {
set_command_feedback.set("当前浏览器不支持图片全屏".to_string());
}
}
>"F"</button>
</div>
</div>
</leptos::portal::Portal>
}.into_any()
}}
{move || {
let Some(anchor) = image_toolbar_anchor.get() else {
return ().into_any();