refactor: split sidebar settings and slash menu view
This commit is contained in:
@@ -19,6 +19,7 @@ pub(crate) mod overlays;
|
||||
pub(crate) mod persistence;
|
||||
pub(crate) mod runtime_bridge;
|
||||
pub(crate) mod slash_actions;
|
||||
pub(crate) mod slash_menu_view;
|
||||
pub(crate) mod style;
|
||||
pub(crate) mod table_commands;
|
||||
pub(crate) mod table_toolbar_view;
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
use crate::editor_runtime::{
|
||||
block_hover_state::HoveredBlockState,
|
||||
block_transform::run_slash_action,
|
||||
bridge_events::{ChangeMetaPayload, ChangePayload},
|
||||
command_sync::read_editor_snapshot,
|
||||
overlays::toolbar_overlay_locked,
|
||||
persistence::{persist_document_state, persisted_document_identity, PersistedDocumentIdentity},
|
||||
runtime_bridge::dispatch_change_event_to_target,
|
||||
slash_actions::{SlashActionKind, SLASH_ACTIONS},
|
||||
};
|
||||
use leptos::prelude::*;
|
||||
use leptos_tiptap::TiptapEditorHandle;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use web_sys::EventTarget;
|
||||
|
||||
pub(crate) struct SlashMenuViewProps {
|
||||
pub(crate) editor: TiptapEditorHandle,
|
||||
pub(crate) signals: SlashMenuSignals,
|
||||
pub(crate) runtime: SlashMenuRuntimeContext,
|
||||
pub(crate) anchor_style: Arc<dyn Fn() -> String + Send + Sync>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SlashMenuSignals {
|
||||
pub(crate) slash_open: ReadSignal<bool>,
|
||||
pub(crate) set_slash_open: WriteSignal<bool>,
|
||||
pub(crate) slash_index: ReadSignal<usize>,
|
||||
pub(crate) slash_query: ReadSignal<String>,
|
||||
pub(crate) dirty_count: ReadSignal<u32>,
|
||||
pub(crate) set_dirty_count: WriteSignal<u32>,
|
||||
pub(crate) document_json: WriteSignal<Value>,
|
||||
pub(crate) html_output: WriteSignal<String>,
|
||||
pub(crate) json_output: WriteSignal<String>,
|
||||
pub(crate) document_id: ReadSignal<Option<String>>,
|
||||
pub(crate) workspace_id: ReadSignal<Option<String>>,
|
||||
pub(crate) title: ReadSignal<String>,
|
||||
pub(crate) editor_focused: ReadSignal<bool>,
|
||||
pub(crate) hovered_block: ReadSignal<Option<HoveredBlockState>>,
|
||||
pub(crate) turn_into_open: ReadSignal<bool>,
|
||||
pub(crate) color_menu_open: ReadSignal<bool>,
|
||||
pub(crate) more_menu_open: ReadSignal<bool>,
|
||||
pub(crate) revision: ReadSignal<Option<i64>>,
|
||||
pub(crate) conflict_detection_key: ReadSignal<Option<String>>,
|
||||
pub(crate) read_only: ReadSignal<bool>,
|
||||
pub(crate) command_feedback: WriteSignal<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SlashMenuRuntimeContext {
|
||||
pub(crate) change_event_target: Option<EventTarget>,
|
||||
pub(crate) dispatch_runtime_state: Arc<dyn Fn(&EventTarget) + Send + Sync>,
|
||||
}
|
||||
|
||||
fn runtime_persisted_identity(
|
||||
document_id: ReadSignal<Option<String>>,
|
||||
workspace_id: ReadSignal<Option<String>>,
|
||||
) -> PersistedDocumentIdentity {
|
||||
persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked())
|
||||
}
|
||||
|
||||
fn filtered_slash_intro_view(
|
||||
query: &str,
|
||||
command_feedback: WriteSignal<String>,
|
||||
) -> impl IntoView {
|
||||
let show_turn_into = !query.is_empty()
|
||||
&& ("zhw".contains(query) || "转换为".contains(query));
|
||||
let show_page = !query.is_empty() && ("ym".contains(query) || "页面".contains(query));
|
||||
|
||||
if show_turn_into || show_page {
|
||||
view! {
|
||||
<>
|
||||
{if show_turn_into {
|
||||
view! {
|
||||
<>
|
||||
<div class="slash-section-label">"转换为"</div>
|
||||
<button
|
||||
class="slash-item"
|
||||
data-active="false"
|
||||
data-testid="slash-item-turn-into"
|
||||
on:click=move |_| {
|
||||
command_feedback.set("转换为入口请通过块菜单或选区工具栏执行".to_string());
|
||||
}
|
||||
>
|
||||
<span class="slash-item-row">
|
||||
<span class="slash-item-icon">"↻"</span>
|
||||
<span class="slash-item-copy">
|
||||
<strong>"转换为"</strong>
|
||||
<span>"打开块类型转换入口"</span>
|
||||
</span>
|
||||
<span class="slash-item-shortcut">"/zhw"</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
{if show_page {
|
||||
view! {
|
||||
<>
|
||||
<div class="slash-section-label">"基础块列表"</div>
|
||||
<button
|
||||
class="slash-item"
|
||||
data-active="false"
|
||||
data-testid="slash-item-page"
|
||||
on:click=move |_| {
|
||||
command_feedback.set("页面入口请通过转换为页面真源链路执行".to_string());
|
||||
}
|
||||
>
|
||||
<span class="slash-item-row">
|
||||
<span class="slash-item-icon">"▣"</span>
|
||||
<span class="slash-item-copy">
|
||||
<strong>"页面"</strong>
|
||||
<span>"创建或转换为页面块"</span>
|
||||
</span>
|
||||
<span class="slash-item-shortcut">"/ym"</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
</>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_slash_item_click(
|
||||
editor: TiptapEditorHandle,
|
||||
kind: SlashActionKind,
|
||||
signals: SlashMenuSignals,
|
||||
runtime: &SlashMenuRuntimeContext,
|
||||
) {
|
||||
match run_slash_action(editor, kind) {
|
||||
Ok(message) => {
|
||||
signals.set_slash_open.set(false);
|
||||
signals.set_dirty_count.update(|count| *count += 1);
|
||||
let (html, snapshot, json_text) = read_editor_snapshot(editor);
|
||||
signals.html_output.set(html.clone());
|
||||
signals.document_json.set(snapshot.clone());
|
||||
signals.json_output.set(json_text);
|
||||
if let Some(target) = runtime.change_event_target.as_ref() {
|
||||
dispatch_change_event_to_target(
|
||||
target,
|
||||
&ChangePayload {
|
||||
document_id: signals.document_id.get_untracked(),
|
||||
workspace_id: signals.workspace_id.get_untracked(),
|
||||
title: signals.title.get_untracked(),
|
||||
content: snapshot.clone(),
|
||||
meta: ChangeMetaPayload {
|
||||
dirty_count: signals.dirty_count.get_untracked(),
|
||||
editor_focused: signals.editor_focused.get_untracked(),
|
||||
slash_open: signals.slash_open.get_untracked(),
|
||||
toolbar_open: toolbar_overlay_locked(
|
||||
signals.turn_into_open.get_untracked(),
|
||||
signals.color_menu_open.get_untracked(),
|
||||
signals.more_menu_open.get_untracked(),
|
||||
),
|
||||
selected_block_index: signals
|
||||
.hovered_block
|
||||
.get_untracked()
|
||||
.map(|block| block.index),
|
||||
revision: signals.revision.get_untracked(),
|
||||
conflict_detection_key: signals.conflict_detection_key.get_untracked(),
|
||||
read_only: signals.read_only.get_untracked(),
|
||||
},
|
||||
},
|
||||
);
|
||||
(runtime.dispatch_runtime_state)(target);
|
||||
}
|
||||
match persist_document_state(
|
||||
&runtime_persisted_identity(signals.document_id, signals.workspace_id),
|
||||
&signals.title.get_untracked(),
|
||||
&snapshot,
|
||||
Some(html),
|
||||
) {
|
||||
Ok(()) => signals
|
||||
.command_feedback
|
||||
.set(format!("{message},并已写入本地草稿")),
|
||||
Err(err) => signals
|
||||
.command_feedback
|
||||
.set(format!("{message},但本地保存失败:{err}")),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
signals.set_slash_open.set(false);
|
||||
signals
|
||||
.command_feedback
|
||||
.set(format!("命令执行失败:{err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn slash_menu_view(props: SlashMenuViewProps) -> impl IntoView {
|
||||
let SlashMenuViewProps {
|
||||
editor,
|
||||
signals,
|
||||
runtime,
|
||||
anchor_style,
|
||||
} = props;
|
||||
|
||||
view! {
|
||||
{move || {
|
||||
if signals.slash_open.get() {
|
||||
let anchor_style = anchor_style.clone();
|
||||
let runtime = runtime.clone();
|
||||
view! {
|
||||
<div
|
||||
class="slash-menu"
|
||||
data-testid="mnote-leptos-tiptap-slash-menu"
|
||||
style=anchor_style()
|
||||
>
|
||||
<div class="slash-list">
|
||||
{move || {
|
||||
let query = signals.slash_query.get().trim().to_ascii_lowercase();
|
||||
filtered_slash_intro_view(&query, signals.command_feedback)
|
||||
}}
|
||||
{SLASH_ACTIONS.iter().enumerate().map(|(index, action)| {
|
||||
let is_selected = move || signals.slash_index.get() == index;
|
||||
let kind = action.kind;
|
||||
let label = action.label;
|
||||
let description = action.description;
|
||||
let shortcut = action.shortcut;
|
||||
let icon = action.icon;
|
||||
let category = action.category;
|
||||
let show_category = index == 0
|
||||
|| SLASH_ACTIONS[index - 1].category != category;
|
||||
let testid = format!("slash-item-{}", action.id);
|
||||
let runtime = runtime.clone();
|
||||
view! {
|
||||
<>
|
||||
{if show_category {
|
||||
view! { <div class="slash-section-label">{category}</div> }.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
<button
|
||||
class="slash-item"
|
||||
data-active=move || is_selected().to_string()
|
||||
data-testid=testid
|
||||
on:click=move |_| {
|
||||
handle_slash_item_click(editor, kind, signals, &runtime);
|
||||
}
|
||||
>
|
||||
<span class="slash-item-row">
|
||||
<span class="slash-item-icon">{icon}</span>
|
||||
<span class="slash-item-copy">
|
||||
<strong>{label}</strong>
|
||||
<span>{description}</span>
|
||||
</span>
|
||||
<span class="slash-item-shortcut">{shortcut}</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ use editor_runtime::runtime_bridge::{
|
||||
dispatch_status_event_to_target, register_runtime_listener, take_unmount_handle,
|
||||
};
|
||||
use editor_runtime::slash_actions::{SlashActionKind, SLASH_ACTIONS};
|
||||
use editor_runtime::slash_menu_view::{
|
||||
slash_menu_view, SlashMenuRuntimeContext, SlashMenuSignals, SlashMenuViewProps,
|
||||
};
|
||||
use editor_runtime::style::SPIKE_STYLE;
|
||||
use editor_runtime::table_toolbar_view::table_toolbar_view;
|
||||
|
||||
@@ -2240,6 +2243,54 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
}
|
||||
};
|
||||
|
||||
let slash_menu_runtime = SlashMenuRuntimeContext {
|
||||
change_event_target: slash_change_event_target.clone(),
|
||||
dispatch_runtime_state: std::sync::Arc::new(move |target: &EventTarget| {
|
||||
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(),
|
||||
);
|
||||
}),
|
||||
};
|
||||
let slash_menu_props = SlashMenuViewProps {
|
||||
editor,
|
||||
signals: SlashMenuSignals {
|
||||
slash_open,
|
||||
set_slash_open,
|
||||
slash_index,
|
||||
slash_query,
|
||||
dirty_count,
|
||||
set_dirty_count,
|
||||
document_json: set_document_json,
|
||||
html_output: set_html_output,
|
||||
json_output: set_json_output,
|
||||
document_id,
|
||||
workspace_id,
|
||||
title,
|
||||
editor_focused,
|
||||
hovered_block,
|
||||
turn_into_open,
|
||||
color_menu_open,
|
||||
more_menu_open,
|
||||
revision,
|
||||
conflict_detection_key,
|
||||
read_only,
|
||||
command_feedback: set_command_feedback,
|
||||
},
|
||||
runtime: slash_menu_runtime,
|
||||
anchor_style: std::sync::Arc::new(slash_menu_anchor_style),
|
||||
};
|
||||
|
||||
view! {
|
||||
<style>{SPIKE_STYLE}</style>
|
||||
<main
|
||||
@@ -3828,192 +3879,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
}
|
||||
}}
|
||||
|
||||
{move || {
|
||||
if slash_open.get() {
|
||||
view! {
|
||||
<div
|
||||
class="slash-menu"
|
||||
data-testid="mnote-leptos-tiptap-slash-menu"
|
||||
style=slash_menu_anchor_style()
|
||||
>
|
||||
<div class="slash-list">
|
||||
{move || {
|
||||
let query = slash_query.get().trim().to_ascii_lowercase();
|
||||
let show_turn_into = !query.is_empty()
|
||||
&& ("zhw".contains(query.as_str())
|
||||
|| "转换为".contains(query.as_str()));
|
||||
let show_page = !query.is_empty()
|
||||
&& ("ym".contains(query.as_str())
|
||||
|| "页面".contains(query.as_str()));
|
||||
if show_turn_into || show_page {
|
||||
view! {
|
||||
<>
|
||||
{if show_turn_into {
|
||||
view! {
|
||||
<>
|
||||
<div class="slash-section-label">"转换为"</div>
|
||||
<button
|
||||
class="slash-item"
|
||||
data-active="false"
|
||||
data-testid="slash-item-turn-into"
|
||||
on:click=move |_| {
|
||||
set_command_feedback.set("转换为入口请通过块菜单或选区工具栏执行".to_string());
|
||||
}
|
||||
>
|
||||
<span class="slash-item-row">
|
||||
<span class="slash-item-icon">"↻"</span>
|
||||
<span class="slash-item-copy">
|
||||
<strong>"转换为"</strong>
|
||||
<span>"打开块类型转换入口"</span>
|
||||
</span>
|
||||
<span class="slash-item-shortcut">"/zhw"</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
{if show_page {
|
||||
view! {
|
||||
<>
|
||||
<div class="slash-section-label">"基础块列表"</div>
|
||||
<button
|
||||
class="slash-item"
|
||||
data-active="false"
|
||||
data-testid="slash-item-page"
|
||||
on:click=move |_| {
|
||||
set_command_feedback.set("页面入口请通过转换为页面真源链路执行".to_string());
|
||||
}
|
||||
>
|
||||
<span class="slash-item-row">
|
||||
<span class="slash-item-icon">"▣"</span>
|
||||
<span class="slash-item-copy">
|
||||
<strong>"页面"</strong>
|
||||
<span>"创建或转换为页面块"</span>
|
||||
</span>
|
||||
<span class="slash-item-shortcut">"/ym"</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
</>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
{SLASH_ACTIONS.iter().enumerate().map(|(index, action)| {
|
||||
let is_selected = move || slash_index.get() == index;
|
||||
let kind = action.kind;
|
||||
let label = action.label;
|
||||
let description = action.description;
|
||||
let shortcut = action.shortcut;
|
||||
let icon = action.icon;
|
||||
let category = action.category;
|
||||
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 {
|
||||
view! { <div class="slash-section-label">{category}</div> }.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
<button
|
||||
class="slash-item"
|
||||
data-active=move || is_selected().to_string()
|
||||
data-testid=testid
|
||||
on:click=move |_| {
|
||||
match run_slash_action(editor, kind) {
|
||||
Ok(message) => {
|
||||
set_slash_open.set(false);
|
||||
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);
|
||||
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(),
|
||||
&snapshot,
|
||||
Some(html),
|
||||
) {
|
||||
Ok(()) => set_command_feedback.set(format!("{message},并已写入本地草稿")),
|
||||
Err(err) => set_command_feedback.set(format!("{message},但本地保存失败:{err}")),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
set_slash_open.set(false);
|
||||
set_command_feedback.set(format!("命令执行失败:{err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
<span class="slash-item-row">
|
||||
<span class="slash-item-icon">{icon}</span>
|
||||
<span class="slash-item-copy">
|
||||
<strong>{label}</strong>
|
||||
<span>{description}</span>
|
||||
</span>
|
||||
<span class="slash-item-shortcut">{shortcut}</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
{slash_menu_view(slash_menu_props)}
|
||||
|
||||
<TiptapEditor
|
||||
id=editor_instance_id.clone()
|
||||
|
||||
Reference in New Issue
Block a user