@djangocfg/ui-tools 2.1.409 → 2.1.412
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/package.json +13 -13
- package/src/{tools/Chat/highlight → lib/browser-bridge}/README.md +46 -18
- package/src/lib/browser-bridge/commands/chat.ts +42 -0
- package/src/lib/browser-bridge/commands/highlight.ts +70 -0
- package/src/lib/browser-bridge/commands/index.ts +15 -0
- package/src/lib/browser-bridge/commands/inspect.ts +31 -0
- package/src/lib/browser-bridge/commands/scroll.ts +31 -0
- package/src/lib/browser-bridge/commands/write.ts +45 -0
- package/src/lib/browser-bridge/directive-bus.ts +120 -0
- package/src/lib/browser-bridge/index.ts +56 -0
- package/src/lib/browser-bridge/logger.ts +27 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/HighlightOverlay.tsx +14 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/HighlightOverlay.test.tsx +52 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/resolveRef.test.ts +39 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/index.ts +8 -5
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/resolveRef.ts +5 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/useHighlightTargets.ts +58 -27
- package/src/lib/browser-bridge/overlay/waitForVisible.ts +70 -0
- package/src/lib/browser-bridge/registry.ts +41 -0
- package/src/lib/browser-bridge/setBridgeResolver.ts +42 -0
- package/src/lib/browser-bridge/window.ts +76 -0
- package/src/lib/page-snapshot/capture/walk.ts +13 -5
- package/src/lib/page-snapshot/engine.ts +9 -4
- package/src/lib/page-snapshot/index.ts +5 -0
- package/src/lib/page-snapshot/react/provider.tsx +70 -3
- package/src/lib/page-snapshot/react/use-page-snapshot.ts +10 -0
- package/src/lib/page-snapshot/refs/__tests__/locator.test.ts +94 -0
- package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +59 -3
- package/src/lib/page-snapshot/refs/locator.ts +218 -0
- package/src/lib/page-snapshot/refs/registry.ts +29 -14
- package/src/tools/Chat/README.md +1 -1
- package/src/tools/Chat/composer/AttachContext.tsx +22 -0
- package/src/tools/Chat/composer/Composer.tsx +108 -6
- package/src/tools/Chat/composer/ComposerMenuButton.tsx +39 -2
- package/src/tools/Chat/composer/fileToAttachment.ts +53 -0
- package/src/tools/Chat/composer/index.ts +16 -1
- package/src/tools/Chat/composer/types.ts +71 -0
- package/src/tools/Chat/composer/useComposerAttach.tsx +218 -0
- package/src/tools/Chat/constants.ts +24 -1
- package/src/tools/Chat/context/ChatProvider.tsx +17 -2
- package/src/tools/Chat/core/logger.ts +15 -2
- package/src/tools/Chat/hooks/useChat.ts +32 -0
- package/src/tools/Chat/hooks/useChatComposer.ts +13 -0
- package/src/tools/Chat/index.ts +34 -2
- package/src/tools/Chat/launcher/ChatDock.tsx +13 -3
- package/src/tools/Chat/launcher/ChatFAB.tsx +4 -2
- package/src/tools/Chat/launcher/ChatGreeting.tsx +3 -2
- package/src/tools/Chat/launcher/ChatLauncher.tsx +42 -7
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +3 -2
- package/src/tools/Chat/launcher/header/ChatHeader.tsx +2 -0
- package/src/tools/Chat/launcher/header/ChatHeaderActionButton.tsx +2 -0
- package/src/tools/Chat/launcher/header/ChatHeaderLanguageButton.tsx +2 -2
- package/src/tools/Chat/launcher/header/HeaderSlots.tsx +16 -9
- package/src/tools/Chat/lazy.tsx +34 -2
- package/src/tools/Chat/messages/MessageBubble.tsx +1 -1
- package/src/tools/Chat/public.ts +17 -0
- package/src/tools/Chat/settings/README.md +87 -0
- package/src/tools/Chat/settings/__tests__/useChatSettings.test.tsx +84 -0
- package/src/tools/Chat/settings/__tests__/useLocalStorage.test.tsx +138 -0
- package/src/tools/Chat/settings/index.ts +23 -0
- package/src/tools/Chat/settings/types.ts +108 -0
- package/src/tools/Chat/settings/useChatSettings.ts +168 -0
- package/src/tools/Chat/types/events.ts +50 -0
- package/src/tools/Chat/types/index.ts +1 -1
- package/src/tools/Chat/types/message.ts +5 -0
- package/src/tools/CronScheduler/CronScheduler.client.tsx +42 -15
- package/src/tools/CronScheduler/components/CustomInput.tsx +26 -7
- package/src/tools/CronScheduler/components/DayChips.tsx +20 -7
- package/src/tools/CronScheduler/components/MonthDayGrid.tsx +35 -10
- package/src/tools/CronScheduler/components/SchedulePreview.tsx +8 -5
- package/src/tools/CronScheduler/components/ScheduleTypeSelector.tsx +12 -3
- package/src/tools/CronScheduler/components/TimeSelector.tsx +36 -13
- package/src/tools/CronScheduler/context/CronSchedulerContext.tsx +4 -0
- package/src/tools/CronScheduler/context/hooks.ts +8 -0
- package/src/tools/CronScheduler/context/index.ts +1 -0
- package/src/tools/CronScheduler/index.tsx +2 -0
- package/src/tools/CronScheduler/lazy.tsx +1 -0
- package/src/tools/CronScheduler/types/index.ts +18 -1
- package/src/tools/Map/lazy.tsx +11 -4
- package/src/tools/Uploader/hooks/useClipboardPaste.ts +3 -1
- package/src/tools/index.ts +2 -0
- /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/SpotlightCanvas.tsx +0 -0
- /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/types.ts +0 -0
package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/HighlightOverlay.test.tsx
RENAMED
|
@@ -19,6 +19,25 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// jsdom has no IntersectionObserver — the overlay uses it to wait for
|
|
23
|
+
// an off-screen target to become visible. Stub it to report the
|
|
24
|
+
// observed element as immediately intersecting so the wait resolves.
|
|
25
|
+
if (typeof globalThis.IntersectionObserver === 'undefined') {
|
|
26
|
+
(globalThis as Record<string, unknown>).IntersectionObserver = class {
|
|
27
|
+
private cb: (entries: unknown[]) => void;
|
|
28
|
+
constructor(cb: (entries: unknown[]) => void) {
|
|
29
|
+
this.cb = cb;
|
|
30
|
+
}
|
|
31
|
+
observe(target: Element) {
|
|
32
|
+
this.cb([
|
|
33
|
+
{ target, isIntersecting: true, intersectionRatio: 1 },
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
unobserve() {}
|
|
37
|
+
disconnect() {}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
let container: HTMLDivElement;
|
|
23
42
|
let root: Root;
|
|
24
43
|
|
|
@@ -109,4 +128,37 @@ describe('HighlightOverlay', () => {
|
|
|
109
128
|
);
|
|
110
129
|
expect(document.activeElement).not.toBe(el);
|
|
111
130
|
});
|
|
131
|
+
|
|
132
|
+
it('scrolls an off-screen target into view before drawing it', async () => {
|
|
133
|
+
const el = document.createElement('button');
|
|
134
|
+
document.body.appendChild(el);
|
|
135
|
+
|
|
136
|
+
// Report the element below the viewport so it counts as off-screen.
|
|
137
|
+
el.getBoundingClientRect = () =>
|
|
138
|
+
({ top: 5000, left: 0, bottom: 5040, right: 80, width: 80, height: 40, x: 0, y: 5000 }) as DOMRect;
|
|
139
|
+
|
|
140
|
+
let scrolled = false;
|
|
141
|
+
el.scrollIntoView = () => {
|
|
142
|
+
scrolled = true;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
await render(
|
|
146
|
+
[{ type: 'point', ref: '@e1' }],
|
|
147
|
+
makeResolver({ '@e1': el }),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Let the visibility wait resolve and its rAF-scheduled re-measure
|
|
151
|
+
// run, so the now-ready target is drawn.
|
|
152
|
+
await act(async () => {
|
|
153
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// The off-screen target was scrolled into view, and once the
|
|
157
|
+
// (stubbed) IntersectionObserver reports it visible the overlay
|
|
158
|
+
// draws the spotlight.
|
|
159
|
+
expect(scrolled).toBe(true);
|
|
160
|
+
expect(
|
|
161
|
+
document.querySelector('[data-chat-highlight-overlay]'),
|
|
162
|
+
).not.toBeNull();
|
|
163
|
+
});
|
|
112
164
|
});
|
package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/resolveRef.test.ts
RENAMED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
3
|
|
|
4
|
+
import { computeLocator } from '../../../page-snapshot/refs/locator';
|
|
5
|
+
import { RefRegistry } from '../../../page-snapshot/refs/registry';
|
|
4
6
|
import { resolveRefs, type RefResolver } from '../resolveRef';
|
|
5
7
|
import type { CSTRefId } from '../types';
|
|
6
8
|
|
|
@@ -53,3 +55,40 @@ describe('resolveRefs', () => {
|
|
|
53
55
|
expect(out.map((o) => o.ref)).toEqual(['@e1']);
|
|
54
56
|
});
|
|
55
57
|
});
|
|
58
|
+
|
|
59
|
+
describe('resolveRefs against a live RefRegistry', () => {
|
|
60
|
+
it('resolves a ref after the captured node is recreated by a re-render', () => {
|
|
61
|
+
// Capture: the snapshot registry stores a locator, not a node.
|
|
62
|
+
const original = document.createElement('a');
|
|
63
|
+
original.id = 'cta';
|
|
64
|
+
original.textContent = 'Browse Catalog';
|
|
65
|
+
document.body.appendChild(original);
|
|
66
|
+
|
|
67
|
+
const registry = new RefRegistry('snap-1');
|
|
68
|
+
registry.set('@e1', computeLocator(original, 'link'));
|
|
69
|
+
|
|
70
|
+
// React re-render replaces the node with a fresh equivalent — the
|
|
71
|
+
// original is now detached, exactly the production failure mode.
|
|
72
|
+
original.remove();
|
|
73
|
+
const fresh = document.createElement('a');
|
|
74
|
+
fresh.id = 'cta';
|
|
75
|
+
fresh.textContent = 'Browse Catalog';
|
|
76
|
+
document.body.appendChild(fresh);
|
|
77
|
+
|
|
78
|
+
const out = resolveRefs(['@e1'], registry);
|
|
79
|
+
expect(out).toHaveLength(1);
|
|
80
|
+
expect(out[0].element).toBe(fresh);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('drops a ref whose element is genuinely gone', () => {
|
|
84
|
+
const el = document.createElement('button');
|
|
85
|
+
el.textContent = 'Delete';
|
|
86
|
+
document.body.appendChild(el);
|
|
87
|
+
|
|
88
|
+
const registry = new RefRegistry('snap-1');
|
|
89
|
+
registry.set('@e1', computeLocator(el, 'button'));
|
|
90
|
+
|
|
91
|
+
el.remove();
|
|
92
|
+
expect(resolveRefs(['@e1'], registry)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Highlight overlay — how a `point` directive is drawn.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* user's data.
|
|
6
|
+
* Resolves CST refs to live elements, tracks their geometry, and draws
|
|
7
|
+
* an SVG-mask spotlight over them. Read-only: it highlights and
|
|
8
|
+
* optionally focuses elements, never changes the user's data.
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
11
|
export { HighlightOverlay, type HighlightOverlayProps } from './HighlightOverlay';
|
|
13
12
|
export { SpotlightCanvas, type SpotlightCanvasProps } from './SpotlightCanvas';
|
|
14
13
|
export { useHighlightTargets } from './useHighlightTargets';
|
|
15
14
|
export { resolveRefs, type RefResolver } from './resolveRef';
|
|
15
|
+
export {
|
|
16
|
+
waitForVisible,
|
|
17
|
+
type WaitForVisibleOptions,
|
|
18
|
+
} from './waitForVisible';
|
|
16
19
|
export type {
|
|
17
20
|
PointDirective,
|
|
18
21
|
HighlightTarget,
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*
|
|
9
9
|
* A ref that no longer resolves is "stale" — the element was removed or
|
|
10
10
|
* the user navigated away. Callers treat a null result as stale.
|
|
11
|
+
*
|
|
12
|
+
* The registry resolves each ref with a live-DOM query, so a ref
|
|
13
|
+
* survives React recreating nodes between capture and directive
|
|
14
|
+
* application. The `isConnected` check below is a defensive backstop —
|
|
15
|
+
* a live query already returns connected elements.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import type { CSTRefId } from './types';
|
|
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|
|
4
4
|
|
|
5
5
|
import { resolveRefs, type RefResolver } from './resolveRef';
|
|
6
6
|
import type { HighlightTarget, PointDirective } from './types';
|
|
7
|
+
import { waitForVisible } from './waitForVisible';
|
|
7
8
|
|
|
8
9
|
/** Is a rect fully inside the viewport. */
|
|
9
10
|
function isOnScreen(rect: DOMRect): boolean {
|
|
@@ -20,8 +21,10 @@ function isOnScreen(rect: DOMRect): boolean {
|
|
|
20
21
|
* targets.
|
|
21
22
|
*
|
|
22
23
|
* - resolves each ref to a DOM element (drops stale ones);
|
|
24
|
+
* - scrolls an off-screen target into view and waits until it is
|
|
25
|
+
* actually visible before exposing it — so the spotlight is drawn at
|
|
26
|
+
* the element's settled position, never mid-scroll;
|
|
23
27
|
* - measures rects and keeps them fresh on scroll / resize;
|
|
24
|
-
* - scrolls the first off-screen target into view;
|
|
25
28
|
* - moves focus into a target when the directive asked for it;
|
|
26
29
|
* - drops a target automatically if its element leaves the DOM.
|
|
27
30
|
*
|
|
@@ -57,11 +60,21 @@ export function useHighlightTargets(
|
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
|
|
63
|
+
let cancelled = false;
|
|
64
|
+
|
|
65
|
+
// A pair is only drawn once `ready` is true. Already-visible
|
|
66
|
+
// targets are ready immediately; off-screen ones become ready
|
|
67
|
+
// after their scroll-into-view settles (or its safety timeout).
|
|
68
|
+
const ready = new WeakSet<HTMLElement>();
|
|
69
|
+
|
|
70
|
+
// Measure all *ready* targets — called now and on every
|
|
71
|
+
// scroll / resize. Off-screen-but-not-yet-settled targets are
|
|
72
|
+
// skipped so their spotlight is never drawn at a stale position.
|
|
61
73
|
const measure = () => {
|
|
62
74
|
const next: HighlightTarget[] = [];
|
|
63
75
|
for (const { element, directive } of pairs) {
|
|
64
76
|
if (!element.isConnected) continue; // element left the DOM
|
|
77
|
+
if (!ready.has(element)) continue; // still scrolling into view
|
|
65
78
|
next.push({
|
|
66
79
|
element,
|
|
67
80
|
rect: element.getBoundingClientRect(),
|
|
@@ -78,37 +91,55 @@ export function useHighlightTargets(
|
|
|
78
91
|
frame = requestAnimationFrame(measure);
|
|
79
92
|
};
|
|
80
93
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
// Split targets into the ones already on screen and the ones that
|
|
95
|
+
// need to be scrolled into view first.
|
|
96
|
+
const offScreen: HTMLElement[] = [];
|
|
97
|
+
for (const { element } of pairs) {
|
|
98
|
+
if (isOnScreen(element.getBoundingClientRect())) {
|
|
99
|
+
ready.add(element);
|
|
100
|
+
} else {
|
|
101
|
+
offScreen.push(element);
|
|
102
|
+
}
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
//
|
|
105
|
+
// Scroll each off-screen target into view, then wait until it is
|
|
106
|
+
// actually visible before marking it ready and re-measuring.
|
|
107
|
+
for (const element of offScreen) {
|
|
108
|
+
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
109
|
+
void waitForVisible(element).then(() => {
|
|
110
|
+
if (cancelled || !element.isConnected) return;
|
|
111
|
+
ready.add(element);
|
|
112
|
+
schedule();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Focus a target if requested — after its scroll-into-view settles
|
|
117
|
+
// so focus() does not fight the smooth scroll.
|
|
89
118
|
const focusPair = pairs.find((p) => p.directive.focus);
|
|
90
|
-
let focusTimer = 0;
|
|
91
119
|
if (focusPair) {
|
|
92
120
|
const el = focusPair.element;
|
|
93
|
-
|
|
94
|
-
()
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
const applyFocus = () => {
|
|
122
|
+
if (cancelled || !el.isConnected) return;
|
|
123
|
+
// Make a non-focusable element focusable so focus() lands.
|
|
124
|
+
const nativelyFocusable = [
|
|
125
|
+
'INPUT',
|
|
126
|
+
'TEXTAREA',
|
|
127
|
+
'SELECT',
|
|
128
|
+
'BUTTON',
|
|
129
|
+
'A',
|
|
130
|
+
].includes(el.tagName);
|
|
131
|
+
if (el.tabIndex < 0 && !nativelyFocusable) el.tabIndex = -1;
|
|
132
|
+
el.focus({ preventScroll: true });
|
|
133
|
+
};
|
|
134
|
+
if (isOnScreen(el.getBoundingClientRect())) {
|
|
135
|
+
applyFocus();
|
|
136
|
+
} else {
|
|
137
|
+
void waitForVisible(el).then(applyFocus);
|
|
138
|
+
}
|
|
109
139
|
}
|
|
110
140
|
|
|
111
|
-
// Initial measure + keep
|
|
141
|
+
// Initial measure (draws any already-visible targets) + keep
|
|
142
|
+
// geometry fresh.
|
|
112
143
|
measure();
|
|
113
144
|
window.addEventListener('scroll', schedule, true);
|
|
114
145
|
window.addEventListener('resize', schedule);
|
|
@@ -116,8 +147,8 @@ export function useHighlightTargets(
|
|
|
116
147
|
for (const { element } of pairs) observer.observe(element);
|
|
117
148
|
|
|
118
149
|
return () => {
|
|
150
|
+
cancelled = true;
|
|
119
151
|
cancelAnimationFrame(frame);
|
|
120
|
-
window.clearTimeout(focusTimer);
|
|
121
152
|
window.removeEventListener('scroll', schedule, true);
|
|
122
153
|
window.removeEventListener('resize', schedule);
|
|
123
154
|
observer.disconnect();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wait until an element is actually visible in the viewport.
|
|
3
|
+
*
|
|
4
|
+
* The highlight overlay scrolls an off-screen target into view before
|
|
5
|
+
* drawing its spotlight — but `scrollIntoView({ behavior: 'smooth' })`
|
|
6
|
+
* settles asynchronously, so measuring the rect right after the call
|
|
7
|
+
* yields a stale (mid-scroll or still off-screen) position. This waits
|
|
8
|
+
* for the browser to confirm the element is on screen.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Options for {@link waitForVisible}. */
|
|
12
|
+
export interface WaitForVisibleOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Fraction of the element that must be inside the viewport before it
|
|
15
|
+
* counts as visible (IntersectionObserver threshold).
|
|
16
|
+
*/
|
|
17
|
+
threshold?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Safety cap (ms). If the element never reaches the threshold — e.g.
|
|
20
|
+
* it is larger than the viewport, or hidden — the promise resolves
|
|
21
|
+
* anyway so the overlay never hangs. Fail soft.
|
|
22
|
+
*/
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve once `element` is at least `threshold` visible in the
|
|
28
|
+
* viewport, or once `timeoutMs` elapses — whichever comes first.
|
|
29
|
+
*
|
|
30
|
+
* Always resolves (never rejects): a never-intersecting element must
|
|
31
|
+
* not stall the overlay. The boolean result reports whether the
|
|
32
|
+
* element became visible (`true`) or the timeout fired (`false`), for
|
|
33
|
+
* callers that want to log it.
|
|
34
|
+
*/
|
|
35
|
+
export function waitForVisible(
|
|
36
|
+
element: Element,
|
|
37
|
+
{ threshold = 0.5, timeoutMs = 800 }: WaitForVisibleOptions = {},
|
|
38
|
+
): Promise<boolean> {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
// No IntersectionObserver (old/test env) — resolve immediately and
|
|
41
|
+
// let the caller measure as before.
|
|
42
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
43
|
+
resolve(false);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let settled = false;
|
|
48
|
+
const finish = (visible: boolean) => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
window.clearTimeout(timer);
|
|
52
|
+
observer.disconnect();
|
|
53
|
+
resolve(visible);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const timer = window.setTimeout(() => finish(false), timeoutMs);
|
|
57
|
+
const observer = new IntersectionObserver(
|
|
58
|
+
(entries) => {
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
|
|
61
|
+
finish(true);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{ threshold },
|
|
67
|
+
);
|
|
68
|
+
observer.observe(element);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bridge command registry — the named, callable capabilities the chat
|
|
5
|
+
* assistant can run against the user's live page.
|
|
6
|
+
*
|
|
7
|
+
* The registry is the core of the bridge: directives, the dev
|
|
8
|
+
* `window.__chatBridge`, and e2e tests all dispatch the same commands
|
|
9
|
+
* through it, so a new capability is added without touching any caller.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One bridge command — a named, documented, callable capability. */
|
|
13
|
+
export interface BridgeCommand<A extends unknown[] = unknown[], R = unknown> {
|
|
14
|
+
/** Invocation name, also the `window.__chatBridge` key. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** One-line human description — shown by `__chatBridge.help()`. */
|
|
17
|
+
description: string;
|
|
18
|
+
/** Whether the command mutates the user's data (vs read-only). */
|
|
19
|
+
mutates: boolean;
|
|
20
|
+
/** The implementation. */
|
|
21
|
+
run: (...args: A) => R;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every registered command, keyed by name. */
|
|
25
|
+
export const registry = new Map<string, BridgeCommand>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register (or replace) a bridge command. Returns the command so it can
|
|
29
|
+
* be exported directly. Replacing is allowed — handy for HMR.
|
|
30
|
+
*/
|
|
31
|
+
export function registerBridgeCommand<A extends unknown[], R>(
|
|
32
|
+
cmd: BridgeCommand<A, R>,
|
|
33
|
+
): BridgeCommand<A, R> {
|
|
34
|
+
registry.set(cmd.name, cmd as BridgeCommand);
|
|
35
|
+
return cmd;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Look up a registered command by name. */
|
|
39
|
+
export function getBridgeCommand(name: string): BridgeCommand | undefined {
|
|
40
|
+
return registry.get(name);
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live connections handed to the bridge by the React layer.
|
|
5
|
+
*
|
|
6
|
+
* The bridge has no React context, so providers publish their
|
|
7
|
+
* capabilities here on mount and retract them on unmount:
|
|
8
|
+
*
|
|
9
|
+
* - the ref→element resolver, owned by `PageSnapshotProvider`;
|
|
10
|
+
* - the chat `sendMessage` function, owned by `ChatProvider`.
|
|
11
|
+
*
|
|
12
|
+
* Bridge commands read them through the getters below.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type RefResolver } from './overlay/resolveRef';
|
|
16
|
+
|
|
17
|
+
let activeResolver: RefResolver | null = null;
|
|
18
|
+
|
|
19
|
+
/** Called by `PageSnapshotProvider` to publish / retract its resolver. */
|
|
20
|
+
export function setBridgeResolver(resolver: RefResolver | null): void {
|
|
21
|
+
activeResolver = resolver;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read the resolver currently published by the provider, if any. */
|
|
25
|
+
export function getActiveResolver(): RefResolver | null {
|
|
26
|
+
return activeResolver;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Sends a message into the chat — same path as the composer's send. */
|
|
30
|
+
export type BridgeSender = (content: string) => Promise<void> | void;
|
|
31
|
+
|
|
32
|
+
let activeSender: BridgeSender | null = null;
|
|
33
|
+
|
|
34
|
+
/** Called by `ChatProvider` to publish / retract its `sendMessage`. */
|
|
35
|
+
export function setBridgeSender(sender: BridgeSender | null): void {
|
|
36
|
+
activeSender = sender;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read the chat sender currently published by the provider, if any. */
|
|
40
|
+
export function getActiveSender(): BridgeSender | null {
|
|
41
|
+
return activeSender;
|
|
42
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `window.__chatBridge` — exposes the bridge registry in development so
|
|
5
|
+
* the commands can be driven manually without an AI / SSE round-trip.
|
|
6
|
+
*
|
|
7
|
+
* Run `__chatBridge.highlight(['@e4'])` in the console to see the
|
|
8
|
+
* spotlight directly. A dev affordance, not a public API surface — it
|
|
9
|
+
* is gated on `isDev` and is a no-op on the server and in production.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isDev } from '@djangocfg/ui-core/lib';
|
|
13
|
+
|
|
14
|
+
import { log } from './logger';
|
|
15
|
+
import { registry } from './registry';
|
|
16
|
+
import {
|
|
17
|
+
highlight,
|
|
18
|
+
focus,
|
|
19
|
+
clear,
|
|
20
|
+
inspect,
|
|
21
|
+
scrollTo,
|
|
22
|
+
fill,
|
|
23
|
+
click,
|
|
24
|
+
sendMessage,
|
|
25
|
+
} from './commands';
|
|
26
|
+
|
|
27
|
+
/** Shape exposed on `window.__chatBridge`. */
|
|
28
|
+
export interface ChatBridge {
|
|
29
|
+
highlight: typeof highlight.run;
|
|
30
|
+
focus: typeof focus.run;
|
|
31
|
+
scrollTo: typeof scrollTo.run;
|
|
32
|
+
clear: typeof clear.run;
|
|
33
|
+
inspect: typeof inspect.run;
|
|
34
|
+
fill: typeof fill.run;
|
|
35
|
+
click: typeof click.run;
|
|
36
|
+
sendMessage: typeof sendMessage.run;
|
|
37
|
+
/** List every registered command with its description. */
|
|
38
|
+
help: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare global {
|
|
42
|
+
interface Window {
|
|
43
|
+
__chatBridge?: ChatBridge;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Install `window.__chatBridge` for manual testing. No-op on the server
|
|
49
|
+
* and in production (gated on `isDev`) — the bridge is a dev affordance,
|
|
50
|
+
* not a public API surface.
|
|
51
|
+
*/
|
|
52
|
+
export function installChatBridge(): void {
|
|
53
|
+
if (typeof window === 'undefined' || !isDev) return;
|
|
54
|
+
|
|
55
|
+
window.__chatBridge = {
|
|
56
|
+
highlight: highlight.run,
|
|
57
|
+
focus: focus.run,
|
|
58
|
+
scrollTo: scrollTo.run,
|
|
59
|
+
clear: clear.run,
|
|
60
|
+
inspect: inspect.run,
|
|
61
|
+
fill: fill.run,
|
|
62
|
+
click: click.run,
|
|
63
|
+
sendMessage: sendMessage.run,
|
|
64
|
+
help: () => {
|
|
65
|
+
// eslint-disable-next-line no-console
|
|
66
|
+
console.table(
|
|
67
|
+
[...registry.values()].map((c) => ({
|
|
68
|
+
command: c.name,
|
|
69
|
+
mutates: c.mutates,
|
|
70
|
+
description: c.description,
|
|
71
|
+
})),
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
log.info('window.__chatBridge installed — run __chatBridge.help()');
|
|
76
|
+
}
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
CSTRefId,
|
|
18
18
|
CSTTextNode,
|
|
19
19
|
} from '../cst/types';
|
|
20
|
+
import { computeLocator, type RefLocator } from '../refs/locator';
|
|
20
21
|
import { accessibleName } from './accessible-name';
|
|
21
22
|
import {
|
|
22
23
|
classify,
|
|
@@ -51,9 +52,14 @@ export const noopRedact: RedactValue = (value) => value;
|
|
|
51
52
|
export interface WalkResult {
|
|
52
53
|
/** Captured child nodes (the root node is assembled by the engine). */
|
|
53
54
|
children: CSTNode[];
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Map of assigned ref id → re-locatable descriptor. A descriptor —
|
|
57
|
+
* not a node pointer — because React recreates DOM nodes between
|
|
58
|
+
* capture and directive application; a frozen `HTMLElement` would be
|
|
59
|
+
* detached by then. The descriptor re-finds the element in the live
|
|
60
|
+
* DOM at resolve time.
|
|
61
|
+
*/
|
|
62
|
+
refMap: Map<CSTRefId, RefLocator>;
|
|
57
63
|
/** Number of elements visited. */
|
|
58
64
|
nodesVisited: number;
|
|
59
65
|
}
|
|
@@ -69,7 +75,7 @@ export interface WalkOptions {
|
|
|
69
75
|
/** Mutable state threaded through one walk. */
|
|
70
76
|
interface WalkState {
|
|
71
77
|
redact: RedactValue;
|
|
72
|
-
refMap: Map<CSTRefId,
|
|
78
|
+
refMap: Map<CSTRefId, RefLocator>;
|
|
73
79
|
refCounter: number;
|
|
74
80
|
visited: number;
|
|
75
81
|
maxNodes: number;
|
|
@@ -98,7 +104,9 @@ function controlValue(el: Element): string | undefined {
|
|
|
98
104
|
function buildInteractive(el: HTMLElement, state: WalkState): CSTInteractiveNode {
|
|
99
105
|
const role = interactiveRole(el)!;
|
|
100
106
|
const ref = `@e${state.refCounter++}` as CSTRefId;
|
|
101
|
-
|
|
107
|
+
// Store a re-locatable descriptor, never the node itself — the node
|
|
108
|
+
// will be recreated by React before the directive arrives.
|
|
109
|
+
state.refMap.set(ref, computeLocator(el, role));
|
|
102
110
|
|
|
103
111
|
const rawValue = controlValue(el);
|
|
104
112
|
const value =
|
|
@@ -58,7 +58,12 @@ export interface CaptureTelemetry {
|
|
|
58
58
|
export interface CaptureResult {
|
|
59
59
|
payload: PageContextPayload;
|
|
60
60
|
telemetry: CaptureTelemetry;
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Per-snapshot ref registry, for resolving `point` directives. Holds
|
|
63
|
+
* re-locatable descriptors (not node pointers) so a ref still
|
|
64
|
+
* resolves after React re-renders the page between capture and
|
|
65
|
+
* directive application.
|
|
66
|
+
*/
|
|
62
67
|
refs: RefRegistry;
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -114,9 +119,9 @@ export class PageSnapshotEngine {
|
|
|
114
119
|
const result = walkDOM(root, { redactValue });
|
|
115
120
|
walked = walked.concat(result.children);
|
|
116
121
|
nodesVisited += result.nodesVisited;
|
|
117
|
-
// Carry the walk's
|
|
118
|
-
for (const [ref,
|
|
119
|
-
refs.set(ref as CSTRefId,
|
|
122
|
+
// Carry the walk's ref locators into the snapshot registry.
|
|
123
|
+
for (const [ref, locator] of result.refMap) {
|
|
124
|
+
refs.set(ref as CSTRefId, locator);
|
|
120
125
|
}
|
|
121
126
|
}
|
|
122
127
|
|