@plmbr/notebook-intelligence 5.0.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/LICENSE +674 -0
- package/README.md +412 -0
- package/lib/api.d.ts +288 -0
- package/lib/api.js +927 -0
- package/lib/cell-output-bundle.d.ts +25 -0
- package/lib/cell-output-bundle.js +129 -0
- package/lib/cell-output-toolbar.d.ts +26 -0
- package/lib/cell-output-toolbar.js +188 -0
- package/lib/chat-progress-feedback.d.ts +3 -0
- package/lib/chat-progress-feedback.js +27 -0
- package/lib/chat-sidebar.d.ts +92 -0
- package/lib/chat-sidebar.js +3452 -0
- package/lib/command-ids.d.ts +39 -0
- package/lib/command-ids.js +44 -0
- package/lib/components/ask-user-question.d.ts +2 -0
- package/lib/components/ask-user-question.js +85 -0
- package/lib/components/checkbox.d.ts +2 -0
- package/lib/components/checkbox.js +30 -0
- package/lib/components/claude-mcp-panel.d.ts +2 -0
- package/lib/components/claude-mcp-panel.js +275 -0
- package/lib/components/claude-mcp-paste.d.ts +7 -0
- package/lib/components/claude-mcp-paste.js +104 -0
- package/lib/components/claude-session-picker.d.ts +8 -0
- package/lib/components/claude-session-picker.js +127 -0
- package/lib/components/form-dialog.d.ts +25 -0
- package/lib/components/form-dialog.js +35 -0
- package/lib/components/launcher-picker.d.ts +6 -0
- package/lib/components/launcher-picker.js +135 -0
- package/lib/components/mcp-util.d.ts +2 -0
- package/lib/components/mcp-util.js +37 -0
- package/lib/components/notebook-generation-popover.d.ts +7 -0
- package/lib/components/notebook-generation-popover.js +60 -0
- package/lib/components/pill.d.ts +2 -0
- package/lib/components/pill.js +5 -0
- package/lib/components/plugins-panel.d.ts +3 -0
- package/lib/components/plugins-panel.js +466 -0
- package/lib/components/settings-panel.d.ts +11 -0
- package/lib/components/settings-panel.js +742 -0
- package/lib/components/skills-panel.d.ts +2 -0
- package/lib/components/skills-panel.js +1264 -0
- package/lib/handler.d.ts +8 -0
- package/lib/handler.js +36 -0
- package/lib/icons.d.ts +45 -0
- package/lib/icons.js +54 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +2079 -0
- package/lib/markdown-renderer.d.ts +10 -0
- package/lib/markdown-renderer.js +64 -0
- package/lib/notebook-generation-toolbar.d.ts +16 -0
- package/lib/notebook-generation-toolbar.js +197 -0
- package/lib/notebook-generation.d.ts +8 -0
- package/lib/notebook-generation.js +12 -0
- package/lib/open-file-refresh-watcher-env.d.ts +4 -0
- package/lib/open-file-refresh-watcher-env.js +33 -0
- package/lib/open-file-refresh-watcher.d.ts +97 -0
- package/lib/open-file-refresh-watcher.js +190 -0
- package/lib/shell-utils.d.ts +6 -0
- package/lib/shell-utils.js +9 -0
- package/lib/task-target-notebook.d.ts +2 -0
- package/lib/task-target-notebook.js +28 -0
- package/lib/terminal-drag-format.d.ts +9 -0
- package/lib/terminal-drag-format.js +23 -0
- package/lib/terminal-drag.d.ts +12 -0
- package/lib/terminal-drag.js +268 -0
- package/lib/tokens.d.ts +149 -0
- package/lib/tokens.js +88 -0
- package/lib/tour/tour-anchors.d.ts +18 -0
- package/lib/tour/tour-anchors.js +18 -0
- package/lib/tour/tour-config.d.ts +66 -0
- package/lib/tour/tour-config.js +99 -0
- package/lib/tour/tour-defaults.json +58 -0
- package/lib/tour/tour-events.d.ts +19 -0
- package/lib/tour/tour-events.js +30 -0
- package/lib/tour/tour-overlay.d.ts +6 -0
- package/lib/tour/tour-overlay.js +350 -0
- package/lib/tour/tour-state.d.ts +20 -0
- package/lib/tour/tour-state.js +81 -0
- package/lib/tour/tour-steps.d.ts +33 -0
- package/lib/tour/tour-steps.js +216 -0
- package/lib/utils.d.ts +53 -0
- package/lib/utils.js +385 -0
- package/package.json +258 -0
- package/schema/plugin.json +42 -0
- package/src/api.ts +1424 -0
- package/src/cell-output-bundle.ts +176 -0
- package/src/cell-output-toolbar.ts +232 -0
- package/src/chat-progress-feedback.ts +35 -0
- package/src/chat-sidebar.tsx +5147 -0
- package/src/command-ids.ts +67 -0
- package/src/components/ask-user-question.tsx +151 -0
- package/src/components/checkbox.tsx +62 -0
- package/src/components/claude-mcp-panel.tsx +543 -0
- package/src/components/claude-mcp-paste.ts +132 -0
- package/src/components/claude-session-picker.tsx +214 -0
- package/src/components/form-dialog.tsx +75 -0
- package/src/components/launcher-picker.tsx +237 -0
- package/src/components/mcp-util.ts +53 -0
- package/src/components/notebook-generation-popover.tsx +127 -0
- package/src/components/pill.tsx +15 -0
- package/src/components/plugins-panel.tsx +774 -0
- package/src/components/settings-panel.tsx +1631 -0
- package/src/components/skills-panel.tsx +2084 -0
- package/src/handler.ts +51 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +2583 -0
- package/src/markdown-renderer.tsx +153 -0
- package/src/notebook-generation-toolbar.tsx +281 -0
- package/src/notebook-generation.ts +23 -0
- package/src/open-file-refresh-watcher-env.ts +52 -0
- package/src/open-file-refresh-watcher.ts +260 -0
- package/src/shell-utils.ts +10 -0
- package/src/svg.d.ts +4 -0
- package/src/task-target-notebook.ts +37 -0
- package/src/terminal-drag-format.ts +29 -0
- package/src/terminal-drag.ts +382 -0
- package/src/tokens.ts +171 -0
- package/src/tour/tour-anchors.ts +21 -0
- package/src/tour/tour-config.ts +160 -0
- package/src/tour/tour-events.ts +34 -0
- package/src/tour/tour-overlay.tsx +474 -0
- package/src/tour/tour-state.ts +87 -0
- package/src/tour/tour-steps.ts +281 -0
- package/src/utils.ts +455 -0
- package/style/base.css +3238 -0
- package/style/icons/cell-toolbar-bug.svg +5 -0
- package/style/icons/cell-toolbar-chat.svg +5 -0
- package/style/icons/cell-toolbar-sparkle.svg +5 -0
- package/style/icons/claude.svg +1 -0
- package/style/icons/copilot-warning.svg +1 -0
- package/style/icons/copilot.svg +1 -0
- package/style/icons/copy.svg +1 -0
- package/style/icons/openai.svg +1 -0
- package/style/icons/opencode.svg +1 -0
- package/style/icons/sparkles-warning.svg +5 -0
- package/style/icons/sparkles.svg +1 -0
- package/style/index.css +1 -0
- package/style/index.js +1 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Admin tour-copy overrides — frontend half.
|
|
5
|
+
*
|
|
6
|
+
* The backend reads a YAML/JSON file (path from `NBI_TOUR_CONFIG_PATH`)
|
|
7
|
+
* and ships the validated overrides via the capabilities response. This
|
|
8
|
+
* module overlays them on the built-in `ALL_TOUR_STEPS` so the same
|
|
9
|
+
* tour code runs against either the default copy or an admin-supplied
|
|
10
|
+
* customization, without rebuilding the extension.
|
|
11
|
+
*
|
|
12
|
+
* Defense in depth: the backend already validated shape and length;
|
|
13
|
+
* the frontend re-applies the same caps here so a backend bug or
|
|
14
|
+
* future server-side regression can't crash the picker layout.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ITourStep } from './tour-steps';
|
|
18
|
+
|
|
19
|
+
// Per-field length caps. Mirror notebook_intelligence/tour_config.py.
|
|
20
|
+
const MAX_TITLE_CHARS = 80;
|
|
21
|
+
const MAX_DESCRIPTION_CHARS = 400;
|
|
22
|
+
const MAX_BUTTON_LABEL_CHARS = 24;
|
|
23
|
+
const MAX_COMMAND_LABEL_CHARS = 40;
|
|
24
|
+
|
|
25
|
+
export interface ITourStepOverride {
|
|
26
|
+
title?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
// Launcher-tiles step only: templates rendered with `{launchers}`
|
|
30
|
+
// substituted by the comma-joined list of installed CLI tools.
|
|
31
|
+
description_singular?: string;
|
|
32
|
+
description_plural?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ITourUiOverride {
|
|
36
|
+
skip?: string;
|
|
37
|
+
next?: string;
|
|
38
|
+
back?: string;
|
|
39
|
+
done?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ITourCommandOverride {
|
|
43
|
+
label?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ITourOverrides {
|
|
47
|
+
steps?: Record<string, ITourStepOverride>;
|
|
48
|
+
ui?: ITourUiOverride;
|
|
49
|
+
command?: ITourCommandOverride;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clamp(value: string, limit: number): string {
|
|
53
|
+
return value.length <= limit ? value : value.slice(0, limit);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply admin-supplied overrides to the canonical step list. Returns a
|
|
58
|
+
* new array; the input is not mutated.
|
|
59
|
+
*
|
|
60
|
+
* Override semantics:
|
|
61
|
+
* - `enabled: false` drops the step entirely (filtered out here, in
|
|
62
|
+
* addition to whatever `requires()` says).
|
|
63
|
+
* - `title` / `description` replace the corresponding fields when set.
|
|
64
|
+
* - For `launcher-tiles`, the dynamic thunk continues to run; the
|
|
65
|
+
* templates feed into it through the per-step override dict so the
|
|
66
|
+
* thunk can use them at resolve time.
|
|
67
|
+
*/
|
|
68
|
+
export function applyTourOverrides(
|
|
69
|
+
steps: readonly ITourStep[],
|
|
70
|
+
overrides: ITourOverrides | undefined
|
|
71
|
+
): ITourStep[] {
|
|
72
|
+
if (!overrides || !overrides.steps) {
|
|
73
|
+
return steps.map(step => ({ ...step }));
|
|
74
|
+
}
|
|
75
|
+
const stepOverrides = overrides.steps;
|
|
76
|
+
const result: ITourStep[] = [];
|
|
77
|
+
for (const step of steps) {
|
|
78
|
+
const override = stepOverrides[step.id];
|
|
79
|
+
if (!override) {
|
|
80
|
+
result.push({ ...step });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (override.enabled === false) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const next: ITourStep = { ...step };
|
|
87
|
+
// Reject empty strings: an empty title would leave the dialog's
|
|
88
|
+
// aria-labelledby target with no accessible name, and an empty
|
|
89
|
+
// description would leave aria-describedby pointing at nothing.
|
|
90
|
+
if (typeof override.title === 'string' && override.title.length > 0) {
|
|
91
|
+
next.title = clamp(override.title, MAX_TITLE_CHARS);
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
typeof override.description === 'string' &&
|
|
95
|
+
override.description.length > 0 &&
|
|
96
|
+
// The launcher-tiles thunk is overridden via templates, not the
|
|
97
|
+
// plain `description` field; the backend validator rejects it
|
|
98
|
+
// outright, but ignore it here too in case an older payload slips
|
|
99
|
+
// through.
|
|
100
|
+
step.id !== 'launcher-tiles'
|
|
101
|
+
) {
|
|
102
|
+
next.description = clamp(override.description, MAX_DESCRIPTION_CHARS);
|
|
103
|
+
}
|
|
104
|
+
result.push(next);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pull the per-step override for the launcher-tiles step's templates
|
|
111
|
+
* (if any). Used by the launcher-tiles description thunk.
|
|
112
|
+
*/
|
|
113
|
+
export function launcherTileTemplates(overrides: ITourOverrides | undefined): {
|
|
114
|
+
singular?: string;
|
|
115
|
+
plural?: string;
|
|
116
|
+
} {
|
|
117
|
+
const step = overrides?.steps?.['launcher-tiles'];
|
|
118
|
+
if (!step) {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
const result: { singular?: string; plural?: string } = {};
|
|
122
|
+
if (typeof step.description_singular === 'string') {
|
|
123
|
+
result.singular = clamp(step.description_singular, MAX_DESCRIPTION_CHARS);
|
|
124
|
+
}
|
|
125
|
+
if (typeof step.description_plural === 'string') {
|
|
126
|
+
result.plural = clamp(step.description_plural, MAX_DESCRIPTION_CHARS);
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve a UI button label: admin override if set, otherwise the
|
|
133
|
+
* default supplied by the caller.
|
|
134
|
+
*/
|
|
135
|
+
export function uiLabel(
|
|
136
|
+
overrides: ITourOverrides | undefined,
|
|
137
|
+
key: keyof ITourUiOverride,
|
|
138
|
+
fallback: string
|
|
139
|
+
): string {
|
|
140
|
+
const raw = overrides?.ui?.[key];
|
|
141
|
+
if (typeof raw === 'string' && raw.length > 0) {
|
|
142
|
+
return clamp(raw, MAX_BUTTON_LABEL_CHARS);
|
|
143
|
+
}
|
|
144
|
+
return fallback;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the command-palette label for the tour command: admin
|
|
149
|
+
* override if set, otherwise the default supplied by the caller.
|
|
150
|
+
*/
|
|
151
|
+
export function commandLabel(
|
|
152
|
+
overrides: ITourOverrides | undefined,
|
|
153
|
+
fallback: string
|
|
154
|
+
): string {
|
|
155
|
+
const raw = overrides?.command?.label;
|
|
156
|
+
if (typeof raw === 'string' && raw.length > 0) {
|
|
157
|
+
return clamp(raw, MAX_COMMAND_LABEL_CHARS);
|
|
158
|
+
}
|
|
159
|
+
return fallback;
|
|
160
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decoupled trigger channel for the first-run tour.
|
|
5
|
+
*
|
|
6
|
+
* The JupyterLab command (`notebook-intelligence:show-tour`) is
|
|
7
|
+
* registered in `src/index.ts` so it shows up in the command palette
|
|
8
|
+
* and can be invoked from anywhere. The tour itself is rendered inside
|
|
9
|
+
* the chat sidebar (`src/chat-sidebar.tsx`) so it can access React
|
|
10
|
+
* state cleanly. Wiring those two together via DOM CustomEvents keeps
|
|
11
|
+
* index.ts free of any direct sidebar import and lets the tour be
|
|
12
|
+
* triggered from any future surface (a Settings dialog entry, a Help
|
|
13
|
+
* menu, a banner) by dispatching the same event.
|
|
14
|
+
*
|
|
15
|
+
* Dispatched on `document` to match the existing `copilotSidebar:*`
|
|
16
|
+
* channel convention elsewhere in this codebase.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const TOUR_START_EVENT = 'nbi:show-tour';
|
|
20
|
+
export const TOUR_STOP_EVENT = 'nbi:hide-tour';
|
|
21
|
+
|
|
22
|
+
export function dispatchShowTour(): void {
|
|
23
|
+
if (typeof document === 'undefined') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
document.dispatchEvent(new CustomEvent(TOUR_START_EVENT));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function dispatchHideTour(): void {
|
|
30
|
+
if (typeof document === 'undefined') {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
document.dispatchEvent(new CustomEvent(TOUR_STOP_EVENT));
|
|
34
|
+
}
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { createPortal } from 'react-dom';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
TourPlacement,
|
|
14
|
+
IResolvedTourStep,
|
|
15
|
+
activeTourSteps
|
|
16
|
+
} from './tour-steps';
|
|
17
|
+
import { markTourCompleted } from './tour-state';
|
|
18
|
+
import { NBIAPI } from '../api';
|
|
19
|
+
import { uiLabel } from './tour-config';
|
|
20
|
+
|
|
21
|
+
const TOOLTIP_GAP_PX = 12;
|
|
22
|
+
const TOOLTIP_WIDTH_PX = 320;
|
|
23
|
+
// Fallback used on the first render before the tooltip is measured.
|
|
24
|
+
// Chosen to be larger than the typical rendered height so the initial
|
|
25
|
+
// clamp errs toward staying on-screen rather than overshooting.
|
|
26
|
+
const TOOLTIP_HEIGHT_FALLBACK_PX = 200;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute the on-screen position of the tooltip given the anchor's
|
|
30
|
+
* bounding rect, the requested placement, and the tooltip's measured
|
|
31
|
+
* height. The clamp keeps the box fully on-screen even when the anchor
|
|
32
|
+
* sits near a viewport edge.
|
|
33
|
+
*
|
|
34
|
+
* Placement semantics: 'top' puts the tooltip above the anchor (its
|
|
35
|
+
* bottom edge gap-px above anchor.top), 'bottom' puts it below, 'left'
|
|
36
|
+
* to the left, 'right' to the right. The previous version omitted
|
|
37
|
+
* tooltip width/height from the top/left placements, which let the box
|
|
38
|
+
* overlap the anchor instead of sitting outside it.
|
|
39
|
+
*/
|
|
40
|
+
function computeTooltipPosition(
|
|
41
|
+
anchorRect: DOMRect,
|
|
42
|
+
placement: Exclude<TourPlacement, 'center'>,
|
|
43
|
+
tooltipHeight: number
|
|
44
|
+
): { top: number; left: number } {
|
|
45
|
+
let top = 0;
|
|
46
|
+
let left = 0;
|
|
47
|
+
switch (placement) {
|
|
48
|
+
case 'top':
|
|
49
|
+
top = anchorRect.top - TOOLTIP_GAP_PX - tooltipHeight;
|
|
50
|
+
left = anchorRect.left + anchorRect.width / 2 - TOOLTIP_WIDTH_PX / 2;
|
|
51
|
+
break;
|
|
52
|
+
case 'bottom':
|
|
53
|
+
top = anchorRect.bottom + TOOLTIP_GAP_PX;
|
|
54
|
+
left = anchorRect.left + anchorRect.width / 2 - TOOLTIP_WIDTH_PX / 2;
|
|
55
|
+
break;
|
|
56
|
+
case 'left':
|
|
57
|
+
top = anchorRect.top + anchorRect.height / 2 - tooltipHeight / 2;
|
|
58
|
+
left = anchorRect.left - TOOLTIP_GAP_PX - TOOLTIP_WIDTH_PX;
|
|
59
|
+
break;
|
|
60
|
+
case 'right':
|
|
61
|
+
top = anchorRect.top + anchorRect.height / 2 - tooltipHeight / 2;
|
|
62
|
+
left = anchorRect.right + TOOLTIP_GAP_PX;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
// Clamp inside the viewport with an 8px margin so the tooltip never
|
|
66
|
+
// disappears off-screen near the edges. The vertical clamp accounts
|
|
67
|
+
// for tooltip height so the bottom edge stays visible even when the
|
|
68
|
+
// anchor is near the bottom of the viewport.
|
|
69
|
+
const margin = 8;
|
|
70
|
+
left = Math.max(
|
|
71
|
+
margin,
|
|
72
|
+
Math.min(left, window.innerWidth - TOOLTIP_WIDTH_PX - margin)
|
|
73
|
+
);
|
|
74
|
+
top = Math.max(
|
|
75
|
+
margin,
|
|
76
|
+
Math.min(top, window.innerHeight - tooltipHeight - margin)
|
|
77
|
+
);
|
|
78
|
+
return { top, left };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function findAnchor(anchorId: string | null): HTMLElement | null {
|
|
82
|
+
if (!anchorId) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return document.querySelector<HTMLElement>(`[data-tour-id="${anchorId}"]`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ITourOverlayProps {
|
|
89
|
+
// Render-control hooks. The parent owns the visibility decision so
|
|
90
|
+
// the overlay stays a pure presentation component.
|
|
91
|
+
onClose: () => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function TourOverlay(props: ITourOverlayProps): JSX.Element | null {
|
|
95
|
+
// Compute the list of steps once. Capabilities can change at runtime
|
|
96
|
+
// (e.g. user switches Claude mode mid-tour) but recomputing the list
|
|
97
|
+
// mid-flight would change indices under the user; freeze on mount.
|
|
98
|
+
const steps = useMemo<IResolvedTourStep[]>(() => activeTourSteps(), []);
|
|
99
|
+
// Freeze the admin overrides snapshot at mount, alongside `steps`, so
|
|
100
|
+
// a config push mid-tour doesn't relabel the active overlay's buttons.
|
|
101
|
+
const labels = useMemo(() => {
|
|
102
|
+
const overrides = NBIAPI.config.tourOverrides;
|
|
103
|
+
return {
|
|
104
|
+
skip: uiLabel(overrides, 'skip', 'Skip tour'),
|
|
105
|
+
next: uiLabel(overrides, 'next', 'Next'),
|
|
106
|
+
back: uiLabel(overrides, 'back', 'Back'),
|
|
107
|
+
done: uiLabel(overrides, 'done', 'Done')
|
|
108
|
+
};
|
|
109
|
+
}, []);
|
|
110
|
+
const [index, setIndex] = useState(0);
|
|
111
|
+
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
|
112
|
+
const [tooltipHeight, setTooltipHeight] = useState<number>(
|
|
113
|
+
TOOLTIP_HEIGHT_FALLBACK_PX
|
|
114
|
+
);
|
|
115
|
+
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
|
116
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
117
|
+
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
118
|
+
|
|
119
|
+
const step = steps[index];
|
|
120
|
+
|
|
121
|
+
// Save focus on mount, restore on unmount. Pairs with the focus trap
|
|
122
|
+
// below so the tour leaves focus where it found it (e.g. the prompt
|
|
123
|
+
// textarea) instead of dumping the user to document.body.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
previouslyFocusedRef.current =
|
|
126
|
+
(document.activeElement as HTMLElement | null) ?? null;
|
|
127
|
+
return () => {
|
|
128
|
+
const prev = previouslyFocusedRef.current;
|
|
129
|
+
if (prev && document.contains(prev) && typeof prev.focus === 'function') {
|
|
130
|
+
prev.focus();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
// Measure the tooltip *before* paint so the next frame clamps its
|
|
136
|
+
// position against the actual rendered height. useLayoutEffect (not
|
|
137
|
+
// useEffect) prevents the user seeing a frame at the fallback height.
|
|
138
|
+
// Re-measure only on step change so a no-op render doesn't pay the
|
|
139
|
+
// getBoundingClientRect tax.
|
|
140
|
+
useLayoutEffect(() => {
|
|
141
|
+
if (!tooltipRef.current) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const h = tooltipRef.current.getBoundingClientRect().height;
|
|
145
|
+
if (h && Math.abs(h - tooltipHeight) > 0.5) {
|
|
146
|
+
setTooltipHeight(h);
|
|
147
|
+
}
|
|
148
|
+
// tooltipHeight intentionally omitted from deps: this effect only
|
|
149
|
+
// re-runs on step change and the > 0.5px guard already prevents
|
|
150
|
+
// infinite loops if measurement converges.
|
|
151
|
+
}, [step?.id, index]);
|
|
152
|
+
|
|
153
|
+
// Resolve the anchor synchronously before the next paint. If the
|
|
154
|
+
// anchor isn't in the DOM yet (parent React tree may not have
|
|
155
|
+
// committed), retry across a handful of animation frames before
|
|
156
|
+
// giving up; missing anchors auto-advance so the user doesn't land
|
|
157
|
+
// on a blank step. useLayoutEffect on the synchronous path
|
|
158
|
+
// eliminates the one-frame "wrong position" flash on step change.
|
|
159
|
+
useLayoutEffect(() => {
|
|
160
|
+
if (!step) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!step.anchorId) {
|
|
164
|
+
setAnchorRect(null);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Clear the stale rect from the previous step immediately so a
|
|
168
|
+
// late paint never shows the spotlight in the old position.
|
|
169
|
+
setAnchorRect(null);
|
|
170
|
+
|
|
171
|
+
let rafId = 0;
|
|
172
|
+
let attempts = 0;
|
|
173
|
+
const MAX_ATTEMPTS = 10;
|
|
174
|
+
let removeListeners = () => {};
|
|
175
|
+
|
|
176
|
+
const tryResolve = () => {
|
|
177
|
+
const anchor = findAnchor(step.anchorId);
|
|
178
|
+
if (anchor) {
|
|
179
|
+
const updateRect = () => setAnchorRect(anchor.getBoundingClientRect());
|
|
180
|
+
updateRect();
|
|
181
|
+
window.addEventListener('resize', updateRect);
|
|
182
|
+
window.addEventListener('scroll', updateRect, true);
|
|
183
|
+
removeListeners = () => {
|
|
184
|
+
window.removeEventListener('resize', updateRect);
|
|
185
|
+
window.removeEventListener('scroll', updateRect, true);
|
|
186
|
+
};
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
attempts += 1;
|
|
190
|
+
if (attempts >= MAX_ATTEMPTS) {
|
|
191
|
+
// Genuinely missing; auto-advance so the user doesn't land on a
|
|
192
|
+
// blank step.
|
|
193
|
+
setIndex(i => i + 1);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
rafId = requestAnimationFrame(tryResolve);
|
|
197
|
+
};
|
|
198
|
+
// Attempt the first resolve synchronously so a happy path step
|
|
199
|
+
// change paints in the right place on the very next frame.
|
|
200
|
+
tryResolve();
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
cancelAnimationFrame(rafId);
|
|
204
|
+
removeListeners();
|
|
205
|
+
};
|
|
206
|
+
}, [step?.anchorId, index]);
|
|
207
|
+
|
|
208
|
+
// Stable callback refs so the keydown handler can stay bound for the
|
|
209
|
+
// overlay's lifetime without re-binding per step. Closing over `index`
|
|
210
|
+
// directly would make the deps load-bearing in a non-obvious way.
|
|
211
|
+
const advanceRef = useRef(() => {});
|
|
212
|
+
const backRef = useRef(() => {});
|
|
213
|
+
const finishRef = useRef(() => {});
|
|
214
|
+
|
|
215
|
+
// Keyboard nav: Esc dismisses, Enter / Right advance, Left back.
|
|
216
|
+
// Tab/Shift-Tab are trapped inside the dialog (paired with
|
|
217
|
+
// aria-modal="true" so screen readers' modal semantics match reality).
|
|
218
|
+
// Ignore key presses targeted at editable elements so the prompt
|
|
219
|
+
// textarea's history-nav arrow keys still work if focus somehow
|
|
220
|
+
// landed there before the tour mounted.
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
const handler = (e: KeyboardEvent) => {
|
|
223
|
+
const target = e.target as HTMLElement | null;
|
|
224
|
+
const inEditable =
|
|
225
|
+
!!target &&
|
|
226
|
+
(target.tagName === 'INPUT' ||
|
|
227
|
+
target.tagName === 'TEXTAREA' ||
|
|
228
|
+
target.isContentEditable);
|
|
229
|
+
// Tab/Shift-Tab focus trap is enforced even when target is an
|
|
230
|
+
// editable element so focus can't escape the dialog through the
|
|
231
|
+
// textarea below it.
|
|
232
|
+
if (e.key === 'Tab') {
|
|
233
|
+
const root = rootRef.current;
|
|
234
|
+
if (!root) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const focusables = Array.from(
|
|
238
|
+
root.querySelectorAll<HTMLElement>(
|
|
239
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
240
|
+
)
|
|
241
|
+
).filter(el => !el.hasAttribute('disabled'));
|
|
242
|
+
if (focusables.length === 0) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const first = focusables[0];
|
|
246
|
+
const last = focusables[focusables.length - 1];
|
|
247
|
+
const active = document.activeElement as HTMLElement | null;
|
|
248
|
+
// If focus is outside the dialog entirely (e.g. clicked through
|
|
249
|
+
// the scrim somehow), pull it back to the first focusable.
|
|
250
|
+
if (!active || !root.contains(active)) {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
first.focus();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (e.shiftKey && active === first) {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
last.focus();
|
|
258
|
+
} else if (!e.shiftKey && active === last) {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
first.focus();
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (inEditable) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (e.key === 'Escape') {
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
finishRef.current();
|
|
270
|
+
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
advanceRef.current();
|
|
273
|
+
} else if (e.key === 'ArrowLeft') {
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
backRef.current();
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
window.addEventListener('keydown', handler);
|
|
279
|
+
return () => window.removeEventListener('keydown', handler);
|
|
280
|
+
}, []);
|
|
281
|
+
|
|
282
|
+
// If we run out of steps (e.g. every anchor disappeared) close the
|
|
283
|
+
// tour on the next tick rather than calling setState inside the
|
|
284
|
+
// render below.
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (!step) {
|
|
287
|
+
finish();
|
|
288
|
+
}
|
|
289
|
+
}, [step]);
|
|
290
|
+
|
|
291
|
+
const advance = () => {
|
|
292
|
+
if (index >= steps.length - 1) {
|
|
293
|
+
finish();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
setIndex(i => i + 1);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const back = () => {
|
|
300
|
+
if (index > 0) {
|
|
301
|
+
setIndex(i => i - 1);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const finish = () => {
|
|
306
|
+
markTourCompleted();
|
|
307
|
+
props.onClose();
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Refresh the callback refs every render so the keydown listener
|
|
311
|
+
// (bound once) always reads the latest closures.
|
|
312
|
+
advanceRef.current = advance;
|
|
313
|
+
backRef.current = back;
|
|
314
|
+
finishRef.current = finish;
|
|
315
|
+
|
|
316
|
+
if (!step) {
|
|
317
|
+
// No step to render right now. The cleanup effect above will fire
|
|
318
|
+
// `finish()` on the next tick; in the meantime return null so
|
|
319
|
+
// React doesn't see a setState inside this render path.
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const isCenter = step.placement === 'center' || !step.anchorId;
|
|
324
|
+
// Anchored steps are "ready" once we have measured the anchor for
|
|
325
|
+
// *this* step. While unmeasured we keep the dialog mounted but
|
|
326
|
+
// visually hidden so the user doesn't see a flash at the centered
|
|
327
|
+
// fallback position before the real position lands.
|
|
328
|
+
const measured = isCenter || anchorRect !== null;
|
|
329
|
+
const tooltipStyle: React.CSSProperties = isCenter
|
|
330
|
+
? {
|
|
331
|
+
top: '50%',
|
|
332
|
+
left: '50%',
|
|
333
|
+
transform: 'translate(-50%, -50%)',
|
|
334
|
+
width: TOOLTIP_WIDTH_PX
|
|
335
|
+
}
|
|
336
|
+
: (() => {
|
|
337
|
+
// Fall back to a zero-size rect at the viewport center when the
|
|
338
|
+
// anchor hasn't been measured yet. `DOMRect` is the standard
|
|
339
|
+
// constructor in the browser; jsdom-only test environments
|
|
340
|
+
// sometimes lack it, so build a plain object that satisfies
|
|
341
|
+
// the fields `computeTooltipPosition` reads.
|
|
342
|
+
const rect: DOMRect =
|
|
343
|
+
anchorRect ??
|
|
344
|
+
({
|
|
345
|
+
top: window.innerHeight / 2,
|
|
346
|
+
left: window.innerWidth / 2,
|
|
347
|
+
right: window.innerWidth / 2,
|
|
348
|
+
bottom: window.innerHeight / 2,
|
|
349
|
+
width: 0,
|
|
350
|
+
height: 0,
|
|
351
|
+
x: window.innerWidth / 2,
|
|
352
|
+
y: window.innerHeight / 2,
|
|
353
|
+
toJSON: () => ({})
|
|
354
|
+
} as DOMRect);
|
|
355
|
+
// step.placement is guaranteed not to be 'center' here (isCenter
|
|
356
|
+
// would have been true otherwise); narrow for the type checker.
|
|
357
|
+
const placement = step.placement as Exclude<TourPlacement, 'center'>;
|
|
358
|
+
return {
|
|
359
|
+
...computeTooltipPosition(rect, placement, tooltipHeight),
|
|
360
|
+
width: TOOLTIP_WIDTH_PX,
|
|
361
|
+
visibility: measured ? ('visible' as const) : ('hidden' as const)
|
|
362
|
+
};
|
|
363
|
+
})();
|
|
364
|
+
|
|
365
|
+
const spotlight = !isCenter && anchorRect && (
|
|
366
|
+
<div
|
|
367
|
+
className="nbi-tour-spotlight"
|
|
368
|
+
style={{
|
|
369
|
+
top: anchorRect.top - 4,
|
|
370
|
+
left: anchorRect.left - 4,
|
|
371
|
+
width: anchorRect.width + 8,
|
|
372
|
+
height: anchorRect.height + 8
|
|
373
|
+
}}
|
|
374
|
+
/>
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const isLast = index === steps.length - 1;
|
|
378
|
+
const progress = ((index + 1) / steps.length) * 100;
|
|
379
|
+
|
|
380
|
+
// Render into document.body via portal so the overlay can extend
|
|
381
|
+
// outside the sidebar's clip region and float over the rest of
|
|
382
|
+
// JupyterLab.
|
|
383
|
+
// ARIA: the dialog is modal-ish (covers the UI), so flag it as such.
|
|
384
|
+
// The label / description IDs change per step, but most screen
|
|
385
|
+
// readers don't re-announce when those attrs change without remount.
|
|
386
|
+
// The step body is wrapped in an aria-live="polite" region so each
|
|
387
|
+
// transition is announced as the content changes.
|
|
388
|
+
const titleId = `nbi-tour-title-${step.id}`;
|
|
389
|
+
const descId = `nbi-tour-desc-${step.id}`;
|
|
390
|
+
// Arrow direction is the inverse of placement (a tooltip placed
|
|
391
|
+
// ABOVE the anchor has its tail pointing DOWN). Center-placement
|
|
392
|
+
// tooltips have no anchor, so no arrow.
|
|
393
|
+
const arrowDirection = isCenter ? null : step.placement;
|
|
394
|
+
return createPortal(
|
|
395
|
+
<div
|
|
396
|
+
ref={rootRef}
|
|
397
|
+
className="nbi-tour-root"
|
|
398
|
+
role="dialog"
|
|
399
|
+
aria-modal="true"
|
|
400
|
+
aria-labelledby={titleId}
|
|
401
|
+
aria-describedby={descId}
|
|
402
|
+
>
|
|
403
|
+
{/* Scrim is non-dismissable on click: industry-standard tour UX.
|
|
404
|
+
Skip / Done / Esc are the explicit exits, so a misclick on
|
|
405
|
+
the dimmed area never abandons the tour. */}
|
|
406
|
+
<div className="nbi-tour-scrim" />
|
|
407
|
+
{spotlight}
|
|
408
|
+
<div
|
|
409
|
+
ref={tooltipRef}
|
|
410
|
+
className={`nbi-tour-tooltip${isCenter ? ' nbi-tour-tooltip-center' : ''}`}
|
|
411
|
+
style={tooltipStyle}
|
|
412
|
+
>
|
|
413
|
+
{arrowDirection && (
|
|
414
|
+
<div
|
|
415
|
+
className={`nbi-tour-arrow nbi-tour-arrow-${arrowDirection}`}
|
|
416
|
+
aria-hidden="true"
|
|
417
|
+
/>
|
|
418
|
+
)}
|
|
419
|
+
<div
|
|
420
|
+
className="nbi-tour-progress"
|
|
421
|
+
role="progressbar"
|
|
422
|
+
aria-valuemin={1}
|
|
423
|
+
aria-valuemax={steps.length}
|
|
424
|
+
aria-valuenow={index + 1}
|
|
425
|
+
aria-label={`Tour progress: step ${index + 1} of ${steps.length}`}
|
|
426
|
+
>
|
|
427
|
+
<div
|
|
428
|
+
className="nbi-tour-progress-bar"
|
|
429
|
+
style={{ width: `${progress}%` }}
|
|
430
|
+
/>
|
|
431
|
+
</div>
|
|
432
|
+
<div
|
|
433
|
+
className="nbi-tour-body"
|
|
434
|
+
aria-live="polite"
|
|
435
|
+
aria-atomic="true"
|
|
436
|
+
key={step.id}
|
|
437
|
+
>
|
|
438
|
+
<div className="nbi-tour-title" id={titleId}>
|
|
439
|
+
{step.title}
|
|
440
|
+
</div>
|
|
441
|
+
<div className="nbi-tour-description" id={descId}>
|
|
442
|
+
{step.description}
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
<div className="nbi-tour-actions">
|
|
446
|
+
<button type="button" className="nbi-tour-skip-link" onClick={finish}>
|
|
447
|
+
{labels.skip}
|
|
448
|
+
</button>
|
|
449
|
+
<div style={{ flexGrow: 1 }} />
|
|
450
|
+
{index > 0 && (
|
|
451
|
+
<button
|
|
452
|
+
type="button"
|
|
453
|
+
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
|
|
454
|
+
onClick={back}
|
|
455
|
+
>
|
|
456
|
+
<div className="jp-Dialog-buttonLabel">{labels.back}</div>
|
|
457
|
+
</button>
|
|
458
|
+
)}
|
|
459
|
+
<button
|
|
460
|
+
type="button"
|
|
461
|
+
className="jp-Dialog-button jp-mod-accept jp-mod-styled"
|
|
462
|
+
onClick={advance}
|
|
463
|
+
autoFocus
|
|
464
|
+
>
|
|
465
|
+
<div className="jp-Dialog-buttonLabel">
|
|
466
|
+
{isLast ? labels.done : labels.next}
|
|
467
|
+
</div>
|
|
468
|
+
</button>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
</div>,
|
|
472
|
+
document.body
|
|
473
|
+
);
|
|
474
|
+
}
|