Skip to content

Commit

Permalink
feat #94: text selection supporting dragging outside
Browse files Browse the repository at this point in the history
  • Loading branch information
bbohlender committed Nov 3, 2024
1 parent 4b01398 commit 4de5898
Show file tree
Hide file tree
Showing 9 changed files with 125 additions and 179 deletions.
3 changes: 3 additions & 0 deletions examples/auth/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Defaults, colors } from '@/theme.js'
import { Button } from '@/button.js'
import { UserAuthForm } from './components/user-auth-form.js'
import { Perf } from 'r3f-perf'
import { noEvents, PointerEvents } from '@react-three/xr'

setPreferredColorScheme('light')

Expand All @@ -23,8 +24,10 @@ export default function App() {
camera={{ position: [0, 0, 18], fov: 35 }}
style={{ height: '100dvh', touchAction: 'none' }}
gl={{ localClippingEnabled: true }}
events={noEvents}
{...canvasInputProps}
>
<PointerEvents />
<Perf />
{/*<Root backgroundColor={0xffffff} sizeX={8.34} sizeY={5.58} pixelSize={0.01}>
<Defaults>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@
"typescript-json-schema": "^0.63.0",
"vite": "^5.0.12",
"vercel": "^34.1.8",
"@react-three/xr": "6.4.1"
"@react-three/xr": "6.4.3"
}
}
2 changes: 1 addition & 1 deletion packages/uikit/src/components/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ function createImageMesh(
if (rootObjectMatrixWorld != null) {
mesh.raycast = makeClippedCast(
mesh,
makePanelRaycast(rootObjectMatrixWorld, boundingSphere, globalMatrix, mesh),
makePanelRaycast(mesh.raycast.bind(mesh), rootObjectMatrixWorld, boundingSphere, globalMatrix, mesh),
root.object,
parentContext.clippingRect,
orderInfo,
Expand Down
49 changes: 29 additions & 20 deletions packages/uikit/src/components/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { Listeners, setupLayoutListeners, setupClippedListeners } from '../liste
import { Object3DRef, ParentContext } from '../context.js'
import { PanelGroupProperties, computedPanelGroupDependencies } from '../panel/instanced-panel-group.js'
import { createInteractionPanel } from '../panel/instanced-panel-mesh.js'
import { EventHandlers } from '../events.js'
import { EventHandlers, ThreeEvent } from '../events.js'
import { Vector2Tuple, Vector2, Vector3Tuple } from 'three'
import { CaretProperties, createCaret } from '../caret.js'
import { SelectionBoxes, SelectionProperties, createSelection } from '../selection.js'
Expand Down Expand Up @@ -342,35 +342,44 @@ export function computedSelectionHandlers(
if (disabled.value) {
return undefined
}
let startCharIndex: number | undefined
let dragState: { startCharIndex: number; pointerId: number } | undefined
const onPointerFinish = (e: ThreeEvent<PointerEvent>) => {
if (dragState == null || dragState.pointerId != e.pointerId) {
return
}
e.stopImmediatePropagation?.()
dragState = undefined
}
return {
onPointerDown: (e) => {
if (e.defaultPrevented || e.uv == null || instancedTextRef.current == null) {
if (dragState != null || e.defaultPrevented || e.uv == null || instancedTextRef.current == null) {
return
}
cancelBlur(e.nativeEvent)
e.stopPropagation?.()
const charIndex = uvToCharIndex(flexState, e.uv, instancedTextRef.current)
startCharIndex = charIndex

setTimeout(() => focus(charIndex, charIndex))
},
onPointerUp: (e) => {
startCharIndex = undefined
},
onPointerLeave: (e) => {
startCharIndex = undefined
e.stopImmediatePropagation?.()
if ('setPointerCapture' in e.object && typeof e.object.setPointerCapture === 'function') {
e.object.setPointerCapture(e.pointerId)
}
const startCharIndex = uvToCharIndex(flexState, e.uv, instancedTextRef.current)
dragState = {
pointerId: e.pointerId,
startCharIndex,
}
setTimeout(() => focus(startCharIndex, startCharIndex))
},
onPointerUp: onPointerFinish,
onPointerLeave: onPointerFinish,
onPointerCancel: onPointerFinish,
onPointerMove: (e) => {
if (startCharIndex == null || e.uv == null || instancedTextRef.current == null) {
if (dragState?.pointerId != e.pointerId || e.uv == null || instancedTextRef.current == null) {
return
}
e.stopPropagation?.()
e.stopImmediatePropagation?.()
const charIndex = uvToCharIndex(flexState, e.uv, instancedTextRef.current)

const start = Math.min(startCharIndex, charIndex)
const end = Math.max(startCharIndex, charIndex)
const direction = startCharIndex < charIndex ? 'forward' : 'backward'
const start = Math.min(dragState.startCharIndex, charIndex)
const end = Math.max(dragState.startCharIndex, charIndex)
const direction = dragState.startCharIndex < charIndex ? 'forward' : 'backward'

setTimeout(() => focus(start, end, direction))
},
Expand Down Expand Up @@ -480,6 +489,6 @@ function uvToCharIndex(
const [bTop, , , bLeft] = borderInset
const [pTop, , , pLeft] = paddingInset
const x = uv.x * width - bLeft - pLeft
const y = -uv.y * height + bTop + pTop
const y = (uv.y - 1) * height + bTop + pTop
return instancedText.getCharIndex(x, y)
}
1 change: 1 addition & 0 deletions packages/uikit/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type ThreeEvent<TSourceEvent> = Intersection & {
defaultPrevented?: boolean
stopped?: boolean
stopPropagation?: () => void
stopImmediatePropagation?: () => void
} & (TSourceEvent extends { pointerId: number } ? { pointerId: number } : {})

export type KeyToEvent<K extends keyof EventHandlers> = Parameters<Required<EventHandlers>[K]>[0]
Expand Down
2 changes: 1 addition & 1 deletion packages/uikit/src/panel/instanced-panel-mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function createInteractionPanel(
if (rootObject != null) {
panel.raycast = makeClippedCast(
panel,
makePanelRaycast(rootObject.matrixWorld, boundingSphere, globalMatrix, panel),
makePanelRaycast(panel.raycast.bind(panel), rootObject.matrixWorld, boundingSphere, globalMatrix, panel),
rootContext.object,
parentClippingRect,
orderInfo,
Expand Down
147 changes: 40 additions & 107 deletions packages/uikit/src/panel/interaction-panel-mesh.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
import { Intersection, Matrix4, Mesh, Object3D, Plane, Ray, Sphere, Vector2, Vector2Tuple, Vector3 } from 'three'
import { Intersection, Matrix4, Mesh, Object3D, Plane, Sphere, Vector2, Vector2Tuple, Vector3 } from 'three'
import { ClippingRect } from '../clipping.js'
import { effect, Signal } from '@preact/signals-core'
import { OrderInfo } from '../order.js'
import { Object3DRef, RootContext } from '../context.js'
import { Object3DRef } from '../context.js'
import { computeMatrixWorld, Initializers } from '../internals.js'
import { clamp } from 'three/src/math/MathUtils.js'

const planeHelper = new Plane()
const vectorHelper = new Vector3()

const sides: Array<Plane> = [
//left
new Plane().setFromNormalAndCoplanarPoint(new Vector3(1, 0, 0), new Vector3(-0.5, 0, 0)),
//right
new Plane().setFromNormalAndCoplanarPoint(new Vector3(-1, 0, 0), new Vector3(0.5, 0, 0)),
//bottom
new Plane().setFromNormalAndCoplanarPoint(new Vector3(0, 1, 0), new Vector3(0, -0.5, 0)),
//top
new Plane().setFromNormalAndCoplanarPoint(new Vector3(0, -1, 0), new Vector3(0, 0.5, 0)),
]

const distancesHelper = [0, 0, 0, 0]

export type AllowedPointerEventsType =
| 'all'
| ((poinerId: number, pointerType: string, pointerState: unknown) => boolean)
Expand All @@ -42,15 +30,8 @@ export type PointerEventsProperties = {
pointerEventsOrder?: number
}

const scaleHelper = new Vector3()
const matrixHelper = new Matrix4()

function isSingularMatrix(matrix: Matrix4) {
scaleHelper.setFromMatrixScale(matrix)
return scaleHelper.x === 0 || scaleHelper.y === 0 || scaleHelper.z === 0
}

const sphereHelper = new Sphere()
const matrixHelper = new Matrix4()

export function makePanelSpherecast(
rootObjectMatrixWorld: Matrix4,
Expand All @@ -60,41 +41,20 @@ export function makePanelSpherecast(
): Exclude<Mesh['spherecast'], undefined> {
return (sphere, intersects) => {
sphereHelper.copy(globalSphereWithLocalScale).applyMatrix4(rootObjectMatrixWorld)
if (!sphereHelper.intersectsSphere(sphere)) {
return
}
if (
isSingularMatrix(matrixHelper) ||
!computeMatrixWorld(matrixHelper, object.matrix, rootObjectMatrixWorld, globalMatrixSignal)
!sphereHelper.intersectsSphere(sphere) ||
!computeMatrixWorld(object.matrixWorld, object.matrix, rootObjectMatrixWorld, globalMatrixSignal)
) {
return
}
planeHelper.constant = 0
planeHelper.normal.set(0, 0, 1)
planeHelper.applyMatrix4(matrixHelper)
vectorHelper.copy(sphere.center).applyMatrix4(matrixHelper.copy(object.matrixWorld).invert())
vectorHelper.x = clamp(vectorHelper.x, -0.5, 0.5)
vectorHelper.x = clamp(vectorHelper.y, -0.5, 0.5)
vectorHelper.z = 0

planeHelper.projectPoint(sphere.center, vectorHelper)

if (vectorHelper.distanceToSquared(sphere.center) > sphere.radius * sphere.radius) {
return
}

for (let i = 0; i < 4; i++) {
const side = sides[i]
planeHelper.copy(side).applyMatrix4(matrixHelper)

let distance = planeHelper.distanceToPoint(vectorHelper)
if (distance < 0) {
if (Math.abs(distance) > sphere.radius) {
return
}
//clamp point
planeHelper.projectPoint(vectorHelper, vectorHelper)
distance = 0
}
distancesHelper[i] = distance
}
const uv = new Vector2(vectorHelper.x, vectorHelper.y)

vectorHelper.applyMatrix4(object.matrixWorld)
const distance = sphere.center.distanceTo(vectorHelper)

if (distance > sphere.radius) {
Expand All @@ -105,15 +65,36 @@ export function makePanelSpherecast(
distance,
object,
point: vectorHelper.clone(),
uv: new Vector2(
distancesHelper[0] / (distancesHelper[0] + distancesHelper[1]),
distancesHelper[3] / (distancesHelper[2] + distancesHelper[3]),
),
uv,
normal: new Vector3(0, 0, 1),
})
}
}

export function makePanelRaycast(
raycast: Mesh['raycast'],
rootObjectMatrixWorld: Matrix4,
globalSphereWithLocalScale: Sphere,
globalMatrixSignal: Signal<Matrix4 | undefined>,
object: Object3D,
): Mesh['raycast'] {
return (raycaster, intersects) => {
sphereHelper.copy(globalSphereWithLocalScale).applyMatrix4(rootObjectMatrixWorld)
if (
!raycaster.ray.intersectsSphere(sphereHelper) ||
!computeMatrixWorld(object.matrixWorld, object.matrix, rootObjectMatrixWorld, globalMatrixSignal)
) {
return
}

raycast(raycaster, intersects)
}
}

export function isInteractionPanel(object: Object3D) {
return 'isInteractionPanel' in object
}

export function computedBoundingSphere(
pixelSize: Signal<number>,
globalMatrixSignal: Signal<Matrix4 | undefined>,
Expand All @@ -129,63 +110,15 @@ export function computedBoundingSphere(
return
}
sphere.center.set(0, 0, 0)
sphere.radius = 0.5
const [w, h] = sizeValue
const maxDiameter = Math.sqrt(w * w + h * h)
sphere.radius = maxDiameter * 0.5 * pixelSize.value
sphere.applyMatrix4(globalMatrix)
sphere.radius *= Math.max(...sizeValue) * pixelSize.value
}),
)
return sphere
}

export function makePanelRaycast(
rootObjectMatrixWorld: Matrix4,
globalSphereWithLocalScale: Sphere,
globalMatrixSignal: Signal<Matrix4 | undefined>,
object: Object3D,
): Mesh['raycast'] {
return (raycaster, intersects) => {
sphereHelper.copy(globalSphereWithLocalScale).applyMatrix4(rootObjectMatrixWorld)
if (!raycaster.ray.intersectsSphere(sphereHelper)) {
return
}
if (
!computeMatrixWorld(matrixHelper, object.matrix, rootObjectMatrixWorld, globalMatrixSignal) ||
isSingularMatrix(matrixHelper)
) {
return
}
planeHelper.constant = 0
planeHelper.normal.set(0, 0, 1)
planeHelper.applyMatrix4(matrixHelper)
if (raycaster.ray.intersectPlane(planeHelper, vectorHelper) == null) {
return
}

for (let i = 0; i < 4; i++) {
const side = sides[i]
planeHelper.copy(side).applyMatrix4(matrixHelper)
if ((distancesHelper[i] = planeHelper.distanceToPoint(vectorHelper)) < 0) {
return
}
}

intersects.push({
distance: vectorHelper.distanceTo(raycaster.ray.origin),
object,
point: vectorHelper.clone(),
uv: new Vector2(
distancesHelper[0] / (distancesHelper[0] + distancesHelper[1]),
distancesHelper[3] / (distancesHelper[2] + distancesHelper[3]),
),
normal: new Vector3(0, 0, 1),
})
}
}

export function isInteractionPanel(object: Object3D) {
return 'isInteractionPanel' in object
}

/**
* clips the sphere / raycast
* also marks the mesh as a interaction panel
Expand Down
Loading

0 comments on commit 4de5898

Please sign in to comment.