Add button overlay in corner (vibe-kanban 4345d34e)

Currently the context menu is only triggered by Option-Click on a component. I would like to add an additional method, where we constantly have a fixed circle in the bottom right of the screen that when clicked, changes the user's cursor to a target, when the user next clicks on the screen the context menu is triggered for the component at that location

packages/click-to-react-component/src/ContextMenu.js
This commit is contained in:
Louis Knight-Webb
2025-09-18 17:55:02 +01:00
parent 230e621771
commit 663ac10089
3 changed files with 175 additions and 60 deletions

View File

@@ -19,6 +19,11 @@ export const State = /** @type {const} */ ({
SELECT: 'SELECT',
})
export const Trigger = /** @type {const} */ ({
ALT_KEY: 'alt-key',
BUTTON: 'button',
})
/**
* @param {Props} props
*/
@@ -28,11 +33,109 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
(State.IDLE)
)
const [trigger, setTrigger] = React.useState(
/** @type {Trigger[keyof Trigger] | null} */
(null)
)
const [target, setTarget] = React.useState(
/** @type {HTMLElement | null} */
(null)
)
const menuRef = React.useRef(
/** @type {any} */
(null)
)
const vkIconUrl = new URL('./assets/vk-icon.png', import.meta.url).href
const TargetButton = React.useCallback(
({ active, onToggle }) => html`
<button
onClick=${function handleButtonClick(e) {
e.stopPropagation()
onToggle()
}}
aria-pressed=${active}
style=${{
position: 'fixed',
bottom: '16px',
right: '16px',
width: '48px',
height: '48px',
borderRadius: '50%',
background: active ? 'royalblue' : 'white',
color: active ? 'white' : 'black',
border: '1px solid #ccc',
boxShadow: '0 2px 6px rgba(0,0,0,.3)',
zIndex: 2147483647,
cursor: 'pointer',
fontSize: '18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8px',
}}
title="Toggle targeting mode"
>
<img
src=${vkIconUrl}
alt="VK Icon"
style=${{
width: '32px',
height: '32px',
filter: active ? 'brightness(0) invert(1)' : 'none',
}}
/>
</button>
`,
[]
)
const toggleTargeting = React.useCallback(() => {
if (state === State.HOVER && trigger === Trigger.BUTTON) {
setState(State.IDLE)
setTrigger(null)
} else {
setState(State.HOVER)
setTrigger(Trigger.BUTTON)
}
}, [state, trigger])
const onContextMenu = React.useCallback(
function handleContextMenu(
/**
* @type {MouseEvent}
*/
event
) {
const { target } = event
// Handle Alt+right-click (original behavior)
if (event.altKey && target instanceof HTMLElement) {
event.preventDefault()
menuRef.current?.open({
x: event.clientX,
y: event.clientY,
target: target,
})
setState(State.SELECT)
setTarget(target)
return
}
// Handle targeting mode right-click
if (state === State.HOVER && target instanceof HTMLElement) {
event.preventDefault()
setState(State.SELECT)
setTarget(target)
}
},
[state]
)
const onClick = React.useCallback(
function handleClick(
/**
@@ -40,7 +143,22 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
*/
event
) {
if (state === State.HOVER && target instanceof HTMLElement) {
// Handle targeting mode click (left-click opens context menu)
if (state === State.HOVER && trigger === Trigger.BUTTON && target instanceof HTMLElement) {
event.preventDefault()
// Use imperative API to open context menu
menuRef.current?.open({
x: event.clientX,
y: event.clientY,
target: event.target,
})
setState(State.SELECT)
setTarget(event.target)
return
}
// Handle Alt+click mode (existing behavior)
if (state === State.HOVER && trigger === Trigger.ALT_KEY && target instanceof HTMLElement) {
const instance = getReactInstancesForElement(target).find((instance) =>
getSourceForInstance(instance)
)
@@ -70,9 +188,10 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
window.location.assign(url)
setState(State.IDLE)
setTrigger(null)
}
},
[editor, pathModifier, state, target]
[editor, pathModifier, state, trigger, target]
)
const onClose = React.useCallback(
@@ -87,29 +206,11 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
}
setState(State.IDLE)
setTrigger(null)
},
[editor]
)
const onContextMenu = React.useCallback(
function handleContextMenu(
/**
* @type {MouseEvent}
*/
event
) {
const { target } = event
if (state === State.HOVER && target instanceof HTMLElement) {
event.preventDefault()
setState(State.SELECT)
setTarget(target)
}
},
[state]
)
const onKeyDown = React.useCallback(
function handleKeyDown(
/**
@@ -119,13 +220,23 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
) {
switch (state) {
case State.IDLE:
if (event.altKey) setState(State.HOVER)
if (event.altKey) {
setState(State.HOVER)
setTrigger(Trigger.ALT_KEY)
}
break
case State.HOVER:
if (event.key === 'Escape' && trigger === Trigger.BUTTON) {
setState(State.IDLE)
setTrigger(null)
}
break
default:
}
},
[state]
[state, trigger]
)
const onKeyUp = React.useCallback(
@@ -137,13 +248,16 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
) {
switch (state) {
case State.HOVER:
setState(State.IDLE)
if (trigger === Trigger.ALT_KEY) {
setState(State.IDLE)
setTrigger(null)
}
break
default:
}
},
[state]
[state, trigger]
)
const onMouseMove = React.useCallback(
@@ -173,6 +287,7 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
switch (state) {
case State.HOVER:
setState(State.IDLE)
setTrigger(null)
break
default:
@@ -193,6 +308,7 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
if (state === State.IDLE) {
delete window.document.body.dataset.clickToComponent
window.document.body.style.removeProperty('--click-to-component-cursor')
if (target) {
delete target.dataset.clickToComponentTarget
}
@@ -202,9 +318,15 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
if (target instanceof HTMLElement) {
window.document.body.dataset.clickToComponent = state
target.dataset.clickToComponentTarget = state
// Set cursor based on trigger type
window.document.body.style.setProperty(
'--click-to-component-cursor',
trigger === Trigger.BUTTON ? 'crosshair' : 'context-menu'
)
}
},
[state, target]
[state, target, trigger]
)
React.useEffect(
@@ -247,8 +369,14 @@ export function ClickToComponent({ editor = 'vscode', pathModifier }) {
</style>
<${FloatingPortal} key="click-to-component-portal">
<${TargetButton}
key="click-to-component-target-button"
active=${state === State.HOVER && trigger === Trigger.BUTTON}
onToggle=${toggleTargeting}
/>
${html`<${ContextMenu}
key="click-to-component-contextmenu"
ref=${menuRef}
onClose=${onClose}
pathModifier=${pathModifier}
/>`}

View File

@@ -105,41 +105,27 @@ export const ContextMenu = React.forwardRef((props, ref) => {
[reference, ref]
)
React.useEffect(() => {
function onContextMenu(
/** @type {MouseEvent} */
e
) {
if (!e.altKey) {
return
}
e.preventDefault()
mergedReferenceRef({
// Expose imperative API for parent component to open the menu
React.useImperativeHandle(ref, () => ({
open({ x, y, target }) {
reference({
getBoundingClientRect() {
return {
x: e.clientX,
y: e.clientY,
x,
y,
width: 0,
height: 0,
top: e.clientY,
right: e.clientX,
bottom: e.clientY,
left: e.clientX,
top: y,
right: x,
bottom: y,
left: x,
}
},
})
setTarget(target)
setOpen(true)
if (e.target instanceof HTMLElement) setTarget(e.target)
}
document.addEventListener('contextmenu', onContextMenu)
return () => {
document.removeEventListener('contextmenu', onContextMenu)
}
}, [mergedReferenceRef])
},
}), [reference])
React.useLayoutEffect(() => {
if (open) {
@@ -167,13 +153,14 @@ export const ContextMenu = React.forwardRef((props, ref) => {
})
}, [arrowX, arrowY, placement])
if (!target) {
return null
}
const instances = getReactInstancesForElement(target).filter((instance) =>
getSourceForInstance(instance)
)
// Build the instance list only when a target exists avoids
// unnecessary work after the menu is closed.
const instances = React.useMemo(() => {
if (!target) return []
return getReactInstancesForElement(target).filter((instance) =>
getSourceForInstance(instance)
)
}, [target])
return html`
<style key="click-to-component-contextmenu-style">

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB