Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
POPOUT_ESTIMATED_HEIGHT,
|
||||
POPOUT_WIDTH_REM,
|
||||
type PopoutBounds,
|
||||
type PopoutPosition,
|
||||
type PopoutSize,
|
||||
readPopoutBounds,
|
||||
setComposerPopoutPosition
|
||||
} from '@/store/composer-popout'
|
||||
|
||||
// Floating surface long-press before it becomes draggable (the 5px platform drags
|
||||
// instantly; this only covers grabbing the composer body itself).
|
||||
const LONG_PRESS_MS = 360
|
||||
const LONG_PRESS_MOVE_TOLERANCE = 10
|
||||
// Upward drag distance from the docked composer that peels it off into a float.
|
||||
const PEEL_OUT_PX = 16
|
||||
const DOCK_ZONE_BOTTOM_PX = 72
|
||||
// How close the composer's center must be to the viewport center (px) to count as
|
||||
// "over the dock". Kept tight so the bottom-left/right corners stay free.
|
||||
const DOCK_ZONE_CENTER_TOLERANCE_PX = 150
|
||||
// Falloff distances over which dock proximity ramps from 1 (in-zone) down to 0.
|
||||
const DOCK_VERTICAL_FALLOFF_PX = 260
|
||||
const DOCK_HORIZONTAL_FALLOFF_PX = 220
|
||||
|
||||
interface PressState {
|
||||
armed: boolean
|
||||
mode: 'dock' | 'float'
|
||||
pointerId: number
|
||||
startBottom: number
|
||||
startRight: number
|
||||
startX: number
|
||||
startY: number
|
||||
}
|
||||
|
||||
interface ComposerPopoutGesturesOptions {
|
||||
composerRef: RefObject<HTMLFormElement | null>
|
||||
/** Layout zone this composer belongs to — the scope its float is stored under. */
|
||||
groupId: string
|
||||
onDock: () => void
|
||||
onPopOut: () => void
|
||||
poppedOut: boolean
|
||||
position: PopoutPosition
|
||||
}
|
||||
|
||||
function gestureTargetOk(target: EventTarget | null) {
|
||||
if (!(target instanceof Element)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !target.closest(
|
||||
'button, a, input, textarea, select, [contenteditable]:not([contenteditable="false"]), [data-slot="composer-rich-input"], [role="menuitem"], [data-radix-popper-content-wrapper]'
|
||||
)
|
||||
}
|
||||
|
||||
/** Docked composer only peels from its exposed grab ring. Pointer events from
|
||||
* the editor and composer surface bubble through the root too, but they belong
|
||||
* to text selection and controls, never to the pop-out gesture. */
|
||||
function isDockDragPlatform(target: EventTarget | null) {
|
||||
return target instanceof Element && Boolean(target.closest('[data-slot="composer-drag-region"]'))
|
||||
}
|
||||
|
||||
/** Floating composer's 5px outer frame — grab here to drag without long-press. */
|
||||
function isFloatDragPlatform(target: EventTarget | null) {
|
||||
if (!(target instanceof Element)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!target.closest('[data-slot="composer-root"][data-popped-out]')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (target.closest('[data-slot="composer-surface"], [data-slot="composer-rich-input"]')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return gestureTargetOk(target)
|
||||
}
|
||||
|
||||
/** 0 (far) → 1 (inside the dock zone). Drives both the dock glow and the
|
||||
* release-to-dock test (which fires at proximity 1).
|
||||
*
|
||||
* Measured against THIS surface's area, not the window: the dock target is the
|
||||
* docked composer, which sits at the bottom-center of its own chat surface. In
|
||||
* a split (or any layout where the chat isn't the full window) the viewport's
|
||||
* bottom-center is somewhere else entirely, so dragging onto the real dock
|
||||
* never registered. */
|
||||
function dockProximityOf(rect: DOMRect, area?: PopoutBounds) {
|
||||
const a = area ?? { bottom: window.innerHeight, left: 0, right: window.innerWidth, top: 0 }
|
||||
const horizontalDist = Math.abs(rect.left + rect.width / 2 - (a.left + a.right) / 2)
|
||||
const verticalGap = a.bottom - DOCK_ZONE_BOTTOM_PX - rect.bottom
|
||||
|
||||
const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX)
|
||||
|
||||
const h =
|
||||
horizontalDist <= DOCK_ZONE_CENTER_TOLERANCE_PX
|
||||
? 1
|
||||
: Math.max(0, 1 - (horizontalDist - DOCK_ZONE_CENTER_TOLERANCE_PX) / DOCK_HORIZONTAL_FALLOFF_PX)
|
||||
|
||||
return v * h
|
||||
}
|
||||
|
||||
const clampOffset = (value: number, max: number) => Math.min(Math.max(0, value), max)
|
||||
|
||||
/** Fixed-position composer uses bottom/right insets; keep the grab point under the pointer. */
|
||||
function popoutPositionUnderPointer(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
grabX: number,
|
||||
grabY: number,
|
||||
boxWidth: number,
|
||||
boxHeight: number
|
||||
): PopoutPosition {
|
||||
return {
|
||||
bottom: window.innerHeight - clientY + grabY - boxHeight,
|
||||
right: window.innerWidth - clientX + grabX - boxWidth
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesture pop-out / dock for the composer — fully gestural, no hold-to-toggle.
|
||||
*
|
||||
* Docked: drag the composer upward (off the dock) to peel it out into a float,
|
||||
* then keep dragging in the same motion.
|
||||
* Floating: drag the 5px frame to move instantly, or long-press the body then
|
||||
* drag; release over the bottom-center dock band to snap back in.
|
||||
*/
|
||||
export function useComposerPopoutGestures({
|
||||
composerRef,
|
||||
groupId,
|
||||
onDock,
|
||||
onPopOut,
|
||||
poppedOut,
|
||||
position
|
||||
}: ComposerPopoutGesturesOptions) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [dockProximity, setDockProximity] = useState(0)
|
||||
|
||||
const stateRef = useRef<PressState | null>(null)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const liveRef = useRef(position)
|
||||
liveRef.current = position
|
||||
|
||||
const onPopOutRef = useRef(onPopOut)
|
||||
onPopOutRef.current = onPopOut
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetGesture = useCallback(() => {
|
||||
clearTimer()
|
||||
stateRef.current = null
|
||||
setDragging(false)
|
||||
setDockProximity(0)
|
||||
}, [clearTimer])
|
||||
|
||||
const beginFloatDrag = useCallback(
|
||||
(state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => {
|
||||
clearTimer()
|
||||
|
||||
const clamped = setComposerPopoutPosition(groupId, next, {
|
||||
area: readPopoutBounds(composerRef.current),
|
||||
size
|
||||
})
|
||||
|
||||
liveRef.current = clamped
|
||||
|
||||
state.mode = 'float'
|
||||
state.armed = true
|
||||
state.startBottom = clamped.bottom
|
||||
state.startRight = clamped.right
|
||||
state.startX = clientX
|
||||
state.startY = clientY
|
||||
|
||||
setDragging(true)
|
||||
},
|
||||
[clearTimer, composerRef, groupId]
|
||||
)
|
||||
|
||||
const peelOffFromDock = useCallback(
|
||||
(state: PressState, clientX: number, clientY: number) => {
|
||||
const composer = composerRef.current
|
||||
|
||||
if (!composer) {
|
||||
return
|
||||
}
|
||||
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
||||
const rect = composer.getBoundingClientRect()
|
||||
const boxWidth = POPOUT_WIDTH_REM * rem
|
||||
const boxHeight = POPOUT_ESTIMATED_HEIGHT
|
||||
const grabX = clampOffset(state.startX - rect.left, boxWidth)
|
||||
const grabY = clampOffset(state.startY - rect.top, boxHeight)
|
||||
const next = popoutPositionUnderPointer(clientX, clientY, grabX, grabY, boxWidth, boxHeight)
|
||||
|
||||
beginFloatDrag(state, clientX, clientY, next, { height: boxHeight, width: boxWidth })
|
||||
onPopOutRef.current()
|
||||
},
|
||||
[beginFloatDrag, composerRef]
|
||||
)
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0 || !gestureTargetOk(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Floating: grabbing the 5px platform drags immediately.
|
||||
if (poppedOut && isFloatDragPlatform(event.target)) {
|
||||
stateRef.current = {
|
||||
armed: true,
|
||||
mode: 'float',
|
||||
pointerId: event.pointerId,
|
||||
startBottom: liveRef.current.bottom,
|
||||
startRight: liveRef.current.right,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY
|
||||
}
|
||||
setDragging(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!poppedOut && !isDockDragPlatform(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
stateRef.current = {
|
||||
armed: false,
|
||||
mode: poppedOut ? 'float' : 'dock',
|
||||
pointerId: event.pointerId,
|
||||
startBottom: liveRef.current.bottom,
|
||||
startRight: liveRef.current.right,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY
|
||||
}
|
||||
|
||||
clearTimer()
|
||||
|
||||
// Docked has NO timer — pop-out is purely the upward peel gesture (handled
|
||||
// in pointermove). Floating arms a long-press to drag the body.
|
||||
if (poppedOut) {
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
const state = stateRef.current
|
||||
|
||||
if (!state || state.armed) {
|
||||
return
|
||||
}
|
||||
|
||||
state.armed = true
|
||||
setDragging(true)
|
||||
}, LONG_PRESS_MS)
|
||||
}
|
||||
},
|
||||
[clearTimer, poppedOut]
|
||||
)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
// Coalesce drag updates to one per frame — pointermove can fire several times
|
||||
// between paints on high-Hz mice, and each update re-renders + clamps.
|
||||
let raf: number | null = null
|
||||
let pending: { x: number; y: number } | null = null
|
||||
|
||||
const cancelRaf = () => {
|
||||
if (raf !== null) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
raf = null
|
||||
const state = stateRef.current
|
||||
|
||||
if (!state?.armed || state.mode !== 'float' || !pending) {
|
||||
return
|
||||
}
|
||||
|
||||
const composer = composerRef.current
|
||||
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
|
||||
const area = readPopoutBounds(composer)
|
||||
|
||||
liveRef.current = setComposerPopoutPosition(
|
||||
groupId,
|
||||
{
|
||||
bottom: state.startBottom - (pending.y - state.startY),
|
||||
right: state.startRight - (pending.x - state.startX)
|
||||
},
|
||||
{ area, size }
|
||||
)
|
||||
|
||||
if (composer) {
|
||||
setDockProximity(dockProximityOf(composer.getBoundingClientRect(), area))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = (event: PointerEvent) => {
|
||||
const state = stateRef.current
|
||||
|
||||
if (!state || event.pointerId !== state.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-arm: cheap threshold checks run inline (no per-frame work yet).
|
||||
if (!state.armed) {
|
||||
const deltaX = event.clientX - state.startX
|
||||
const deltaY = event.clientY - state.startY
|
||||
|
||||
if (state.mode === 'dock') {
|
||||
// Peel off only on a clear upward drag — not a sideways/down wiggle.
|
||||
if (-deltaY > PEEL_OUT_PX && -deltaY > Math.abs(deltaX)) {
|
||||
peelOffFromDock(state, event.clientX, event.clientY)
|
||||
} else if (Math.abs(deltaX) > PEEL_OUT_PX || deltaY > LONG_PRESS_MOVE_TOLERANCE) {
|
||||
resetGesture()
|
||||
}
|
||||
} else if (Math.abs(deltaX) > LONG_PRESS_MOVE_TOLERANCE || Math.abs(deltaY) > LONG_PRESS_MOVE_TOLERANCE) {
|
||||
// Float body long-press pending: movement cancels the hold.
|
||||
resetGesture()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (state.mode !== 'float') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
pending = { x: event.clientX, y: event.clientY }
|
||||
raf ??= requestAnimationFrame(flush)
|
||||
}
|
||||
|
||||
const handleUp = (event: PointerEvent) => {
|
||||
const state = stateRef.current
|
||||
|
||||
if (!state || event.pointerId !== state.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
cancelRaf()
|
||||
|
||||
if (state.armed && state.mode === 'float') {
|
||||
const composer = composerRef.current
|
||||
const rect = composer?.getBoundingClientRect()
|
||||
const area = readPopoutBounds(composer)
|
||||
|
||||
if (rect && dockProximityOf(rect, area) >= 1) {
|
||||
onDock()
|
||||
} else {
|
||||
// Persist the resting position once, on release — never per move.
|
||||
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
|
||||
setComposerPopoutPosition(groupId, liveRef.current, { area, persist: true, size })
|
||||
}
|
||||
}
|
||||
|
||||
resetGesture()
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleMove)
|
||||
window.addEventListener('pointerup', handleUp)
|
||||
window.addEventListener('pointercancel', handleUp)
|
||||
|
||||
return () => {
|
||||
cancelRaf()
|
||||
window.removeEventListener('pointermove', handleMove)
|
||||
window.removeEventListener('pointerup', handleUp)
|
||||
window.removeEventListener('pointercancel', handleUp)
|
||||
}
|
||||
}, [composerRef, groupId, onDock, peelOffFromDock, resetGesture])
|
||||
|
||||
useEffect(() => clearTimer, [clearTimer])
|
||||
|
||||
return { dockProximity, dragging, onPointerDown }
|
||||
}
|
||||
Reference in New Issue
Block a user