refactor: extract editor command persistence runtime
This commit is contained in:
+50
-4
@@ -120,13 +120,16 @@ rust/spikes/leptos-tiptap-spike/src/
|
|||||||
|
|
||||||
### P2:抽出 command sync / persistence
|
### P2:抽出 command sync / persistence
|
||||||
|
|
||||||
- [ ] 新增 `editor_runtime/command_sync.rs`。
|
- [x] 新增 `editor_runtime/command_sync.rs`。
|
||||||
- [ ] 迁出:
|
- [x] 新增 `editor_runtime/persistence.rs`。
|
||||||
|
- [x] 迁出:
|
||||||
- `read_editor_snapshot`
|
- `read_editor_snapshot`
|
||||||
- `sync_editor_outputs`
|
- `sync_editor_outputs`
|
||||||
- `sync_persisted_editor_command`
|
- `sync_persisted_editor_command`
|
||||||
- change/status event dispatch 的编辑器侧包装
|
- `apply_document_update`(当前保持零调用者现状)
|
||||||
- [ ] 明确 `set_content` 只允许用于:
|
- [ ] 迁出 change/status event dispatch 的编辑器侧包装。
|
||||||
|
- 2026-05-24 复核:`apply_html_update` 仍依赖 `ChangePayload` / `ChangeMetaPayload`、`dispatch_change_event`、`dispatch_runtime_state`、`HoveredBlockState` 和 toolbar overlay state,暂留 `lib.rs`,等待 P4 `bridge_events.rs` / `overlays.rs` 后再迁。
|
||||||
|
- [x] 明确 `set_content` 只允许用于:
|
||||||
- 初始加载
|
- 初始加载
|
||||||
- host 替换文档
|
- host 替换文档
|
||||||
- 外部同步/恢复
|
- 外部同步/恢复
|
||||||
@@ -193,6 +196,8 @@ rust/spikes/leptos-tiptap-spike/src/
|
|||||||
允许读取/修改:
|
允许读取/修改:
|
||||||
- rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs
|
- rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs
|
||||||
- rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs
|
- rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs
|
||||||
|
- rust/spikes/leptos-tiptap-spike/src/editor_runtime/command_sync.rs
|
||||||
|
- rust/spikes/leptos-tiptap-spike/src/editor_runtime/persistence.rs
|
||||||
- scripts/task488-local-attachment-link-delete-undo-smoke.js
|
- scripts/task488-local-attachment-link-delete-undo-smoke.js
|
||||||
|
|
||||||
禁止:
|
禁止:
|
||||||
@@ -290,6 +295,12 @@ Codex 主控复核修正:
|
|||||||
- `cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`(35 个既有 warning)
|
- `cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`(35 个既有 warning)
|
||||||
- `cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release`
|
- `cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release`
|
||||||
- `wasm-bindgen rust/spikes/leptos-tiptap-spike/target/wasm32-unknown-unknown/release/mnote_leptos_tiptap_spike.wasm --target web --out-dir rust/spikes/leptos-tiptap-spike/generated/island --out-name mnote-leptos-tiptap-spike-island`
|
- `wasm-bindgen rust/spikes/leptos-tiptap-spike/target/wasm32-unknown-unknown/release/mnote_leptos_tiptap_spike.wasm --target web --out-dir rust/spikes/leptos-tiptap-spike/generated/island --out-name mnote-leptos-tiptap-spike-island`
|
||||||
|
- `node scripts/task488-local-attachment-link-delete-undo-smoke.js`
|
||||||
|
- 结果:`ok=true`
|
||||||
|
- 证据:`tmp/task488-local-attachment-link-delete-undo-smoke/result.json`
|
||||||
|
- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`
|
||||||
|
- 结果:`ok=true`
|
||||||
|
- 证据:命令 stdout JSON,覆盖 `editor-upload-to-page-resource-dir`、`filetree-folder-drop-to-target`、`broken-link-after-real-file-delete`、`direct-attach-open-without-active-md`、`editor-delete-one-attachment-preserves-adjacent` 等检查。
|
||||||
- `node scripts/task488-local-attachment-link-delete-undo-smoke.js`
|
- `node scripts/task488-local-attachment-link-delete-undo-smoke.js`
|
||||||
- 结果:`ok=true`
|
- 结果:`ok=true`
|
||||||
- 步骤:`uploaded -> selected -> deleted -> undo -> redo`
|
- 步骤:`uploaded -> selected -> deleted -> undo -> redo`
|
||||||
@@ -301,6 +312,41 @@ Codex 主控复核修正:
|
|||||||
- 结果:索引 318 个文件,状态 up to date。
|
- 结果:索引 318 个文件,状态 up to date。
|
||||||
- 复核:`delete_selected_attachment_link_with_history`、`selected_local_attachment_link_text`、`is_local_attachment_link_selected` 均可通过 CodeGraph 搜索定位到 `editor_runtime/*`。
|
- 复核:`delete_selected_attachment_link_with_history`、`selected_local_attachment_link_text`、`is_local_attachment_link_selected` 均可通过 CodeGraph 搜索定位到 `editor_runtime/*`。
|
||||||
|
|
||||||
|
### 10.3 Batch 2 执行记录(2026-05-24)
|
||||||
|
|
||||||
|
Reasonix read-only audit:
|
||||||
|
|
||||||
|
- run id: `reasonix-2026-05-24T15-01-33-138Z-dc32246a`
|
||||||
|
- worker worktree: `/mnt/Data1T/mnote-wt-5-28-command-sync-b2`
|
||||||
|
- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T15-01-33-138Z-dc32246a/process-handoff.md`
|
||||||
|
- 类型:只读审计,主控已读取 Hindsight recall、`process-handoff.md`、`process-handoff.json`。
|
||||||
|
|
||||||
|
采纳内容:
|
||||||
|
|
||||||
|
- 新增 `editor_runtime/command_sync.rs`,迁出 `read_editor_snapshot`、`sync_editor_outputs`、`sync_persisted_editor_command`。
|
||||||
|
- 新增 `editor_runtime/persistence.rs`,迁出 `PersistedSpikeDocument`、`PersistedDocumentIdentity`、identity normalization、storage key、localStorage load/persist。
|
||||||
|
- `apply_document_update` 迁入 `command_sync.rs`,保持迁移前零调用者现状。
|
||||||
|
- `apply_html_update` 暂留 `lib.rs`:它仍强依赖 host change/status event、`HoveredBlockState` 和 toolbar overlay state;后续等 `bridge_events.rs` / `overlays.rs` 拆出后再迁。
|
||||||
|
|
||||||
|
Codex 主控复核:
|
||||||
|
|
||||||
|
- `persist_document_state` 的 payload、storage key、`localStorage` 写入错误文案保持不变。
|
||||||
|
- `sync_persisted_editor_command` 现在委托 `persist_current_editor_snapshot(...)`,dirty count、自身快照读取、HTML/JSON output 更新和反馈文案顺序保持不变。
|
||||||
|
- `normalize_identity_value` 改为 `pub(crate)`,用于 mindmap draft identity;`persisted_document_storage_key` 只在 `#[cfg(test)]` 下导入。
|
||||||
|
|
||||||
|
已通过验证:
|
||||||
|
|
||||||
|
- `cargo fmt --manifest-path rust/Cargo.toml --all --check`
|
||||||
|
- `cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`(35 个既有 warning)
|
||||||
|
- `cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release`(35 个既有 warning)
|
||||||
|
- `wasm-bindgen rust/spikes/leptos-tiptap-spike/target/wasm32-unknown-unknown/release/mnote_leptos_tiptap_spike.wasm --target web --out-dir rust/spikes/leptos-tiptap-spike/generated/island --out-name mnote-leptos-tiptap-spike-island`
|
||||||
|
|
||||||
|
受阻验证:
|
||||||
|
|
||||||
|
- `cargo test --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml persisted_document_storage_key_isolated_per_document_identity`
|
||||||
|
- `cargo test --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml standalone_mindmap_object_uses_isolated_draft_identity`
|
||||||
|
- 阻塞原因:lib test 编译阶段命中既有 `slash_menu_css_uses_viewport_overlay_layer` 中 `STYLE` 未定义;HEAD 中该测试已引用 `STYLE`,而实际常量名为 `SPIKE_STYLE`。该问题不属于本批拆分,已记录为后续单独处理。
|
||||||
|
|
||||||
## 11. 测试与验收
|
## 11. 测试与验收
|
||||||
|
|
||||||
### Rust 检查
|
### Rust 检查
|
||||||
|
|||||||
+1
-1
@@ -1284,7 +1284,7 @@ function __wbg_get_imports() {
|
|||||||
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: [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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -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 attachment_links;
|
||||||
|
pub(crate) mod command_sync;
|
||||||
pub(crate) mod history_safe_commands;
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,13 +17,22 @@ use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
|
|||||||
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||||
use web_sys::{
|
use web_sys::{
|
||||||
window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement,
|
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_STAGE_SELECTOR: &str = "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]";
|
||||||
const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror";
|
const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror";
|
||||||
const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell";
|
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 HANDLE_MULTILINE_HEIGHT: f64 = 40.0;
|
||||||
const CONTENT_COLUMN_MAX_WIDTH: f64 = 708.0;
|
const CONTENT_COLUMN_MAX_WIDTH: f64 = 708.0;
|
||||||
const CONTENT_COLUMN_HORIZONTAL_PADDING: f64 = 48.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)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct BridgeEnvelope<T>
|
struct BridgeEnvelope<T>
|
||||||
@@ -4863,10 +4853,6 @@ struct PendingDragState {
|
|||||||
start_y: i32,
|
start_y: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn local_storage() -> Option<Storage> {
|
|
||||||
window().and_then(|win| win.local_storage().ok().flatten())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_title() -> String {
|
fn default_title() -> String {
|
||||||
"Leptos Tiptap 主编辑器 P0".to_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(
|
fn resolve_persisted_document_identity(
|
||||||
explicit_document_id: Option<String>,
|
explicit_document_id: Option<String>,
|
||||||
explicit_workspace_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(
|
fn persisted_mindmap_object_identity(
|
||||||
object: Option<&RuntimeStandaloneObject>,
|
object: Option<&RuntimeStandaloneObject>,
|
||||||
) -> Option<PersistedDocumentIdentity> {
|
) -> Option<PersistedDocumentIdentity> {
|
||||||
@@ -4950,41 +4905,6 @@ fn runtime_persisted_identity(
|
|||||||
persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked())
|
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 {
|
fn url_requests_embedded_mode() -> bool {
|
||||||
window()
|
window()
|
||||||
.and_then(|win| win.location().search().ok())
|
.and_then(|win| win.location().search().ok())
|
||||||
@@ -5094,34 +5014,6 @@ where
|
|||||||
dispatch_runtime_event(event_name, payload);
|
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> {
|
fn editor_root_element() -> Option<Element> {
|
||||||
window()
|
window()
|
||||||
.and_then(|win| win.document())
|
.and_then(|win| win.document())
|
||||||
@@ -6317,52 +6209,6 @@ fn try_sync_editor_overlay_state(
|
|||||||
true
|
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(
|
fn apply_html_update(
|
||||||
editor: TiptapEditorHandle,
|
editor: TiptapEditorHandle,
|
||||||
persisted_identity: &PersistedDocumentIdentity,
|
persisted_identity: &PersistedDocumentIdentity,
|
||||||
@@ -6524,34 +6370,6 @@ fn insert_editor_paragraph_relative_to_block(
|
|||||||
Ok(new_index)
|
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> {
|
fn focus_top_level_block_start(editor: TiptapEditorHandle, index: usize) -> Result<(), String> {
|
||||||
editor
|
editor
|
||||||
.focus()
|
.focus()
|
||||||
|
|||||||
Reference in New Issue
Block a user