Skip to content

Commit dac9c9d

Browse files
feat(browser): Setting to limit click events to buttons and links (#1176)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Johan Suleiko Allansson <allansson@users.noreply.github.com>
1 parent 96383c9 commit dac9c9d

File tree

13 files changed

+363
-108
lines changed

13 files changed

+363
-108
lines changed

extension/src/cdp/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ if (!isInFrame()) {
1212

1313
trackTabFocus(client)
1414
initializeView(client, storage)
15-
startRecording(client)
15+
startRecording(client, storage)
1616
}

extension/src/cdp/storage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function configureStorage(
2929
})
3030

3131
return {
32-
get initial() {
32+
getCurrent() {
3333
return settings
3434
},
3535
load() {

extension/src/frontend/manager.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@ export class WindowEventManager {
2727
* avoid recording follow-up events that are triggered as a side-effect of the
2828
* original event.
2929
*/
30-
block<T extends keyof WindowEventMap>(type: T, target: EventTarget): this {
30+
block<T extends keyof WindowEventMap>(
31+
type: T,
32+
target: EventTarget | null
33+
): this {
34+
if (target === null) {
35+
return this
36+
}
37+
3138
let blockedForType = this.#blockedEvents[type]
3239

3340
if (blockedForType === undefined) {
@@ -106,3 +113,5 @@ export class WindowEventManager {
106113
return blockedForType.delete(ev.target)
107114
}
108115
}
116+
117+
export const eventManager = new WindowEventManager()

extension/src/frontend/recording.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,31 @@ import {
1313
isNonButtonInput,
1414
} from '../utils/dom'
1515

16-
import { WindowEventManager } from './manager'
16+
import { eventManager } from './manager'
1717
import { getTabId } from './utils'
18+
import { SettingsStorage } from './view/SettingsProvider'
19+
20+
export function startRecording(
21+
client: BrowserExtensionClient,
22+
settings: SettingsStorage
23+
) {
24+
function findClickTarget(element: Element): Element | null {
25+
switch (settings.getCurrent().clickRecordingMode) {
26+
case 'interactive-only':
27+
return findInteractiveElement(element)
28+
29+
case 'any':
30+
// From the user's point of view, they clicked a button and not a `<span />` inside a
31+
// button. So whenever we record a click we try to find the underlying interactive
32+
// element. Only if there's no such element do we record a click on the actual
33+
// target.
34+
return findInteractiveElement(element) ?? element
35+
36+
default:
37+
return null
38+
}
39+
}
1840

19-
export function startRecording(client: BrowserExtensionClient) {
2041
function getButton(button: number) {
2142
switch (button) {
2243
case 0:
@@ -40,31 +61,19 @@ export function startRecording(client: BrowserExtensionClient) {
4061
})
4162
}
4263

43-
const manager = new WindowEventManager()
64+
const manager = eventManager
4465

4566
manager.capture('click', (ev, manager) => {
4667
if (ev.target instanceof Element === false) {
4768
return
4869
}
4970

50-
// From the user's point of view, they clicked a button and not a `<span />` inside a
51-
// button. So whenever we record a click we try to find the underlying interactive
52-
// element. Only if there's no such element do we record a click on the actual
53-
// target.
54-
//
55-
// In the future, we might want to have this behavior configurable:
56-
//
57-
// - Ignore any click on non-interactive elements
58-
// - Record click on the interactive element with fallback (current behavior).
59-
// - Record all clicks exactly as they happened.
60-
//
61-
// The first option would be especially useful since it can reduce noise
62-
// in the recordings.
63-
const clickTarget = findInteractiveElement(ev.target) ?? ev.target
71+
const clickTarget = findClickTarget(ev.target)
6472

6573
// We don't want to capture clicks on form elements since they will be
6674
// interacted with using e.g. the `selectOption` or `type` functions.
6775
if (
76+
clickTarget === null ||
6877
isNonButtonInput(clickTarget) ||
6978
clickTarget instanceof HTMLTextAreaElement ||
7079
clickTarget instanceof HTMLSelectElement ||

extension/src/frontend/view/EventDrawer.tsx

Lines changed: 94 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { css } from '@emotion/react'
1+
import { css, keyframes } from '@emotion/react'
22
import * as Dialog from '@radix-ui/react-dialog'
33
import { XIcon } from 'lucide-react'
44

@@ -8,23 +8,39 @@ import { BrowserEvent } from '@/schemas/recording'
88
import { NodeSelector } from '@/schemas/selectors'
99
import { RecordingContext } from '@/views/Recorder/RecordingContext'
1010

11+
import { RecorderSettings } from './RecorderSettings'
12+
import { useInBrowserSettings } from './SettingsProvider'
1113
import { useStudioClient } from './StudioClientProvider'
14+
import { ToolBoxLogo } from './ToolBox/ToolBoxLogo'
15+
16+
const slideIn = keyframes`
17+
from {
18+
transform: translateX(100%);
19+
}
20+
to {
21+
transform: translateX(0);
22+
}
23+
`
24+
25+
const slideOut = keyframes`
26+
from {
27+
transform: translateX(0);
28+
}
29+
to {
30+
transform: translateX(100%);
31+
}
32+
`
1233

1334
interface EventDrawerProps {
1435
open: boolean
15-
editing: boolean
1636
events: BrowserEvent[]
1737
onOpenChange: (open: boolean) => void
1838
}
1939

20-
export function EventDrawer({
21-
open,
22-
editing,
23-
events,
24-
onOpenChange,
25-
}: EventDrawerProps) {
40+
export function EventDrawer({ open, events, onOpenChange }: EventDrawerProps) {
2641
const client = useStudioClient()
2742
const container = useContainerElement()
43+
const [settings, setSettings] = useInBrowserSettings()
2844

2945
const handleHighlight = (selector: NodeSelector | null) => {
3046
client.send({
@@ -42,11 +58,32 @@ export function EventDrawer({
4258

4359
return (
4460
<RecordingContext recording>
45-
<Dialog.Root modal={false} open={open} onOpenChange={onOpenChange}>
46-
<Dialog.Portal container={container} forceMount>
47-
<Dialog.Overlay />
61+
<Dialog.Root modal={true} open={open} onOpenChange={onOpenChange}>
62+
<Dialog.Portal container={container}>
63+
<Dialog.Overlay
64+
css={css`
65+
position: fixed;
66+
inset: 0;
67+
z-index: var(--studio-layer-0);
68+
background: rgb(0 0 0 / 0.28);
69+
opacity: 0;
70+
pointer-events: none;
71+
transition: opacity 0.2s ease-out;
72+
73+
&[data-state='open'] {
74+
opacity: 1;
75+
pointer-events: auto;
76+
}
77+
78+
@media (prefers-reduced-motion: reduce) {
79+
transition: none;
80+
}
81+
`}
82+
onPointerDown={() => {
83+
onOpenChange(false)
84+
}}
85+
/>
4886
<Dialog.Content
49-
forceMount
5087
css={css`
5188
position: fixed;
5289
top: 0;
@@ -66,32 +103,18 @@ export function EventDrawer({
66103
overflow-y: hidden;
67104
overscroll-behavior: contain;
68105
69-
/* Default closed state */
70-
transform: translateX(100%);
71-
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
72-
73106
&[data-state='open'] {
74-
transform: translateX(0);
107+
animation: ${slideIn} 0.3s cubic-bezier(0.22, 1, 0.36, 1);
75108
}
76109
77110
&[data-state='closed'] {
78-
transform: translateX(100%);
111+
animation: ${slideOut} 0.3s cubic-bezier(0.22, 1, 0.36, 1);
79112
}
80113
81114
@media (prefers-reduced-motion: reduce) {
82115
transition: none;
83116
}
84117
`}
85-
onEscapeKeyDown={(event) => {
86-
// If the user is currently editing something, the escape key should deselect
87-
// the tool and not close the drawer.
88-
if (editing) {
89-
event.preventDefault()
90-
}
91-
}}
92-
onInteractOutside={(event) => {
93-
event.preventDefault()
94-
}}
95118
>
96119
<div
97120
css={css`
@@ -108,9 +131,13 @@ export function EventDrawer({
108131
<Dialog.Title
109132
css={css`
110133
margin: 0;
134+
display: flex;
135+
align-items: center;
136+
gap: var(--studio-spacing-2);
111137
`}
112138
>
113-
Events
139+
<ToolBoxLogo size={24} />
140+
<span>k6 Studio</span>
114141
</Dialog.Title>
115142
<Dialog.Close
116143
aria-label="Close event list"
@@ -137,16 +164,47 @@ export function EventDrawer({
137164
</div>
138165
<div
139166
css={css`
140-
padding: 0 var(--studio-spacing-4);
141-
overflow-x: auto;
142-
overscroll-behavior: contain;
167+
display: flex;
168+
flex-direction: column;
143169
flex: 1 1 0;
144170
`}
145171
>
146-
<BrowserEventList
147-
events={events}
148-
onNavigate={handleNavigate}
149-
onHighlight={handleHighlight}
172+
<div
173+
css={css`
174+
overflow-x: auto;
175+
overflow-y: auto;
176+
overscroll-behavior: contain;
177+
flex: 1 1 0;
178+
min-height: 0;
179+
`}
180+
>
181+
<BrowserEventList
182+
events={events}
183+
onNavigate={handleNavigate}
184+
onHighlight={handleHighlight}
185+
/>
186+
</div>
187+
</div>
188+
<div
189+
css={css`
190+
flex-shrink: 0;
191+
border-top: 1px solid var(--studio-border-color);
192+
padding: var(--studio-spacing-4);
193+
background-color: inherit;
194+
`}
195+
>
196+
<h2
197+
css={css`
198+
margin: 0 0 var(--studio-spacing-3);
199+
font-size: var(--studio-font-size-3);
200+
font-weight: 700;
201+
`}
202+
>
203+
Settings
204+
</h2>
205+
<RecorderSettings
206+
settings={settings}
207+
onSettingsChange={setSettings}
150208
/>
151209
</div>
152210
</Dialog.Content>

extension/src/frontend/view/InBrowserControls.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { TextSelectionPopover } from './TextSelectionPopover'
88
import { ToolBox } from './ToolBox'
99
import { useRecordedEvents } from './hooks/useRecordedEvents'
1010
import { useInBrowserUIStore } from './store'
11+
import { Tool } from './types'
1112

1213
export function InBrowserControls() {
1314
const client = useStudioClient()
@@ -19,10 +20,23 @@ export function InBrowserControls() {
1920

2021
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
2122

23+
const handleSelectTool = (tool: Tool | null) => {
24+
setIsDrawerOpen(false)
25+
selectTool(tool)
26+
}
27+
2228
const handleDeselectTool = () => {
2329
selectTool(null)
2430
}
2531

32+
const handleToggleDrawer = (open: boolean) => {
33+
if (open) {
34+
selectTool(null)
35+
}
36+
37+
setIsDrawerOpen(open)
38+
}
39+
2640
const handleStopRecording = () => {
2741
client.send({
2842
type: 'stop-recording',
@@ -40,15 +54,14 @@ export function InBrowserControls() {
4054
isDrawerOpen={isDrawerOpen}
4155
recordedEventCount={recordedEvents.length}
4256
tool={tool}
43-
onSelectTool={selectTool}
57+
onSelectTool={handleSelectTool}
4458
onStopRecording={handleStopRecording}
45-
onToggleDrawer={setIsDrawerOpen}
59+
onToggleDrawer={handleToggleDrawer}
4660
/>
4761
<EventDrawer
4862
open={isDrawerOpen}
49-
editing={tool !== null}
5063
events={recordedEvents}
51-
onOpenChange={setIsDrawerOpen}
64+
onOpenChange={handleToggleDrawer}
5265
/>
5366
</>
5467
)

0 commit comments

Comments
 (0)