File size: 4,101 Bytes
e66cfb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import dash_mantine_components as dmc
from dash import html, dcc, Input, Output, State, clientside_callback, ALL

_TOC_CB_REGISTERED = False


def _indent_px_by_level(level: int, base_px: int = 14) -> int:
    try:
        lvl = int(level or 1)
    except Exception:
        lvl = 1

    if lvl <= 1:
        return 0              # H1
    elif lvl == 2:
        return base_px        # H2
    else:
        return base_px * 2    # H3 以上


def make_toc(
    titles,
    *,
    top_offset: int = 80,
    col_span=None,
    visible_from: str = "md",
):
    global _TOC_CB_REGISTERED

    if not _TOC_CB_REGISTERED:
        clientside_callback(
            """
            function(n_list, id_list, topOffset) {
                if (!n_list || !Array.isArray(n_list) || !id_list) {
                    return window.dash_clientside.no_update;
                }
                const ctx = dash_clientside.callback_context;
                if (!ctx.triggered.length) {
                    return window.dash_clientside.no_update;
                }
                const propId = ctx.triggered[0].prop_id;
                const m = propId.match(/\\[(\\d+)\\]\\.n_clicks$/);
                let idx = -1;
                if (m) {
                    idx = parseInt(m[1]);
                } else {
                    idx = n_list.findIndex(v => v);
                }
                if (idx < 0 || !id_list[idx]) {
                    return window.dash_clientside.no_update;
                }
                const targetId = id_list[idx].target;
                const el = document.getElementById(targetId);
                if (el) {
                    const y = el.getBoundingClientRect().top + window.scrollY - (parseInt(topOffset) || 0);
                    window.scrollTo({ top: y, behavior: 'smooth' });
                }
                return new Array(n_list.length).fill(null);
            }
            """,
            Output({"type": "toc-btn", "target": ALL}, "n_clicks"),
            Input({"type": "toc-btn", "target": ALL}, "n_clicks"),
            State({"type": "toc-btn", "target": ALL}, "id"),
            State({"type": "toc-offset"}, "data"),
            prevent_initial_call=True,
        )
        _TOC_CB_REGISTERED = True

    if titles and isinstance(titles, (list, tuple)) and titles[0]:
        first_label = titles[0].get("label") or ""
    else:
        first_label = ""

    if first_label:
        toc_title = f"目錄({first_label})"
        iter_titles = titles[1:]
    else:
        toc_title = "目錄"
        iter_titles = titles

    buttons = []
    for t in iter_titles:
        lvl = int(t.get("level", 1))
        pad_left = _indent_px_by_level(lvl, base_px=14)

        wrapper = html.Div(
            dmc.Button(
                t["label"],
                id={"type": "toc-btn", "target": t["id"]},
                variant="subtle",
                fullWidth=True,
                fw=400,
                className="toc-button",
                style={
                    "justifyContent": "flex-start",
                    "textAlign": "left",
                    "fontWeight": 400,
                    "fontSize": "16px",
                    "width": "100%",
                    "padding": "6px 0",
                    "display": "flex",
                },
            ),
            style={"paddingLeft": f"{pad_left}px"},
            **{
                "data-level": str(lvl),
                "data-target": t["id"],
            },
            className="toc-item",
        )
        buttons.append(wrapper)

    scroll_box = html.Div(
        dmc.Stack(
            children=[
                dmc.Title(toc_title, order=3),
                dcc.Store(id={"type": "toc-offset"}, data=int(top_offset)),
                *buttons,
            ],
            gap="xs",
        ),
        className="toc-scroll",
        id="toc-root",
        **{"data-toc-offset": int(top_offset)},
    )

    return dmc.Stack(
        [scroll_box],
        gap="xs",
        className="toc-container",
        style={
            "textAlign": "left",
        },
    )