flexdesk 0.16.1 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/LICENSE +0 -0
- package/README.md +2435 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/canvas.js +18 -0
- package/charts.js +21 -0
- package/core.js +14 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3165 -159
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +707 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
- package/dist/chunk-LWV23YVE.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
- package/dist/chunk-RMTRWWEO.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3165 -159
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +657 -234
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3290 -2124
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -347
- package/dist/wm.js.map +4 -4
- package/editor.js +20 -0
- package/flow.js +21 -0
- package/host.js +14 -0
- package/package.json +12 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +7 -7
- package/src/tiles/scope_picker.js +0 -0
- package/src/tiles/tile_base.js +1 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +2 -0
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +293 -0
- package/src/ui/components/cell_editors.js +480 -0
- package/src/ui/components/cell_grid.js +761 -0
- package/src/ui/components/cell_paste.js +243 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +696 -71
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +0 -0
- package/src/ui/components/staged_edits.js +361 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/tsv.js +74 -0
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/tiles.js +27 -0
- package/widgets.js +69 -0
- package/wm.js +28 -0
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-B5Q53LAJ.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- package/dist/chunk-W6FBJLPW.js +0 -367
- package/dist/chunk-W6FBJLPW.js.map +0 -7
- package/src/ui/controllers/panel_state_machine.js +0 -634
|
@@ -27,7 +27,8 @@ const ESC = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').
|
|
|
27
27
|
/**
|
|
28
28
|
* @typedef {Object} DetailHeaderBadge
|
|
29
29
|
* @property {string} text
|
|
30
|
-
* @property {string} [className] - Extra
|
|
30
|
+
* @property {string} [className] - Extra class(es) of your own (e.g., 'my-badge--live'); the
|
|
31
|
+
* pill itself is `.twm-detail-header__badge`
|
|
31
32
|
* @property {string} [icon] - Material Symbols icon name
|
|
32
33
|
*/
|
|
33
34
|
|
|
@@ -35,8 +36,10 @@ const ESC = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').
|
|
|
35
36
|
* @typedef {Object} DetailHeaderAction
|
|
36
37
|
* @property {string} key - data-action value
|
|
37
38
|
* @property {string} label - Button text
|
|
38
|
-
* @property {string} icon
|
|
39
|
-
* @property {'primary'|'secondary'|'danger'} [variant='secondary']
|
|
39
|
+
* @property {string} [icon] - Material Symbols icon name (none: label only)
|
|
40
|
+
* @property {'primary'|'secondary'|'danger'} [variant='secondary'] - The button is a `.twm-btn`;
|
|
41
|
+
* 'primary' and 'danger' add `.twm-btn--primary` / `.twm-btn--danger`, and every
|
|
42
|
+
* variant is also named `.twm-detail-header__btn--<variant>`
|
|
40
43
|
* @property {boolean} [disabled=false]
|
|
41
44
|
* @property {string} [title] - Tooltip
|
|
42
45
|
* @property {Function} handler - Click callback
|
|
@@ -107,10 +110,10 @@ function _populate(header, config) {
|
|
|
107
110
|
|
|
108
111
|
for (const badge of badges) {
|
|
109
112
|
const badgeEl = document.createElement('span');
|
|
110
|
-
badgeEl.className = `detail-header__badge${badge.className ? ` ${badge.className}` : ''}`;
|
|
113
|
+
badgeEl.className = `twm-detail-header__badge${badge.className ? ` ${badge.className}` : ''}`;
|
|
111
114
|
if (badge.icon) {
|
|
112
115
|
const bi = document.createElement('span');
|
|
113
|
-
bi.className = 'material-symbols-outlined';
|
|
116
|
+
bi.className = 'twm-detail-header__badge-icon material-symbols-outlined';
|
|
114
117
|
bi.textContent = badge.icon;
|
|
115
118
|
badgeEl.appendChild(bi);
|
|
116
119
|
}
|
|
@@ -141,16 +144,24 @@ function _populate(header, config) {
|
|
|
141
144
|
const btn = document.createElement('button');
|
|
142
145
|
btn.type = 'button';
|
|
143
146
|
const variant = action.variant || 'secondary';
|
|
144
|
-
|
|
147
|
+
// The shared `.twm-btn` look (BUG-0015): 'primary' and 'danger'
|
|
148
|
+
// wear its colour variants; 'secondary' is the plain button.
|
|
149
|
+
const look = variant === 'secondary' ? '' : ` twm-btn--${variant}`;
|
|
150
|
+
btn.className = `twm-btn${look} twm-detail-header__btn twm-detail-header__btn--${variant}`;
|
|
145
151
|
btn.dataset.action = action.key;
|
|
146
152
|
if (action.disabled) btn.disabled = true;
|
|
147
153
|
if (action.title) btn.title = action.title;
|
|
148
154
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
if (action.icon) {
|
|
156
|
+
const iconSpan = document.createElement('span');
|
|
157
|
+
iconSpan.className = 'material-symbols-outlined twm-btn__glyph';
|
|
158
|
+
iconSpan.textContent = action.icon;
|
|
159
|
+
btn.appendChild(iconSpan);
|
|
160
|
+
}
|
|
161
|
+
const labelSpan = document.createElement('span');
|
|
162
|
+
labelSpan.className = 'twm-btn__label';
|
|
163
|
+
labelSpan.textContent = action.label ?? '';
|
|
164
|
+
btn.appendChild(labelSpan);
|
|
154
165
|
|
|
155
166
|
btn.addEventListener('click', () => action.handler?.());
|
|
156
167
|
actionsEl.appendChild(btn);
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* document_outline.js — A LONG DOCUMENT'S OUTLINE: its sections listed and
|
|
3
|
+
* numbered, the one in view marked while the document scrolls, and a click
|
|
4
|
+
* that goes to a section. (0.17.0)
|
|
5
|
+
*
|
|
6
|
+
* ┌──────────────────────────┐
|
|
7
|
+
* │ 1 Introduction │
|
|
8
|
+
* │ 1.1 Scope │ ← the section in view
|
|
9
|
+
* │ 1.2 Terms │
|
|
10
|
+
* │ 2 Method │
|
|
11
|
+
* └──────────────────────────┘
|
|
12
|
+
*
|
|
13
|
+
* const outline = createDocumentOutline(sidebarEl, {
|
|
14
|
+
* entries: [{ id: 'intro', level: 1, label: 'Introduction' }, …],
|
|
15
|
+
* numbering: true,
|
|
16
|
+
* scrollContainer: articleEl,
|
|
17
|
+
* resolveTarget: (entry) => articleEl.querySelector(`[data-section="${entry.id}"]`),
|
|
18
|
+
* onSelect: (id) => { … },
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* Where the entries come from (headings in markup, cells in a notebook, a
|
|
22
|
+
* server's table of contents) is the embedder's business. The outline is told
|
|
23
|
+
* `{id, level, label}` and, through `resolveTarget`, which element in the
|
|
24
|
+
* scroll container each one stands for.
|
|
25
|
+
*
|
|
26
|
+
* NUMBERING (`numbering: true`) is hierarchical: levels 1, 2, 2, 1 are numbered
|
|
27
|
+
* 1, 1.1, 1.2, 2. An entry sits one deeper than the nearest entry above it of
|
|
28
|
+
* a lower level, so the shallowest level is depth 1 and a skipped level is one
|
|
29
|
+
* step, not two (1, 3, 3, 2 is 1, 1.1, 1.2, 1.3, never 1.0.1); and nothing goes
|
|
30
|
+
* deeper than `maxDepth` — a deeper entry is listed, indented and numbered at
|
|
31
|
+
* `maxDepth`.
|
|
32
|
+
*
|
|
33
|
+
* THE SCROLL-SPY. With a `scrollContainer`, every resolved target is watched by
|
|
34
|
+
* an IntersectionObserver rooted at that container, with `rootMargin` (default
|
|
35
|
+
* `'0px 0px -70% 0px'`: the top 30% of the container is "in view"). Of the
|
|
36
|
+
* targets in view, the one listed FIRST is the active section — the topmost
|
|
37
|
+
* visible one. When none is in view the active section stays what it was, so
|
|
38
|
+
* the long body of a section keeps its heading marked. An entry whose target
|
|
39
|
+
* cannot be resolved is listed, but it is never watched and never marked
|
|
40
|
+
* active, by the spy or by `setActive`.
|
|
41
|
+
*
|
|
42
|
+
* A CLICK keeps its entry active. A click on an entry marks it at once, scrolls
|
|
43
|
+
* its target into view (unless `scrollOnSelect: false`, when `onSelect` does
|
|
44
|
+
* the scrolling) and calls `onSelect(id, entry)`. A smooth scroll passes over
|
|
45
|
+
* every section between here and there, so the spy is SUPPRESSED while it runs:
|
|
46
|
+
* it resumes on the container's `scrollend`, or after `scrollEndTimeout` ms
|
|
47
|
+
* (default 1000) when no `scrollend` comes — an engine without the event, or a
|
|
48
|
+
* target that was already in place and so never scrolled.
|
|
49
|
+
*
|
|
50
|
+
* `refresh()` resolves the targets again (the document re-rendered);
|
|
51
|
+
* `setEntries(entries)` replaces the list. `dispose()` disconnects the observer,
|
|
52
|
+
* clears the timer and takes the outline off the page.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {Object} OutlineEntry
|
|
57
|
+
* @property {string} id - Unique among the entries.
|
|
58
|
+
* @property {number} [level=1] - 1 for a top-level section, 2 under it, …
|
|
59
|
+
* @property {string} label - The entry's words, drawn as text (never markup).
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {Object} DocumentOutlineConfig
|
|
64
|
+
* @property {OutlineEntry[]} [entries]
|
|
65
|
+
* @property {boolean} [numbering=false] - Hierarchical numbers before the labels.
|
|
66
|
+
* @property {number} [maxDepth=3] - The deepest indentation and numbering depth.
|
|
67
|
+
* @property {Function} [onSelect] - `(id, entry) => void`, on a click on an entry.
|
|
68
|
+
* @property {boolean} [scrollOnSelect=true] - A click scrolls the entry's target
|
|
69
|
+
* into view (`scrollIntoView({block: 'start', behavior: scrollBehavior})`).
|
|
70
|
+
* @property {ScrollBehavior} [scrollBehavior='smooth']
|
|
71
|
+
* @property {HTMLElement|null} [scrollContainer] - The element that scrolls the
|
|
72
|
+
* document; the observer's root. Without it there is no scroll-spy.
|
|
73
|
+
* @property {Function} [resolveTarget] - `(entry, scrollContainer) => Element|null`.
|
|
74
|
+
* Defaults to the element inside the container whose `id` is the entry's id.
|
|
75
|
+
* @property {string} [rootMargin='0px 0px -70% 0px']
|
|
76
|
+
* @property {number} [scrollEndTimeout=1000] - ms before a click's suppression
|
|
77
|
+
* lifts when no `scrollend` arrives.
|
|
78
|
+
* @property {string|Node|Function|null} [emptyState='No headings'] - Shown with no entries.
|
|
79
|
+
* @property {string} [label='Outline'] - The navigation's accessible name.
|
|
80
|
+
* @property {string} [className] - Added to the root.
|
|
81
|
+
* @property {Function} [IntersectionObserver] - The observer class; defaults to
|
|
82
|
+
* the global one. Without either there is no scroll-spy.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
export class DocumentOutline {
|
|
86
|
+
/**
|
|
87
|
+
* @param {HTMLElement|null} host appended to, if given
|
|
88
|
+
* @param {DocumentOutlineConfig} [config]
|
|
89
|
+
*/
|
|
90
|
+
constructor(host, config = {}) {
|
|
91
|
+
this.config = {
|
|
92
|
+
entries: [],
|
|
93
|
+
numbering: false,
|
|
94
|
+
maxDepth: 3,
|
|
95
|
+
onSelect: null,
|
|
96
|
+
scrollOnSelect: true,
|
|
97
|
+
scrollBehavior: 'smooth',
|
|
98
|
+
scrollContainer: null,
|
|
99
|
+
resolveTarget: null,
|
|
100
|
+
rootMargin: '0px 0px -70% 0px',
|
|
101
|
+
scrollEndTimeout: 1000,
|
|
102
|
+
emptyState: 'No headings',
|
|
103
|
+
label: 'Outline',
|
|
104
|
+
className: null,
|
|
105
|
+
IntersectionObserver: null,
|
|
106
|
+
...config,
|
|
107
|
+
};
|
|
108
|
+
this._entries = [];
|
|
109
|
+
this._rows = []; // [{id, entry, depth, number, li, target}] in list order
|
|
110
|
+
this._byId = new Map(); // id → row
|
|
111
|
+
this._byTarget = new Map(); // target element → row
|
|
112
|
+
this._inView = new Set(); // rows whose target intersects
|
|
113
|
+
this._activeId = null;
|
|
114
|
+
this._listeners = new Set();
|
|
115
|
+
this._observer = null;
|
|
116
|
+
this._suppressed = false;
|
|
117
|
+
this._timer = null;
|
|
118
|
+
this._disposed = false;
|
|
119
|
+
|
|
120
|
+
const root = document.createElement('nav');
|
|
121
|
+
root.className = 'twm-doc-outline'
|
|
122
|
+
+ (this.config.className ? ` ${this.config.className}` : '');
|
|
123
|
+
if (this.config.label) root.setAttribute('aria-label', this.config.label);
|
|
124
|
+
const list = document.createElement('ol');
|
|
125
|
+
list.className = 'twm-doc-outline__items';
|
|
126
|
+
root.appendChild(list);
|
|
127
|
+
this.element = root;
|
|
128
|
+
this.listElement = list;
|
|
129
|
+
|
|
130
|
+
this._onClick = (ev) => this._click(ev);
|
|
131
|
+
this._onScrollEnd = () => this._resume();
|
|
132
|
+
list.addEventListener('click', this._onClick);
|
|
133
|
+
this.config.scrollContainer?.addEventListener?.('scrollend', this._onScrollEnd);
|
|
134
|
+
|
|
135
|
+
this._setEntries(this.config.entries);
|
|
136
|
+
if (host) host.appendChild(root);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── public ───────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Replace the entries. The active entry stays active if it is still
|
|
142
|
+
* listed and resolved; otherwise there is none. */
|
|
143
|
+
setEntries(entries) {
|
|
144
|
+
if (this._disposed) return;
|
|
145
|
+
this._setEntries(entries);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The entries, as last given. */
|
|
149
|
+
getEntries() {
|
|
150
|
+
return this._entries.slice();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Resolve every target again and watch the new ones: the document under
|
|
154
|
+
* the outline was re-rendered. */
|
|
155
|
+
refresh() {
|
|
156
|
+
if (this._disposed) return;
|
|
157
|
+
this._observe();
|
|
158
|
+
this._keepActive();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Mark an entry active (`null` for none). An id that is not listed, or
|
|
162
|
+
* whose target is not resolved, is refused: returns false. */
|
|
163
|
+
setActive(id) {
|
|
164
|
+
if (this._disposed) return false;
|
|
165
|
+
if (id == null) { this._activate(null); return true; }
|
|
166
|
+
const row = this._byId.get(String(id));
|
|
167
|
+
if (!row?.target) return false;
|
|
168
|
+
this._activate(row.id);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The active entry's id, or null. */
|
|
173
|
+
getActive() {
|
|
174
|
+
return this._activeId;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `fn(id, entry)` on every change of the active entry (`(null, null)` when
|
|
178
|
+
* none). Returns the function that unsubscribes. */
|
|
179
|
+
onActiveChange(fn) {
|
|
180
|
+
if (typeof fn !== 'function' || this._disposed) return () => {};
|
|
181
|
+
this._listeners.add(fn);
|
|
182
|
+
return () => this._listeners.delete(fn);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The `<li>` drawn for an id, or null. */
|
|
186
|
+
getItem(id) {
|
|
187
|
+
return this._byId.get(String(id))?.li || null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
dispose() {
|
|
191
|
+
if (this._disposed) return;
|
|
192
|
+
this._disposed = true;
|
|
193
|
+
this._observer?.disconnect();
|
|
194
|
+
this._observer = null;
|
|
195
|
+
this._clearTimer();
|
|
196
|
+
this._suppressed = false;
|
|
197
|
+
this.config.scrollContainer?.removeEventListener?.('scrollend', this._onScrollEnd);
|
|
198
|
+
this.listElement.removeEventListener('click', this._onClick);
|
|
199
|
+
this._listeners.clear();
|
|
200
|
+
this._byId.clear();
|
|
201
|
+
this._byTarget.clear();
|
|
202
|
+
this._inView.clear();
|
|
203
|
+
this._rows = [];
|
|
204
|
+
this.element.remove();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── drawing ──────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
_setEntries(entries) {
|
|
210
|
+
this._entries = Array.isArray(entries) ? entries.slice() : [];
|
|
211
|
+
this._rows = numberEntries(this._entries, this.config.maxDepth);
|
|
212
|
+
this._byId = new Map(this._rows.map((row) => [row.id, row]));
|
|
213
|
+
this._draw();
|
|
214
|
+
this._observe();
|
|
215
|
+
this._keepActive();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The active entry survives a redraw only while it is listed and resolved. */
|
|
219
|
+
_keepActive() {
|
|
220
|
+
if (this._activeId !== null && !this._byId.get(this._activeId)?.target) this._activate(null);
|
|
221
|
+
else this._paint();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_draw() {
|
|
225
|
+
const list = this.listElement;
|
|
226
|
+
list.replaceChildren();
|
|
227
|
+
this.element.querySelector(':scope > .twm-doc-outline__empty')?.remove();
|
|
228
|
+
this.element.classList.toggle('twm-doc-outline--empty', this._rows.length === 0);
|
|
229
|
+
this.element.classList.toggle('twm-doc-outline--numbered', !!this.config.numbering);
|
|
230
|
+
if (!this._rows.length) { this._drawEmpty(); return; }
|
|
231
|
+
for (const row of this._rows) {
|
|
232
|
+
const li = document.createElement('li');
|
|
233
|
+
li.className = `twm-doc-outline__item twm-doc-outline__item--level-${row.depth}`;
|
|
234
|
+
li.style.setProperty('--twm-doc-outline-depth', String(row.depth - 1));
|
|
235
|
+
const button = document.createElement('button');
|
|
236
|
+
button.type = 'button';
|
|
237
|
+
button.className = 'twm-doc-outline__link';
|
|
238
|
+
if (this.config.numbering) {
|
|
239
|
+
const num = document.createElement('span');
|
|
240
|
+
num.className = 'twm-doc-outline__number';
|
|
241
|
+
num.textContent = row.number;
|
|
242
|
+
button.appendChild(num);
|
|
243
|
+
}
|
|
244
|
+
const label = document.createElement('span');
|
|
245
|
+
label.className = 'twm-doc-outline__label';
|
|
246
|
+
label.textContent = String(row.entry.label ?? '');
|
|
247
|
+
button.appendChild(label);
|
|
248
|
+
button.title = (this.config.numbering ? `${row.number} ` : '') + label.textContent;
|
|
249
|
+
li.appendChild(button);
|
|
250
|
+
row.li = li;
|
|
251
|
+
list.appendChild(li);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
_drawEmpty() {
|
|
256
|
+
let content = this.config.emptyState;
|
|
257
|
+
if (typeof content === 'function') content = content();
|
|
258
|
+
if (content == null || content === false || content === '') return;
|
|
259
|
+
const p = document.createElement('p');
|
|
260
|
+
p.className = 'twm-doc-outline__empty';
|
|
261
|
+
if (typeof Node !== 'undefined' && content instanceof Node) p.appendChild(content);
|
|
262
|
+
else p.textContent = String(content);
|
|
263
|
+
this.element.appendChild(p);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
_paint() {
|
|
267
|
+
for (const row of this._rows) {
|
|
268
|
+
if (!row.li) continue;
|
|
269
|
+
const active = row.id === this._activeId;
|
|
270
|
+
row.li.classList.toggle('twm-doc-outline__item--active', active);
|
|
271
|
+
row.li.classList.toggle('twm-doc-outline__item--unresolved', !row.target);
|
|
272
|
+
const button = row.li.firstChild;
|
|
273
|
+
if (active) button.setAttribute('aria-current', 'location');
|
|
274
|
+
else button.removeAttribute('aria-current');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_activate(id) {
|
|
279
|
+
const next = id ?? null;
|
|
280
|
+
if (next === this._activeId) { this._paint(); return; }
|
|
281
|
+
this._activeId = next;
|
|
282
|
+
this._paint();
|
|
283
|
+
const entry = next === null ? null : this._byId.get(next)?.entry ?? null;
|
|
284
|
+
for (const fn of [...this._listeners]) fn(next, entry);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── the scroll-spy ───────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
_resolve(entry) {
|
|
290
|
+
const container = this.config.scrollContainer;
|
|
291
|
+
if (typeof this.config.resolveTarget === 'function') {
|
|
292
|
+
return this.config.resolveTarget(entry, container) || null;
|
|
293
|
+
}
|
|
294
|
+
if (!container) return null;
|
|
295
|
+
const id = String(entry.id);
|
|
296
|
+
for (const el of container.querySelectorAll('[id]')) if (el.id === id) return el;
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
_observe() {
|
|
301
|
+
this._observer?.disconnect();
|
|
302
|
+
this._byTarget.clear();
|
|
303
|
+
this._inView.clear();
|
|
304
|
+
for (const row of this._rows) row.target = this._resolve(row.entry);
|
|
305
|
+
for (const row of this._rows) {
|
|
306
|
+
if (row.target && !this._byTarget.has(row.target)) this._byTarget.set(row.target, row);
|
|
307
|
+
}
|
|
308
|
+
const container = this.config.scrollContainer;
|
|
309
|
+
const IO = this.config.IntersectionObserver || globalThis.IntersectionObserver;
|
|
310
|
+
if (!container || typeof IO !== 'function') return;
|
|
311
|
+
if (!this._observer) {
|
|
312
|
+
this._observer = new IO((records) => this._intersect(records), {
|
|
313
|
+
root: container,
|
|
314
|
+
rootMargin: this.config.rootMargin,
|
|
315
|
+
threshold: 0,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
for (const target of this._byTarget.keys()) this._observer.observe(target);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_intersect(records) {
|
|
322
|
+
if (this._disposed) return;
|
|
323
|
+
for (const rec of records) {
|
|
324
|
+
const row = this._byTarget.get(rec.target);
|
|
325
|
+
if (!row) continue;
|
|
326
|
+
if (rec.isIntersecting) this._inView.add(row);
|
|
327
|
+
else this._inView.delete(row);
|
|
328
|
+
}
|
|
329
|
+
if (this._suppressed) return;
|
|
330
|
+
// The topmost section in view: the first listed.
|
|
331
|
+
const top = this._rows.find((row) => this._inView.has(row));
|
|
332
|
+
if (top) this._activate(top.id);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
_click(ev) {
|
|
336
|
+
const li = ev.target?.closest?.('.twm-doc-outline__item');
|
|
337
|
+
if (!li || !this.listElement.contains(li)) return;
|
|
338
|
+
const row = this._rows.find((r) => r.li === li);
|
|
339
|
+
if (!row) return;
|
|
340
|
+
if (row.target) {
|
|
341
|
+
this._activate(row.id);
|
|
342
|
+
this._suppress();
|
|
343
|
+
if (this.config.scrollOnSelect && typeof row.target.scrollIntoView === 'function') {
|
|
344
|
+
row.target.scrollIntoView({ block: 'start', behavior: this.config.scrollBehavior });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
this.config.onSelect?.(row.id, row.entry);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_suppress() {
|
|
351
|
+
this._suppressed = true;
|
|
352
|
+
this._clearTimer();
|
|
353
|
+
this._timer = setTimeout(() => this._resume(), this.config.scrollEndTimeout);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_resume() {
|
|
357
|
+
this._clearTimer();
|
|
358
|
+
this._suppressed = false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
_clearTimer() {
|
|
362
|
+
if (this._timer !== null) clearTimeout(this._timer);
|
|
363
|
+
this._timer = null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Build a document outline, append it to `host` (if given) and draw it.
|
|
369
|
+
*
|
|
370
|
+
* @param {HTMLElement|null} host
|
|
371
|
+
* @param {DocumentOutlineConfig} [config]
|
|
372
|
+
* @returns {DocumentOutline}
|
|
373
|
+
*/
|
|
374
|
+
export function createDocumentOutline(host, config = {}) {
|
|
375
|
+
return new DocumentOutline(host, config);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Each entry's row: its id as a string, its depth and its hierarchical number.
|
|
380
|
+
* An entry's depth is one more than the number of open sections of a lower
|
|
381
|
+
* level above it, so the shallowest level is depth 1 and a skipped level is
|
|
382
|
+
* one deeper, never two; then at most `maxDepth`.
|
|
383
|
+
*/
|
|
384
|
+
function numberEntries(entries, maxDepth) {
|
|
385
|
+
const cap = Math.max(1, Math.floor(Number(maxDepth) || 3));
|
|
386
|
+
const open = []; // the levels of the sections this entry may sit under
|
|
387
|
+
const counters = [];
|
|
388
|
+
return entries.map((entry) => {
|
|
389
|
+
const level = Math.max(1, Math.floor(Number(entry?.level) || 1));
|
|
390
|
+
while (open.length && open[open.length - 1] >= level) open.pop();
|
|
391
|
+
const depth = Math.min(cap, open.length + 1);
|
|
392
|
+
open.push(level);
|
|
393
|
+
counters.length = depth;
|
|
394
|
+
counters[depth - 1] = (counters[depth - 1] || 0) + 1;
|
|
395
|
+
return { id: String(entry?.id), entry, depth, number: counters.join('.'), li: null, target: null };
|
|
396
|
+
});
|
|
397
|
+
}
|
|
File without changes
|
|
File without changes
|