refactor: extract editor command persistence runtime

This commit is contained in:
lix-2026
2026-05-24 23:12:29 +08:00
parent 0a03572601
commit b4c74bbb81
7 changed files with 282 additions and 198 deletions
@@ -1284,7 +1284,7 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 745, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 758, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf);
return ret;
},
@@ -0,0 +1,122 @@
use crate::editor_runtime::persistence::{persist_document_state, PersistedDocumentIdentity};
use leptos::prelude::{GetUntracked, ReadSignal, Set, Update, WriteSignal};
use leptos_tiptap::{TiptapContent, TiptapEditorHandle};
use serde_json::{json, Value};
pub(crate) fn read_editor_snapshot(editor: TiptapEditorHandle) -> (String, Value, String) {
let html = editor
.get_html()
.unwrap_or_else(|err| format!("读取 HTML 失败:{err}"));
let json_value = editor.get_json().unwrap_or_else(|err| {
json!({
"type": "error",
"message": format!("读取 JSON 失败:{err}"),
})
});
let json_text = serde_json::to_string_pretty(&json_value)
.unwrap_or_else(|err| format!("格式化 JSON 失败:{err}"));
(html, json_value, json_text)
}
pub(crate) fn sync_editor_outputs(
editor: TiptapEditorHandle,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
) -> Value {
let (html, json_value, json_text) = read_editor_snapshot(editor);
set_html_output.set(html);
set_document_json.set(json_value.clone());
set_json_output.set(json_text);
json_value
}
pub(crate) fn apply_document_update(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
next_document: Value,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
success_message: impl Into<String>,
) {
let success_message = success_message.into();
let next_payload = next_document
.get("content")
.and_then(Value::as_array)
.cloned()
.map(Value::Array)
.unwrap_or_else(|| next_document.clone());
match editor.set_content(TiptapContent::json(next_payload)) {
Ok(()) => {
persist_current_editor_snapshot(
editor,
persisted_identity,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
success_message,
);
}
Err(err) => {
set_command_feedback.set(format!("更新文档失败:{err}"));
}
}
}
pub(crate) fn sync_persisted_editor_command(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
success_message: impl Into<String>,
) {
persist_current_editor_snapshot(
editor,
persisted_identity,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
success_message.into(),
);
}
fn persist_current_editor_snapshot(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
success_message: String,
) {
set_dirty_count.update(|count| *count += 1);
let (html, snapshot, json_text) = read_editor_snapshot(editor);
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
match persist_document_state(
persisted_identity,
&title.get_untracked(),
&snapshot,
Some(html),
) {
Ok(()) => set_command_feedback.set(format!("{success_message},并已写入本地草稿")),
Err(err) => set_command_feedback.set(format!("{success_message},但本地保存失败:{err}")),
}
}
@@ -1,2 +1,4 @@
pub(crate) mod attachment_links;
pub(crate) mod command_sync;
pub(crate) mod history_safe_commands;
pub(crate) mod persistence;
@@ -0,0 +1,96 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use web_sys::{window, Storage};
const SPIKE_STORAGE_KEY: &str = "mnote.leptos-tiptap-spike.document";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PersistedSpikeDocument {
#[serde(default)]
pub(crate) document_id: Option<String>,
#[serde(default)]
pub(crate) workspace_id: Option<String>,
pub(crate) title: String,
pub(crate) content: Value,
#[serde(default)]
pub(crate) html: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PersistedDocumentIdentity {
pub(crate) document_id: Option<String>,
pub(crate) workspace_id: Option<String>,
}
fn local_storage() -> Option<Storage> {
window().and_then(|win| win.local_storage().ok().flatten())
}
pub(crate) fn normalize_identity_value(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
pub(crate) fn persisted_document_identity(
document_id: Option<String>,
workspace_id: Option<String>,
) -> PersistedDocumentIdentity {
PersistedDocumentIdentity {
document_id: normalize_identity_value(document_id),
workspace_id: normalize_identity_value(workspace_id),
}
}
pub(crate) fn persisted_document_storage_key(identity: &PersistedDocumentIdentity) -> String {
match (&identity.workspace_id, &identity.document_id) {
(Some(workspace_id), Some(document_id)) => {
format!("{SPIKE_STORAGE_KEY}:{workspace_id}:{document_id}")
}
(None, Some(document_id)) => format!("{SPIKE_STORAGE_KEY}:{document_id}"),
_ => SPIKE_STORAGE_KEY.to_string(),
}
}
pub(crate) fn persist_document_state(
identity: &PersistedDocumentIdentity,
title: &str,
content: &Value,
html: Option<String>,
) -> Result<(), String> {
let payload = PersistedSpikeDocument {
document_id: identity.document_id.clone(),
workspace_id: identity.workspace_id.clone(),
title: title.to_string(),
content: content.clone(),
html,
};
let raw = serde_json::to_string(&payload).map_err(|err| format!("序列化草稿失败:{err}"))?;
local_storage()
.ok_or_else(|| "浏览器 localStorage 不可用".to_string())?
.set_item(&persisted_document_storage_key(identity), &raw)
.map_err(|err| format!("写入 localStorage 失败:{err:?}"))
}
pub(crate) fn load_persisted_document(
identity: &PersistedDocumentIdentity,
) -> Option<PersistedSpikeDocument> {
let raw = local_storage()?
.get_item(&persisted_document_storage_key(identity))
.ok()
.flatten()?;
let document = serde_json::from_str::<PersistedSpikeDocument>(&raw).ok()?;
let stored_identity =
persisted_document_identity(document.document_id.clone(), document.workspace_id.clone());
if stored_identity == *identity || identity.document_id.is_none() {
Some(document)
} else {
None
}
}
+11 -193
View File
@@ -17,13 +17,22 @@ use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
use wasm_bindgen_futures::{spawn_local, JsFuture};
use web_sys::{
window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement,
HtmlInputElement, MouseEvent, Node, RequestInit, RequestMode, Response, Storage, WheelEvent,
HtmlInputElement, MouseEvent, Node, RequestInit, RequestMode, Response, WheelEvent,
};
use editor_runtime::command_sync::{
read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command,
};
use editor_runtime::persistence::{
load_persisted_document, normalize_identity_value, persist_document_state,
persisted_document_identity, PersistedDocumentIdentity,
};
#[cfg(test)]
use editor_runtime::persistence::persisted_document_storage_key;
const EDITOR_STAGE_SELECTOR: &str = "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]";
const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror";
const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell";
const SPIKE_STORAGE_KEY: &str = "mnote.leptos-tiptap-spike.document";
const HANDLE_MULTILINE_HEIGHT: f64 = 40.0;
const CONTENT_COLUMN_MAX_WIDTH: f64 = 708.0;
const CONTENT_COLUMN_HORIZONTAL_PADDING: f64 = 48.0;
@@ -2996,25 +3005,6 @@ const SLASH_ACTIONS: [SlashAction; 22] = [
},
];
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistedSpikeDocument {
#[serde(default)]
document_id: Option<String>,
#[serde(default)]
workspace_id: Option<String>,
title: String,
content: Value,
#[serde(default)]
html: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PersistedDocumentIdentity {
document_id: Option<String>,
workspace_id: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BridgeEnvelope<T>
@@ -4863,10 +4853,6 @@ struct PendingDragState {
start_y: i32,
}
fn local_storage() -> Option<Storage> {
window().and_then(|win| win.local_storage().ok().flatten())
}
fn default_title() -> String {
"Leptos Tiptap 主编辑器 P0".to_string()
}
@@ -4887,27 +4873,6 @@ fn current_query_param(key: &str) -> Option<String> {
})
}
fn normalize_identity_value(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn persisted_document_identity(
document_id: Option<String>,
workspace_id: Option<String>,
) -> PersistedDocumentIdentity {
PersistedDocumentIdentity {
document_id: normalize_identity_value(document_id),
workspace_id: normalize_identity_value(workspace_id),
}
}
fn resolve_persisted_document_identity(
explicit_document_id: Option<String>,
explicit_workspace_id: Option<String>,
@@ -4918,16 +4883,6 @@ fn resolve_persisted_document_identity(
)
}
fn persisted_document_storage_key(identity: &PersistedDocumentIdentity) -> String {
match (&identity.workspace_id, &identity.document_id) {
(Some(workspace_id), Some(document_id)) => {
format!("{SPIKE_STORAGE_KEY}:{workspace_id}:{document_id}")
}
(None, Some(document_id)) => format!("{SPIKE_STORAGE_KEY}:{document_id}"),
_ => SPIKE_STORAGE_KEY.to_string(),
}
}
fn persisted_mindmap_object_identity(
object: Option<&RuntimeStandaloneObject>,
) -> Option<PersistedDocumentIdentity> {
@@ -4950,41 +4905,6 @@ fn runtime_persisted_identity(
persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked())
}
fn persist_document_state(
identity: &PersistedDocumentIdentity,
title: &str,
content: &Value,
html: Option<String>,
) -> Result<(), String> {
let payload = PersistedSpikeDocument {
document_id: identity.document_id.clone(),
workspace_id: identity.workspace_id.clone(),
title: title.to_string(),
content: content.clone(),
html,
};
let raw = serde_json::to_string(&payload).map_err(|err| format!("序列化草稿失败:{err}"))?;
local_storage()
.ok_or_else(|| "浏览器 localStorage 不可用".to_string())?
.set_item(&persisted_document_storage_key(identity), &raw)
.map_err(|err| format!("写入 localStorage 失败:{err:?}"))
}
fn load_persisted_document(identity: &PersistedDocumentIdentity) -> Option<PersistedSpikeDocument> {
let raw = local_storage()?
.get_item(&persisted_document_storage_key(identity))
.ok()
.flatten()?;
let document = serde_json::from_str::<PersistedSpikeDocument>(&raw).ok()?;
let stored_identity =
persisted_document_identity(document.document_id.clone(), document.workspace_id.clone());
if stored_identity == *identity || identity.document_id.is_none() {
Some(document)
} else {
None
}
}
fn url_requests_embedded_mode() -> bool {
window()
.and_then(|win| win.location().search().ok())
@@ -5094,34 +5014,6 @@ where
dispatch_runtime_event(event_name, payload);
}
fn read_editor_snapshot(editor: TiptapEditorHandle) -> (String, Value, String) {
let html = editor
.get_html()
.unwrap_or_else(|err| format!("读取 HTML 失败:{err}"));
let json_value = editor.get_json().unwrap_or_else(|err| {
json!({
"type": "error",
"message": format!("读取 JSON 失败:{err}"),
})
});
let json_text = serde_json::to_string_pretty(&json_value)
.unwrap_or_else(|err| format!("格式化 JSON 失败:{err}"));
(html, json_value, json_text)
}
fn sync_editor_outputs(
editor: TiptapEditorHandle,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
) -> Value {
let (html, json_value, json_text) = read_editor_snapshot(editor);
set_html_output.set(html);
set_document_json.set(json_value.clone());
set_json_output.set(json_text);
json_value
}
fn editor_root_element() -> Option<Element> {
window()
.and_then(|win| win.document())
@@ -6317,52 +6209,6 @@ fn try_sync_editor_overlay_state(
true
}
fn apply_document_update(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
next_document: Value,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
success_message: impl Into<String>,
) {
let success_message = success_message.into();
let next_payload = next_document
.get("content")
.and_then(Value::as_array)
.cloned()
.map(Value::Array)
.unwrap_or_else(|| next_document.clone());
match editor.set_content(TiptapContent::json(next_payload)) {
Ok(()) => {
set_dirty_count.update(|count| *count += 1);
let (html, snapshot, json_text) = read_editor_snapshot(editor);
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
match persist_document_state(
persisted_identity,
&title.get_untracked(),
&snapshot,
Some(html),
) {
Ok(()) => {
set_command_feedback.set(format!("{success_message},并已写入本地草稿"));
}
Err(err) => {
set_command_feedback.set(format!("{success_message},但本地保存失败:{err}"));
}
}
}
Err(err) => {
set_command_feedback.set(format!("更新文档失败:{err}"));
}
}
}
fn apply_html_update(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
@@ -6524,34 +6370,6 @@ fn insert_editor_paragraph_relative_to_block(
Ok(new_index)
}
fn sync_persisted_editor_command(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
success_message: impl Into<String>,
) {
let success_message = success_message.into();
set_dirty_count.update(|count| *count += 1);
let (html, snapshot, json_text) = read_editor_snapshot(editor);
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
match persist_document_state(
persisted_identity,
&title.get_untracked(),
&snapshot,
Some(html),
) {
Ok(()) => set_command_feedback.set(format!("{success_message},并已写入本地草稿")),
Err(err) => set_command_feedback.set(format!("{success_message},但本地保存失败:{err}")),
}
}
fn focus_top_level_block_start(editor: TiptapEditorHandle, index: usize) -> Result<(), String> {
editor
.focus()