feat: finish rust web dual pane document shell

This commit is contained in:
lix-2026
2026-05-08 23:15:00 +08:00
parent 83805f9254
commit 3d5e0c9d5a
16 changed files with 3776 additions and 761 deletions
+7
View File
@@ -1,5 +1,6 @@
use crate::middleware::request_context::inject_request_context;
use crate::routes::build_router;
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
use axum::Router;
use std::env;
use std::fs;
@@ -132,18 +133,24 @@ fn read_env_or_dotenv(key: &str) -> Option<String> {
#[derive(Debug, Clone)]
pub struct AppState {
config: Arc<AppConfig>,
local_folder_watcher_registry: LocalFolderWatcherRegistry,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
Self {
config: Arc::new(config),
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(),
}
}
pub fn config(&self) -> &AppConfig {
self.config.as_ref()
}
pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry {
&self.local_folder_watcher_registry
}
}
pub fn build_app(state: AppState) -> Router {
+1
View File
@@ -1,6 +1,7 @@
pub mod app;
pub mod context;
pub mod error;
pub mod local_folder_watcher_registry;
pub mod middleware;
pub mod page_aggregate;
pub mod routes;
@@ -0,0 +1,348 @@
use notify::event::ModifyKind;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::{broadcast, mpsc, oneshot};
#[derive(Clone)]
pub struct LocalFolderWatcherRegistry {
inner: Arc<LocalFolderWatcherRegistryInner>,
}
impl std::fmt::Debug for LocalFolderWatcherRegistry {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LocalFolderWatcherRegistry")
.finish_non_exhaustive()
}
}
impl LocalFolderWatcherRegistry {
pub fn new() -> Self {
Self {
inner: Arc::new(LocalFolderWatcherRegistryInner {
entries: Mutex::new(HashMap::new()),
}),
}
}
pub(crate) fn subscribe(
&self,
canonical_root: &Path,
) -> Result<LocalFolderWatcherSubscription, String> {
let key = canonical_root_uri(canonical_root);
let channel = self.inner.get_or_create_channel(&key, canonical_root)?;
channel.subscriber_count.fetch_add(1, Ordering::SeqCst);
Ok(LocalFolderWatcherSubscription {
receiver: channel.sender.subscribe(),
guard: LocalFolderWatcherSubscriptionGuard {
registry: Arc::downgrade(&self.inner),
key,
channel,
},
})
}
#[cfg(test)]
pub fn active_watcher_count(&self) -> usize {
self.inner
.entries
.lock()
.expect("registry lock")
.len()
}
}
struct LocalFolderWatcherRegistryInner {
entries: Mutex<HashMap<String, Arc<LocalFolderWatchChannel>>>,
}
impl LocalFolderWatcherRegistryInner {
fn get_or_create_channel(
&self,
key: &str,
canonical_root: &Path,
) -> Result<Arc<LocalFolderWatchChannel>, String> {
if let Some(existing) = self.entries.lock().expect("registry lock").get(key).cloned() {
return Ok(existing);
}
let channel = Arc::new(LocalFolderWatchChannel::new(
key.to_string(),
spawn_local_folder_watcher(key, canonical_root.to_path_buf())?,
));
let mut entries = self.entries.lock().expect("registry lock");
if let Some(existing) = entries.get(key).cloned() {
return Ok(existing);
}
entries.insert(key.to_string(), channel.clone());
Ok(channel)
}
fn remove_if_idle(&self, key: &str, channel: &Arc<LocalFolderWatchChannel>) {
let mut entries = self.entries.lock().expect("registry lock");
let should_remove = entries
.get(key)
.map(|current| {
Arc::ptr_eq(current, channel)
&& channel.subscriber_count.load(Ordering::SeqCst) == 0
})
.unwrap_or(false);
if should_remove {
entries.remove(key);
channel.shutdown();
}
}
}
struct LocalFolderWatchChannel {
sender: broadcast::Sender<Value>,
subscriber_count: AtomicUsize,
shutdown_tx: Mutex<Option<oneshot::Sender<()>>>,
}
impl LocalFolderWatchChannel {
fn new(
_root_uri: String,
parts: (broadcast::Sender<Value>, oneshot::Sender<()>),
) -> Self {
Self {
sender: parts.0,
subscriber_count: AtomicUsize::new(0),
shutdown_tx: Mutex::new(Some(parts.1)),
}
}
fn shutdown(&self) {
if let Some(sender) = self.shutdown_tx.lock().expect("shutdown lock").take() {
let _ = sender.send(());
}
}
}
pub(crate) struct LocalFolderWatcherSubscription {
pub(crate) receiver: broadcast::Receiver<Value>,
guard: LocalFolderWatcherSubscriptionGuard,
}
impl LocalFolderWatcherSubscription {
pub(crate) fn root_uri(&self) -> &str {
self.guard.key.as_str()
}
}
struct LocalFolderWatcherSubscriptionGuard {
registry: Weak<LocalFolderWatcherRegistryInner>,
key: String,
channel: Arc<LocalFolderWatchChannel>,
}
impl Drop for LocalFolderWatcherSubscriptionGuard {
fn drop(&mut self) {
let previous = self.channel.subscriber_count.fetch_sub(1, Ordering::SeqCst);
if previous != 1 {
return;
}
if let Some(registry) = self.registry.upgrade() {
registry.remove_if_idle(&self.key, &self.channel);
}
}
}
fn spawn_local_folder_watcher(
root_uri: &str,
canonical_root: PathBuf,
) -> Result<(broadcast::Sender<Value>, oneshot::Sender<()>), String> {
let (event_sender, mut event_receiver) = mpsc::unbounded_channel::<notify::Result<Event>>();
let mut watcher = RecommendedWatcher::new(
move |result| {
let _ = event_sender.send(result);
},
Config::default(),
)
.map_err(|error| format!("本地文件事件监听启动失败: {error}"))?;
watcher
.watch(&canonical_root, RecursiveMode::Recursive)
.map_err(|error| format!("本地文件夹监听失败: {error}"))?;
let (sender, _) = broadcast::channel::<Value>(256);
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
let sender_for_task = sender.clone();
let root_uri_for_task = root_uri.to_string();
tokio::spawn(async move {
let _watcher = watcher;
loop {
tokio::select! {
_ = &mut shutdown_rx => {
return;
}
maybe_result = event_receiver.recv() => {
let Some(result) = maybe_result else {
return;
};
let Ok(event) = result else {
continue;
};
if !should_emit_event_kind(&event.kind) {
continue;
}
for path in event.paths {
if !is_markdown_path(&path) {
continue;
}
let Some(relative_path) = relative_path_string(&canonical_root, &path) else {
continue;
};
let payload = json!({
"sourceKind": "local_folder",
"rootUri": root_uri_for_task,
"relativePath": relative_path,
"documentId": format!("local-md:{}", encode_local_id_segment(&relative_path)),
"eventKind": format!("{:?}", event.kind),
"revision": event_revision(&path),
});
let _ = sender_for_task.send(payload);
}
}
}
}
});
Ok((sender, shutdown_tx))
}
fn canonical_root_uri(root: &Path) -> String {
format!("file://{}", root.display())
}
fn relative_path_string(root: &Path, path: &Path) -> Option<String> {
path.strip_prefix(root)
.ok()
.map(|relative| relative.to_string_lossy().replace('\\', "/"))
.filter(|relative| !relative.is_empty())
}
fn is_markdown_path(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| {
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
})
.unwrap_or(false)
}
fn should_emit_event_kind(kind: &EventKind) -> bool {
match kind {
EventKind::Create(_) | EventKind::Remove(_) => true,
EventKind::Modify(modify_kind) => matches!(
modify_kind,
ModifyKind::Any | ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Metadata(_)
),
_ => false,
}
}
fn encode_local_id_segment(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 event_revision(path: &Path) -> u128 {
std::fs::metadata(path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.map(system_time_ms)
.unwrap_or_else(|| system_time_ms(SystemTime::now()))
}
fn system_time_ms(time: SystemTime) -> u128 {
time.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::{should_emit_event_kind, LocalFolderWatcherRegistry};
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
use notify::EventKind;
fn test_root(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!(
"mnote-local-folder-watcher-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&root).expect("create temp root");
root
}
#[tokio::test]
async fn same_root_subscribers_share_single_watcher() {
let registry = LocalFolderWatcherRegistry::new();
let root = test_root("shared");
let first = registry.subscribe(&root).expect("first subscription");
let second = registry.subscribe(&root).expect("second subscription");
assert_eq!(registry.active_watcher_count(), 1);
drop(first);
tokio::task::yield_now().await;
assert_eq!(registry.active_watcher_count(), 1);
drop(second);
tokio::task::yield_now().await;
assert_eq!(registry.active_watcher_count(), 0);
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn different_roots_create_independent_watchers() {
let registry = LocalFolderWatcherRegistry::new();
let first_root = test_root("first");
let second_root = test_root("second");
let first = registry.subscribe(&first_root).expect("first root subscription");
let second = registry
.subscribe(&second_root)
.expect("second root subscription");
assert_eq!(registry.active_watcher_count(), 2);
drop(first);
drop(second);
tokio::task::yield_now().await;
assert_eq!(registry.active_watcher_count(), 0);
let _ = std::fs::remove_dir_all(first_root);
let _ = std::fs::remove_dir_all(second_root);
}
#[test]
fn event_kind_filter_ignores_access_events() {
assert!(should_emit_event_kind(&EventKind::Create(CreateKind::File)));
assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data(
DataChange::Content,
))));
assert!(!should_emit_event_kind(&EventKind::Access(AccessKind::Read)));
}
}
@@ -1,18 +1,17 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::decode_local_id_segment;
use axum::extract::{Extension, Query};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use futures_util::stream;
use notify::event::ModifyKind;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Deserialize;
use serde_json::{json, Value};
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::mpsc;
use tokio::sync::broadcast::error::RecvError;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -22,6 +21,7 @@ pub struct LocalFolderEventsQuery {
}
pub async fn local_folder_events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFolderEventsQuery>,
) -> Result<
@@ -43,78 +43,49 @@ pub async fn local_folder_events(
.document_id
.as_deref()
.and_then(local_markdown_relative_path_from_document_id);
let (sender, receiver) = mpsc::unbounded_channel::<Value>();
let (watch_sender, mut watch_receiver) =
mpsc::unbounded_channel::<notify::Result<Event>>();
let mut watcher = RecommendedWatcher::new(
move |result| {
let _ = watch_sender.send(result);
},
Config::default(),
)
.map_err(|error| {
WebError::internal(format!("本地文件事件监听启动失败: {error}")).with_context(&context)
})?;
watcher
.watch(&canonical_root, RecursiveMode::Recursive)
.map_err(|error| {
WebError::internal(format!("本地文件夹监听失败: {error}")).with_context(&context)
})?;
let root_for_task = canonical_root.clone();
let root_uri = query.root_uri.clone();
tokio::spawn(async move {
let _watcher = watcher;
while let Some(result) = watch_receiver.recv().await {
let Ok(event) = result else {
continue;
};
if !should_emit_event_kind(&event.kind) {
continue;
}
for path in event.paths {
if !is_markdown_path(&path) {
continue;
}
let Some(relative_path) = relative_path_string(&root_for_task, &path) else {
continue;
};
if let Some(expected) = document_relative_path.as_deref() {
if expected != relative_path {
continue;
}
}
let payload = json!({
"sourceKind": "local_folder",
"rootUri": root_uri,
"relativePath": relative_path,
"documentId": format!("local-md:{}", encode_local_id_segment(&relative_path)),
"eventKind": format!("{:?}", event.kind),
"revision": event_revision(&path),
});
if sender.send(payload).is_err() {
return;
}
}
}
});
let subscription = state
.local_folder_watcher_registry()
.subscribe(&canonical_root)
.map_err(|error| WebError::internal(error).with_context(&context))?;
let initial = json!({
"sourceKind": "local_folder",
"rootUri": query.root_uri,
"rootUri": subscription.root_uri(),
"documentId": query.document_id,
"revision": system_time_ms(SystemTime::now()),
});
let stream = stream::unfold((Some(initial), receiver), |(initial, mut receiver)| async move {
let stream = stream::unfold(
(Some(initial), subscription, document_relative_path),
|(initial, mut subscription, document_relative_path)| async move {
if let Some(payload) = initial {
return Some((Ok(stream_event("ready", &payload)), (None, receiver)));
return Some((
Ok(stream_event("ready", &payload)),
(None, subscription, document_relative_path),
));
}
receiver
.recv()
.await
.map(|payload| (Ok(stream_event("change", &payload)), (None, receiver)))
});
loop {
match subscription.receiver.recv().await {
Ok(payload) => {
if let Some(expected) = document_relative_path.as_deref() {
let relative_path = payload
.get("relativePath")
.and_then(Value::as_str)
.unwrap_or_default();
if expected != relative_path {
continue;
}
}
return Some((
Ok(stream_event("change", &payload)),
(None, subscription, document_relative_path),
));
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
},
);
let mut headers = HeaderMap::new();
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
@@ -151,55 +122,6 @@ fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<St
decode_local_id_segment(encoded).ok()
}
fn relative_path_string(root: &Path, path: &Path) -> Option<String> {
path.strip_prefix(root)
.ok()
.map(|relative| relative.to_string_lossy().replace('\\', "/"))
.filter(|relative| !relative.is_empty())
}
fn is_markdown_path(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| {
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
})
.unwrap_or(false)
}
fn should_emit_event_kind(kind: &EventKind) -> bool {
match kind {
EventKind::Create(_) | EventKind::Remove(_) => true,
EventKind::Modify(modify_kind) => matches!(
modify_kind,
ModifyKind::Any | ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Metadata(_)
),
_ => false,
}
}
fn encode_local_id_segment(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 event_revision(path: &Path) -> u128 {
std::fs::metadata(path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.map(system_time_ms)
.unwrap_or_else(|| system_time_ms(SystemTime::now()))
}
fn system_time_ms(time: SystemTime) -> u128 {
time.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
@@ -237,20 +159,8 @@ mod tests {
}
#[test]
fn markdown_path_filter_accepts_markdown_files_only() {
assert!(is_markdown_path(Path::new("README.md")));
assert!(is_markdown_path(Path::new("README.markdown")));
assert!(!is_markdown_path(Path::new("image.png")));
}
#[test]
fn event_kind_filter_ignores_access_events() {
assert!(should_emit_event_kind(&EventKind::Create(notify::event::CreateKind::File)));
assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data(
notify::event::DataChange::Content,
))));
assert!(!should_emit_event_kind(&EventKind::Access(
notify::event::AccessKind::Read,
)));
fn parse_file_root_uri_requires_file_scheme() {
assert!(parse_file_root_uri("file:///tmp/example").is_ok());
assert!(parse_file_root_uri("/tmp/example").is_err());
}
}
@@ -2655,6 +2655,15 @@ fn local_markdown_conflict_detection_key(
document_id: &str,
markdown_path: &Path,
) -> Result<String, WebError> {
let content = fs::read(markdown_path).map_err(|error| {
WebError::bad_request_code(
"local_markdown_read_failed",
format!(
"无法读取本地 Markdown 文件 {}: {error}",
markdown_path.display()
),
)
})?;
let meta = fs::metadata(markdown_path).map_err(|error| {
WebError::bad_request_code(
"local_markdown_stat_failed",
@@ -2665,8 +2674,11 @@ fn local_markdown_conflict_detection_key(
)
})?;
let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
let content_hash = hasher.finish();
Ok(format!(
"local-md:{document_id}:{modified_ms}:{}",
"local-md:{document_id}:{modified_ms}:{}:{content_hash:016x}",
meta.len()
))
}
@@ -2965,6 +2977,25 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_conflict_detection_key_changes_when_content_changes_with_same_size() {
let root = temp_root("mnote-local-conflict-key-content");
let file = root.join("README.md");
std::fs::write(&file, "aaaa\n").expect("write first content");
let first = super::local_markdown_conflict_detection_key("local-md:README.md", &file)
.expect("first conflict key");
std::fs::write(&file, "bbbb\n").expect("write second content");
let second = super::local_markdown_conflict_detection_key("local-md:README.md", &file)
.expect("second conflict key");
assert_ne!(first, second);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() {
let blocks = crate::routes::local_markdown_parser::markdown_to_blocks(
File diff suppressed because it is too large Load Diff
+228 -53
View File
@@ -7,6 +7,142 @@ use crate::ssr::pages::layout::PageLayout;
use leptos::prelude::*;
use serde_json::Value;
#[derive(Clone)]
pub struct DocumentPaneViewModel {
pub pane_role: &'static str,
pub title: String,
pub document_id: String,
pub workspace_id: String,
pub page_wide_layout: bool,
pub page_small_text: bool,
pub page_layout_density: String,
pub page_font: String,
pub page_show_heading_numbers: bool,
pub has_page_subtree: bool,
pub primary_legacy_ids: bool,
pub visible: bool,
}
#[component]
fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView {
let pane_hidden = !model.visible;
let pane_role = model.pane_role.to_string();
let pane_role_attr = pane_role.clone();
let pane_role_attr_two = pane_role.clone();
let pane_role_attr_three = pane_role.clone();
let pane_role_attr_four = pane_role.clone();
let pane_role_attr_five = pane_role.clone();
let pane_label = if model.pane_role == "secondary" {
"右侧文档"
} else {
"主文档"
};
let title_input_id = if model.primary_legacy_ids {
Some("mnote-page-title-input".to_string())
} else {
None
};
let editor_island_id = if model.primary_legacy_ids {
Some("mnote-editor-island".to_string())
} else {
None
};
let editor_root_id = if model.primary_legacy_ids {
Some("mnote-leptos-tiptap-island-editor-root".to_string())
} else {
None
};
view! {
<section
class="document-pane"
data-document-pane="true"
data-pane-role={pane_role_attr}
data-pane-document-id={model.document_id.clone()}
data-pane-workspace-id={model.workspace_id.clone()}
data-pane-visible={model.visible.to_string()}
aria-label={pane_label}
hidden={pane_hidden}
>
<main
class="document-shell"
data-editor-host="leptos_tiptap_island"
data-document-id={model.document_id.clone()}
data-workspace-id={model.workspace_id.clone()}
data-pane-role={pane_role_attr_two}
data-page-wide-layout={model.page_wide_layout.to_string()}
data-page-small-text={model.page_small_text.to_string()}
data-layout-density={model.page_layout_density.clone()}
data-page-font={model.page_font.clone()}
data-page-show-heading-numbers={model.page_show_heading_numbers.to_string()}
>
<header class="document-shell-header">
<div class="document-page-icon" aria-hidden="true">
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
</div>
<div class="document-pane-header-row">
<h1 class="document-title-heading">
<textarea
id={title_input_id}
class="document-title-input"
aria-label="页面标题"
data-page-title-input="true"
data-document-id={model.document_id.clone()}
data-workspace-id={model.workspace_id.clone()}
data-pane-role={pane_role_attr_three}
data-title-endpoint="/api/documents/title"
rows="1"
>{model.title.clone()}</textarea>
</h1>
<Show when={move || model.pane_role == "secondary"}>
<button
type="button"
class="document-pane-close"
data-mnote-pane-close="secondary"
aria-label="关闭右侧文档"
title="关闭右侧文档"
>
<span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span>
</button>
</Show>
</div>
<div class="document-shell-meta" aria-label="页面元信息">
<span data-page-title-current="true">{model.title.clone()}</span>
</div>
</header>
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
<section
data-testid="mnote-page-subtree"
data-page-tree-source="page_aggregate.tree.pageSubtree"
data-page-subtree-present={model.has_page_subtree.to_string()}
data-pane-role={pane_role_attr_four}
></section>
<section
id={editor_island_id}
data-editor-host="leptos_tiptap_island"
data-pane-role={pane_role_attr_five}
>
<div
id={editor_root_id}
data-testid="mnote-leptos-tiptap-island-editor-root"
data-editor-host-kind="leptos_tiptap_island"
data-runtime-editor-status="booting"
data-pane-role={pane_role.clone()}
></div>
<div
class="sr-only"
data-editor-host-observability="rust-web-inline-island"
data-editor-host-active="leptos_tiptap_island"
data-editor-host-requested="leptos_tiptap_island"
data-editor-host-status="booting"
data-pane-role={pane_role}
></div>
</section>
</main>
</section>
}
}
/// MNOTE 文档页面
///
/// 渲染文档编辑器的 SSR 壳结构:
@@ -37,6 +173,21 @@ pub fn DocumentPage(
/// Page Aggregate 页面选项 JSON(可选)
#[prop(optional)]
page_options_json: Option<String>,
/// 右侧文档标题(可选)
#[prop(optional)]
secondary_title: String,
/// 右侧文档 id(可选)
#[prop(optional)]
secondary_document_id: String,
/// 右侧 workspace id(可选)
#[prop(optional)]
secondary_workspace_id: String,
/// 右侧 Page Aggregate 子树 JSON(可选)
#[prop(optional)]
secondary_page_subtree_json: String,
/// 右侧页面选项 JSON(可选)
#[prop(optional)]
secondary_page_options_json: String,
) -> impl IntoView {
let has_page_subtree = page_subtree_json
.as_deref()
@@ -71,62 +222,86 @@ pub fn DocumentPage(
.unwrap_or("default")
.to_string();
let page_show_heading_numbers = false;
let secondary_has_page_subtree = Some(secondary_page_subtree_json.as_str())
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "null")
.is_some();
let secondary_page_options = Some(secondary_page_options_json.as_str())
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.unwrap_or(Value::Null);
let secondary_page_wide_layout = secondary_page_options
.get("wideLayout")
.and_then(Value::as_bool)
.unwrap_or(false);
let secondary_page_small_text = secondary_page_options
.get("smallText")
.and_then(Value::as_bool)
.unwrap_or(false);
let secondary_page_layout_density = secondary_page_options
.get("layoutDensity")
.and_then(Value::as_str)
.unwrap_or("normal")
.to_string();
let secondary_page_font = secondary_page_options
.get("pageFont")
.and_then(Value::as_str)
.unwrap_or("default")
.to_string();
let secondary_page_show_heading_numbers = false;
let secondary_visible = Some(secondary_document_id.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let primary_model = DocumentPaneViewModel {
pane_role: "primary",
title: title.clone(),
document_id: document_id.clone(),
workspace_id: workspace_id.clone(),
page_wide_layout,
page_small_text,
page_layout_density: page_layout_density.clone(),
page_font: page_font.clone(),
page_show_heading_numbers,
has_page_subtree,
primary_legacy_ids: true,
visible: true,
};
let secondary_model = DocumentPaneViewModel {
pane_role: "secondary",
title: secondary_title,
document_id: secondary_document_id,
workspace_id: if secondary_workspace_id.trim().is_empty() {
workspace_id.clone()
} else {
secondary_workspace_id
},
page_wide_layout: secondary_page_wide_layout,
page_small_text: secondary_page_small_text,
page_layout_density: secondary_page_layout_density,
page_font: secondary_page_font,
page_show_heading_numbers: secondary_page_show_heading_numbers,
has_page_subtree: secondary_has_page_subtree,
primary_legacy_ids: false,
visible: secondary_visible,
};
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
<main
class="document-shell"
data-editor-host="leptos_tiptap_island"
data-document-id={document_id.clone()}
data-workspace-id={workspace_id.clone()}
data-page-wide-layout={page_wide_layout.to_string()}
data-page-small-text={page_small_text.to_string()}
data-layout-density={page_layout_density.clone()}
data-page-font={page_font.clone()}
data-page-show-heading-numbers={page_show_heading_numbers.to_string()}
<div
class="document-workspace"
data-testid="mnote-document-workspace"
data-has-secondary-pane={secondary_visible.to_string()}
>
<header class="document-shell-header">
<div class="document-page-icon" aria-hidden="true">
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
</div>
<h1 class="document-title-heading">
<textarea
id="mnote-page-title-input"
class="document-title-input"
aria-label="页面标题"
data-page-title-input="true"
data-document-id={document_id.clone()}
data-workspace-id={workspace_id.clone()}
data-title-endpoint="/api/documents/title"
rows="1"
>{title.clone()}</textarea>
</h1>
<div class="document-shell-meta" aria-label="页面元信息">
<span><span aria-hidden="true">""</span>{workspace_label}</span>
<span><span aria-hidden="true">""</span>"已同步"</span>
</div>
</header>
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
<section
data-testid="mnote-page-subtree"
data-page-tree-source="page_aggregate.tree.pageSubtree"
data-page-subtree-present={has_page_subtree.to_string()}
></section>
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island">
<div
id="mnote-leptos-tiptap-island-editor-root"
data-testid="mnote-leptos-tiptap-island-editor-root"
data-editor-host-kind="leptos_tiptap_island"
data-runtime-editor-status="booting"
></div>
<div
class="sr-only"
data-editor-host-observability="rust-web-inline-island"
data-editor-host-active="leptos_tiptap_island"
data-editor-host-requested="leptos_tiptap_island"
data-editor-host-status="booting"
></div>
</section>
</main>
<DocumentPane model={primary_model} />
<div
class="document-pane-resizer"
data-testid="mnote-secondary-pane-resizer"
data-document-pane-resizer="true"
aria-hidden="true"
hidden={!secondary_visible}
></div>
<DocumentPane model={secondary_model} />
</div>
<div class="sr-only" data-mnote-workspace-label>{workspace_label}</div>
</PageLayout>
}
}
@@ -300,7 +300,7 @@ const SIDEBAR_TREE_JS: &str = r##"
function copyWorkspaceSourceParams(targetUrl) {
var params = new URLSearchParams(window.location.search);
['sourceKind', 'rootUri'].forEach(function(name) {
['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) {
var value = (params.get(name) || '').trim();
if (value) targetUrl.searchParams.set(name, value);
});
+102 -2
View File
@@ -1828,12 +1828,68 @@ body {
margin: 0 auto;
}
.document-workspace {
--mnote-secondary-pane-width: minmax(320px, 42%);
--mnote-secondary-pane-resizer-width: 6px;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
width: 100%;
min-width: 0;
}
.document-workspace[data-has-secondary-pane="true"] {
grid-template-columns: minmax(0, 1fr) var(--mnote-secondary-pane-resizer-width) var(--mnote-secondary-pane-width);
}
.document-pane {
min-width: 0;
}
.document-pane[hidden] {
display: none !important;
}
.document-pane-resizer {
display: none;
width: var(--mnote-secondary-pane-resizer-width);
min-height: calc(100vh - 40px);
cursor: col-resize;
position: relative;
}
.document-workspace[data-has-secondary-pane="true"] .document-pane-resizer {
display: block;
}
.document-pane-resizer::after {
content: "";
position: absolute;
left: 2px;
top: 64px;
bottom: 48px;
width: 2px;
border-radius: 999px;
background: rgba(27, 28, 28, 0.08);
}
.document-shell-header {
max-width: none;
padding: 0;
margin: 0 0 50px;
}
.document-pane-header-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.document-pane-header-row .document-title-heading {
flex: 1;
min-width: 0;
}
.document-page-icon {
display: none;
}
@@ -1886,6 +1942,26 @@ body {
text-underline-offset: 6px;
}
.document-pane-close {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: #6D6A65;
cursor: pointer;
flex: 0 0 auto;
}
.document-pane-close:hover {
background: #F4F3F3;
color: #1B1C1C;
}
.document-shell-meta {
display: none;
}
@@ -2048,6 +2124,16 @@ body {
width: min(100%, 980px);
}
.document-pane[data-pane-role="secondary"] .document-shell {
width: min(100%, 820px);
padding-left: 28px;
padding-right: 20px;
}
.document-pane[data-pane-role="secondary"] .document-shell-header {
margin-bottom: 36px;
}
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror,
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror li {
@@ -2601,6 +2687,20 @@ body {
display: none;
}
.document-workspace[data-has-secondary-pane="true"] {
grid-template-columns: minmax(0, 1fr);
}
.document-workspace[data-has-secondary-pane="true"] .document-pane-resizer {
display: none;
}
.document-pane[data-pane-role="secondary"] .document-shell {
padding-top: 32px;
padding-left: 0;
padding-right: 0;
}
.document-shell {
width: 100%;
padding: 48px 28px 120px;
@@ -2711,7 +2811,7 @@ mod tests {
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// 菜单与本地 SVG mask 图标会增加体积,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 46000);
// 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 70000);
}
}
@@ -46,29 +46,28 @@ export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly mount: (a: any, b: any) => [number, number, number];
readonly unmount: (a: number) => [number, number];
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
readonly intounderlyingsink_write: (a: number, b: any) => any;
readonly intounderlyingsink_close: (a: number) => any;
readonly intounderlyingsink_abort: (a: number, b: any) => any;
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
readonly intounderlyingbytesource_type: (a: number) => number;
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
readonly intounderlyingbytesource_cancel: (a: number) => void;
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
readonly intounderlyingsink_write: (a: number, b: any) => any;
readonly intounderlyingsink_close: (a: number) => any;
readonly intounderlyingsink_abort: (a: number, b: any) => any;
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
readonly intounderlyingsource_pull: (a: number, b: any) => any;
readonly intounderlyingsource_cancel: (a: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
readonly wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
readonly 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__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __externref_table_alloc: () => number;
@@ -785,7 +785,7 @@ function __wbg_get_imports() {
const a = state0.a;
state0.a = 0;
try {
return wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(a, state0.b, arg0, arg1);
return wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(a, state0.b, arg0, arg1);
} finally {
state0.a = a;
}
@@ -1014,24 +1014,6 @@ function __wbg_get_imports() {
const ret = arg0.right;
return ret;
},
__wbg_run_0b0a622deae25fda: function(arg0, arg1, arg2) {
try {
var state0 = {a: arg1, b: arg2};
var cb0 = () => {
const a = state0.a;
state0.a = 0;
try {
return wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(a, state0.b, );
} finally {
state0.a = a;
}
};
const ret = arg0.run(cb0);
return ret;
} finally {
state0.a = 0;
}
},
__wbg_scrollHeight_5fe8cbb97ae906d8: function(arg0) {
const ret = arg0.scrollHeight;
return ret;
@@ -1039,10 +1021,21 @@ function __wbg_get_imports() {
__wbg_scrollIntoView_7725227126cff177: function(arg0, arg1) {
arg0.scrollIntoView(arg1 !== 0);
},
__wbg_scrollTo_f357e55cd25f406f: function(arg0, arg1, arg2) {
arg0.scrollTo(arg1, arg2);
},
__wbg_scrollTop_f548101d48000fe9: function(arg0) {
const ret = arg0.scrollTop;
return ret;
},
__wbg_scrollX_c821c038bb4594f3: function() { return handleError(function (arg0) {
const ret = arg0.scrollX;
return ret;
}, arguments); },
__wbg_scrollY_e80bdf3571bdf5f3: function() { return handleError(function (arg0) {
const ret = arg0.scrollY;
return ret;
}, arguments); },
__wbg_search_ceee70e1153af3ec: function() { return handleError(function (arg0, arg1) {
const ret = arg1.search;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
@@ -1082,6 +1075,9 @@ function __wbg_get_imports() {
__wbg_set_body_be11680f34217f75: function(arg0, arg1) {
arg0.body = arg1;
},
__wbg_set_bubbles_50e942fa177ba6bd: function(arg0, arg1) {
arg0.bubbles = arg1 !== 0;
},
__wbg_set_detail_68bec5c91196ba57: function(arg0, arg1) {
arg0.detail = arg1;
},
@@ -1115,10 +1111,6 @@ function __wbg_get_imports() {
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_static_accessor_CREATE_TASK_f3ab6a6954bda493: function() {
const ret = typeof console === 'undefined' ? null : console?.createTask;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_GLOBAL_8cfadc87a297ca02: function() {
const ret = typeof global === 'undefined' ? null : global;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
@@ -1196,12 +1188,6 @@ function __wbg_get_imports() {
const ret = arg0.view;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_warn_3cc416af27dbdc02: function(arg0) {
console.warn(arg0);
},
__wbg_warn_bd0f407277b102f4: function(arg0, arg1, arg2) {
console.warn(arg0, arg1, arg2);
},
__wbg_width_9673a519d7bd5a6a: function(arg0) {
const ret = arg0.width;
return ret;
@@ -1219,43 +1205,43 @@ function __wbg_get_imports() {
}
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1476, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1025, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1715, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 794, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1806, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1632, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 916, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1717, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1631, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 918, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1653, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 940, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1716, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 976, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0) {
@@ -1299,48 +1285,43 @@ function __wbg_get_imports() {
};
}
function wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1);
return ret !== 0;
function wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3);
function wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3);
}
@@ -3,29 +3,28 @@
export const memory: WebAssembly.Memory;
export const mount: (a: any, b: any) => [number, number, number];
export const unmount: (a: number) => [number, number];
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
export const intounderlyingsink_write: (a: number, b: any) => any;
export const intounderlyingsink_close: (a: number) => any;
export const intounderlyingsink_abort: (a: number, b: any) => any;
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
export const intounderlyingbytesource_type: (a: number) => number;
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
export const intounderlyingbytesource_start: (a: number, b: any) => void;
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
export const intounderlyingbytesource_cancel: (a: number) => void;
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
export const intounderlyingsink_write: (a: number, b: any) => any;
export const intounderlyingsink_close: (a: number) => any;
export const intounderlyingsink_abort: (a: number, b: any) => any;
export const __wbg_intounderlyingsource_free: (a: number, b: number) => void;
export const intounderlyingsource_pull: (a: number, b: any) => any;
export const intounderlyingsource_cancel: (a: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
export const wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
export const 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__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __externref_table_alloc: () => number;
+321 -136
View File
@@ -19,7 +19,7 @@ use web_sys::{
MouseEvent, Node, RequestInit, RequestMode, Response, Storage, WheelEvent,
};
const EDITOR_STAGE_SELECTOR: &str = "#editor-stage";
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";
@@ -2497,6 +2497,22 @@ mod tests {
);
}
#[test]
fn runtime_editor_instance_id_uses_mount_id_when_available() {
assert_eq!(
runtime_editor_instance_id(Some(7)),
"mnote-leptos-tiptap-spike-7"
);
}
#[test]
fn runtime_editor_instance_id_falls_back_for_standalone_mode() {
assert_eq!(
runtime_editor_instance_id(None),
"mnote-leptos-tiptap-spike-standalone"
);
}
#[test]
fn persisted_document_storage_key_isolated_per_document_identity() {
let doc_a =
@@ -2625,6 +2641,13 @@ fn runtime_mount_options() -> Option<RuntimeMountOptions> {
RUNTIME_MOUNT_OPTIONS.with(|cell| cell.borrow().clone())
}
fn runtime_editor_instance_id(mount_id: Option<u32>) -> String {
mount_id
.filter(|value| *value > 0)
.map(|value| format!("mnote-leptos-tiptap-spike-{value}"))
.unwrap_or_else(|| "mnote-leptos-tiptap-spike-standalone".to_string())
}
fn runtime_event_target() -> Option<EventTarget> {
if let Some((_, target, _)) = runtime_mount_context() {
return Some(target);
@@ -2762,6 +2785,40 @@ fn schedule_scroll_mnote_block_anchor_from_hash_retry(remaining: u8) {
callback.forget();
}
fn current_viewport_scroll() -> Option<(f64, f64)> {
let win = window()?;
let x = win.scroll_x().ok()?;
let y = win.scroll_y().ok()?;
Some((x, y))
}
fn restore_viewport_scroll(x: f64, y: f64) {
if let Some(win) = window() {
win.scroll_to_with_x_and_y(x, y);
}
}
fn schedule_restore_viewport_scroll(x: f64, y: f64, remaining: u8) {
restore_viewport_scroll(x, y);
let Some(win) = window() else {
return;
};
if remaining == 0 {
return;
}
let callback = Closure::<dyn FnMut()>::new(move || {
restore_viewport_scroll(x, y);
if remaining > 1 {
schedule_restore_viewport_scroll(x, y, remaining - 1);
}
});
let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
callback.as_ref().unchecked_ref(),
80,
);
callback.forget();
}
fn dispatch_runtime_event<T>(event_name: &'static str, payload: &T)
where
T: Serialize,
@@ -2817,22 +2874,42 @@ fn dispatch_ready_event(payload: &ReadyPayload) {
dispatch_runtime_event(READY_EVENT, payload);
}
fn dispatch_ready_event_to_target(target: &EventTarget, payload: &ReadyPayload) {
dispatch_custom_event_to_target(target, READY_EVENT, payload);
}
fn dispatch_change_event(payload: &ChangePayload) {
dispatch_runtime_event(CHANGE_EVENT, payload);
}
fn dispatch_change_event_to_target(target: &EventTarget, payload: &ChangePayload) {
dispatch_custom_event_to_target(target, CHANGE_EVENT, payload);
}
fn dispatch_state_event(payload: &StatePayload) {
dispatch_runtime_event(STATE_EVENT, payload);
}
fn dispatch_state_event_to_target(target: &EventTarget, payload: &StatePayload) {
dispatch_custom_event_to_target(target, STATE_EVENT, payload);
}
fn dispatch_status_event(payload: &HostStatusPayload) {
dispatch_runtime_event(STATUS_EVENT, payload);
}
fn dispatch_status_event_to_target(target: &EventTarget, payload: &HostStatusPayload) {
dispatch_custom_event_to_target(target, STATUS_EVENT, payload);
}
fn dispatch_selection_event(payload: &SelectionPayload) {
dispatch_runtime_event(SELECTION_EVENT, payload);
}
fn dispatch_selection_event_to_target(target: &EventTarget, payload: &SelectionPayload) {
dispatch_custom_event_to_target(target, SELECTION_EVENT, payload);
}
fn register_unmount_handle<M: Any + leptos::prelude::Mountable + 'static>(
id: u32,
target: EventTarget,
@@ -4960,6 +5037,52 @@ fn dispatch_runtime_state(
));
}
fn dispatch_runtime_state_to_target(
target: &EventTarget,
document_id: Option<String>,
workspace_id: Option<String>,
title: String,
dirty_count: u32,
hovered_block: Option<HoveredBlockState>,
editor_focused: bool,
slash_open: bool,
turn_into_open: bool,
color_menu_open: bool,
more_menu_open: bool,
read_only: bool,
) {
let state = state_payload(
document_id.clone(),
workspace_id.clone(),
title.clone(),
dirty_count,
hovered_block.clone(),
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
read_only,
);
dispatch_state_event_to_target(target, &state);
dispatch_status_event_to_target(
target,
&status_payload(
document_id,
workspace_id,
title,
dirty_count,
hovered_block,
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
read_only,
),
);
}
fn send_selection_state(
selection: &TiptapSelectionState,
editor_focused: bool,
@@ -4972,6 +5095,18 @@ fn send_selection_state(
));
}
fn send_selection_state_to_target(
target: &EventTarget,
selection: &TiptapSelectionState,
editor_focused: bool,
current_block_index: Option<usize>,
) {
dispatch_selection_event_to_target(
target,
&selection_payload(selection, editor_focused, current_block_index),
);
}
fn apply_host_document_payload(
editor: TiptapEditorHandle,
payload: HostDocumentPayload,
@@ -5004,6 +5139,7 @@ fn apply_host_document_payload(
set_conflict_detection_key.set(next_conflict_detection_key.clone());
if let Some(content) = payload.content {
let viewport_scroll = current_viewport_scroll();
let next_content = TiptapContent::json(content);
match editor.set_content(next_content) {
Ok(()) => {
@@ -5015,6 +5151,9 @@ fn apply_host_document_payload(
);
set_dirty_count.set(0);
set_command_feedback.set("宿主文档已同步到编辑器".to_string());
if let Some((x, y)) = viewport_scroll {
schedule_restore_viewport_scroll(x, y, 10);
}
}
Err(err) => {
set_command_feedback.set(format!("宿主文档同步失败:{err}"));
@@ -6303,6 +6442,16 @@ fn normalize_layout_density(value: Option<String>) -> String {
#[component]
fn App(mount_options: MountOptions) -> impl IntoView {
let editor = TiptapEditorHandle::new();
let current_mount_id = runtime_mount_context().map(|(id, _, _)| id);
let editor_instance_id = runtime_editor_instance_id(current_mount_id);
let editor_stage_id = format!("{editor_instance_id}-stage");
let runtime_event_target = runtime_mount_context()
.map(|(_, target, _)| target)
.or_else(|| {
window()
.and_then(|win| win.document())
.map(|document| document.into())
});
let initial_document_id = mount_options
.document_id
.clone()
@@ -6420,6 +6569,11 @@ fn App(mount_options: MountOptions) -> impl IntoView {
.as_ref()
.and_then(|opts| opts.embed_default_block_id.clone()),
);
let command_event_target = runtime_event_target.clone();
let ready_event_target = runtime_event_target.clone();
let change_event_target = runtime_event_target.clone();
let selection_event_target = runtime_event_target.clone();
let slash_change_event_target = runtime_event_target.clone();
{
let block_menu_open = block_menu_open;
@@ -6483,6 +6637,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let set_show_heading_numbers = set_show_heading_numbers;
let set_embed_default_block_id = set_embed_default_block_id;
move |_| {
let command_event_target = command_event_target.clone();
let command_listener =
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
@@ -6564,19 +6719,22 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let editable = payload.editable.unwrap_or(true);
set_editor_editable.set(editable);
set_read_only.set(!editable);
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
if let Some(target) = command_event_target.as_ref() {
dispatch_runtime_state_to_target(
target,
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
}
}
HostCommandKind::SetPageOptions => {
if let Some(page_options) = payload.page_options {
@@ -6602,22 +6760,27 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let current_block = current_block_info_from_index(
hovered_block.get_untracked().map(|block| block.index),
);
dispatch_status_event(&HostStatusPayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
dirty_count: dirty_count.get_untracked(),
selected_block_index: current_block.index,
current_block_id: current_block.block_id,
editor_focused: editor_focused.get_untracked(),
read_only: read_only.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
});
if let Some(target) = command_event_target.as_ref() {
dispatch_status_event_to_target(
target,
&HostStatusPayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
dirty_count: dirty_count.get_untracked(),
selected_block_index: current_block.index,
current_block_id: current_block.block_id,
editor_focused: editor_focused.get_untracked(),
read_only: read_only.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
},
);
}
}
HostCommandKind::InsertInlineReference
| HostCommandKind::InsertEmbedReference => {
@@ -7379,7 +7542,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}}
<div
id="editor-stage"
id=editor_stage_id.clone()
class="editor-stage"
data-testid="mnote-leptos-tiptap-editor-stage"
data-page-wide-layout=move || wide_layout.get().to_string()
@@ -9169,6 +9332,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let show_category = index == 0
|| SLASH_ACTIONS[index - 1].category != category;
let testid = format!("slash-item-{}", action.id);
let slash_change_event_target = slash_change_event_target.clone();
view! {
<>
{if show_category {
@@ -9189,39 +9353,45 @@ fn App(mount_options: MountOptions) -> impl IntoView {
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
dispatch_change_event(&ChangePayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
content: snapshot.clone(),
meta: ChangeMetaPayload {
dirty_count: dirty_count.get_untracked(),
editor_focused: editor_focused.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
revision: revision.get_untracked(),
conflict_detection_key: conflict_detection_key.get_untracked(),
read_only: read_only.get_untracked(),
},
});
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
if let Some(target) = slash_change_event_target.as_ref() {
dispatch_change_event_to_target(
target,
&ChangePayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
content: snapshot.clone(),
meta: ChangeMetaPayload {
dirty_count: dirty_count.get_untracked(),
editor_focused: editor_focused.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
revision: revision.get_untracked(),
conflict_detection_key: conflict_detection_key.get_untracked(),
read_only: read_only.get_untracked(),
},
},
);
dispatch_runtime_state_to_target(
target,
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
}
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
@@ -9261,7 +9431,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}}
<TiptapEditor
id="mnote-leptos-tiptap-spike"
id=editor_instance_id.clone()
editor=editor
initial_content=initial_editor_content.clone()
placeholder="输入 “/” 打开命令菜单;试试 heading / list / todo / quote / code block / divider"
@@ -9290,39 +9460,45 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let snapshot =
sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
schedule_scroll_mnote_block_anchor_from_hash();
dispatch_ready_event(&ReadyPayload {
runtime_name: RUNTIME_NAME,
selectors: BridgeSelectorsPayload {
root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
},
supported_commands: vec![
"replaceContent",
"setEditable",
"undo",
"redo",
"focus",
"requestCurrentBlockId",
],
supports_embedded_mode: true,
});
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
if let Some(target) = ready_event_target.as_ref() {
dispatch_ready_event_to_target(
target,
&ReadyPayload {
runtime_name: RUNTIME_NAME,
selectors: BridgeSelectorsPayload {
root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
},
supported_commands: vec![
"replaceContent",
"setEditable",
"undo",
"redo",
"focus",
"requestCurrentBlockId",
],
supports_embedded_mode: true,
},
);
dispatch_runtime_state_to_target(
target,
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
}
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
@@ -9345,39 +9521,45 @@ fn App(mount_options: MountOptions) -> impl IntoView {
set_dirty_count.update(|count| *count += 1);
let snapshot =
sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
dispatch_change_event(&ChangePayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
content: snapshot.clone(),
meta: ChangeMetaPayload {
dirty_count: dirty_count.get_untracked(),
editor_focused: editor_focused.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
revision: revision.get_untracked(),
conflict_detection_key: conflict_detection_key.get_untracked(),
read_only: read_only.get_untracked(),
},
});
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
if let Some(target) = change_event_target.as_ref() {
dispatch_change_event_to_target(
target,
&ChangePayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
content: snapshot.clone(),
meta: ChangeMetaPayload {
dirty_count: dirty_count.get_untracked(),
editor_focused: editor_focused.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
revision: revision.get_untracked(),
conflict_detection_key: conflict_detection_key.get_untracked(),
read_only: read_only.get_untracked(),
},
},
);
dispatch_runtime_state_to_target(
target,
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
}
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
@@ -9422,11 +9604,14 @@ fn App(mount_options: MountOptions) -> impl IntoView {
set_block_menu_anchor.set(None);
set_hovered_block.set(None);
}
send_selection_state(
&selection_clone,
editor_focused.get_untracked(),
hovered_block.get_untracked().map(|block| block.index),
);
if let Some(target) = selection_event_target.as_ref() {
send_selection_state_to_target(
target,
&selection_clone,
editor_focused.get_untracked(),
hovered_block.get_untracked().map(|block| block.index),
);
}
}
attr:class="editor-surface"
attr:data-testid="mnote-leptos-tiptap-editor-root"