refactor: externalize local upload helpers

Add local-upload-runtime.js as a browser runtime module for upload and attachment helper functions, with sidebar fallback wrappers and a fixed runtime asset route.

While validating task479, fix the editor attachment delete path so DOM-selected local attachment links are mapped back to a Tiptap text selection before delete_selection(), preserving undo history and adjacent links.
This commit is contained in:
lix-2026
2026-05-24 22:42:53 +08:00
parent 418f8ff8b0
commit 0a03572601
10 changed files with 353 additions and 19 deletions
@@ -1,3 +1,4 @@
use leptos_tiptap::TiptapRange;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{window, Element};
@@ -9,20 +10,17 @@ fn editor_root_element() -> Option<Element> {
.and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten())
}
/// 当前 DOM 选中文本匹配本地附件链接文本时返回 `true`。
pub(crate) fn selected_local_attachment_link_text() -> bool {
fn selected_local_attachment_link_dom_info() -> Option<(String, String)> {
let selection = match window().and_then(|win| win.get_selection().ok().flatten()) {
Some(selection) if !selection.is_collapsed() => selection,
_ => return false,
_ => return None,
};
let selected_text = String::from(selection.to_string()).trim().to_string();
if selected_text.is_empty() {
return false;
return None;
}
let Some(root) = editor_root_element() else {
return false;
};
let root = editor_root_element()?;
let links = root.get_elements_by_tag_name("a");
for index in 0..links.length() {
let Some(link) = links.item(index) else {
@@ -33,11 +31,16 @@ pub(crate) fn selected_local_attachment_link_text() -> bool {
continue;
}
if link.text_content().unwrap_or_default().trim() == selected_text {
return true;
return Some((selected_text, href));
}
}
false
None
}
/// 当前 DOM 选中文本匹配本地附件链接文本时返回 `true`。
pub(crate) fn selected_local_attachment_link_text() -> bool {
selected_local_attachment_link_dom_info().is_some()
}
/// 当前非空选区命中本地附件链接 mark 时返回 `true`。
@@ -85,3 +88,54 @@ pub(crate) fn selected_local_attachment_link_in_editor_state() -> bool {
pub(crate) fn is_local_attachment_link_selected() -> bool {
selected_local_attachment_link_in_editor_state() || selected_local_attachment_link_text()
}
pub(crate) fn selected_local_attachment_link_range() -> Option<TiptapRange> {
let (selected_text, selected_href) = selected_local_attachment_link_dom_info()?;
let root = editor_root_element()?;
let editor_value = js_sys::Reflect::get(root.as_ref(), &JsValue::from_str("editor")).ok()?;
let find_range = js_sys::Function::new_with_args(
"editor, selectedText, selectedHref",
r#"
if (!editor || !editor.state || !editor.state.doc) return null;
var text = String(selectedText || '').trim();
var hrefNeedle = String(selectedHref || '').trim();
if (!text) return null;
var match = null;
editor.state.doc.descendants(function(node, pos) {
if (match || !node || !node.isText) return;
var nodeText = String(node.text || '');
var offset = nodeText.indexOf(text);
if (offset < 0) return;
var hasAttachmentLink = Array.isArray(node.marks) && node.marks.some(function(mark) {
var href = mark && mark.attrs ? String(mark.attrs.href || '') : '';
return mark.type && mark.type.name === 'link'
&& href.indexOf('/api/local-folder/files/open') >= 0
&& (!hrefNeedle || href === hrefNeedle);
});
if (!hasAttachmentLink) return;
match = { from: pos + offset, to: pos + offset + text.length };
});
return match;
"#,
);
let range_value = find_range
.call3(
&JsValue::NULL,
&editor_value,
&JsValue::from_str(&selected_text),
&JsValue::from_str(&selected_href),
)
.ok()?;
if range_value.is_null() || range_value.is_undefined() {
return None;
}
let from = js_sys::Reflect::get(&range_value, &JsValue::from_str("from"))
.ok()
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite() && *value >= 0.0)? as u32;
let to = js_sys::Reflect::get(&range_value, &JsValue::from_str("to"))
.ok()
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite() && *value >= from as f64)? as u32;
Some(TiptapRange { from, to })
}
@@ -12,6 +12,9 @@ pub(crate) fn delete_selected_attachment_link_with_history(
if !attachment_links::is_local_attachment_link_selected() {
return Err("当前选区不是本地附件链接".to_string());
}
if let Some(range) = attachment_links::selected_local_attachment_link_range() {
let _ = editor.set_text_selection(range);
}
// 继续走 Tiptap 命令路径,保留撤销/重做历史。
editor.delete_selection().map_err(|error| error.to_string())
}