diff --git a/packages/v4/DESIGN.md b/packages/v4/DESIGN.md index f784e07f0..9e8029638 100644 --- a/packages/v4/DESIGN.md +++ b/packages/v4/DESIGN.md @@ -735,7 +735,7 @@ A service is a shared source of props components subscribe to: `ticked`, `scroll It also exposed a defect of its own, which had been invisible while nothing read a run's first props: `deltaX`/`deltaY` were measured against the position the _previous_ run ended at, so a service restarted after the page had moved announced a scroll nobody performed — 100 px of it, in the test that now guards it. A run's first props carry no movement. -- **Scoped to a target.** `useScroll(target?)` takes an element or the window, `useResize(target?)`, `useScrollProgress(target, options?)` and `useInView(target, init?)` take an element, `useMutation(target, init?)` takes any node, and `useDrag(el)` takes an `HTMLElement` or an `SVGElement`; `useWindowScroll()` and `useWindowSize()` name the default cases, the split VueUse, solid-primitives, react-use and runed all make. `useRaf()`, `usePointer()` and `useBreakpoint()` have nothing to scope — the frame is the clock, the pointer is read from the window, and a media query answers about the viewport. `useScroll(document.documentElement)` is the window service, because the document scroller dispatches its events at the document. +- **Scoped to a target.** `useScroll(target?)` takes an element or the window, `useResize(target?)`, `useScrollProgress(target, options?)` and `useInView(target, init?)` take an element, `useMutation(target, init?)` takes any node, and `useDrag(el)` takes an `HTMLElement` or an `SVGElement`; `useWindowScroll()` and `useWindowSize()` name the default cases, the split VueUse, solid-primitives, react-use and runed all make. `usePointer(target?)` takes an element too, and answers about the viewport without one. `useRaf()` and `useBreakpoint()` have nothing to scope — the frame is the clock, and a media query answers about the viewport. `useScroll(document.documentElement)` is the window service, because the document scroller dispatches its events at the document. - **One instance per target and service options,** keyed in a `WeakMap` by `perTarget()`. This is lifecycle bookkeeping rather than throughput: reference counting only means something against a target, so the last subscriber of one element must release that element's observer and leave the others running. `useDrag()` includes its axis, inertia, damping and threshold choices; `useInView()` includes every `IntersectionObserverInit` field in the key and gives object roots stable weak identities; `useScrollProgress()` includes its resolved offset. Sharing one observer across targets was measured indifferent — the widespread claim traces to a single 2017 measurement, and 500 idle observers now cost ~0.02 ms/frame in total (`service.bench.ts`) — so nothing tries to group them. - **Bound per mount cycle, by a mixin.** `withRaf`/`withScroll`/`withResize`/`withScrollProgress`/`withPointer`/`withDrag`/`withInView`/`withMutation` override `mounted()`, subscribe the component's `ticked`/`scrolled`/`resized`/`scrolledInView`/`moved`/`dragged`/`intersected`/`mutated` method, and hand the unsubscribe back as a cleanup — so `$destroy()` releases it and a remount subscribes again, with `Base` knowing nothing about services. The mixin is the primitive because it needs no build step; `@withScroll()` is the decorator sugar over it, and both are tree-shakeable: an unimported service cannot make a hook silently do nothing. `withInView` observes a component that is already mounted; it does not replace the `visible` or `in-view` mount strategy. `withScrollProgress` keeps the useful v3 `scrolledInView` hook but not the old decorator's damping or mount control. Its first raw measurement is immediate by default, and a render returned by the hook goes through the instance `$write()` lane. - **One method name per mixin, and it is the service's own.** A hook is sugar for the default target; `target` is the only option. There is no `hook` option: two layers with the same name collapsed into one subscription with no warning, a custom name lost the hook's props typing entirely, and renaming one compiled, shipped and silently stopped updating. Any other target is an explicit subscription in `mounted()`, where the returned unsubscribe is the cleanup: @@ -796,7 +796,11 @@ A service is a shared source of props components subscribe to: `ticked`, `scroll - **Extents are observed, not sampled once.** A scroll container's own box never grows with its content, and content growing announces itself with no `scroll` and no `resize`: `maxY` stayed at 400 for content that had gone from 500 to 5000 px. The scroll service therefore watches the scroller **and its element children** with a `ResizeObserver`, plus a `childList` `MutationObserver` to keep that set in sync — `1 + n` observed boxes per scroller, lazy and released with the last subscriber like everything else. - **Props are flat, one per axis, and nothing derivable is a field.** `ScrollProps` is `x`/`y`, `deltaX`/`deltaY`, `maxX`/`maxY`, `progressX`/`progressY`, `directionX`/`directionY`, `isScrolling`. The grouped objects (`last`, `delta`, `max`, `progress`, `direction`, `changed`) are gone, and so are the derivations v3 shipped as fields: `lastX` is `x - deltaX`, `changedX` is `deltaX !== 0`. `directionX`/`directionY` are `-1 | 0 | 1`, one signed value that **multiplies**, replacing `isUp`/`isRight`/`isDown`/`isLeft` — which also settles the collision between a `ScrollProps.isDown` meaning "scrolling down" and a `PointerProps.isDown` meaning "pressed". `PointerProps` and `DragProps` follow the same convention, which flattens `origin`, `distance` and `final`; drag drops `isGrabbing`/`hasInertia`/`target`, all readings of `mode`, and `DragMode` gains `idle` for what `props()` reports outside a gesture. A handler destructures what it uses — `scrolled({ deltaY, directionY })` — instead of reaching through a group. - **Every prop field is `readonly`, and the props object belongs to its service.** It is valid for the duration of the call that received it: a service may hand the same object to every subscriber and overwrite it on the next update, which is what the sampled sources do rather than allocate per frame. `{ ...props }` is how you keep one. Without `readonly`, `useScroll().subscribe((p) => { p.y = 999 })` compiled and corrupted every other subscriber on the page. What a callback may return is a type parameter too, so `RafRender` is enforced — `useRaf().subscribe(() => 42)` used to compile and run a stray return as a DOM mutation every frame. -- **What the simplification dropped.** `PointerService` is pointer-events-only and viewport-relative (v3 branched on `TouchEvent` and took a target element), and follows one `pointerId` at a time so a second finger cannot end a live gesture; `ResizeService` keeps `width`/`height`/`ratio`/`orientation` and drops `breakpoints`/`activeBreakpoints`; `DragService` drops `props.MODES` from the props and fixes the `dragTreshold` spelling. +- **The pointer is placed in a box — `usePointer(target)`.** v3 shipped element-relative coordinates as `withRelativePointer`, a decorator whose whole content was a target and the subtraction. v4 puts both in the service: `usePointer()` is the viewport singleton it always was, `usePointer(el)` is one lazy service per target, and `ElementPointerProps extends PointerProps` with `relativeX`/`relativeY` and `relativeProgressX`/`relativeProgressY` beside the viewport fields — a superset, so `x` never changes meaning with the way the service was obtained. The targeted service **subscribes to the singleton** rather than listening again, so one document listener set serves every target and the `pointerId` tracking is the same code. `withPointer` therefore defaults its target to `$el`, like every other targeted mixin: a component asking about the pointer nearly always asks in relation to itself, and the viewport fields are still in the same object. + + **The box is cached, because the read is the expensive half.** `getBoundingClientRect()` is a layout read and a mouse reports up to 1000 events a second. Measured in Chromium over 1000 reads: **1.7 µs** each against a clean layout and **31.6 µs** each when a write sits between them — the forced reflow, which is the realistic case since the effect being driven writes to the DOM. So the box is measured on demand and kept until a `scroll` (captured at the document, so every scroller counts), a `resize`, or the target's own `ResizeObserver` can have moved it: 1000 events cost **one** read instead of 1000, asserted by counting them in the spec. The layout box is deliberately the frame of reference — a transform the consumer applies from `moved()` does not invalidate it, so a hover effect cannot feed its own output back in. + +- **What the simplification dropped.** `PointerService` is pointer-events-only (v3 branched on `TouchEvent`), and follows one `pointerId` at a time so a second finger cannot end a live gesture; `ResizeService` keeps `width`/`height`/`ratio`/`orientation` and drops `breakpoints`/`activeBreakpoints`; `DragService` drops `props.MODES` from the props and fixes the `dragTreshold` spelling. - **Closed sets of strings are named, and the type is derived from the name.** `DRAG_MODES` is a module-level `as const` object, with `DragMode = (typeof DRAG_MODES)[keyof typeof DRAG_MODES]`. This partly reverses the line above, and the reversal is narrower than it looks: what v3 shipped was `props.MODES`, a copy of the set on **every emission**, which deserved to go. A module export is a different thing, and the original decision — "the `DragMode` union types it" — weighed only the TypeScript audience. The first-class audience here writes components in plain JavaScript with **no build step**, and a literal union gives them nothing: no completion, no typo protection, no way to discover the set at all. `DRAG_MODES.INERTIA` gives all three, the literals still type-check, and deriving the type from the object keeps one source of truth. This is the pattern for every closed set of strings in the framework, not just this one. - **Breakpoints are their own source — `useBreakpoint()`.** A media query answers about the viewport, so a `breakpoint` field of `ResizeProps` said nothing about the element that service was observing. It is backed by `matchMedia` `change` listeners, which emit on **crossings** rather than once per resize frame and are the only mechanism that reports a change of the reader's font size. `setBreakpoints()` replaces the named set — the values v3 ships are only the default — and re-emits at once instead of leaving a stale name until something unrelated resized. The matching `MediaQueryList` objects are built once instead of once per breakpoint per resize, which measured 5.2× slower. When `defineFeatures` lands it carries the set; this setter is what it will call. diff --git a/packages/v4/src/exports.spec.ts b/packages/v4/src/exports.spec.ts index b0b55837b..8bfa5e193 100644 --- a/packages/v4/src/exports.spec.ts +++ b/packages/v4/src/exports.spec.ts @@ -14,11 +14,13 @@ import { useDrag, useInView, useMutation, + usePointer, useScrollProgress, watchAttributes, withDrag, withInView, withMutation, + withPointer, withScrollProgress, type DefineManifestOptions, type DomMutation, @@ -27,6 +29,7 @@ import { type DragMixinOptions, type DragOptions, type DragProps, + type ElementPointerProps, type InViewHook, type InViewMixinOptions, type ExtendableDetail, @@ -35,6 +38,9 @@ import { type MutationHook, type MutationMixinOptions, type MutationProps, + type PointerHook, + type PointerMixinOptions, + type PointerProps, type AttributeChange, type AttributeWatcher, type ContextCallback, @@ -64,6 +70,9 @@ import useInViewFromSubpath, { import useMutationFromSubpath, { useMutation as namedUseMutationFromSubpath, } from '@studiometa/js-toolkit-v4/useMutation'; +import usePointerFromSubpath, { + usePointer as namedUsePointerFromSubpath, +} from '@studiometa/js-toolkit-v4/usePointer'; import useScrollProgressSubpath from '@studiometa/js-toolkit-v4/useScrollProgress'; import watchAttributesFromSubpath, { watchAttributes as namedWatchAttributesFromSubpath, @@ -83,6 +92,9 @@ import withInViewFromSubpath, { import withMutationFromSubpath, { withMutation as namedWithMutationFromSubpath, } from '@studiometa/js-toolkit-v4/withMutation'; +import withPointerFromSubpath, { + withPointer as namedWithPointerFromSubpath, +} from '@studiometa/js-toolkit-v4/withPointer'; import withScrollProgressSubpath from '@studiometa/js-toolkit-v4/withScrollProgress'; function toolkitDiagnosticDetailTypeAssertions(detail: ToolkitDiagnosticDetail): void { @@ -248,6 +260,25 @@ describe('the package entry points', () => { expectTypeOf().toMatchTypeOf(); }); + it('serves usePointer and withPointer from the root and their symbol subpaths', () => { + expect(usePointerFromSubpath).toBe(usePointer); + expect(namedUsePointerFromSubpath).toBe(usePointer); + expect(withPointerFromSubpath).toBe(withPointer); + expect(namedWithPointerFromSubpath).toBe(withPointer); + // The viewport pointer and the element-scoped one are one function. + expectTypeOf(usePointer()).toEqualTypeOf>(); + expectTypeOf(usePointer(document.documentElement)).toEqualTypeOf< + Service + >(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf<{ + moved?: (props: ElementPointerProps) => void; + }>(); + expectTypeOf().toMatchTypeOf<{ + target?: (instance: Base) => Element; + }>(); + }); + it('exports manifest generation from the root and symbol subpaths', () => { expect(defineManifestFromSubpath).toBe(defineManifest); expect(fromMetaGlobFromSubpath).toBe(fromMetaGlob); diff --git a/packages/v4/src/index.ts b/packages/v4/src/index.ts index 847a3303a..6ccd6e85b 100644 --- a/packages/v4/src/index.ts +++ b/packages/v4/src/index.ts @@ -122,6 +122,7 @@ export { export { usePointer, withPointer, + type ElementPointerProps, type PointerHook, type PointerMixinOptions, type PointerProps, diff --git a/packages/v4/src/services/pointer.spec.ts b/packages/v4/src/services/pointer.spec.ts index 5d2673e0f..ec848d266 100644 --- a/packages/v4/src/services/pointer.spec.ts +++ b/packages/v4/src/services/pointer.spec.ts @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vitest'; -import { usePointer, type PointerProps } from './pointer.js'; +import { afterEach, describe, expect, expectTypeOf, it } from 'vitest'; +import { Base } from '../Base.js'; +import { + usePointer, + withPointer, + type ElementPointerProps, + type PointerHook, + type PointerMixinOptions, + type PointerProps, +} from './pointer.js'; +import type { Service } from './service.js'; +import type { Toggle } from './toggle.js'; function snapshot(props: PointerProps) { return { ...props }; @@ -9,6 +19,70 @@ function move(x: number, y: number): void { document.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y })); } +/** A box at a known place in the viewport, whichever way the page is scrolled. */ +function fixedBox(left: number, top: number, width: number, height: number): HTMLElement { + const el = document.createElement('div'); + el.style.cssText = `position:fixed;left:${left}px;top:${top}px;width:${width}px;height:${height}px`; + document.body.append(el); + return el; +} + +/** + * Count the layout reads one element serves, without touching the prototype. + * `box()` measures without counting, so a test can check geometry itself. + */ +function countReads(el: Element) { + const box = el.getBoundingClientRect.bind(el); + let reads = 0; + el.getBoundingClientRect = () => { + reads += 1; + return box(); + }; + return { box, reads: () => reads }; +} + +/** A `ResizeObserver` delivers after the frame's callbacks, so two frames is the safe wait. */ +async function frames(count = 2): Promise { + for (let index = 0; index < count; index += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } +} + +async function scrollWindowTo(y: number): Promise { + const scrolled = new Promise((resolve) => + window.addEventListener('scroll', () => resolve(), { once: true }), + ); + window.scrollTo(0, y); + await scrolled; +} + +function typeAssertions(instance: Follower): void { + expectTypeOf(usePointer()).toEqualTypeOf>(); + expectTypeOf(usePointer(document.body)).toEqualTypeOf>(); + expectTypeOf().toMatchTypeOf<{ + moved?: (props: ElementPointerProps) => void; + }>(); + expectTypeOf(instance.$services.moved).toEqualTypeOf(); + // @ts-expect-error service props belong to the service + usePointer(document.body).props().relativeX = 1; +} +void typeAssertions; + +const mixinOptionsTypeAssertions: PointerMixinOptions = { + target: (instance) => instance.$el, + manual: true, + immediate: false, +}; +void mixinOptionsTypeAssertions; + +class Follower extends withPointer(Base) { + seen: ElementPointerProps[] = []; + + moved(props: ElementPointerProps): void { + this.seen.push({ ...props }); + } +} + describe('usePointer', () => { it('starts centered, so progress means something before the first move', () => { const props = usePointer().props(); @@ -150,3 +224,204 @@ describe('usePointer', () => { expect(usePointer().props().event).toBeNull(); }); }); + +describe('usePointer(target)', () => { + afterEach(async () => { + window.scrollTo(0, 0); + document.body.innerHTML = ''; + await frames(1); + }); + + it('places the pointer inside a positioned element', () => { + const el = fixedBox(100, 50, 200, 100); + const seen: ElementPointerProps[] = []; + const unsubscribe = usePointer(el).subscribe((props) => seen.push({ ...props })); + + move(150, 80); + expect(seen.at(-1)?.x).toBe(150); + expect(seen.at(-1)?.relativeX).toBe(50); + expect(seen.at(-1)?.relativeY).toBe(30); + expect(seen.at(-1)?.relativeProgressX).toBeCloseTo(0.25, 5); + expect(seen.at(-1)?.relativeProgressY).toBeCloseTo(0.3, 5); + + // Outside the box the position keeps its sign instead of clamping. + move(80, 80); + expect(seen.at(-1)?.relativeX).toBe(-20); + expect(seen.at(-1)?.relativeProgressX).toBeCloseTo(-0.1, 5); + + unsubscribe(); + }); + + it('answers a cold read from the shared pointer, placed in the box', () => { + const el = fixedBox(100, 50, 200, 100); + const props = usePointer(el).props(); + + expect(props.event).toBeNull(); + expect(props.relativeX).toBe(usePointer().props().x - 100); + expect(props.relativeY).toBe(usePointer().props().y - 50); + }); + + it('delivers immediately once the shared pointer has been seen', () => { + const el = fixedBox(100, 50, 200, 100); + const cold: ElementPointerProps[] = []; + const first = usePointer(el).subscribe((props) => cold.push({ ...props }), { + immediate: true, + }); + expect(cold).toEqual([]); + + move(150, 80); + expect(cold).toHaveLength(1); + + const warm: ElementPointerProps[] = []; + const second = usePointer(el).subscribe((props) => warm.push({ ...props }), { + immediate: true, + }); + expect(warm.at(-1)?.relativeX).toBe(50); + expect(cold).toHaveLength(1); + + first(); + second(); + }); + + it('reads the target box once for a burst of events, not once per event', () => { + const el = fixedBox(100, 50, 200, 100); + const { reads } = countReads(el); + const unsubscribe = usePointer(el).subscribe(() => {}); + + expect(reads()).toBe(0); + for (let index = 0; index < 100; index += 1) { + move(100 + index, 50 + index); + } + expect(reads()).toBe(1); + + unsubscribe(); + }); + + it('measures again when the page scrolled under the pointer', async () => { + const el = document.createElement('div'); + el.style.cssText = 'position:absolute;left:100px;top:800px;width:200px;height:100px'; + const spacer = document.createElement('div'); + spacer.style.cssText = 'height:3000px'; + document.body.append(el, spacer); + + const seen: ElementPointerProps[] = []; + const { box, reads } = countReads(el); + const unsubscribe = usePointer(el).subscribe((props) => seen.push({ ...props })); + + const before = box(); + move(150, before.top + 20); + expect(seen.at(-1)?.relativeY).toBe(20); + + await scrollWindowTo(300); + const after = box(); + expect(after.top).toBe(before.top - 300); + + move(150, after.top + 20); + expect(seen.at(-1)?.relativeY).toBe(20); + // One read for the first box, one for the box the scroll moved. + expect(reads()).toBe(2); + + unsubscribe(); + }); + + it('measures again when the target resized under the pointer', async () => { + const el = fixedBox(100, 50, 200, 100); + const seen: ElementPointerProps[] = []; + const unsubscribe = usePointer(el).subscribe((props) => seen.push({ ...props })); + + move(150, 80); + expect(seen.at(-1)?.relativeProgressX).toBeCloseTo(0.25, 5); + + el.style.width = '400px'; + await frames(); + + move(150, 80); + expect(seen.at(-1)?.relativeProgressX).toBeCloseTo(0.125, 5); + + unsubscribe(); + }); + + it('shares one service per target and releases the shared pointer with its last subscriber', () => { + const el = fixedBox(100, 50, 200, 100); + const other = fixedBox(0, 0, 10, 10); + expect(usePointer(el)).toBe(usePointer(el)); + expect(usePointer(el)).not.toBe(usePointer(other)); + + let first = 0; + let second = 0; + const unsubscribeFirst = usePointer(el).subscribe(() => { + first += 1; + }); + const unsubscribeSecond = usePointer(el).subscribe(() => { + second += 1; + }); + + move(150, 80); + expect([first, second]).toEqual([1, 1]); + + unsubscribeFirst(); + move(160, 80); + expect([first, second]).toEqual([1, 2]); + + unsubscribeSecond(); + move(170, 80); + expect([first, second]).toEqual([1, 2]); + // The shared viewport pointer is reference-counted through it. + expect(usePointer().props().event).toBeNull(); + }); +}); + +describe('withPointer', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('reads the pointer in the component root box', () => { + const el = fixedBox(100, 50, 200, 100); + const instance = new Follower(el).$mount(); + + move(150, 80); + expect(instance.seen.at(-1)?.x).toBe(150); + expect(instance.seen.at(-1)?.relativeX).toBe(50); + expect(instance.seen.at(-1)?.relativeY).toBe(30); + + instance.$terminate(); + move(160, 80); + expect(instance.seen).toHaveLength(1); + }); + + it('shares one service and one layout read between two components on one target', () => { + const box = fixedBox(100, 50, 200, 100); + const { reads } = countReads(box); + const seen: Array<[string, number]> = []; + + class Watcher extends withPointer(Base, { target: () => box }) { + moved(props: ElementPointerProps): void { + seen.push([this.$el.id, props.relativeX]); + } + } + + const hostA = document.createElement('div'); + hostA.id = 'a'; + const hostB = document.createElement('div'); + hostB.id = 'b'; + document.body.append(hostA, hostB); + const a = new Watcher(hostA).$mount(); + const b = new Watcher(hostB).$mount(); + + move(150, 80); + expect(seen).toEqual([ + ['a', 50], + ['b', 50], + ]); + expect(reads()).toBe(1); + + a.$terminate(); + move(160, 80); + expect(seen.at(-1)).toEqual(['b', 60]); + + b.$terminate(); + move(170, 80); + expect(seen).toHaveLength(3); + }); +}); diff --git a/packages/v4/src/services/pointer.ts b/packages/v4/src/services/pointer.ts index d88e5eda6..bfbac1790 100644 --- a/packages/v4/src/services/pointer.ts +++ b/packages/v4/src/services/pointer.ts @@ -1,6 +1,6 @@ import { getSharedRuntimeSlot } from '../shared-runtime.js'; import { createServiceMixin, type ServiceHandles, type ServiceMixinOptions } from './mixin.js'; -import { createService, type MutableProps, type Service } from './service.js'; +import { createService, perTarget, type MutableProps, type Service } from './service.js'; /** Pointer state for both viewport axes. */ export interface PointerProps { @@ -21,6 +21,16 @@ export interface PointerProps { readonly progressY: number; } +/** Pointer state placed in a target element's box, beside the viewport one. */ +export interface ElementPointerProps extends PointerProps { + /** Position inside the target's box, from its top-left corner. */ + readonly relativeX: number; + readonly relativeY: number; + /** Position over the target's box, from `0` to `1` inside it and past that range outside. */ + readonly relativeProgressX: number; + readonly relativeProgressY: number; +} + /** Pointer event types observed by the service. */ const EVENTS = ['pointermove', 'pointerdown', 'pointerup', 'pointercancel'] as const; @@ -106,29 +116,140 @@ function createPointerService(): Service { }); } -const pointerState = /* @__PURE__ */ getSharedRuntimeSlot<{ +/** + * Place the shared pointer in a target element's box. + * + * The maths belongs to the service rather than to each consumer, and so does + * the layout read it needs: `getBoundingClientRect()` on every pointer event + * would be one forced measurement per event, and a mouse reports up to 1000 of + * them a second. + */ +function createElementPointerService(target: Element): Service { + const pointer = usePointer(); + const props: MutableProps = { + ...pointer.props(), + relativeX: 0, + relativeY: 0, + relativeProgressX: 0, + relativeProgressY: 0, + }; + + /** + * The target's box, kept between the events that can move it. + * + * It is held only while the service runs, because that is when the listeners + * which drop it are attached. The layout box is deliberately the frame of + * reference: a transform the consumer applies from `moved()` does not + * invalidate it, so a hover effect cannot feed its own output back in. + */ + let box: DOMRect | null = null; + let isRunning = false; + + function measure(): DOMRect { + if (box) { + return box; + } + const rect = target.getBoundingClientRect(); + if (isRunning) { + box = rect; + } + return rect; + } + + function sync(source: PointerProps): ElementPointerProps { + props.event = source.event; + props.isDown = source.isDown; + props.x = source.x; + props.y = source.y; + props.deltaX = source.deltaX; + props.deltaY = source.deltaY; + props.maxX = source.maxX; + props.maxY = source.maxY; + props.progressX = source.progressX; + props.progressY = source.progressY; + + const rect = measure(); + props.relativeX = source.x - rect.left; + props.relativeY = source.y - rect.top; + // A collapsed box has no inside to be at a fraction of. + props.relativeProgressX = rect.width === 0 ? 0 : props.relativeX / rect.width; + props.relativeProgressY = rect.height === 0 ? 0 : props.relativeY / rect.height; + + return props; + } + + return createService({ + // A cold read is the shared pointer's own answer, placed in the box. + props: () => sync(pointer.props()), + hasProps: () => pointer.props().event !== null, + start(emit) { + isRunning = true; + const invalidate = () => { + box = null; + }; + + // Capture reaches every scroller, the document included. + document.addEventListener('scroll', invalidate, { passive: true, capture: true }); + window.addEventListener('resize', invalidate, { passive: true }); + const observer = new ResizeObserver(invalidate); + observer.observe(target); + const unsubscribe = pointer.subscribe((source) => emit(sync(source))); + + return () => { + isRunning = false; + box = null; + unsubscribe(); + observer.disconnect(); + document.removeEventListener('scroll', invalidate, { capture: true }); + window.removeEventListener('resize', invalidate); + // Release the event so its target subtree can be collected. + props.event = null; + }; + }, + }); +} + +interface PointerRuntimeState { service: Service | undefined; -}>('service:pointer', 1, () => ({ service: undefined })); + readonly services: (target: Element) => Service; +} + +const pointerState = /* @__PURE__ */ getSharedRuntimeSlot( + 'service:pointer', + 2, + () => ({ + service: undefined, + services: perTarget(createElementPointerService), + }), +); /** Use the viewport-relative pointer service. */ -export function usePointer(): Service { +export function usePointer(): Service; +/** Use one lazy service per target, adding the pointer's position inside its box. */ +export function usePointer(target: Element): Service; +export function usePointer(target?: Element): Service | Service { + if (target) { + return pointerState.services(target); + } pointerState.service ??= createPointerService(); return pointerState.service; } /** The method `withPointer()` subscribes for the component. */ export interface PointerHook { - moved?(props: PointerProps): void; + moved?(props: ElementPointerProps): void; } -export type PointerMixinOptions = ServiceMixinOptions; +export type PointerMixinOptions = ServiceMixinOptions; -/** Subscribe `moved()` to the viewport pointer for each mount cycle. */ +/** + * Subscribe `moved()` to the pointer for each mount cycle. The root element is the default target, so a component reads the pointer both in the viewport and inside its own box. + */ export const withPointer = /* @__PURE__ */ createServiceMixin< PointerHook & ServiceHandles<'moved'>, - void + Element >({ hook: 'moved', - target: () => undefined, - use: () => usePointer(), + target: (instance) => instance.$el, + use: (target) => usePointer(target), });