Skip to main content

yggdrasil/pages/admin/note_editor/
composer.rs

1use crate::bridges::library::{
2    library_ready, use_browser_library, BrowserLibrary, LibraryLoadError,
3};
4use crate::bridges::tiptap::{UploadErrorEntry, UploadsInFlight};
5use crate::components::assets::{AssetPickerModal, AssetSelection};
6use leptos::prelude::*;
7
8#[cfg(target_arch = "wasm32")]
9type Handle = crate::bridges::tiptap::EditorHandle;
10#[cfg(not(target_arch = "wasm32"))]
11type Handle = ();
12
13/// 把焦点放进正文编辑区。
14pub(super) fn focus_editor() {
15    #[cfg(target_arch = "wasm32")]
16    {
17        use wasm_bindgen::JsCast;
18        if let Some(el) = web_sys::window()
19            .and_then(|w| w.document())
20            .and_then(|d| {
21                d.query_selector("#note-tiptap [contenteditable]")
22                    .ok()
23                    .flatten()
24            })
25            .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
26        {
27            let _ = el.focus();
28        }
29    }
30}
31
32/// 富文本编辑与笔记专用的受保护图片上传。素材库中的图片本来就是公开素材,
33/// 选择面板的标题写明这一点;私密图片在编辑器里上传。
34///
35/// 视图在服务端与浏览器一致(编辑器实例只在 Effect 里创建);`initial` 只在创建时读取,
36/// 之后内容由编辑器自己持有,父级通过更换组件(`editor_epoch`)重建。
37#[component]
38pub(super) fn NoteComposer(
39    initial: String,
40    on_change: Callback<String>,
41    uploading: RwSignal<bool>,
42) -> impl IntoView {
43    let library = use_browser_library(BrowserLibrary::Tiptap, || true);
44    let editor = StoredValue::new_local(None::<Handle>);
45    let ready = RwSignal::new(false);
46    let error = RwSignal::new(String::new());
47    let counts = RwSignal::new(UploadsInFlight::default());
48    let errors = RwSignal::new(Vec::<UploadErrorEntry>::new());
49    let pick = RwSignal::new(false);
50    let picking_upload = RwSignal::new(false);
51    let container = NodeRef::<leptos::html::Div>::new();
52    // 卸载时丢弃句柄:Drop 先 destroy 实例,再释放回调。
53    on_cleanup(move || {
54        let _ = editor.try_update_value(|handle| *handle = None);
55    });
56    Effect::new(move |_| {
57        let counts = counts.get();
58        uploading.set(counts.uploading > 0 || counts.error > 0 || picking_upload.get());
59    });
60    Effect::new(move |_| {
61        // 库就绪且容器挂入文档后才创建;等一帧确保容器已在文档里。
62        if container.get().is_none() || !library_ready(library) {
63            return;
64        }
65        let initial = initial.clone();
66        request_animation_frame(move || {
67            #[cfg(target_arch = "wasm32")]
68            if let Some(element) = container.get_untracked() {
69                create_editor(
70                    &element, editor, initial, on_change, ready, error, counts, errors, pick,
71                );
72            }
73            #[cfg(not(target_arch = "wasm32"))]
74            let _ = (initial, on_change);
75        });
76    });
77
78    view! {
79        <LibraryLoadError library/>
80        {move || {
81            let message = error.get();
82            (!message.is_empty())
83                .then(|| {
84                    view! {
85                        <p class="write-notice" role="alert">
86                            {message}
87                        </p>
88                    }
89                })
90        }}
91        {move || {
92            errors
93                .get()
94                .into_iter()
95                .map(|item| {
96                    let id = item.id.clone();
97                    view! {
98                        <p class="write-notice" role="alert">
99                            <span>{item.message}</span>
100                            <button
101                                class="btn btn-sm btn-ghost"
102                                type="button"
103                                on:click=move |_| {
104                                    #[cfg(target_arch = "wasm32")]
105                                    editor
106                                        .with_value(|handle| {
107                                            if let Some(handle) = handle.as_ref() {
108                                                handle.instance().remove_upload_by_upload_id(&id);
109                                            }
110                                        });
111                                    #[cfg(not(target_arch = "wasm32"))]
112                                    let _ = &id;
113                                }
114                            >
115                                "移除失败图片"
116                            </button>
117                        </p>
118                    }
119                })
120                .collect_view()
121        }}
122        {move || {
123            (!ready.get())
124                .then(|| {
125                    view! {
126                        <p class="note-editor-loading" role="status">
127                            "正在加载编辑器…"
128                        </p>
129                    }
130                })
131        }}
132        <div id="note-tiptap" class="write-editor" node_ref=container></div>
133        {move || {
134            pick.get()
135                .then(|| {
136                    view! {
137                        <AssetPickerModal
138                            visible=pick
139                            cover_uploading=picking_upload
140                            title="选择公开素材"
141                            multi=true
142                            on_select=Callback::new(move |items: Vec<AssetSelection>| {
143                                #[cfg(target_arch = "wasm32")]
144                                editor
145                                    .with_value(|handle| {
146                                        if let Some(handle) = handle.as_ref() {
147                                            if let Ok(json) = serde_json::to_string(&items) {
148                                                handle.instance().insert_images_from_library(&json);
149                                            }
150                                        }
151                                    });
152                                #[cfg(not(target_arch = "wasm32"))]
153                                let _ = &items;
154                                pick.set(false);
155                            })
156                        />
157                    }
158                })
159        }}
160    }
161}
162
163/// 在容器里创建 Tiptap 实例并把回调随句柄一起交给 `editor`。已存在实例或组件已卸载时什么都不做。
164#[cfg(target_arch = "wasm32")]
165#[allow(clippy::too_many_arguments)]
166fn create_editor(
167    container: &web_sys::HtmlElement,
168    editor: StoredValue<Option<Handle>, leptos::reactive::owner::LocalStorage>,
169    initial: String,
170    on_change: Callback<String>,
171    ready: RwSignal<bool>,
172    error: RwSignal<String>,
173    counts: RwSignal<UploadsInFlight>,
174    errors: RwSignal<Vec<UploadErrorEntry>>,
175    pick: RwSignal<bool>,
176) {
177    use crate::bridges::tiptap::{self, EditorOptions};
178    use wasm_bindgen::prelude::*;
179
180    // `None`:组件已卸载;`Some(true)`:已有实例。
181    if editor.try_with_value(Option::is_some) != Some(false) {
182        return;
183    }
184    let update = Closure::new(move |md: String| {
185        on_change.try_run(md);
186    });
187    let on_ready = Closure::new(move || {
188        let _ = ready.try_set(true);
189    });
190    let upload = Closure::new(move |file: web_sys::File| {
191        wasm_bindgen_futures::future_to_promise(async move {
192            tiptap::wasm::upload_note_image_file(file)
193                .await
194                .map(|s| JsValue::from_str(&s))
195                .map_err(|e| JsValue::from_str(&e))
196        })
197    });
198    let upload_event = Closure::new(move |ev: tiptap::UploadEventJs| {
199        if counts.try_get_untracked().is_some() && errors.try_get_untracked().is_some() {
200            tiptap::consume_upload_event(&ev, counts, errors)
201        }
202    });
203    let run = tiptap::make_run_code_closure();
204    let render_mermaid = tiptap::make_render_mermaid_closure();
205    let picker = Closure::new(move || {
206        let _ = pick.try_set(true);
207    });
208    let opts = EditorOptions::default();
209    opts.set_placeholder("从一个想法开始。输入 / 插入代码、图片、公式或引用…");
210    opts.set_on_update(&update);
211    opts.set_on_ready(&on_ready);
212    opts.set_on_image_upload(&upload);
213    opts.set_on_upload_event(&upload_event);
214    opts.set_on_run_code(&run);
215    opts.set_on_pick_from_library(&picker);
216    opts.set_on_render_mermaid(&render_mermaid);
217    match tiptap::create(container, &opts) {
218        Ok(inst) => {
219            let _ = inst.set_markdown(&initial);
220            let _ = editor.try_set_value(Some(
221                Handle::new(inst, update, upload, on_ready, upload_event, run, picker)
222                    .with_render_mermaid(render_mermaid),
223            ));
224        }
225        Err(_) => {
226            let _ = error.try_set("编辑器初始化失败,请刷新后重试".into());
227        }
228    }
229}