@treelocator/runtime 0.3.1 → 0.4.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/dist/browserApi.d.ts +45 -0
- package/dist/browserApi.js +23 -1
- package/dist/components/RecordingOutline.d.ts +5 -0
- package/dist/components/RecordingOutline.js +53 -0
- package/dist/components/RecordingResults.d.ts +25 -0
- package/dist/components/RecordingResults.js +272 -0
- package/dist/components/Runtime.js +505 -70
- package/dist/dejitter/recorder.d.ts +91 -0
- package/dist/dejitter/recorder.js +908 -0
- package/dist/functions/enrichAncestrySourceMaps.js +9 -2
- package/dist/functions/formatAncestryChain.js +27 -7
- package/dist/functions/formatAncestryChain.test.js +114 -0
- package/dist/functions/getUsableName.js +24 -2
- package/dist/output.css +13 -0
- package/package.json +2 -2
- package/src/browserApi.ts +74 -1
- package/src/components/RecordingOutline.tsx +66 -0
- package/src/components/RecordingResults.tsx +287 -0
- package/src/components/Runtime.tsx +534 -80
- package/src/dejitter/recorder.ts +938 -0
- package/src/functions/enrichAncestrySourceMaps.ts +9 -2
- package/src/functions/formatAncestryChain.test.ts +123 -0
- package/src/functions/formatAncestryChain.ts +28 -7
- package/src/functions/getUsableName.ts +24 -2
- package/.turbo/turbo-build.log +0 -32
- package/.turbo/turbo-dev.log +0 -32
- package/.turbo/turbo-test.log +0 -14
- package/.turbo/turbo-ts.log +0 -4
- package/LICENSE +0 -22
|
@@ -3,14 +3,21 @@ import { normalizeFilePath } from "./normalizeFilePath";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Check if any DOM element has React 19 fibers (with _debugStack instead of _debugSource).
|
|
6
|
+
* Must walk the _debugOwner chain because DOM element fibers (HostComponent) never have
|
|
7
|
+
* _debugStack — only function component fibers do.
|
|
6
8
|
*/
|
|
7
9
|
function isReact19Environment() {
|
|
8
10
|
const el = document.querySelector("[class]") || document.body;
|
|
9
11
|
if (!el) return false;
|
|
10
12
|
const fiberKey = Object.keys(el).find(k => k.startsWith("__reactFiber$"));
|
|
11
13
|
if (!fiberKey) return false;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
let fiber = el[fiberKey];
|
|
15
|
+
while (fiber) {
|
|
16
|
+
if (fiber._debugSource) return false; // React 18
|
|
17
|
+
if (fiber._debugStack) return true; // React 19
|
|
18
|
+
fiber = fiber._debugOwner || null;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
/**
|
|
@@ -105,6 +105,22 @@ export function collectAncestry(node) {
|
|
|
105
105
|
}
|
|
106
106
|
return items;
|
|
107
107
|
}
|
|
108
|
+
function getInnermostNamedComponent(item) {
|
|
109
|
+
if (!item) return undefined;
|
|
110
|
+
if (item.ownerComponents && item.ownerComponents.length > 0) {
|
|
111
|
+
// Find the innermost non-Anonymous component
|
|
112
|
+
for (let i = item.ownerComponents.length - 1; i >= 0; i--) {
|
|
113
|
+
if (item.ownerComponents[i].name !== "Anonymous") {
|
|
114
|
+
return item.ownerComponents[i].name;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Fall back to componentName if it's not Anonymous
|
|
119
|
+
if (item.componentName && item.componentName !== "Anonymous") {
|
|
120
|
+
return item.componentName;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
108
124
|
export function formatAncestryChain(items) {
|
|
109
125
|
if (items.length === 0) {
|
|
110
126
|
return "";
|
|
@@ -118,11 +134,12 @@ export function formatAncestryChain(items) {
|
|
|
118
134
|
const prefix = index === 0 ? "" : "└─ ";
|
|
119
135
|
|
|
120
136
|
// Get the previous item's component to detect component boundaries
|
|
137
|
+
// Ignore "Anonymous" when resolving component names — these are framework internals
|
|
121
138
|
const prevItem = index > 0 ? reversed[index - 1] : null;
|
|
122
|
-
const prevComponentName = prevItem
|
|
139
|
+
const prevComponentName = getInnermostNamedComponent(prevItem);
|
|
123
140
|
|
|
124
|
-
// Get current item's innermost component
|
|
125
|
-
const currentComponentName = item
|
|
141
|
+
// Get current item's innermost named component
|
|
142
|
+
const currentComponentName = getInnermostNamedComponent(item);
|
|
126
143
|
|
|
127
144
|
// Determine the display name for the element
|
|
128
145
|
// Use component name ONLY when crossing a component boundary (root element of a component)
|
|
@@ -132,14 +149,17 @@ export function formatAncestryChain(items) {
|
|
|
132
149
|
const isComponentBoundary = currentComponentName && currentComponentName !== prevComponentName;
|
|
133
150
|
if (isComponentBoundary) {
|
|
134
151
|
if (item.ownerComponents && item.ownerComponents.length > 0) {
|
|
135
|
-
// Use innermost component as display name, show outer ones in "in X > Y"
|
|
136
|
-
|
|
152
|
+
// Use innermost named component as display name, show outer ones in "in X > Y"
|
|
153
|
+
// Filter out "Anonymous" components — these are framework-internal wrappers
|
|
154
|
+
// (e.g. Next.js App Router) that add noise without useful context
|
|
155
|
+
const named = item.ownerComponents.filter(c => c.name !== "Anonymous");
|
|
156
|
+
const innermost = named[named.length - 1];
|
|
137
157
|
if (innermost) {
|
|
138
158
|
displayName = innermost.name;
|
|
139
159
|
// Outer components (excluding innermost)
|
|
140
|
-
outerComponents =
|
|
160
|
+
outerComponents = named.slice(0, -1).map(c => c.name);
|
|
141
161
|
}
|
|
142
|
-
} else if (item.componentName) {
|
|
162
|
+
} else if (item.componentName && item.componentName !== "Anonymous") {
|
|
143
163
|
displayName = item.componentName;
|
|
144
164
|
}
|
|
145
165
|
}
|
|
@@ -125,4 +125,118 @@ describe("formatAncestryChain", () => {
|
|
|
125
125
|
// Single component becomes the display name, no "in X" needed
|
|
126
126
|
expect(result).toBe("Button at src/Button.tsx:10");
|
|
127
127
|
});
|
|
128
|
+
describe("Anonymous component filtering", () => {
|
|
129
|
+
it("filters Anonymous from outer components in 'in X > Y' display", () => {
|
|
130
|
+
const items = [{
|
|
131
|
+
elementName: "div",
|
|
132
|
+
nthChild: 2,
|
|
133
|
+
componentName: "Anonymous",
|
|
134
|
+
filePath: "app/page.tsx",
|
|
135
|
+
line: 82,
|
|
136
|
+
ownerComponents: [{
|
|
137
|
+
name: "Anonymous"
|
|
138
|
+
}, {
|
|
139
|
+
name: "Home",
|
|
140
|
+
filePath: "app/page.tsx",
|
|
141
|
+
line: 82
|
|
142
|
+
}]
|
|
143
|
+
}, {
|
|
144
|
+
elementName: "div",
|
|
145
|
+
componentName: "App",
|
|
146
|
+
filePath: "src/App.tsx",
|
|
147
|
+
line: 1
|
|
148
|
+
}];
|
|
149
|
+
const result = formatAncestryChain(items);
|
|
150
|
+
// "Anonymous" should be filtered out — just "Home" as display name, no "in Anonymous"
|
|
151
|
+
expect(result).toBe(`App at src/App.tsx:1
|
|
152
|
+
└─ Home:nth-child(2) at app/page.tsx:82`);
|
|
153
|
+
});
|
|
154
|
+
it("filters Anonymous from component boundary detection", () => {
|
|
155
|
+
// When prev item's innermost component is Anonymous, it shouldn't count
|
|
156
|
+
// as a different component from the current item
|
|
157
|
+
const items = [{
|
|
158
|
+
elementName: "p",
|
|
159
|
+
componentName: "Home",
|
|
160
|
+
filePath: "app/page.tsx",
|
|
161
|
+
line: 100
|
|
162
|
+
}, {
|
|
163
|
+
elementName: "div",
|
|
164
|
+
componentName: "Anonymous",
|
|
165
|
+
filePath: "app/page.tsx",
|
|
166
|
+
line: 82,
|
|
167
|
+
ownerComponents: [{
|
|
168
|
+
name: "Anonymous"
|
|
169
|
+
}, {
|
|
170
|
+
name: "Home",
|
|
171
|
+
filePath: "app/page.tsx",
|
|
172
|
+
line: 82
|
|
173
|
+
}]
|
|
174
|
+
}];
|
|
175
|
+
const result = formatAncestryChain(items);
|
|
176
|
+
// Both items resolve to "Home" — no boundary crossing, so element name for child
|
|
177
|
+
expect(result).toBe(`Home at app/page.tsx:82
|
|
178
|
+
└─ p at app/page.tsx:100`);
|
|
179
|
+
});
|
|
180
|
+
it("handles all-Anonymous owner chain gracefully", () => {
|
|
181
|
+
const items = [{
|
|
182
|
+
elementName: "div",
|
|
183
|
+
componentName: "Anonymous",
|
|
184
|
+
filePath: "app/layout.tsx",
|
|
185
|
+
line: 10,
|
|
186
|
+
ownerComponents: [{
|
|
187
|
+
name: "Anonymous"
|
|
188
|
+
}, {
|
|
189
|
+
name: "Anonymous"
|
|
190
|
+
}]
|
|
191
|
+
}];
|
|
192
|
+
const result = formatAncestryChain(items);
|
|
193
|
+
// All components are Anonymous — falls back to element name
|
|
194
|
+
expect(result).toBe("div at app/layout.tsx:10");
|
|
195
|
+
});
|
|
196
|
+
it("skips Anonymous-only componentName without ownerComponents", () => {
|
|
197
|
+
const items = [{
|
|
198
|
+
elementName: "main",
|
|
199
|
+
componentName: "Anonymous",
|
|
200
|
+
filePath: "app/page.tsx",
|
|
201
|
+
line: 50
|
|
202
|
+
}, {
|
|
203
|
+
elementName: "div",
|
|
204
|
+
componentName: "App",
|
|
205
|
+
filePath: "src/App.tsx",
|
|
206
|
+
line: 1
|
|
207
|
+
}];
|
|
208
|
+
const result = formatAncestryChain(items);
|
|
209
|
+
// "Anonymous" componentName should not be used as display name
|
|
210
|
+
expect(result).toBe(`App at src/App.tsx:1
|
|
211
|
+
└─ main at app/page.tsx:50`);
|
|
212
|
+
});
|
|
213
|
+
it("preserves named components when mixed with Anonymous", () => {
|
|
214
|
+
const items = [{
|
|
215
|
+
elementName: "div",
|
|
216
|
+
componentName: "Sidebar",
|
|
217
|
+
filePath: "src/Sidebar.tsx",
|
|
218
|
+
line: 20,
|
|
219
|
+
ownerComponents: [{
|
|
220
|
+
name: "Sidebar",
|
|
221
|
+
filePath: "src/Sidebar.tsx",
|
|
222
|
+
line: 20
|
|
223
|
+
}, {
|
|
224
|
+
name: "Anonymous"
|
|
225
|
+
}, {
|
|
226
|
+
name: "GlassPanel",
|
|
227
|
+
filePath: "src/GlassPanel.tsx",
|
|
228
|
+
line: 5
|
|
229
|
+
}]
|
|
230
|
+
}, {
|
|
231
|
+
elementName: "div",
|
|
232
|
+
componentName: "App",
|
|
233
|
+
filePath: "src/App.tsx",
|
|
234
|
+
line: 1
|
|
235
|
+
}];
|
|
236
|
+
const result = formatAncestryChain(items);
|
|
237
|
+
// Anonymous in the middle is filtered, Sidebar (outer) and GlassPanel (inner) remain
|
|
238
|
+
expect(result).toBe(`App at src/App.tsx:1
|
|
239
|
+
└─ GlassPanel in Sidebar at src/Sidebar.tsx:20`);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
128
242
|
});
|
|
@@ -32,16 +32,38 @@ export function getUsableName(fiber) {
|
|
|
32
32
|
if (fiber.elementType.name) {
|
|
33
33
|
return fiber.elementType.name;
|
|
34
34
|
}
|
|
35
|
-
// Not sure about this
|
|
36
35
|
if (fiber.elementType.displayName) {
|
|
37
36
|
return fiber.elementType.displayName;
|
|
38
37
|
}
|
|
39
|
-
// Used in
|
|
38
|
+
// Used in React.memo
|
|
40
39
|
if (fiber.elementType.type?.name) {
|
|
41
40
|
return fiber.elementType.type.name;
|
|
42
41
|
}
|
|
42
|
+
if (fiber.elementType.type?.displayName) {
|
|
43
|
+
return fiber.elementType.type.displayName;
|
|
44
|
+
}
|
|
45
|
+
// React.forwardRef wraps the render function in .render
|
|
46
|
+
if (fiber.elementType.render?.name) {
|
|
47
|
+
return fiber.elementType.render.name;
|
|
48
|
+
}
|
|
49
|
+
if (fiber.elementType.render?.displayName) {
|
|
50
|
+
return fiber.elementType.render.displayName;
|
|
51
|
+
}
|
|
52
|
+
// React lazy components store resolved module in _payload._result
|
|
43
53
|
if (fiber.elementType._payload?._result?.name) {
|
|
44
54
|
return fiber.elementType._payload._result.name;
|
|
45
55
|
}
|
|
56
|
+
if (fiber.elementType._payload?._result?.displayName) {
|
|
57
|
+
return fiber.elementType._payload._result.displayName;
|
|
58
|
+
}
|
|
59
|
+
// fiber.type can differ from elementType in some React internals
|
|
60
|
+
if (fiber.type && typeof fiber.type !== "string" && fiber.type !== fiber.elementType) {
|
|
61
|
+
if (fiber.type.name) {
|
|
62
|
+
return fiber.type.name;
|
|
63
|
+
}
|
|
64
|
+
if (fiber.type.displayName) {
|
|
65
|
+
return fiber.type.displayName;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
46
68
|
return "Anonymous";
|
|
47
69
|
}
|
package/dist/output.css
CHANGED
|
@@ -827,6 +827,10 @@ input:where([type='file']):focus {
|
|
|
827
827
|
position: relative;
|
|
828
828
|
}
|
|
829
829
|
|
|
830
|
+
.sticky {
|
|
831
|
+
position: sticky;
|
|
832
|
+
}
|
|
833
|
+
|
|
830
834
|
.-bottom-7 {
|
|
831
835
|
bottom: -1.75rem;
|
|
832
836
|
}
|
|
@@ -1758,10 +1762,19 @@ input:where([type='file']):focus {
|
|
|
1758
1762
|
--tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity, 1));
|
|
1759
1763
|
}
|
|
1760
1764
|
|
|
1765
|
+
.blur {
|
|
1766
|
+
--tw-blur: blur(8px);
|
|
1767
|
+
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1761
1770
|
.filter {
|
|
1762
1771
|
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
|
1763
1772
|
}
|
|
1764
1773
|
|
|
1774
|
+
.backdrop-filter {
|
|
1775
|
+
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1765
1778
|
.transition {
|
|
1766
1779
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
|
1767
1780
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@treelocator/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "TreeLocatorJS runtime for component ancestry tracking. Alt+click any element to copy its component tree to clipboard. Exposes window.__treelocator__ API for browser automation (Playwright, Puppeteer, Selenium, Cypress).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"locator",
|
|
@@ -73,5 +73,5 @@
|
|
|
73
73
|
"directory": "packages/runtime"
|
|
74
74
|
},
|
|
75
75
|
"license": "MIT",
|
|
76
|
-
"gitHead": "
|
|
76
|
+
"gitHead": "4f74117d9076e063c072c6b172f785e5572b3be9"
|
|
77
77
|
}
|
package/src/browserApi.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
AncestryItem,
|
|
7
7
|
} from "./functions/formatAncestryChain";
|
|
8
8
|
import { enrichAncestryWithSourceMaps } from "./functions/enrichAncestrySourceMaps";
|
|
9
|
+
import type { DejitterFinding, DejitterSummary } from "./dejitter/recorder";
|
|
10
|
+
import type { InteractionEvent } from "./components/RecordingResults";
|
|
9
11
|
|
|
10
12
|
export interface LocatorJSAPI {
|
|
11
13
|
/**
|
|
@@ -107,6 +109,53 @@ export interface LocatorJSAPI {
|
|
|
107
109
|
* console.log(help);
|
|
108
110
|
*/
|
|
109
111
|
help(): string;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Replay the last recorded interaction sequence.
|
|
115
|
+
* Dispatches the recorded clicks at the original positions and timing.
|
|
116
|
+
* Must have a completed recording with interactions to replay.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* // In browser console
|
|
120
|
+
* window.__treelocator__.replay();
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* // In Playwright
|
|
124
|
+
* await page.evaluate(() => window.__treelocator__.replay());
|
|
125
|
+
*/
|
|
126
|
+
replay(): void;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Replay the last recorded interaction sequence while recording an element's property changes.
|
|
130
|
+
* Combines replay and dejitter recording: plays back stored clicks at original timing while
|
|
131
|
+
* tracking visual changes (opacity, transform, position, size) on the target element.
|
|
132
|
+
* Returns the dejitter analysis results when replay completes.
|
|
133
|
+
*
|
|
134
|
+
* @param elementOrSelector - HTMLElement or CSS selector for the element to record during replay
|
|
135
|
+
* @returns Promise resolving to recording results with findings, summary, and interaction log
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* // Record the sliding panel while replaying user clicks
|
|
139
|
+
* const results = await window.__treelocator__.replayWithRecord('[data-locatorjs-id="SlidingPanel"]');
|
|
140
|
+
* console.log(results.findings); // anomaly analysis
|
|
141
|
+
* console.log(results.path); // component ancestry
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* // In Playwright - automated regression test
|
|
145
|
+
* const results = await page.evaluate(async () => {
|
|
146
|
+
* return await window.__treelocator__.replayWithRecord('.my-panel');
|
|
147
|
+
* });
|
|
148
|
+
* expect(results.findings.filter(f => f.severity === 'high')).toHaveLength(0);
|
|
149
|
+
*/
|
|
150
|
+
replayWithRecord(
|
|
151
|
+
elementOrSelector: HTMLElement | string
|
|
152
|
+
): Promise<{
|
|
153
|
+
path: string;
|
|
154
|
+
findings: DejitterFinding[];
|
|
155
|
+
summary: DejitterSummary | null;
|
|
156
|
+
data: any;
|
|
157
|
+
interactions: InteractionEvent[];
|
|
158
|
+
} | null>;
|
|
110
159
|
}
|
|
111
160
|
|
|
112
161
|
let adapterId: AdapterId | undefined;
|
|
@@ -179,7 +228,22 @@ METHODS:
|
|
|
179
228
|
console.log(data.path) // formatted string
|
|
180
229
|
console.log(data.ancestry) // structured array
|
|
181
230
|
|
|
182
|
-
4.
|
|
231
|
+
4. replay()
|
|
232
|
+
Replays the last recorded interaction sequence as a macro.
|
|
233
|
+
|
|
234
|
+
Usage:
|
|
235
|
+
window.__treelocator__.replay()
|
|
236
|
+
|
|
237
|
+
5. replayWithRecord(elementOrSelector)
|
|
238
|
+
Replays stored interactions while recording element changes.
|
|
239
|
+
Returns dejitter analysis when replay completes.
|
|
240
|
+
|
|
241
|
+
Usage:
|
|
242
|
+
const results = await window.__treelocator__.replayWithRecord('[data-locatorjs-id="SlidingPanel"]')
|
|
243
|
+
console.log(results.findings) // anomaly analysis
|
|
244
|
+
console.log(results.path) // component ancestry
|
|
245
|
+
|
|
246
|
+
6. help()
|
|
183
247
|
Displays this help message.
|
|
184
248
|
|
|
185
249
|
PLAYWRIGHT EXAMPLES:
|
|
@@ -280,6 +344,15 @@ export function createBrowserAPI(
|
|
|
280
344
|
help(): string {
|
|
281
345
|
return HELP_TEXT;
|
|
282
346
|
},
|
|
347
|
+
|
|
348
|
+
replay() {
|
|
349
|
+
// Replaced by Runtime component once mounted
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
replayWithRecord() {
|
|
353
|
+
// Replaced by Runtime component once mounted
|
|
354
|
+
return Promise.resolve(null);
|
|
355
|
+
},
|
|
283
356
|
};
|
|
284
357
|
}
|
|
285
358
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createSignal, onCleanup, onMount } from "solid-js";
|
|
2
|
+
|
|
3
|
+
type RecordingOutlineProps = {
|
|
4
|
+
element: HTMLElement;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function RecordingOutline(props: RecordingOutlineProps) {
|
|
8
|
+
const [box, setBox] = createSignal(props.element.getBoundingClientRect());
|
|
9
|
+
|
|
10
|
+
let rafId: number;
|
|
11
|
+
const updateBox = () => {
|
|
12
|
+
setBox(props.element.getBoundingClientRect());
|
|
13
|
+
rafId = requestAnimationFrame(updateBox);
|
|
14
|
+
};
|
|
15
|
+
onMount(() => {
|
|
16
|
+
rafId = requestAnimationFrame(updateBox);
|
|
17
|
+
});
|
|
18
|
+
onCleanup(() => cancelAnimationFrame(rafId));
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
style={{
|
|
23
|
+
position: "fixed",
|
|
24
|
+
"z-index": "2",
|
|
25
|
+
left: box().x + "px",
|
|
26
|
+
top: box().y + "px",
|
|
27
|
+
width: box().width + "px",
|
|
28
|
+
height: box().height + "px",
|
|
29
|
+
border: "2px dashed #ef4444",
|
|
30
|
+
"border-radius": "2px",
|
|
31
|
+
"pointer-events": "none",
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<div
|
|
35
|
+
style={{
|
|
36
|
+
position: "absolute",
|
|
37
|
+
top: "-22px",
|
|
38
|
+
left: "4px",
|
|
39
|
+
display: "flex",
|
|
40
|
+
"align-items": "center",
|
|
41
|
+
gap: "4px",
|
|
42
|
+
padding: "2px 8px",
|
|
43
|
+
background: "rgba(239, 68, 68, 0.9)",
|
|
44
|
+
"border-radius": "4px",
|
|
45
|
+
color: "#fff",
|
|
46
|
+
"font-size": "10px",
|
|
47
|
+
"font-family": "system-ui, sans-serif",
|
|
48
|
+
"font-weight": "600",
|
|
49
|
+
"letter-spacing": "0.5px",
|
|
50
|
+
"white-space": "nowrap",
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<div
|
|
54
|
+
style={{
|
|
55
|
+
width: "6px",
|
|
56
|
+
height: "6px",
|
|
57
|
+
"border-radius": "50%",
|
|
58
|
+
background: "#fff",
|
|
59
|
+
animation: "treelocator-rec-pulse 1s ease-in-out infinite",
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
REC
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|