yggdrasil/pages/admin/note_editor/
composer.rs1use 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
13pub(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#[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 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 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#[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 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}