@limrun/ui 0.9.0-rc.4 → 0.9.0-rc.6
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/dist/components/inspect-overlay.d.ts +33 -0
- package/dist/components/remote-control.d.ts +86 -0
- package/dist/core/ax-fetcher.d.ts +49 -0
- package/dist/core/ax-tree.d.ts +99 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1485 -778
- package/package.json +7 -3
- package/src/components/inspect-overlay.css +223 -0
- package/src/components/inspect-overlay.tsx +437 -0
- package/src/components/remote-control.tsx +547 -9
- package/src/core/ax-fetcher.test.ts +418 -0
- package/src/core/ax-fetcher.ts +377 -0
- package/src/core/ax-tree.test.ts +491 -0
- package/src/core/ax-tree.ts +416 -0
- package/src/demo.tsx +93 -10
- package/src/index.ts +17 -0
- package/vitest.config.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@limrun/ui",
|
|
3
|
-
"version": "0.9.0-rc.
|
|
3
|
+
"version": "0.9.0-rc.6",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"build": "tsc && vite build",
|
|
31
31
|
"prepublishOnly": "npm run build",
|
|
32
32
|
"lint": "eslint .",
|
|
33
|
-
"preview": "vite preview"
|
|
33
|
+
"preview": "vite preview",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:watch": "vitest"
|
|
34
36
|
},
|
|
35
37
|
"repository": {
|
|
36
38
|
"type": "git",
|
|
@@ -43,12 +45,14 @@
|
|
|
43
45
|
"@types/react": "^19.1.12",
|
|
44
46
|
"@types/react-dom": "^19.1.9",
|
|
45
47
|
"@vitejs/plugin-react-swc": "^4.0.1",
|
|
48
|
+
"jsdom": "^26.0.0",
|
|
46
49
|
"path": "^0.12.7",
|
|
47
50
|
"react": "^19.1.1",
|
|
48
51
|
"react-dom": "^19.1.1",
|
|
49
52
|
"vite": "^7.1.4",
|
|
50
53
|
"vite-plugin-dts": "^4.5.4",
|
|
51
|
-
"vite-plugin-lib-inject-css": "^2.2.2"
|
|
54
|
+
"vite-plugin-lib-inject-css": "^2.2.2",
|
|
55
|
+
"vitest": "^2.1.9"
|
|
52
56
|
},
|
|
53
57
|
"dependencies": {
|
|
54
58
|
"@foxt/js-srp": "^0.0.3-patch2",
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
.rc-inspect-overlay {
|
|
2
|
+
position: absolute;
|
|
3
|
+
z-index: 25;
|
|
4
|
+
pointer-events: none;
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.rc-inspect-overlay-select {
|
|
9
|
+
/* When click-to-select is enabled, the container also captures clicks
|
|
10
|
+
that fall outside any box so we can clear selection. */
|
|
11
|
+
pointer-events: auto;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.rc-inspect-box {
|
|
15
|
+
position: absolute;
|
|
16
|
+
box-sizing: border-box;
|
|
17
|
+
border: 1px solid rgba(64, 169, 255, 0.55);
|
|
18
|
+
background: rgba(64, 169, 255, 0.06);
|
|
19
|
+
border-radius: 2px;
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
pointer-events: none;
|
|
22
|
+
transition:
|
|
23
|
+
border-color 80ms ease,
|
|
24
|
+
border-width 80ms ease,
|
|
25
|
+
background-color 80ms ease;
|
|
26
|
+
padding: 0;
|
|
27
|
+
margin: 0;
|
|
28
|
+
font: inherit;
|
|
29
|
+
color: inherit;
|
|
30
|
+
outline: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.rc-inspect-overlay-select .rc-inspect-box {
|
|
34
|
+
pointer-events: auto;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.rc-inspect-box-disabled {
|
|
38
|
+
opacity: 0.55;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.rc-inspect-box[data-highlighted='true'] {
|
|
42
|
+
border-color: rgba(255, 197, 28, 0.95);
|
|
43
|
+
border-width: 1.5px;
|
|
44
|
+
background: rgba(255, 197, 28, 0.16);
|
|
45
|
+
z-index: 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.rc-inspect-box[data-selected='true'] {
|
|
49
|
+
border-color: rgba(0, 122, 255, 1);
|
|
50
|
+
border-width: 2px;
|
|
51
|
+
background: rgba(0, 122, 255, 0.22);
|
|
52
|
+
z-index: 2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.rc-inspect-card {
|
|
56
|
+
/* Card is rendered via React portal to document.body so it can escape
|
|
57
|
+
any ancestor overflow:hidden (e.g. customer-provided .device-wrapper
|
|
58
|
+
in our demo). Coordinates passed in via inline style are
|
|
59
|
+
viewport-absolute. */
|
|
60
|
+
position: fixed;
|
|
61
|
+
z-index: 2147483600;
|
|
62
|
+
pointer-events: auto;
|
|
63
|
+
background: rgba(22, 24, 30, 0.97);
|
|
64
|
+
color: #f5f5f7;
|
|
65
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
66
|
+
border-radius: 10px;
|
|
67
|
+
padding: 9px 11px 11px;
|
|
68
|
+
min-width: 200px;
|
|
69
|
+
max-width: 260px;
|
|
70
|
+
/* Cap card height. Anything longer scrolls inside the card instead of
|
|
71
|
+
pushing the viewport. */
|
|
72
|
+
max-height: 220px;
|
|
73
|
+
overflow-y: auto;
|
|
74
|
+
box-shadow:
|
|
75
|
+
0 12px 36px rgba(0, 0, 0, 0.4),
|
|
76
|
+
0 1px 0 rgba(255, 255, 255, 0.04) inset;
|
|
77
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
78
|
+
font-size: 12px;
|
|
79
|
+
line-height: 1.4;
|
|
80
|
+
backdrop-filter: blur(8px);
|
|
81
|
+
-webkit-backdrop-filter: blur(8px);
|
|
82
|
+
scrollbar-width: thin;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.rc-inspect-card-header {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
gap: 5px;
|
|
89
|
+
flex-wrap: wrap;
|
|
90
|
+
margin-bottom: 4px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.rc-inspect-card-role {
|
|
94
|
+
font-size: 10px;
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
text-transform: uppercase;
|
|
97
|
+
letter-spacing: 0.06em;
|
|
98
|
+
color: rgba(245, 245, 247, 0.55);
|
|
99
|
+
display: inline-block;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.rc-inspect-card-tag {
|
|
104
|
+
font-size: 9px;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
text-transform: uppercase;
|
|
107
|
+
letter-spacing: 0.05em;
|
|
108
|
+
padding: 1px 5px;
|
|
109
|
+
border-radius: 4px;
|
|
110
|
+
background: rgba(255, 255, 255, 0.08);
|
|
111
|
+
color: rgba(245, 245, 247, 0.7);
|
|
112
|
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.rc-inspect-card-tag-blue {
|
|
116
|
+
background: rgba(10, 132, 255, 0.18);
|
|
117
|
+
border-color: rgba(10, 132, 255, 0.45);
|
|
118
|
+
color: rgb(140, 195, 255);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.rc-inspect-card-title {
|
|
122
|
+
font-size: 12.5px;
|
|
123
|
+
font-weight: 600;
|
|
124
|
+
color: #fff;
|
|
125
|
+
margin: 0 0 7px;
|
|
126
|
+
word-break: break-word;
|
|
127
|
+
/* Clamp to 2 lines so a paragraph-long AXLabel doesn't take over. The
|
|
128
|
+
full text is available on hover via the title attribute and via the
|
|
129
|
+
value/id rows below. */
|
|
130
|
+
display: -webkit-box;
|
|
131
|
+
-webkit-line-clamp: 2;
|
|
132
|
+
line-clamp: 2;
|
|
133
|
+
-webkit-box-orient: vertical;
|
|
134
|
+
overflow: hidden;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.rc-inspect-card-row {
|
|
138
|
+
display: flex;
|
|
139
|
+
gap: 6px;
|
|
140
|
+
align-items: baseline;
|
|
141
|
+
font-size: 10.5px;
|
|
142
|
+
color: rgba(245, 245, 247, 0.72);
|
|
143
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
|
144
|
+
monospace;
|
|
145
|
+
margin: 1px 0;
|
|
146
|
+
min-width: 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.rc-inspect-card-row-label {
|
|
150
|
+
color: rgba(245, 245, 247, 0.42);
|
|
151
|
+
font-weight: 500;
|
|
152
|
+
flex-shrink: 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.rc-inspect-card-row-value {
|
|
156
|
+
flex: 1 1 auto;
|
|
157
|
+
min-width: 0;
|
|
158
|
+
word-break: break-word;
|
|
159
|
+
/* Clamp long values (selectors, IDs) to two lines, full value visible
|
|
160
|
+
via the row's title attr or via "Copy" action. */
|
|
161
|
+
display: -webkit-box;
|
|
162
|
+
-webkit-line-clamp: 2;
|
|
163
|
+
line-clamp: 2;
|
|
164
|
+
-webkit-box-orient: vertical;
|
|
165
|
+
overflow: hidden;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.rc-inspect-card-actions {
|
|
169
|
+
display: flex;
|
|
170
|
+
flex-wrap: wrap;
|
|
171
|
+
gap: 6px;
|
|
172
|
+
margin-top: 10px;
|
|
173
|
+
padding-top: 9px;
|
|
174
|
+
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.rc-inspect-card-btn {
|
|
178
|
+
appearance: none;
|
|
179
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
180
|
+
background: rgba(255, 255, 255, 0.06);
|
|
181
|
+
color: #f5f5f7;
|
|
182
|
+
font: inherit;
|
|
183
|
+
font-size: 11px;
|
|
184
|
+
font-weight: 500;
|
|
185
|
+
padding: 4px 9px;
|
|
186
|
+
border-radius: 6px;
|
|
187
|
+
cursor: pointer;
|
|
188
|
+
transition:
|
|
189
|
+
background-color 100ms ease,
|
|
190
|
+
border-color 100ms ease,
|
|
191
|
+
color 100ms ease;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.rc-inspect-card-btn:hover:not(:disabled) {
|
|
195
|
+
background: rgba(255, 255, 255, 0.12);
|
|
196
|
+
border-color: rgba(255, 255, 255, 0.2);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.rc-inspect-card-btn:active:not(:disabled) {
|
|
200
|
+
background: rgba(255, 255, 255, 0.16);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.rc-inspect-card-btn:disabled {
|
|
204
|
+
opacity: 0.4;
|
|
205
|
+
cursor: not-allowed;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.rc-inspect-card-btn-primary {
|
|
209
|
+
background: rgba(0, 122, 255, 0.85);
|
|
210
|
+
border-color: rgba(0, 122, 255, 0.9);
|
|
211
|
+
color: #fff;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.rc-inspect-card-btn-primary:hover:not(:disabled) {
|
|
215
|
+
background: rgba(10, 132, 255, 1);
|
|
216
|
+
border-color: rgba(10, 132, 255, 1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.rc-inspect-card-btn-copied {
|
|
220
|
+
background: rgba(48, 209, 88, 0.22);
|
|
221
|
+
border-color: rgba(48, 209, 88, 0.55);
|
|
222
|
+
color: rgb(159, 240, 178);
|
|
223
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { clsx } from 'clsx';
|
|
4
|
+
import './inspect-overlay.css';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
AxElement,
|
|
8
|
+
AxPlatform,
|
|
9
|
+
AxSnapshot,
|
|
10
|
+
axElementRoleLabel,
|
|
11
|
+
axElementSelectorExpression,
|
|
12
|
+
axElementSummary,
|
|
13
|
+
axElementsEqual,
|
|
14
|
+
clampAxFrameForScreen,
|
|
15
|
+
} from '../core/ax-tree';
|
|
16
|
+
|
|
17
|
+
// Geometry of the rendered video content inside the RemoteControl container,
|
|
18
|
+
// after object-fit:contain letterboxing. All values are in container-local
|
|
19
|
+
// pixels (relative to .rc-container's content box) so the overlay boxes line
|
|
20
|
+
// up with the video.
|
|
21
|
+
//
|
|
22
|
+
// The InfoCard uses pointer-event coordinates (clientX/clientY) directly for
|
|
23
|
+
// its viewport-fixed placement and does NOT need any geometry — the boxes
|
|
24
|
+
// are the only thing geometry-locked.
|
|
25
|
+
export interface InspectOverlayGeometry {
|
|
26
|
+
left: number;
|
|
27
|
+
top: number;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type InspectMode = 'select' | 'hover-only';
|
|
33
|
+
|
|
34
|
+
export interface InspectOverlayProps {
|
|
35
|
+
snapshot: AxSnapshot | null;
|
|
36
|
+
geometry: InspectOverlayGeometry | null;
|
|
37
|
+
highlightedId: string | null;
|
|
38
|
+
selectedId: string | null;
|
|
39
|
+
mode: InspectMode;
|
|
40
|
+
// Current pointer position in viewport coordinates (clientX/Y). Drives the
|
|
41
|
+
// cursor-anchored preview card while hovering. null when the pointer is
|
|
42
|
+
// outside the device.
|
|
43
|
+
cursorPosition: { x: number; y: number } | null;
|
|
44
|
+
// Position where the selection was made (viewport coords). The card stays
|
|
45
|
+
// anchored here once an element is selected so the user can interact with
|
|
46
|
+
// its action buttons.
|
|
47
|
+
frozenCursorPosition: { x: number; y: number } | null;
|
|
48
|
+
// Selection callback. `clickPosition` is the viewport-space pointer
|
|
49
|
+
// position at the moment of the click; pass null to clear selection.
|
|
50
|
+
onSelectChange: (element: AxElement | null, clickPosition?: { x: number; y: number } | null) => void;
|
|
51
|
+
// Called when the user presses "Tap" in the info card. `tapAt` is the
|
|
52
|
+
// viewport-space pointer position the user originally aimed at (i.e. the
|
|
53
|
+
// frozen click position) so we can tap that exact spot instead of an
|
|
54
|
+
// averaged center.
|
|
55
|
+
onTapElement: (element: AxElement, tapAt: { x: number; y: number }) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const copyToClipboard = async (text: string): Promise<boolean> => {
|
|
59
|
+
if (!text) return false;
|
|
60
|
+
try {
|
|
61
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
62
|
+
await navigator.clipboard.writeText(text);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Fall through to the textarea fallback.
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const ta = document.createElement('textarea');
|
|
70
|
+
ta.value = text;
|
|
71
|
+
ta.style.position = 'fixed';
|
|
72
|
+
ta.style.opacity = '0';
|
|
73
|
+
document.body.appendChild(ta);
|
|
74
|
+
ta.select();
|
|
75
|
+
const ok = document.execCommand('copy');
|
|
76
|
+
document.body.removeChild(ta);
|
|
77
|
+
return ok;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
// Single element box
|
|
85
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
interface InspectBoxProps {
|
|
88
|
+
element: AxElement;
|
|
89
|
+
screen: { width: number; height: number };
|
|
90
|
+
highlighted: boolean;
|
|
91
|
+
selected: boolean;
|
|
92
|
+
selectable: boolean;
|
|
93
|
+
onClick: (element: AxElement, clickPosition: { x: number; y: number }) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const InspectBox = memo(
|
|
97
|
+
function InspectBox({ element, screen, highlighted, selected, selectable, onClick }: InspectBoxProps) {
|
|
98
|
+
const visible = clampAxFrameForScreen(element.frame, screen);
|
|
99
|
+
if (!visible) return null;
|
|
100
|
+
const summary = axElementSummary(element);
|
|
101
|
+
return (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className={clsx('rc-inspect-box', !element.enabled && 'rc-inspect-box-disabled')}
|
|
105
|
+
data-ax-id={element.id}
|
|
106
|
+
data-ax-path={element.path}
|
|
107
|
+
data-ax-label={element.label || undefined}
|
|
108
|
+
data-ax-role={element.role || undefined}
|
|
109
|
+
data-highlighted={highlighted ? 'true' : 'false'}
|
|
110
|
+
data-selected={selected ? 'true' : 'false'}
|
|
111
|
+
aria-label={summary}
|
|
112
|
+
// No onMouseEnter/Leave or `title` attr here — hover state is driven
|
|
113
|
+
// by JS hit-testing in RemoteControl's handleInteraction (single
|
|
114
|
+
// source of truth that handles overlapping boxes deterministically),
|
|
115
|
+
// and the InfoCard already shows all the info the title would.
|
|
116
|
+
onClick={(e) => {
|
|
117
|
+
if (!selectable) return;
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
onClick(element, { x: e.clientX, y: e.clientY });
|
|
121
|
+
}}
|
|
122
|
+
style={{
|
|
123
|
+
left: `${(visible.x / screen.width) * 100}%`,
|
|
124
|
+
top: `${(visible.y / screen.height) * 100}%`,
|
|
125
|
+
width: `${(visible.width / screen.width) * 100}%`,
|
|
126
|
+
height: `${(visible.height / screen.height) * 100}%`,
|
|
127
|
+
}}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
(prev, next) =>
|
|
132
|
+
prev.highlighted === next.highlighted &&
|
|
133
|
+
prev.selected === next.selected &&
|
|
134
|
+
prev.selectable === next.selectable &&
|
|
135
|
+
prev.screen.width === next.screen.width &&
|
|
136
|
+
prev.screen.height === next.screen.height &&
|
|
137
|
+
prev.onClick === next.onClick &&
|
|
138
|
+
axElementsEqual(prev.element, next.element),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
// Info card (shown next to the focus element)
|
|
143
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
interface InfoCardProps {
|
|
146
|
+
element: AxElement;
|
|
147
|
+
platform: AxPlatform;
|
|
148
|
+
// Viewport-coordinate anchor used to position the card. When cursorAnchored
|
|
149
|
+
// is true the card sits at top-right of `anchor` and follows the cursor.
|
|
150
|
+
// When false the card stays put at `anchor` (frozen on selection).
|
|
151
|
+
anchor: { x: number; y: number };
|
|
152
|
+
cursorAnchored: boolean;
|
|
153
|
+
showActions: boolean;
|
|
154
|
+
// Receives the element AND the viewport-space coordinate to tap at
|
|
155
|
+
// (the frozen click position). Tapping at the click point — rather than
|
|
156
|
+
// the element's frame center — preserves the user's aim when the
|
|
157
|
+
// selected element is a container whose children are not exposed by the
|
|
158
|
+
// accessibility tree (e.g. iOS UITabBar's button children).
|
|
159
|
+
onTap: (element: AxElement, tapAt: { x: number; y: number }) => void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Upper bounds used only for "does the card fit on this side?" decisions.
|
|
163
|
+
// We do NOT use these to compute the actual top/left coordinates — see the
|
|
164
|
+
// CSS-transform-based placement below. Matches the CSS max-width /
|
|
165
|
+
// max-height so flip decisions stay accurate even when the card is at its
|
|
166
|
+
// largest (selected state with all action buttons visible).
|
|
167
|
+
const INFO_CARD_ESTIMATED_WIDTH = 260;
|
|
168
|
+
const INFO_CARD_ESTIMATED_HEIGHT = 220;
|
|
169
|
+
// Gap between the cursor and the nearest card edge. Tight enough that the
|
|
170
|
+
// card visibly "hangs" off the cursor, but not so tight that the OS pointer
|
|
171
|
+
// arrow visually overlaps the card border.
|
|
172
|
+
const CURSOR_OFFSET = 6;
|
|
173
|
+
// Distance from the window edge we try to maintain so the card never gets
|
|
174
|
+
// flush against the page chrome.
|
|
175
|
+
const VIEWPORT_PADDING = 8;
|
|
176
|
+
|
|
177
|
+
const InfoCard = memo(function InfoCard({
|
|
178
|
+
element,
|
|
179
|
+
platform,
|
|
180
|
+
anchor,
|
|
181
|
+
cursorAnchored,
|
|
182
|
+
showActions,
|
|
183
|
+
onTap,
|
|
184
|
+
}: InfoCardProps) {
|
|
185
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!copied) return;
|
|
189
|
+
const t = window.setTimeout(() => setCopied(null), 1100);
|
|
190
|
+
return () => window.clearTimeout(t);
|
|
191
|
+
}, [copied]);
|
|
192
|
+
|
|
193
|
+
// Anchor the nearest card corner exactly CURSOR_OFFSET px from the cursor
|
|
194
|
+
// using CSS transforms. By offsetting the card's coordinate by ±100% on
|
|
195
|
+
// each axis as needed, we don't need to know the card's actual rendered
|
|
196
|
+
// height/width — the corner that's nearest to the cursor sits at a known
|
|
197
|
+
// distance regardless of card content. This solves the "huge gap" issue
|
|
198
|
+
// that arises when the card content is much smaller than the assumed
|
|
199
|
+
// worst-case height.
|
|
200
|
+
//
|
|
201
|
+
// Default: card's bottom-left corner sits to the top-right of the cursor
|
|
202
|
+
// (so the card "hangs" off the cursor up-and-right, similar to system
|
|
203
|
+
// tooltips). Flips to bottom-right if no room to the right, top-left if
|
|
204
|
+
// no room above, and bottom-right if no room above-or-right.
|
|
205
|
+
const cardStyle = useMemo<React.CSSProperties>(() => {
|
|
206
|
+
const W = INFO_CARD_ESTIMATED_WIDTH;
|
|
207
|
+
const H = INFO_CARD_ESTIMATED_HEIGHT;
|
|
208
|
+
const O = CURSOR_OFFSET;
|
|
209
|
+
const P = VIEWPORT_PADDING;
|
|
210
|
+
|
|
211
|
+
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1024;
|
|
212
|
+
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 768;
|
|
213
|
+
|
|
214
|
+
// Decide which quadrant of the cursor the card sits in. Use the
|
|
215
|
+
// estimated max dimensions as the worst-case footprint when checking
|
|
216
|
+
// for room.
|
|
217
|
+
const placeRight = anchor.x + O + W <= viewportW - P;
|
|
218
|
+
const placeAbove = anchor.y - O - H >= P;
|
|
219
|
+
|
|
220
|
+
let left = placeRight ? anchor.x + O : anchor.x - O;
|
|
221
|
+
let top = placeAbove ? anchor.y - O : anchor.y + O;
|
|
222
|
+
|
|
223
|
+
// translate(-100% …) shifts the card by its own measured width/height,
|
|
224
|
+
// which is what gives us the "corner anchored to cursor" behaviour
|
|
225
|
+
// without having to know the rendered size in JS.
|
|
226
|
+
const tx = placeRight ? '0' : '-100%';
|
|
227
|
+
const ty = placeAbove ? '-100%' : '0';
|
|
228
|
+
const transform = `translate(${tx}, ${ty})`;
|
|
229
|
+
|
|
230
|
+
// Final clamp so the card never escapes the window even on tiny
|
|
231
|
+
// viewports. Using the estimated dimensions as a safety margin.
|
|
232
|
+
left = Math.max(P + (placeRight ? 0 : W), Math.min(viewportW - P - (placeRight ? W : 0), left));
|
|
233
|
+
top = Math.max(P + (placeAbove ? H : 0), Math.min(viewportH - P - (placeAbove ? 0 : H), top));
|
|
234
|
+
|
|
235
|
+
return { left: `${left}px`, top: `${top}px`, transform };
|
|
236
|
+
}, [anchor.x, anchor.y]);
|
|
237
|
+
|
|
238
|
+
const selectorExpr = useMemo(() => axElementSelectorExpression(element, platform), [element, platform]);
|
|
239
|
+
const primaryIdField = platform === 'ios' ? element.selectors.AXUniqueId : element.selectors.resourceId;
|
|
240
|
+
const primaryIdLabel = platform === 'ios' ? 'AXUniqueId' : 'resourceId';
|
|
241
|
+
|
|
242
|
+
const handleCopy = useCallback(async (text: string | undefined, key: string) => {
|
|
243
|
+
if (!text) return;
|
|
244
|
+
const ok = await copyToClipboard(text);
|
|
245
|
+
if (ok) setCopied(key);
|
|
246
|
+
}, []);
|
|
247
|
+
|
|
248
|
+
const roleLabel = axElementRoleLabel(element);
|
|
249
|
+
// Title shows the element's label (clamped). Falls back to the role when no
|
|
250
|
+
// label exists so the card never looks empty.
|
|
251
|
+
const titleText = element.label || element.value || roleLabel;
|
|
252
|
+
// axElementSummary is used as the full tooltip (truncated to ~80 chars).
|
|
253
|
+
const fullSummary = axElementSummary(element);
|
|
254
|
+
|
|
255
|
+
// We do not guard against SSR here: RemoteControl is fundamentally a
|
|
256
|
+
// browser-only component (it instantiates RTCPeerConnection in its main
|
|
257
|
+
// effect), so the InfoCard never gets rendered in a server environment.
|
|
258
|
+
|
|
259
|
+
return createPortal(
|
|
260
|
+
<div
|
|
261
|
+
className="rc-inspect-card"
|
|
262
|
+
data-cursor-anchored={cursorAnchored ? 'true' : 'false'}
|
|
263
|
+
style={cardStyle}
|
|
264
|
+
// The card sits in document.body but is logically a child of the
|
|
265
|
+
// overlay React subtree, so events bubble back to rc-container's
|
|
266
|
+
// mousemove handler. We stop them so hovering the card doesn't make
|
|
267
|
+
// it chase itself.
|
|
268
|
+
onClick={(e) => e.stopPropagation()}
|
|
269
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
270
|
+
onMouseMove={(e) => e.stopPropagation()}
|
|
271
|
+
>
|
|
272
|
+
<div className="rc-inspect-card-header">
|
|
273
|
+
<span className="rc-inspect-card-role">{roleLabel}</span>
|
|
274
|
+
{!element.enabled && <span className="rc-inspect-card-tag">disabled</span>}
|
|
275
|
+
{element.focused && <span className="rc-inspect-card-tag rc-inspect-card-tag-blue">focused</span>}
|
|
276
|
+
</div>
|
|
277
|
+
<p className="rc-inspect-card-title" title={fullSummary}>
|
|
278
|
+
{titleText}
|
|
279
|
+
</p>
|
|
280
|
+
{primaryIdField && (
|
|
281
|
+
<div className="rc-inspect-card-row">
|
|
282
|
+
<span className="rc-inspect-card-row-label">{primaryIdLabel}:</span>
|
|
283
|
+
<span className="rc-inspect-card-row-value">{primaryIdField}</span>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
{element.value && element.value !== element.label && (
|
|
287
|
+
<div className="rc-inspect-card-row">
|
|
288
|
+
<span className="rc-inspect-card-row-label">value:</span>
|
|
289
|
+
<span className="rc-inspect-card-row-value">{element.value}</span>
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
<div className="rc-inspect-card-row">
|
|
293
|
+
<span className="rc-inspect-card-row-label">frame:</span>
|
|
294
|
+
<span className="rc-inspect-card-row-value">
|
|
295
|
+
{Math.round(element.frame.width)}×{Math.round(element.frame.height)} @ {Math.round(element.frame.x)}
|
|
296
|
+
,{Math.round(element.frame.y)}
|
|
297
|
+
</span>
|
|
298
|
+
</div>
|
|
299
|
+
{showActions && (
|
|
300
|
+
<div className="rc-inspect-card-actions">
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
className="rc-inspect-card-btn rc-inspect-card-btn-primary"
|
|
304
|
+
onClick={() => onTap(element, anchor)}
|
|
305
|
+
>
|
|
306
|
+
Tap
|
|
307
|
+
</button>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
className={clsx('rc-inspect-card-btn', copied === 'selector' && 'rc-inspect-card-btn-copied')}
|
|
311
|
+
disabled={!selectorExpr}
|
|
312
|
+
title={selectorExpr ?? 'No usable selector for this element'}
|
|
313
|
+
onClick={() => handleCopy(selectorExpr ?? undefined, 'selector')}
|
|
314
|
+
>
|
|
315
|
+
{copied === 'selector' ? 'Copied!' : 'Copy selector'}
|
|
316
|
+
</button>
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
className={clsx('rc-inspect-card-btn', copied === 'id' && 'rc-inspect-card-btn-copied')}
|
|
320
|
+
disabled={!primaryIdField}
|
|
321
|
+
title={primaryIdField ?? `No ${primaryIdLabel}`}
|
|
322
|
+
onClick={() => handleCopy(primaryIdField, 'id')}
|
|
323
|
+
>
|
|
324
|
+
{copied === 'id' ? 'Copied!' : `Copy ${primaryIdLabel}`}
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</div>,
|
|
329
|
+
document.body,
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
334
|
+
// Overlay root
|
|
335
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
export const InspectOverlay = memo(function InspectOverlay({
|
|
338
|
+
snapshot,
|
|
339
|
+
geometry,
|
|
340
|
+
highlightedId,
|
|
341
|
+
selectedId,
|
|
342
|
+
mode,
|
|
343
|
+
cursorPosition,
|
|
344
|
+
frozenCursorPosition,
|
|
345
|
+
onSelectChange,
|
|
346
|
+
onTapElement,
|
|
347
|
+
}: InspectOverlayProps) {
|
|
348
|
+
const selectable = mode === 'select';
|
|
349
|
+
|
|
350
|
+
// Card behaviour:
|
|
351
|
+
// - In `hover-only` mode there is no selection. The card always follows
|
|
352
|
+
// the cursor and only renders when something is highlighted.
|
|
353
|
+
// - In `select` mode the card follows the cursor whenever the user is
|
|
354
|
+
// hovering an element OTHER than the currently selected one (preview).
|
|
355
|
+
// Hovering the selected element or moving the cursor off any box keeps
|
|
356
|
+
// the card frozen at the click position so the action buttons remain
|
|
357
|
+
// reachable.
|
|
358
|
+
const isPreviewingHover =
|
|
359
|
+
selectable ? highlightedId !== null && highlightedId !== selectedId : highlightedId !== null;
|
|
360
|
+
const focusId = selectable ? highlightedId ?? selectedId : highlightedId;
|
|
361
|
+
|
|
362
|
+
// Build an `id → element` index once per snapshot so focus lookups are O(1)
|
|
363
|
+
// even on max-size trees. Hooks must run unconditionally — gate rendering
|
|
364
|
+
// afterwards via the `renderable` check.
|
|
365
|
+
const elementsById = useMemo(() => {
|
|
366
|
+
const m = new Map<string, AxElement>();
|
|
367
|
+
if (!snapshot) return m;
|
|
368
|
+
for (const el of snapshot.elements) m.set(el.id, el);
|
|
369
|
+
return m;
|
|
370
|
+
}, [snapshot]);
|
|
371
|
+
|
|
372
|
+
const focusElement = useMemo(() => {
|
|
373
|
+
if (!focusId) return null;
|
|
374
|
+
return elementsById.get(focusId) ?? null;
|
|
375
|
+
}, [focusId, elementsById]);
|
|
376
|
+
|
|
377
|
+
// The overlay has nothing to draw until both a snapshot and the device
|
|
378
|
+
// geometry are available, and the snapshot has at least one usable
|
|
379
|
+
// element. Conditional rendering goes here — AFTER all hooks — so the
|
|
380
|
+
// hook-count is stable across the snapshot-arrives-after-mount transition.
|
|
381
|
+
const renderable =
|
|
382
|
+
!!snapshot &&
|
|
383
|
+
!!geometry &&
|
|
384
|
+
snapshot.screen.width > 0 &&
|
|
385
|
+
snapshot.screen.height > 0 &&
|
|
386
|
+
snapshot.elements.length > 0;
|
|
387
|
+
if (!renderable) return null;
|
|
388
|
+
|
|
389
|
+
const anchor = isPreviewingHover ? cursorPosition : frozenCursorPosition ?? cursorPosition;
|
|
390
|
+
const cursorAnchored = isPreviewingHover;
|
|
391
|
+
const showActions = selectable && !isPreviewingHover && selectedId !== null;
|
|
392
|
+
|
|
393
|
+
const handleContainerClick = (e: React.MouseEvent) => {
|
|
394
|
+
if (!selectable) return;
|
|
395
|
+
// Click was on the container itself (not a box) — clear selection.
|
|
396
|
+
if (e.target === e.currentTarget) {
|
|
397
|
+
onSelectChange(null, null);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<>
|
|
403
|
+
<div
|
|
404
|
+
className={clsx('rc-inspect-overlay', selectable && 'rc-inspect-overlay-select')}
|
|
405
|
+
style={{
|
|
406
|
+
left: `${geometry!.left}px`,
|
|
407
|
+
top: `${geometry!.top}px`,
|
|
408
|
+
width: `${geometry!.width}px`,
|
|
409
|
+
height: `${geometry!.height}px`,
|
|
410
|
+
}}
|
|
411
|
+
onClick={handleContainerClick}
|
|
412
|
+
>
|
|
413
|
+
{snapshot!.elements.map((element) => (
|
|
414
|
+
<InspectBox
|
|
415
|
+
key={element.id}
|
|
416
|
+
element={element}
|
|
417
|
+
screen={snapshot!.screen}
|
|
418
|
+
highlighted={element.id === highlightedId}
|
|
419
|
+
selected={element.id === selectedId}
|
|
420
|
+
selectable={selectable}
|
|
421
|
+
onClick={onSelectChange}
|
|
422
|
+
/>
|
|
423
|
+
))}
|
|
424
|
+
</div>
|
|
425
|
+
{focusElement && anchor && (
|
|
426
|
+
<InfoCard
|
|
427
|
+
element={focusElement}
|
|
428
|
+
platform={snapshot!.platform}
|
|
429
|
+
anchor={anchor}
|
|
430
|
+
cursorAnchored={cursorAnchored}
|
|
431
|
+
showActions={showActions}
|
|
432
|
+
onTap={onTapElement}
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
435
|
+
</>
|
|
436
|
+
);
|
|
437
|
+
});
|