flexdesk 0.16.1 → 0.17.0
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 +93 -0
- package/LICENSE +0 -0
- package/README.md +2217 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3146 -164
- 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 +709 -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-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
- package/dist/chunk-MCIXFNVF.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
- package/dist/chunk-Q36EPT2R.js.map +7 -0
- package/dist/chunk-QWQJUDPD.js +610 -0
- package/dist/chunk-QWQJUDPD.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 +3146 -164
- 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 +3252 -2124
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -347
- package/dist/wm.js.map +4 -4
- package/package.json +3 -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 +211 -0
- package/src/ui/components/cell_grid.js +648 -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 +601 -62
- 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 +343 -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/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/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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ══ A STACK OF COLLAPSIBLE SECTIONS ══════════════════════════════════════
|
|
3
|
+
*
|
|
4
|
+
* Inspector-style panel content: titled sections, one above the other, each
|
|
5
|
+
* opened and closed from its header (FlexDesk STORY-0036). Until this, only a
|
|
6
|
+
* tree's categories could collapse, and every embedder that wanted a panel of
|
|
7
|
+
* "Notes / Assistant / Parameters" built the headers, the arrows and the
|
|
8
|
+
* remembering itself.
|
|
9
|
+
*
|
|
10
|
+
* const stack = createSectionStack(panel, {
|
|
11
|
+
* sections: [
|
|
12
|
+
* { id: 'notes', title: 'Notes', content: notesEl },
|
|
13
|
+
* { id: 'chat', title: 'Assistant', fill: true,
|
|
14
|
+
* actions: [{ id: 'clear', icon: 'delete', label: 'Clear', onClick: clear }] },
|
|
15
|
+
* { id: 'params', title: 'Parameters', defaultExpanded: false },
|
|
16
|
+
* ],
|
|
17
|
+
* key: 'inspector.sections',
|
|
18
|
+
* stateStore: { read: (key) => saved[key], write: (key, map) => { saved[key] = map; } },
|
|
19
|
+
* });
|
|
20
|
+
* stack.body('chat').appendChild(chatEl);
|
|
21
|
+
* const off = stack.on('toggled', ({ id, expanded }) => …);
|
|
22
|
+
*
|
|
23
|
+
* A SECTION is `{ id, title, icon?, content?, actions?, defaultExpanded?, fill? }`.
|
|
24
|
+
* `content` is a Node put into the section's body at mount; `body(id)` hands
|
|
25
|
+
* the same element over for anything mounted later. `defaultExpanded` is true
|
|
26
|
+
* unless it says otherwise.
|
|
27
|
+
*
|
|
28
|
+
* THE HEADER TOGGLES; ITS ACTIONS DO NOT. A click anywhere on the header opens
|
|
29
|
+
* or closes the section and turns its arrow — except on one of its `actions`
|
|
30
|
+
* (`{ id?, icon, label, title?, onClick(sectionId, event) }`), which runs its
|
|
31
|
+
* own `onClick` and leaves the section as it was.
|
|
32
|
+
*
|
|
33
|
+
* A FILL SECTION (`fill: true`) takes the height the others leave, and its
|
|
34
|
+
* body scrolls inside it (`min-height: 0`), so a long conversation scrolls in
|
|
35
|
+
* its section instead of growing the panel past the window. Give the stack's
|
|
36
|
+
* container a height — it is `height: 100%` of it.
|
|
37
|
+
*
|
|
38
|
+
* REMEMBERING is the embedder's: `stateStore` is `{ read(key), write(key, map) }`
|
|
39
|
+
* and `map` is `{ [sectionId]: expanded }`, written whenever a section opens or
|
|
40
|
+
* closes. `read` may answer a promise; until it does, sections show their
|
|
41
|
+
* `defaultExpanded`, and a section somebody toggled meanwhile keeps their
|
|
42
|
+
* choice. Such a toggle is written once the read has settled, merged over
|
|
43
|
+
* the stored map, so the defaults on screen never overwrite it. A store that throws, rejects, or answers something that is not a map
|
|
44
|
+
* leaves every section on its `defaultExpanded` — remembering is a convenience,
|
|
45
|
+
* never a reason for a panel not to draw. `ready` resolves once the read has
|
|
46
|
+
* been applied (at once without a store).
|
|
47
|
+
*
|
|
48
|
+
* `setVisible(id, false)` takes a section off the stack without forgetting it:
|
|
49
|
+
* `setVisible(id, true)` brings it back open or closed exactly as it was.
|
|
50
|
+
*
|
|
51
|
+
* The `toggled` event carries `{ id, expanded, source }`, `source` being
|
|
52
|
+
* `'user'` (a header click), `'api'` (expand / collapse / toggle) or
|
|
53
|
+
* `'restore'` (a stored state that arrived after mount). It fires only on a
|
|
54
|
+
* change. `on()` answers the function that stops listening.
|
|
55
|
+
*
|
|
56
|
+
* `dispose()` removes the one listener the stack adds and every node it made;
|
|
57
|
+
* `content` the embedder handed in goes with its section.
|
|
58
|
+
*
|
|
59
|
+
* Styled by `.twm-collapsible-*` (the tree's primitive) plus `.twm-section-stack*`.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {HTMLElement} container
|
|
64
|
+
* @param {object} opts
|
|
65
|
+
* @param {Array} opts.sections `{ id, title, icon?, content?, actions?, defaultExpanded?, fill? }`
|
|
66
|
+
* @param {object} [opts.stateStore] `{ read(key): map|Promise<map>, write(key, map) }`
|
|
67
|
+
* @param {string} [opts.key] the key the expanded states live under; required with a store
|
|
68
|
+
*/
|
|
69
|
+
export function createSectionStack(container, { sections = [], stateStore = null, key = null } = {}) {
|
|
70
|
+
if (stateStore && !key) throw new Error('createSectionStack: a stateStore needs a { key }');
|
|
71
|
+
const doc = container.ownerDocument;
|
|
72
|
+
|
|
73
|
+
const root = doc.createElement('div');
|
|
74
|
+
root.className = 'twm-section-stack';
|
|
75
|
+
|
|
76
|
+
/** id -> { def, box, toggle, arrow, body, expanded, visible, touched } */
|
|
77
|
+
const byId = new Map();
|
|
78
|
+
/** action button -> { sectionId, action } */
|
|
79
|
+
const actionOf = new Map();
|
|
80
|
+
/** header -> section id */
|
|
81
|
+
const headerOf = new Map();
|
|
82
|
+
const listeners = new Set();
|
|
83
|
+
let disposed = false;
|
|
84
|
+
// While a promised read is out, the sections nobody touched still show
|
|
85
|
+
// their defaults, not what the store holds: writing them then would
|
|
86
|
+
// overwrite the stored states with defaults. A toggle meanwhile only marks
|
|
87
|
+
// the map as owed; it is written once the read has settled.
|
|
88
|
+
let readPending = false;
|
|
89
|
+
let writeOwed = false;
|
|
90
|
+
|
|
91
|
+
for (const def of sections) {
|
|
92
|
+
if (!def || def.id == null || byId.has(def.id)) {
|
|
93
|
+
throw new Error(`createSectionStack: every section needs a unique id (got ${def?.id})`);
|
|
94
|
+
}
|
|
95
|
+
const expanded = def.defaultExpanded !== false;
|
|
96
|
+
|
|
97
|
+
const box = doc.createElement('section');
|
|
98
|
+
box.className = 'twm-collapsible-box twm-section-stack__section'
|
|
99
|
+
+ (def.fill ? ' twm-section-stack__section--fill' : '');
|
|
100
|
+
|
|
101
|
+
const header = doc.createElement('div');
|
|
102
|
+
header.className = 'twm-collapsible-header twm-section-stack__header';
|
|
103
|
+
|
|
104
|
+
const toggle = doc.createElement('button');
|
|
105
|
+
toggle.type = 'button';
|
|
106
|
+
toggle.className = 'twm-section-stack__toggle';
|
|
107
|
+
const arrow = doc.createElement('span');
|
|
108
|
+
arrow.className = 'twm-collapsible-arrow material-symbols-outlined';
|
|
109
|
+
arrow.setAttribute('aria-hidden', 'true');
|
|
110
|
+
arrow.textContent = 'expand_more';
|
|
111
|
+
toggle.appendChild(arrow);
|
|
112
|
+
if (def.icon) {
|
|
113
|
+
const icon = doc.createElement('span');
|
|
114
|
+
icon.className = 'twm-section-stack__icon material-symbols-outlined';
|
|
115
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
116
|
+
icon.textContent = def.icon;
|
|
117
|
+
toggle.appendChild(icon);
|
|
118
|
+
}
|
|
119
|
+
const title = doc.createElement('span');
|
|
120
|
+
title.className = 'twm-section-stack__title';
|
|
121
|
+
title.textContent = def.title ?? String(def.id);
|
|
122
|
+
toggle.appendChild(title);
|
|
123
|
+
header.appendChild(toggle);
|
|
124
|
+
|
|
125
|
+
if (Array.isArray(def.actions) && def.actions.length) {
|
|
126
|
+
const bar = doc.createElement('div');
|
|
127
|
+
bar.className = 'twm-section-stack__actions';
|
|
128
|
+
for (const action of def.actions) {
|
|
129
|
+
const btn = doc.createElement('button');
|
|
130
|
+
btn.type = 'button';
|
|
131
|
+
btn.className = 'twm-section-stack__action';
|
|
132
|
+
if (action.label) btn.setAttribute('aria-label', action.label);
|
|
133
|
+
if (action.title ?? action.label) btn.setAttribute('title', action.title ?? action.label);
|
|
134
|
+
const glyph = doc.createElement('span');
|
|
135
|
+
glyph.className = 'material-symbols-outlined';
|
|
136
|
+
glyph.setAttribute('aria-hidden', 'true');
|
|
137
|
+
glyph.textContent = action.icon ?? '';
|
|
138
|
+
btn.appendChild(glyph);
|
|
139
|
+
bar.appendChild(btn);
|
|
140
|
+
actionOf.set(btn, { sectionId: def.id, action });
|
|
141
|
+
}
|
|
142
|
+
header.appendChild(bar);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const body = doc.createElement('div');
|
|
146
|
+
body.className = 'twm-collapsible-content twm-section-stack__body';
|
|
147
|
+
if (def.content) body.appendChild(def.content);
|
|
148
|
+
|
|
149
|
+
box.append(header, body);
|
|
150
|
+
root.appendChild(box);
|
|
151
|
+
headerOf.set(header, def.id);
|
|
152
|
+
const entry = { def, box, toggle, arrow, body, expanded, visible: true, touched: false };
|
|
153
|
+
byId.set(def.id, entry);
|
|
154
|
+
paint(entry);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function paint(entry) {
|
|
158
|
+
entry.box.classList.toggle('twm-section-stack__section--expanded', entry.expanded);
|
|
159
|
+
entry.body.classList.toggle('visible', entry.expanded);
|
|
160
|
+
entry.arrow.classList.toggle('collapsed', !entry.expanded);
|
|
161
|
+
entry.toggle.setAttribute('aria-expanded', String(entry.expanded));
|
|
162
|
+
entry.box.hidden = !entry.visible;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function emit(detail) {
|
|
166
|
+
for (const fn of [...listeners]) {
|
|
167
|
+
try { fn(detail); } catch (err) { console.error('[section-stack] toggled listener failed', err); }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function persist() {
|
|
172
|
+
if (!stateStore?.write) return;
|
|
173
|
+
if (readPending) { writeOwed = true; return; }
|
|
174
|
+
const map = {};
|
|
175
|
+
for (const [id, entry] of byId) map[id] = entry.expanded;
|
|
176
|
+
write(map);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function write(map) {
|
|
180
|
+
try {
|
|
181
|
+
const pending = stateStore.write(key, map);
|
|
182
|
+
pending?.catch?.(() => { /* storage refused: not ours to fix */ });
|
|
183
|
+
} catch { /* storage refused: not ours to fix */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Set one section's state; true when it changed. */
|
|
187
|
+
function set(id, expanded, source) {
|
|
188
|
+
const entry = byId.get(id);
|
|
189
|
+
if (disposed || !entry || entry.expanded === expanded) return false;
|
|
190
|
+
entry.expanded = expanded;
|
|
191
|
+
if (source !== 'restore') entry.touched = true;
|
|
192
|
+
paint(entry);
|
|
193
|
+
if (source !== 'restore') persist();
|
|
194
|
+
emit({ id, expanded, source });
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const isMap = (saved) => !!saved && typeof saved === 'object' && !Array.isArray(saved);
|
|
199
|
+
|
|
200
|
+
function restore(saved, source) {
|
|
201
|
+
if (disposed || !isMap(saved)) return;
|
|
202
|
+
for (const [id, entry] of byId) {
|
|
203
|
+
if (entry.touched || typeof saved[id] !== 'boolean') continue;
|
|
204
|
+
if (source === 'restore') set(id, saved[id], 'restore');
|
|
205
|
+
else { entry.expanded = saved[id]; paint(entry); }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A promised read has settled (`saved` is null when it rejected). */
|
|
210
|
+
function settle(saved) {
|
|
211
|
+
restore(saved, 'restore');
|
|
212
|
+
readPending = false;
|
|
213
|
+
if (!writeOwed) return;
|
|
214
|
+
writeOwed = false;
|
|
215
|
+
// The touched sections over what the store holds — also after a
|
|
216
|
+
// dispose, which must not lose a choice made before the read answered.
|
|
217
|
+
const map = {};
|
|
218
|
+
for (const [id, entry] of byId) {
|
|
219
|
+
map[id] = !entry.touched && isMap(saved) && typeof saved[id] === 'boolean'
|
|
220
|
+
? saved[id] : entry.expanded;
|
|
221
|
+
}
|
|
222
|
+
write(map);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function onClick(event) {
|
|
226
|
+
const target = event.target;
|
|
227
|
+
const btn = target?.closest?.('.twm-section-stack__action');
|
|
228
|
+
if (btn && actionOf.has(btn)) {
|
|
229
|
+
const { sectionId, action } = actionOf.get(btn);
|
|
230
|
+
event.stopPropagation();
|
|
231
|
+
try { action.onClick?.(sectionId, event); } catch (err) {
|
|
232
|
+
console.error(`[section-stack] action on "${sectionId}" failed`, err);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const header = target?.closest?.('.twm-section-stack__header');
|
|
237
|
+
if (!header || !headerOf.has(header)) return;
|
|
238
|
+
const id = headerOf.get(header);
|
|
239
|
+
set(id, !byId.get(id).expanded, 'user');
|
|
240
|
+
}
|
|
241
|
+
root.addEventListener('click', onClick);
|
|
242
|
+
|
|
243
|
+
// Synchronous stored state is applied before the stack is on screen, so it
|
|
244
|
+
// never flashes the defaults; a promise is applied when it answers.
|
|
245
|
+
let ready = Promise.resolve();
|
|
246
|
+
if (stateStore?.read) {
|
|
247
|
+
let saved = null;
|
|
248
|
+
try { saved = stateStore.read(key); } catch { saved = null; }
|
|
249
|
+
if (saved && typeof saved.then === 'function') {
|
|
250
|
+
readPending = true;
|
|
251
|
+
ready = Promise.resolve(saved)
|
|
252
|
+
.then((value) => settle(value), () => settle(null));
|
|
253
|
+
} else {
|
|
254
|
+
restore(saved, 'mount');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
container.appendChild(root);
|
|
259
|
+
|
|
260
|
+
const need = (id) => {
|
|
261
|
+
if (!byId.has(id)) throw new Error(`createSectionStack: no section "${id}"`);
|
|
262
|
+
return byId.get(id);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
root,
|
|
267
|
+
ready,
|
|
268
|
+
/** The section's body element, for content mounted after the stack. */
|
|
269
|
+
body: (id) => need(id).body,
|
|
270
|
+
expand(id) { need(id); set(id, true, 'api'); },
|
|
271
|
+
collapse(id) { need(id); set(id, false, 'api'); },
|
|
272
|
+
toggle(id) { set(id, !need(id).expanded, 'api'); },
|
|
273
|
+
isExpanded: (id) => need(id).expanded,
|
|
274
|
+
/** Off the stack, or back on it — its expanded state is kept either way. */
|
|
275
|
+
setVisible(id, visible) {
|
|
276
|
+
const entry = need(id);
|
|
277
|
+
entry.visible = !!visible;
|
|
278
|
+
paint(entry);
|
|
279
|
+
},
|
|
280
|
+
isVisible: (id) => need(id).visible,
|
|
281
|
+
/** `on('toggled', fn)` — answers the function that stops listening. */
|
|
282
|
+
on(event, fn) {
|
|
283
|
+
if (event !== 'toggled') throw new Error(`createSectionStack: no "${event}" event`);
|
|
284
|
+
if (disposed) return () => {};
|
|
285
|
+
listeners.add(fn);
|
|
286
|
+
return () => listeners.delete(fn);
|
|
287
|
+
},
|
|
288
|
+
dispose() {
|
|
289
|
+
if (disposed) return;
|
|
290
|
+
disposed = true;
|
|
291
|
+
root.removeEventListener('click', onClick);
|
|
292
|
+
listeners.clear();
|
|
293
|
+
actionOf.clear();
|
|
294
|
+
headerOf.clear();
|
|
295
|
+
root.remove();
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* series.js — STORY-0089. What a FILL HANDLE writes: `detectSeries` reads the
|
|
3
|
+
* block a fill starts from, and `projectSeries` continues it. Pure functions,
|
|
4
|
+
* no DOM, so the rules are tested on their own (tests/series.test.mjs) and a
|
|
5
|
+
* consumer that builds its own grid fills the way DataTable does.
|
|
6
|
+
*
|
|
7
|
+
* The rules, as a spreadsheet has them:
|
|
8
|
+
* - one value -> repeat it
|
|
9
|
+
* - two or more numbers a constant
|
|
10
|
+
* step apart -> continue the arithmetic series
|
|
11
|
+
* - two or more dates (`YYYY-MM-DD`
|
|
12
|
+
* or `Date`) a constant number of
|
|
13
|
+
* days apart -> continue them that many days apart
|
|
14
|
+
* - two or more texts with one shared
|
|
15
|
+
* prefix and suffix around a number
|
|
16
|
+
* a constant step apart ("Item 1") -> continue the number, zero-padded
|
|
17
|
+
* as the first is
|
|
18
|
+
* - anything else (equal values
|
|
19
|
+
* included) -> repeat the block, in a cycle
|
|
20
|
+
* - `forceCopy` (Ctrl held) -> repeat the block, in a cycle
|
|
21
|
+
*
|
|
22
|
+
* `values` is the seed IN FILL ORDER: a fill up or left hands the block in
|
|
23
|
+
* reverse, so the value next to the gap comes last and the series runs away
|
|
24
|
+
* from the block.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
28
|
+
const TRAILING_NUMBER = /^(.*?)(\d+)(\D*)$/;
|
|
29
|
+
const NUMERIC_TEXT = /^[-+]?(\d+\.?\d*|\.\d+)$/;
|
|
30
|
+
const DAY_MS = 86400000;
|
|
31
|
+
|
|
32
|
+
const isBlank = (v) => v === null || v === undefined || v === '';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What the seed is: `{kind, step}` with `kind` one of 'copy', 'number',
|
|
36
|
+
* 'date' (step in days), 'text-number' (also `prefix`, `suffix`, `pad`) or
|
|
37
|
+
* 'cycle' — or null for an empty seed or one of blanks only.
|
|
38
|
+
*
|
|
39
|
+
* @param {any[]} values
|
|
40
|
+
* @returns {{kind: string, step: number, prefix?: string, suffix?: string, pad?: number}|null}
|
|
41
|
+
*/
|
|
42
|
+
export function detectSeries(values) {
|
|
43
|
+
if (!Array.isArray(values) || values.length === 0) return null;
|
|
44
|
+
if (values.every(isBlank)) return null;
|
|
45
|
+
if (values.length === 1) return { kind: 'copy', step: 0 };
|
|
46
|
+
|
|
47
|
+
const numbers = values.map(toNumber);
|
|
48
|
+
if (numbers.every((n) => n !== null)) {
|
|
49
|
+
const step = numbers[1] - numbers[0];
|
|
50
|
+
const constant = numbers.every((n, i) => i === 0 || nearlyEqual(n - numbers[i - 1], step));
|
|
51
|
+
if (constant && step !== 0) return { kind: 'number', step };
|
|
52
|
+
if (constant) return { kind: 'copy', step: 0 };
|
|
53
|
+
return { kind: 'cycle', step: 0 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const days = values.map(toDayMs);
|
|
57
|
+
if (days.every((d) => d !== null)) {
|
|
58
|
+
const step = Math.round((days[1] - days[0]) / DAY_MS);
|
|
59
|
+
const constant = days.every((d, i) => i === 0 || Math.round((d - days[i - 1]) / DAY_MS) === step);
|
|
60
|
+
if (constant && step !== 0) return { kind: 'date', step };
|
|
61
|
+
if (constant) return { kind: 'copy', step: 0 };
|
|
62
|
+
return { kind: 'cycle', step: 0 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// "Item 1", "Item 2": one prefix and suffix around a number.
|
|
66
|
+
const parts = values.map((v) => (typeof v === 'string' ? TRAILING_NUMBER.exec(v) : null));
|
|
67
|
+
if (parts.every(Boolean)) {
|
|
68
|
+
const prefix = parts[0][1];
|
|
69
|
+
const suffix = parts[0][3];
|
|
70
|
+
if (parts.every((p) => p[1] === prefix && p[3] === suffix)) {
|
|
71
|
+
const nums = parts.map((p) => Number(p[2]));
|
|
72
|
+
const step = nums[1] - nums[0];
|
|
73
|
+
const constant = nums.every((n, i) => i === 0 || n - nums[i - 1] === step);
|
|
74
|
+
if (constant && step !== 0) {
|
|
75
|
+
return { kind: 'text-number', step, prefix, suffix, pad: parts[0][2].length };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { kind: 'cycle', step: 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The `count` values a fill writes after `values` (the seed, in fill order).
|
|
85
|
+
* A series keeps its seed's type: number seeds give numbers, text seeds text
|
|
86
|
+
* (with the seed's decimals), `Date` seeds `Date`s and `YYYY-MM-DD` seeds
|
|
87
|
+
* `YYYY-MM-DD`. A repeat hands back the seed's own values.
|
|
88
|
+
*
|
|
89
|
+
* @param {any[]} values
|
|
90
|
+
* @param {number} count
|
|
91
|
+
* @param {{forceCopy?: boolean}} [options] `forceCopy`: repeat the block
|
|
92
|
+
* even when it is a series (Ctrl held).
|
|
93
|
+
* @returns {any[]}
|
|
94
|
+
*/
|
|
95
|
+
export function projectSeries(values, count, { forceCopy = false } = {}) {
|
|
96
|
+
const out = [];
|
|
97
|
+
const n = Math.max(0, Math.floor(Number(count) || 0));
|
|
98
|
+
if (!Array.isArray(values) || values.length === 0 || n === 0) return out;
|
|
99
|
+
const series = forceCopy ? null : detectSeries(values);
|
|
100
|
+
const last = values[values.length - 1];
|
|
101
|
+
if (series?.kind === 'number') {
|
|
102
|
+
const base = toNumber(last);
|
|
103
|
+
const decimals = Math.min(15, Math.max(...values.map(decimalsOf)));
|
|
104
|
+
const allNumbers = values.every((v) => typeof v === 'number');
|
|
105
|
+
for (let i = 1; i <= n; i++) {
|
|
106
|
+
const next = base + series.step * i;
|
|
107
|
+
out.push(allNumbers ? Number(next.toFixed(decimals)) : next.toFixed(decimals));
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
if (series?.kind === 'date') {
|
|
112
|
+
// Calendar days, not 24-hour blocks: a `Date` keeps its wall-clock
|
|
113
|
+
// time across a daylight-saving change (local calendar), and a
|
|
114
|
+
// YYYY-MM-DD text stays on the UTC calendar it was parsed on.
|
|
115
|
+
const asDate = last instanceof Date;
|
|
116
|
+
for (let i = 1; i <= n; i++) {
|
|
117
|
+
if (asDate) {
|
|
118
|
+
const next = new Date(last.getTime());
|
|
119
|
+
next.setDate(next.getDate() + series.step * i);
|
|
120
|
+
out.push(next);
|
|
121
|
+
} else {
|
|
122
|
+
const next = new Date(toDayMs(last));
|
|
123
|
+
next.setUTCDate(next.getUTCDate() + series.step * i);
|
|
124
|
+
out.push(next.toISOString().slice(0, 10));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
if (series?.kind === 'text-number') {
|
|
130
|
+
const base = Number(TRAILING_NUMBER.exec(last)[2]);
|
|
131
|
+
for (let i = 1; i <= n; i++) {
|
|
132
|
+
const next = base + series.step * i;
|
|
133
|
+
const digits = String(Math.abs(next)).padStart(series.pad, '0');
|
|
134
|
+
out.push(`${series.prefix}${next < 0 ? '-' : ''}${digits}${series.suffix}`);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
for (let i = 0; i < n; i++) out.push(values[i % values.length]);
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A number, or a text that is exactly one; never NaN. */
|
|
143
|
+
function toNumber(value) {
|
|
144
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
145
|
+
if (typeof value !== 'string') return null;
|
|
146
|
+
const text = value.trim();
|
|
147
|
+
return NUMERIC_TEXT.test(text) ? Number(text) : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A `Date`, or a `YYYY-MM-DD` text (read as UTC midnight), in ms. */
|
|
151
|
+
function toDayMs(value) {
|
|
152
|
+
if (value instanceof Date) {
|
|
153
|
+
const t = value.getTime();
|
|
154
|
+
return Number.isNaN(t) ? null : t;
|
|
155
|
+
}
|
|
156
|
+
if (typeof value !== 'string' || !ISO_DATE.test(value.trim())) return null;
|
|
157
|
+
const text = value.trim();
|
|
158
|
+
const t = new Date(`${text}T00:00:00Z`).getTime();
|
|
159
|
+
// 2026-02-30 parses to March 2nd in some engines: not a date.
|
|
160
|
+
if (Number.isNaN(t) || new Date(t).toISOString().slice(0, 10) !== text) return null;
|
|
161
|
+
return t;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The decimals a seed value is written with ("2.50" → 2, 0.1 → 1). */
|
|
165
|
+
function decimalsOf(value) {
|
|
166
|
+
const text = typeof value === 'number' ? String(value) : String(value).trim();
|
|
167
|
+
if (/e/i.test(text)) return 15;
|
|
168
|
+
return (text.split('.')[1] || '').length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function nearlyEqual(a, b) {
|
|
172
|
+
return Math.abs(a - b) < 1e-9;
|
|
173
|
+
}
|