diff --git a/packages/core/src/components.ts b/packages/core/src/components.ts deleted file mode 100644 index 843bcdb..0000000 --- a/packages/core/src/components.ts +++ /dev/null @@ -1,760 +0,0 @@ -import { - computed, - defineComponent, - h, - inject, - onBeforeUnmount, - onMounted, - onScopeDispose, - provide, - ref, - shallowReactive, - watch, - type PropType, - type InjectionKey, - type VNode, -} from "vue"; -import { - routeLocationKey, - RouterView, - type RouteLocationNormalizedLoaded, - type RouteLocationRaw, -} from "vue-router"; -import { nativeRouterKey } from "./runtime"; -import type { - NativeDirection, - NativeNavigationOptions, - NativeRouterRuntime, - NativeSourceRect, - NativeViewEntry, - NativeViewLifecycle, - NativeViewRole, -} from "./types"; - -const nativeViewLifecycleKey: InjectionKey = Symbol( - "native-view-lifecycle", -); - -export function useNativeRouter() { - const runtime = inject(nativeRouterKey); - if (!runtime) - throw new Error( - "Native Vue Router is not installed. Call app.use(nativeRouter).", - ); - return runtime; -} - -export function useNativeViewLifecycle() { - const lifecycle = inject(nativeViewLifecycleKey); - if (!lifecycle) - throw new Error( - "Native view lifecycle APIs must be used inside NativeRouterView.", - ); - return lifecycle; -} - -type NativeViewHook = () => void; - -function onNativeViewState( - source: Readonly<{ value: boolean }>, - entering: boolean, - hook: NativeViewHook, -) { - onMounted(() => { - if (entering && source.value) hook(); - }); - watch( - () => source.value, - (value, previous) => { - if (value === entering && previous !== entering) hook(); - }, - { flush: "sync" }, - ); -} - -export function onNativeViewActivate(hook: NativeViewHook) { - onNativeViewState(useNativeViewLifecycle().isActive, true, hook); -} - -export function onNativeViewDeactivate(hook: NativeViewHook) { - onNativeViewState(useNativeViewLifecycle().isActive, false, hook); -} - -export function onNativeViewShow(hook: NativeViewHook) { - onNativeViewState(useNativeViewLifecycle().isVisible, true, hook); -} - -export function onNativeViewHide(hook: NativeViewHook) { - onNativeViewState(useNativeViewLifecycle().isVisible, false, hook); -} - -export function onNativeViewEvict( - hook: (reason: NativeViewLifecycle["evictionReason"]["value"]) => void, -) { - const lifecycle = useNativeViewLifecycle(); - onBeforeUnmount(() => { - if (lifecycle.status.value === "evicted") - hook(lifecycle.evictionReason.value); - }); -} - -function useNativeViewEffect( - source: Readonly<{ value: boolean }>, - effect: () => void | (() => void), -) { - let cleanup: void | (() => void); - const stopEffect = () => { - cleanup?.(); - cleanup = undefined; - }; - const stopWatch = watch( - () => source.value, - (enabled) => { - stopEffect(); - if (enabled) cleanup = effect(); - }, - { immediate: true, flush: "sync" }, - ); - onScopeDispose(() => { - stopWatch(); - stopEffect(); - }); -} - -/** Runs an effect only while this route is the semantically active route. */ -export function useNativeViewActiveEffect(effect: () => void | (() => void)) { - useNativeViewEffect(useNativeViewLifecycle().isActive, effect); -} - -/** Runs an effect while this route is active or participating in a transition. */ -export function useNativeViewVisibleEffect(effect: () => void | (() => void)) { - useNativeViewEffect(useNativeViewLifecycle().isVisible, effect); -} - -const NativeRouteScope = defineComponent({ - name: "NativeRouteScope", - props: { - route: { - type: Object as PropType, - required: true, - }, - entryKey: { type: String, required: true }, - }, - setup(props, { slots }) { - const runtime = useNativeRouter(); - const scopedRoute = shallowReactive({ - ...props.route, - }) as RouteLocationNormalizedLoaded; - watch( - () => props.route, - (route) => Object.assign(scopedRoute, route), - { immediate: true }, - ); - provide(routeLocationKey, scopedRoute); - const entry = computed(() => - runtime.entries.value.find( - (candidate) => candidate.key === props.entryKey, - ), - ); - const role = computed(() => - entry.value ? interactiveRole(entry.value, runtime) : "inactive", - ); - provide(nativeViewLifecycleKey, { - key: props.entryKey, - route: computed(() => entry.value?.route ?? props.route), - status: computed(() => entry.value?.status ?? "evicted"), - role, - isActive: computed(() => runtime.activeKey.value === props.entryKey), - isVisible: computed(() => role.value !== "inactive"), - isPreview: computed( - () => runtime.transaction.value?.toKey === props.entryKey, - ), - isCached: computed(() => - Boolean(entry.value?.mounted && role.value === "inactive"), - ), - evictionReason: computed(() => entry.value?.evictionReason), - }); - return () => slots.default?.(); - }, -}); - -function interactiveRole( - entry: NativeViewEntry, - runtime: NativeRouterRuntime, -): NativeViewRole { - const transaction = runtime.transaction.value; - if (!transaction) - return entry.key === runtime.activeKey.value ? "active" : "inactive"; - if (entry.key === transaction.fromKey) return "from"; - if (entry.key === transaction.toKey) return "to"; - return "inactive"; -} - -export const NativeRouterView = defineComponent({ - name: "NativeRouterView", - setup(_, { slots }) { - const runtime = useNativeRouter(); - return () => { - const transaction = runtime.transaction.value; - const style = transaction - ? { "--native-progress": String(transaction.progress) } - : undefined; - const children = runtime.entries.value - .filter((entry) => entry.mounted) - .map((entry) => { - const role = interactiveRole(entry, runtime); - const definition = transaction - ? runtime.presentationFor(transaction.presentation) - : undefined; - const customStyle = - transaction && (role === "from" || role === "to") - ? definition?.layerStyle?.({ - progress: transaction.progress, - role, - direction: transaction.direction, - sourceRect: transaction.sourceRect, - }) - : undefined; - return h( - "section", - { - key: entry.key, - class: ["nvr-view", `nvr-view--${role}`], - style: customStyle, - "data-native-role": role, - "data-native-route": entry.route.fullPath, - "data-native-presentation": transaction?.presentation, - "data-native-direction": transaction?.direction, - inert: role === "inactive" ? "" : undefined, - "aria-hidden": role === "inactive" ? "true" : undefined, - }, - [ - h( - RouterView, - { route: entry.route }, - { - default: ({ - Component, - route, - }: { - Component: VNode | null; - route: RouteLocationNormalizedLoaded; - }) => { - if (slots.default) - return slots.default({ Component, route, entry }); - return Component - ? h( - NativeRouteScope, - { route, entryKey: entry.key }, - { default: () => Component }, - ) - : null; - }, - }, - ), - ], - ); - }); - return h( - "div", - { - class: [ - "nvr-router-view", - transaction && "nvr-router-view--interactive", - ], - style, - "data-native-presentation": transaction?.presentation, - "data-native-direction": transaction?.direction, - "data-native-transaction": transaction?.id, - "data-native-velocity": transaction - ? String(transaction.velocity) - : undefined, - }, - children, - ); - }; - }, -}); - -export const NativeLink = defineComponent({ - name: "NativeLink", - props: { - to: { - type: [String, Object] as PropType, - required: true, - }, - replace: Boolean, - presentation: String, - }, - setup(props, { slots, attrs }) { - const runtime = useNativeRouter(); - const href = computed(() => runtime.router.resolve(props.to).href); - const activate = (event: MouseEvent) => { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) - return; - event.preventDefault(); - void (props.replace - ? runtime.replace(props.to, { presentation: props.presentation }) - : runtime.push(props.to, { presentation: props.presentation })); - }; - return () => - h( - "a", - { ...attrs, href: href.value, onClick: activate }, - slots.default?.(), - ); - }, -}); - -function shouldIgnoreGesture(target: EventTarget | null) { - if (!(target instanceof Element)) return true; - return Boolean( - target.closest( - '[data-native-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]', - ), - ); -} - -function sourceRect(element: HTMLElement): NativeSourceRect { - const rect = element.getBoundingClientRect(); - return { - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - }; -} - -function createPointerGesture( - element: () => HTMLElement | null, - begin: (direction: NativeDirection) => Promise, - directionForDelta: (deltaX: number) => NativeDirection | null, -) { - let pointerId = -1; - let startX = 0; - let startY = 0; - let lastX = 0; - let lastTime = 0; - let captured = false; - let beginPromise: Promise | null = null; - let ending = false; - let bufferedProgress = 0; - let bufferedVelocity = 0; - let gestureSign = 0; - - const down = (event: PointerEvent) => { - if ( - !event.isPrimary || - event.button !== 0 || - shouldIgnoreGesture(event.target) - ) - return; - pointerId = event.pointerId; - startX = lastX = event.clientX; - startY = event.clientY; - lastTime = event.timeStamp; - captured = false; - beginPromise = null; - ending = false; - bufferedProgress = 0; - bufferedVelocity = 0; - gestureSign = 0; - }; - const move = async (event: PointerEvent) => { - if (event.pointerId !== pointerId) return; - const dx = event.clientX - startX; - const dy = event.clientY - startY; - if (!captured) { - if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy) * 1.15) return; - const direction = directionForDelta(dx); - if (!direction) return reset(); - captured = true; - ending = false; - gestureSign = Math.sign(dx); - element()?.setPointerCapture(pointerId); - beginPromise = begin(direction); - } - event.preventDefault(); - const elapsed = Math.max(8, event.timeStamp - lastTime); - const width = Math.max(1, element()?.clientWidth ?? window.innerWidth); - bufferedVelocity = - ((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width); - bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width)); - lastX = event.clientX; - lastTime = event.timeStamp; - const pending = beginPromise; - if (pending) { - const id = await pending; - if (pending !== beginPromise || ending) return; - if (id === null) return reset(); - const runtime = injectRuntimeFromElement(element()); - runtime?.updateInteractive(bufferedProgress, bufferedVelocity); - } - }; - const up = async (event: PointerEvent) => { - if (event.pointerId !== pointerId) return; - ending = true; - const runtime = injectRuntimeFromElement(element()); - const pending = beginPromise; - const shouldFinish = captured; - const progress = bufferedProgress; - const velocity = bufferedVelocity; - // Detach this pointer before awaiting preload/navigation/animation work. A - // new gesture may now start without this release callback erasing it. - reset(); - const id = pending ? await pending : null; - if (shouldFinish && id !== null && runtime?.transaction.value?.id === id) { - runtime.updateInteractive(progress, velocity); - await runtime.finishInteractive(); - } - }; - const cancel = async () => { - ending = true; - const runtime = injectRuntimeFromElement(element()); - const pending = beginPromise; - const shouldCancel = captured; - reset(); - const id = pending ? await pending : null; - if (shouldCancel && id !== null && runtime?.transaction.value?.id === id) - await runtime.cancelInteractive(); - }; - const reset = () => { - pointerId = -1; - captured = false; - beginPromise = null; - ending = false; - bufferedProgress = 0; - bufferedVelocity = 0; - gestureSign = 0; - }; - return { down, move, up, cancel }; -} - -const runtimeByElement = new WeakMap(); -function injectRuntimeFromElement(element: Element | null) { - return element ? runtimeByElement.get(element) : undefined; -} - -export const NativeGestureLink = defineComponent({ - name: "NativeGestureLink", - inheritAttrs: false, - props: { - to: { - type: [String, Object] as PropType, - required: true, - }, - presentation: { type: String, default: "reveal" }, - replace: Boolean, - direction: { - type: String as PropType<"left" | "right" | "any">, - default: "any", - }, - as: { type: String, default: "div" }, - }, - setup(props, { slots, attrs }) { - const runtime = useNativeRouter(); - const root = ref(null); - let dragDistance = 0; - let suppressClick = false; - const gesture = createPointerGesture( - () => root.value, - async (direction) => - runtime.beginInteractive("push", props.to, { - presentation: props.presentation, - replace: props.replace, - direction, - sourceRect: root.value ? sourceRect(root.value) : undefined, - }), - (dx) => { - if (props.direction === "left" && dx >= 0) return null; - if (props.direction === "right" && dx <= 0) return null; - return dx < 0 ? "forward" : "back"; - }, - ); - onBeforeUnmount(() => void runtime.cancelInteractive()); - const click = (event: MouseEvent) => { - if (suppressClick) { - suppressClick = false; - event.preventDefault(); - event.stopPropagation(); - return; - } - void runtime.push(props.to, { - presentation: props.presentation, - replace: props.replace, - }); - }; - return () => - h( - props.as, - { - ...attrs, - ref: (value: unknown) => { - root.value = value as HTMLElement | null; - if (root.value) runtimeByElement.set(root.value, runtime); - }, - class: ["nvr-gesture-link", attrs.class], - onPointerdown: (event: PointerEvent) => { - // Component-owned gestures outrank their containing navigator. - event.stopPropagation(); - dragDistance = 0; - suppressClick = false; - gesture.down(event); - }, - onPointermove: (event: PointerEvent) => { - if (event.buttons) { - dragDistance += Math.abs(event.movementX); - if (dragDistance > 8) suppressClick = true; - } - void gesture.move(event); - }, - onPointerup: gesture.up, - onPointercancel: gesture.cancel, - onClick: click, - }, - slots.default?.(), - ); - }, -}); - -export const NativeNavigator = defineComponent({ - name: "NativeNavigator", - props: { - siblings: { - type: Array as PropType, - default: () => [], - }, - edgeWidth: { type: Number, default: 28 }, - }, - setup(props, { slots }) { - const runtime = useNativeRouter(); - const root = ref(null); - let candidate: "back" | "sibling" | null = null; - const gesture = createPointerGesture( - () => root.value, - async (direction) => { - if (candidate === "back") return runtime.beginInteractive("pop"); - const current = runtime.router.currentRoute.value.fullPath; - const index = props.siblings.findIndex( - (route) => runtime.router.resolve(route).fullPath === current, - ); - const nextIndex = index + (direction === "forward" ? 1 : -1); - const target = props.siblings[nextIndex]; - if (index < 0 || !target) return null; - return runtime.beginInteractive("sibling", target, { - replace: - runtime.router.resolve(target).meta.native?.siblingHistory !== - "push", - direction, - presentation: "slide", - }); - }, - (dx) => { - const rtl = - getComputedStyle(root.value ?? document.documentElement).direction === - "rtl"; - const logicalDx = rtl ? -dx : dx; - if (candidate === "back") return logicalDx > 0 ? "back" : null; - return logicalDx < 0 ? "forward" : "back"; - }, - ); - const gestureSettingAllowsNavigation = () => - runtime.router.currentRoute.value.meta.native?.gesture !== false; - const atLeadingEdge = (event: PointerEvent) => { - const rect = root.value?.getBoundingClientRect(); - const rtl = - getComputedStyle(root.value ?? document.documentElement).direction === - "rtl"; - return rect - ? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <= - props.edgeWidth - : false; - }; - const captureDown = (event: PointerEvent) => { - if ( - !event.isPrimary || - event.button !== 0 || - shouldIgnoreGesture(event.target) - ) - return; - if ( - !atLeadingEdge(event) || - !gestureSettingAllowsNavigation() || - !runtime.canGoBack.value - ) - return; - candidate = "back"; - gesture.down(event); - // The application shell owns the physical back edge. Component-owned - // route gestures retain priority everywhere else. - event.stopPropagation(); - }; - const down = (event: PointerEvent) => { - candidate = - props.siblings.length && gestureSettingAllowsNavigation() - ? "sibling" - : null; - if (candidate) gesture.down(event); - }; - return () => - h( - "div", - { - ref: (value: unknown) => { - root.value = value as HTMLElement | null; - if (root.value) runtimeByElement.set(root.value, runtime); - }, - class: "nvr-navigator", - "data-native-can-go-back": String(runtime.canGoBack.value), - onPointerdownCapture: captureDown, - onPointerdown: down, - onPointermove: gesture.move, - onPointerup: gesture.up, - onPointercancel: gesture.cancel, - }, - slots.default?.(), - ); - }, -}); - -export const NativeDismissGesture = defineComponent({ - name: "NativeDismissGesture", - inheritAttrs: false, - props: { - as: { type: String, default: "div" }, - }, - setup(props, { slots, attrs }) { - const runtime = useNativeRouter(); - const root = ref(null); - let pointerId = -1; - let startX = 0; - let startY = 0; - let lastY = 0; - let lastTime = 0; - let captured = false; - let beginPromise: Promise | null = null; - let ending = false; - let progress = 0; - let velocity = 0; - - const reset = () => { - pointerId = -1; - captured = false; - beginPromise = null; - ending = false; - progress = 0; - velocity = 0; - }; - const down = (event: PointerEvent) => { - if ( - !event.isPrimary || - event.button !== 0 || - shouldIgnoreGesture(event.target) - ) - return; - event.stopPropagation(); - pointerId = event.pointerId; - startX = event.clientX; - startY = lastY = event.clientY; - lastTime = event.timeStamp; - captured = false; - beginPromise = null; - ending = false; - progress = 0; - velocity = 0; - }; - const move = async (event: PointerEvent) => { - if (event.pointerId !== pointerId) return; - const dx = event.clientX - startX; - const dy = event.clientY - startY; - if (!captured) { - if (dy < 8 || Math.abs(dy) < Math.abs(dx) * 1.15) return; - captured = true; - ending = false; - root.value?.setPointerCapture(pointerId); - beginPromise = runtime.beginInteractive("dismiss", undefined, { - direction: "back", - }); - } - event.preventDefault(); - progress = Math.max( - 0, - Math.min( - 1, - dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight), - ), - ); - const height = Math.max( - 1, - root.value?.clientHeight ?? window.innerHeight, - ); - velocity = - ((event.clientY - lastY) * 1000) / - (Math.max(8, event.timeStamp - lastTime) * height); - lastY = event.clientY; - lastTime = event.timeStamp; - const pending = beginPromise; - if (pending) { - const id = await pending; - if (pending !== beginPromise || ending) return; - if (id === null) return reset(); - runtime.updateInteractive(progress, velocity); - } - }; - const up = async (event: PointerEvent) => { - if (event.pointerId !== pointerId) return; - ending = true; - const pending = beginPromise; - const shouldFinish = captured; - const finalProgress = progress; - const finalVelocity = velocity; - reset(); - const id = pending ? await pending : null; - if (shouldFinish && id !== null && runtime.transaction.value?.id === id) { - runtime.updateInteractive(finalProgress, finalVelocity); - await runtime.finishInteractive(); - } - }; - const cancel = async () => { - ending = true; - const pending = beginPromise; - const shouldCancel = captured; - reset(); - const id = pending ? await pending : null; - if (shouldCancel && id !== null && runtime.transaction.value?.id === id) - await runtime.cancelInteractive(); - }; - onBeforeUnmount(() => void cancel()); - return () => - h( - props.as, - { - ...attrs, - ref: (value: unknown) => { - root.value = value as HTMLElement | null; - }, - class: ["nvr-dismiss-gesture", attrs.class], - onPointerdown: down, - onPointermove: move, - onPointerup: up, - onPointercancel: cancel, - }, - slots.default?.(), - ); - }, -}); - -export function navigationOptionsFromElement( - element: HTMLElement, -): NativeNavigationOptions { - return { sourceRect: sourceRect(element) }; -} diff --git a/packages/core/src/components/NativeDismissGesture.vue b/packages/core/src/components/NativeDismissGesture.vue new file mode 100755 index 0000000..63ddccc --- /dev/null +++ b/packages/core/src/components/NativeDismissGesture.vue @@ -0,0 +1,138 @@ + + + diff --git a/packages/core/src/components/NativeGestureLink.vue b/packages/core/src/components/NativeGestureLink.vue new file mode 100755 index 0000000..0550ccb --- /dev/null +++ b/packages/core/src/components/NativeGestureLink.vue @@ -0,0 +1,104 @@ + + + diff --git a/packages/core/src/components/NativeLink.vue b/packages/core/src/components/NativeLink.vue new file mode 100755 index 0000000..42d4720 --- /dev/null +++ b/packages/core/src/components/NativeLink.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/core/src/components/NativeNavigator.vue b/packages/core/src/components/NativeNavigator.vue new file mode 100755 index 0000000..c8c23d4 --- /dev/null +++ b/packages/core/src/components/NativeNavigator.vue @@ -0,0 +1,105 @@ + + + diff --git a/packages/core/src/components/NativeRouteScope.vue b/packages/core/src/components/NativeRouteScope.vue new file mode 100755 index 0000000..82ffc78 --- /dev/null +++ b/packages/core/src/components/NativeRouteScope.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/core/src/components/NativeRouterView.vue b/packages/core/src/components/NativeRouterView.vue new file mode 100755 index 0000000..db232bb --- /dev/null +++ b/packages/core/src/components/NativeRouterView.vue @@ -0,0 +1,97 @@ + + + diff --git a/packages/core/src/components/gestures.ts b/packages/core/src/components/gestures.ts new file mode 100755 index 0000000..54d7ca7 --- /dev/null +++ b/packages/core/src/components/gestures.ts @@ -0,0 +1,136 @@ +import type { + NativeDirection, + NativeNavigationOptions, + NativeRouterRuntime, + NativeSourceRect, +} from "../types"; + +export function shouldIgnoreGesture(target: EventTarget | null) { + if (!(target instanceof Element)) return true; + return Boolean( + target.closest( + '[data-native-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]', + ), + ); +} + +export function sourceRect(element: HTMLElement): NativeSourceRect { + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }; +} + +export function createPointerGesture( + element: () => HTMLElement | null, + runtime: NativeRouterRuntime, + begin: (direction: NativeDirection) => Promise, + directionForDelta: (deltaX: number) => NativeDirection | null, +) { + let pointerId = -1; + let startX = 0; + let startY = 0; + let lastX = 0; + let lastTime = 0; + let captured = false; + let beginPromise: Promise | null = null; + let ending = false; + let bufferedProgress = 0; + let bufferedVelocity = 0; + let gestureSign = 0; + + const reset = () => { + pointerId = -1; + captured = false; + beginPromise = null; + ending = false; + bufferedProgress = 0; + bufferedVelocity = 0; + gestureSign = 0; + }; + const down = (event: PointerEvent) => { + if ( + !event.isPrimary || + event.button !== 0 || + shouldIgnoreGesture(event.target) + ) + return; + pointerId = event.pointerId; + startX = lastX = event.clientX; + startY = event.clientY; + lastTime = event.timeStamp; + captured = false; + beginPromise = null; + ending = false; + bufferedProgress = 0; + bufferedVelocity = 0; + gestureSign = 0; + }; + const move = async (event: PointerEvent) => { + if (event.pointerId !== pointerId) return; + const dx = event.clientX - startX; + const dy = event.clientY - startY; + if (!captured) { + if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy) * 1.15) return; + const direction = directionForDelta(dx); + if (!direction) return reset(); + captured = true; + ending = false; + gestureSign = Math.sign(dx); + element()?.setPointerCapture(pointerId); + beginPromise = begin(direction); + } + event.preventDefault(); + const elapsed = Math.max(8, event.timeStamp - lastTime); + const width = Math.max(1, element()?.clientWidth ?? window.innerWidth); + bufferedVelocity = + ((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width); + bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width)); + lastX = event.clientX; + lastTime = event.timeStamp; + const pending = beginPromise; + if (pending) { + const id = await pending; + if (pending !== beginPromise || ending) return; + if (id === null) return reset(); + runtime.updateInteractive(bufferedProgress, bufferedVelocity); + } + }; + const up = async (event: PointerEvent) => { + if (event.pointerId !== pointerId) return; + ending = true; + const pending = beginPromise; + const shouldFinish = captured; + const progress = bufferedProgress; + const velocity = bufferedVelocity; + // Detach this pointer before awaiting preload/navigation/animation work. A + // new gesture may now start without this release callback erasing it. + reset(); + const id = pending ? await pending : null; + if (shouldFinish && id !== null && runtime.transaction.value?.id === id) { + runtime.updateInteractive(progress, velocity); + await runtime.finishInteractive(); + } + }; + const cancel = async () => { + ending = true; + const pending = beginPromise; + const shouldCancel = captured; + reset(); + const id = pending ? await pending : null; + if (shouldCancel && id !== null && runtime.transaction.value?.id === id) + await runtime.cancelInteractive(); + }; + return { down, move, up, cancel }; +} + +export function navigationOptionsFromElement( + element: HTMLElement, +): NativeNavigationOptions { + return { sourceRect: sourceRect(element) }; +} diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts new file mode 100755 index 0000000..ed00970 --- /dev/null +++ b/packages/core/src/components/index.ts @@ -0,0 +1,17 @@ +export { default as NativeDismissGesture } from "./NativeDismissGesture.vue"; +export { default as NativeGestureLink } from "./NativeGestureLink.vue"; +export { default as NativeLink } from "./NativeLink.vue"; +export { default as NativeNavigator } from "./NativeNavigator.vue"; +export { default as NativeRouterView } from "./NativeRouterView.vue"; +export { navigationOptionsFromElement } from "./gestures"; +export { + onNativeViewActivate, + onNativeViewDeactivate, + onNativeViewEvict, + onNativeViewHide, + onNativeViewShow, + useNativeRouter, + useNativeViewActiveEffect, + useNativeViewLifecycle, + useNativeViewVisibleEffect, +} from "./lifecycle"; diff --git a/packages/core/src/components/lifecycle.ts b/packages/core/src/components/lifecycle.ts new file mode 100755 index 0000000..efcf1d6 --- /dev/null +++ b/packages/core/src/components/lifecycle.ts @@ -0,0 +1,110 @@ +import { + inject, + onBeforeUnmount, + onMounted, + onScopeDispose, + watch, + type InjectionKey, +} from "vue"; +import { nativeRouterKey } from "../runtime"; +import type { NativeRouterRuntime, NativeViewLifecycle } from "../types"; + +export const nativeViewLifecycleKey: InjectionKey = Symbol( + "native-view-lifecycle", +); + +export function useNativeRouter() { + const runtime = inject(nativeRouterKey); + if (!runtime) + throw new Error( + "Native Vue Router is not installed. Call app.use(nativeRouter).", + ); + return runtime; +} + +export function useNativeViewLifecycle() { + const lifecycle = inject(nativeViewLifecycleKey); + if (!lifecycle) + throw new Error( + "Native view lifecycle APIs must be used inside NativeRouterView.", + ); + return lifecycle; +} + +type NativeViewHook = () => void; + +function onNativeViewState( + source: Readonly<{ value: boolean }>, + entering: boolean, + hook: NativeViewHook, +) { + onMounted(() => { + if (entering && source.value) hook(); + }); + watch( + () => source.value, + (value, previous) => { + if (value === entering && previous !== entering) hook(); + }, + { flush: "sync" }, + ); +} + +export function onNativeViewActivate(hook: NativeViewHook) { + onNativeViewState(useNativeViewLifecycle().isActive, true, hook); +} + +export function onNativeViewDeactivate(hook: NativeViewHook) { + onNativeViewState(useNativeViewLifecycle().isActive, false, hook); +} + +export function onNativeViewShow(hook: NativeViewHook) { + onNativeViewState(useNativeViewLifecycle().isVisible, true, hook); +} + +export function onNativeViewHide(hook: NativeViewHook) { + onNativeViewState(useNativeViewLifecycle().isVisible, false, hook); +} + +export function onNativeViewEvict( + hook: (reason: NativeViewLifecycle["evictionReason"]["value"]) => void, +) { + const lifecycle = useNativeViewLifecycle(); + onBeforeUnmount(() => { + if (lifecycle.status.value === "evicted") + hook(lifecycle.evictionReason.value); + }); +} + +function useNativeViewEffect( + source: Readonly<{ value: boolean }>, + effect: () => void | (() => void), +) { + let cleanup: void | (() => void); + const stopEffect = () => { + cleanup?.(); + cleanup = undefined; + }; + const stopWatch = watch( + () => source.value, + (enabled) => { + stopEffect(); + if (enabled) cleanup = effect(); + }, + { immediate: true, flush: "sync" }, + ); + onScopeDispose(() => { + stopWatch(); + stopEffect(); + }); +} + +/** Runs an effect only while this route is the semantically active route. */ +export function useNativeViewActiveEffect(effect: () => void | (() => void)) { + useNativeViewEffect(useNativeViewLifecycle().isActive, effect); +} + +/** Runs an effect while this route is active or participating in a transition. */ +export function useNativeViewVisibleEffect(effect: () => void | (() => void)) { + useNativeViewEffect(useNativeViewLifecycle().isVisible, effect); +}