【dioxus】目次を作成する

[Reactで目次を作成しているemma gotoさんの記事](https://www.emgoto.com/react-table-of-contents/)を参考に，dioxus(ウェブ)を用いて以下のようなサイドバーの目次を作成します．今回はdioxusを使いますが，yewやsycamore，leptosなど他のフレームワークでも同じように作成できると思います．またcssは元記事とほとんど同じため解説は省略します．

![msedge_VgUAFjtuJS_圧縮済み.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1578803/32a3e41d-13ce-f3f8-fe06-eb484657932d.gif)

コード全体は[こちら](https://github.com/deepgreenAN/dioxus-toc-example)．利用するdioxusのバージョンは`"0.2.4"`です．

最終的な完成形として以下のように利用できる`TableObContents`を作成していきます．`TableObContents`にはプロパティとして何も渡さないので，`use_effect`内で`querySelectorAll`を利用してデータを取得することに注意します．
```rust
use dioxus::prelude::*;

fn App(cx: Scope) -> Element {
    let contents_str = include_str!("../contents/contents.html");

    cx.render(rsx! {
        div { class:"container",
            div { class:"main-contents", dangerous_inner_html: "{contents_str}" }
            TableObContents{}
        }
    })
}
```

## 目次を生成する

目次を表示させるだけなら`TableObContents`を以下のように実装できます．`web_sys::Document::query_selector_all`を用いて対象となる`h2`要素と`h3`要素を取得するため，レンダリングの後にクロージャーが実行される`use_effect`を利用します．`Element`(`web_sys::Element`)のVecを状態として保持することで，最初は空であるため何も描画されず，最初のレンダリングの後に状態が更新されて再レンダリングを行います．

```rust
use dioxus::core::to_owned;
use gloo_utils::document;
use wasm_bindgen::JsCast;
use web_sys::Element as WebSysElement;

fn TableObContents(cx: Scope) -> Element {
    let heading_elements = use_state(&cx, Vec::<WebSysElement>::new);

    use_effect(&cx, (), {
        to_owned![heading_elements];
        |_| async move {
            let node_list = document().query_selector_all("h2, h3").unwrap(); // web_sys::NodeList
            let elements = (0..node_list.length())
                .map(|i| {node_list.item(i).unwrap().unchecked_into()})
                .collect::<Vec<WebSysElement>>();

            heading_elements.set(elements);
    });

    cx.render(rsx! {
        div {
            nav{ aria_label:"Table of Contents",
                {
                    heading_elements.get().iter().enumerate().map(|(i,element)|{
                        let class = match element.tag_name().as_str() {
                            "H2" => "h2-toc-item",
                            "H3" => "h3-toc-item",
                            _ => unreachable!()
                        };

                        rsx! {
                            div {key:"{i}", class:"{class}", 
                                [element.inner_html()]
                            }
                        }
                    })
                }
            }
        }
    })
}
```

描画部分では状態`heading_elements`をイテレーションさせて描画します．それによってコンテンツに対応した目次を生成できます．`h2`要素と`h3`要素でクラス名を分けることでcssによってネストを表現できます．

```rust
heading_elements.get().iter().enumerate().map(|(i,element)|{
	let class = match element.tag_name().as_str() {
		"H2" => "h2-toc-item",
		"H3" => "h3-toc-item",
		_ => unreachable!()
	};

	rsx! {
		div {key:"{i}", class:"{class}", 
			[element.inner_html()]
		}
	}
})
```

次に目次をクリックすると対応するコンテンツにスクロールするようにします．以下のように`onclick`イベント内で`web_sys::Element::scroll_into_view`等を利用します．

```rust
use web_sys::ScrollIntoViewOptions, ScrollBehavior;


fn TableObContents(cx: Scope) -> Element {
    let heading_elements = use_state(&cx, Vec::<WebSysElement>::new);

    use_effect(&cx, (), {
        to_owned![heading_elements];
        |_| async move {
            let node_list = document().query_selector_all("h2, h3").unwrap(); // web_sys::NodeList
            let elements = (0..node_list.length())
                .map(|i| {node_list.item(i).unwrap().unchecked_into()})
                .collect::<Vec<WebSysElement>>();

            heading_elements.set(elements);
    });

    cx.render(rsx! {
        div {
            nav{ aria_label:"Table of Contents",
                {
                    heading_elements.get().iter().enumerate().map(|(i,element)|{
                        let class = match element.tag_name().as_str() {
                            "H2" => "h2-toc-item",
                            "H3" => "h3-toc-item",
                            _ => unreachable!()
                        };

+                       let mut scroll_options = ScrollIntoViewOptions::new();
+                       scroll_options.behavior(ScrollBehavior::Smooth);

                        rsx! {
                            div {key:"{i}", class:"{class}", 
+                               onclick: move |_|{element.scroll_into_view_with_scroll_into_view_options(&scroll_options)}, 
                                [element.inner_html()]
                            }
                        }
                    })
                }
            }
        }
    })
}
```

## 交差オブザーバーAPIを利用して現在位置を表示
次に交差オブザーバーAPIを利用して，現在表示されているコンテンツと対応した目次の要素の見た目を変更します(新しいクラス名を足す)．`web_sys::IntersectionObserver`は`js_sys::Function`が必要など不便な部分があるため，手前みそですが以前自分が作成した簡単なラッパー[intersection-wrapper](https://github.com/deepgreenAN/intersection-wrapper)(`gloo-intersection`として利用している)を利用します．
具体的には交差オブザーバーAPIを用いて以下のように現在位置を求めます．
1. 交差イベントが発火したHtml要素のうちエントリーの`is_intersecting`が`true`つまり画面に入ってきた要素をリストアップする
2. 同じく`is_intersecting`が`false`つまり画面から出ていった要素をリストから削除する．よってリストは画面に映っているHtml要素が入っている．
3. リスト内の要素のうち，最も上部にあるHtml要素を現在表示されているコンテンツとする．

ここで今回は"上部"とはHtml文書で早く出現したものとし，実際には位置を比べるのではなく出現した順番を比較することに注意します．

その画面内の要素のリストについては，要素のIDをキー，画面内にあるかどうかを示す`bool`をバリューとしたハッシュマップとします．今回はさらにインサートされた順番を保つ`indexmap::IndexMap`を利用します．`web_sys::NodeList`のインデックスは要素が出現した順番であるため，`web_sys::NodeList`と同じ順番で`indexmap::IndexMap`にインサートすることで，`IndexMap`をイテレーションした場合に最初にバリューが`true`となる要素を現在位置と決めることができます．


```rust
use gloo_intersection::IntersectionObserverHandler;
use std::rc::Rc;
use std::cell::{RefCell, Cell};
use indexmap::IndexMap;

fn TableObContents(cx: Scope) -> Element {
    let heading_elements = use_state(&cx, Vec::<WebSysElement>::new);
+   let visible_map = cx.use_hook(|_|{Rc::new(RefCell::new(IndexMap::<String, bool>::new()))});
+   let active_id = use_state(&cx, || Option::<String>::None);
+   let intersection_observer_handler = cx.use_hook(|_|Rc::new(Cell::new(Option::<IntersectionObserverHandler>::None))); 

    use_effect(&cx, (), {
        to_owned![
            heading_elements,
+           visible_map,
+           active_id,
+           intersection_observer_handler
        ];
        |_| async move {
            let node_list = document().query_selector_all("h2, h3").unwrap();
            let elements = (0..node_list.length())
                .map(|i| {
                    let element:WebSysElement = node_list.item(i).unwrap().unchecked_into();
+                   {
+                       visible_map.borrow_mut().insert(element.id(), false);
+                   }
                    element
                })
                .collect::<Vec<_>>();

            heading_elements.set(elements);

+           let handler = IntersectionObserverHandler::new(
+               move |entries, _|{
+                   entries.into_iter().for_each(|entry|{
+                       if let Some(is_visible) = visible_map.borrow_mut().get_mut(&entry.target().id()) {
+                           *is_visible = entry.is_intersecting();
+                       };
+                   });
+                   let visible_map = visible_map.borrow();
+                   let (found_key, _found_value) = visible_map.iter().find(|(_id, is_visible)|{**is_visible}).unwrap();
+                   active_id.set(Some(found_key.clone()));
+               }
+           ).unwrap();

+           for i in 0..node_list.length() {
+               handler.observe(&(node_list.item(i).unwrap().unchecked_into()));
+           }

+           intersection_observer_handler.set(Some(handler));
        }
    });

    cx.render(rsx! {
        div {
            nav{ aria_label:"Table of Contents",
                {
                    heading_elements.get().iter().enumerate().map(|(i,element)|{
                        let mut class = match element.tag_name().as_str() {
                            "H2" => {"h2-toc-item".to_string()},
                            "H3" => {"h3-toc-item".to_string()},
                            _ => {unreachable!()}
                        };
+                       if let Some(active_id) = active_id.get() {
+                           if element.id() == *active_id {
+                               class.push_str(" active");
+                           }
+                       }

                        let mut scroll_options = ScrollIntoViewOptions::new();
                        scroll_options.behavior(ScrollBehavior::Smooth);

                        rsx! {
                            div {key:"{i}", class:"{class}", 
                                onclick: move |_|{element.scroll_into_view_with_scroll_into_view_options(&scroll_options)}, 
                                [element.inner_html()]
                            }
                        }
                    })
                }
            }
        }
    })
}
```

新しく利用する状態は以下です．画面で表示されているHtml要素を示す`visible_map`，現在位置のHtml要素のIDを示す`active_id`(この状態の更新時に再レンダリングされます)，交差オブザーバーAPIのハンドラを保持するためだけの`intersection_observer_handler`の3つです．

```rust
let visible_map = cx.use_hook(|_|{Rc::new(RefCell::new(IndexMap::<String, bool>::new()))});
let active_id = use_state(&cx, || Option::<String>::None);
let intersection_observer_handler = cx.use_hook(|_|Rc::new(Cell::new(Option::<IntersectionObserverHandler>::None)));
```

交差オブザーバーAPIの利用部分は以下です．クロージャ―の引数として`web_sys::IntersectionObserverEntry`の`Vec`を取るため，それをイテレーションして`visible_map`を更新しています．そして更新した`visible_map`から作成したイテレータから`find`メソッドを用いることで現在位置となる要素のIDを取得します．また作成した`IntersectionObserverHandler`は忘れず`observe`します．

```rust
let handler = IntersectionObserverHandler::new(
	move |entries, _|{
		entries.into_iter().for_each(|entry|{
			if let Some(is_visible) = visible_map.borrow_mut().get_mut(&entry.target().id()) {
				*is_visible = entry.is_intersecting();
			};
		});
		let visible_map = visible_map.borrow();
		let (found_key, _found_value) = visible_map.iter().find(|(_id, is_visible)|{**is_visible}).unwrap();
		active_id.set(Some(found_key.clone()));
	}
).unwrap();

for i in 0..node_list.length() {
	handler.observe(&(node_list.item(i).unwrap().unchecked_into()));
}

intersection_observer_handler.set(Some(handler));
```

`active_id`に対応した目次の要素に`active`というクラス名を追加することでスタイルを変更します．

```rust
let mut class = match element.tag_name().as_str() {
	"H2" => {"h2-toc-item".to_string()},
	"H3" => {"h3-toc-item".to_string()},
	_ => {unreachable!()}
};
if let Some(active_id) = active_id.get() {
	if element.id() == *active_id {
		class.push_str(" active");
	}
}
```

以上のような`TableObContents`を最初に書いたように利用することで目次を作成できます．