Move components.ts into many SFC files
This commit is contained in:
@@ -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<NativeViewLifecycle> = Symbol(
|
|
||||||
"native-view-lifecycle",
|
|
||||||
);
|
|
||||||
|
|
||||||
export function useNativeRouter() {
|
|
||||||
const runtime = inject<NativeRouterRuntime>(nativeRouterKey);
|
|
||||||
if (!runtime)
|
|
||||||
throw new Error(
|
|
||||||
"Native Vue Router is not installed. Call app.use(nativeRouter).",
|
|
||||||
);
|
|
||||||
return runtime;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useNativeViewLifecycle() {
|
|
||||||
const lifecycle = inject<NativeViewLifecycle>(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<RouteLocationNormalizedLoaded>,
|
|
||||||
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<NativeViewRole>(() =>
|
|
||||||
entry.value ? interactiveRole(entry.value, runtime) : "inactive",
|
|
||||||
);
|
|
||||||
provide<NativeViewLifecycle>(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<RouteLocationRaw>,
|
|
||||||
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<number | null>,
|
|
||||||
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<number | null> | 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<Element, NativeRouterRuntime>();
|
|
||||||
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<RouteLocationRaw>,
|
|
||||||
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<HTMLElement | null>(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<RouteLocationRaw[]>,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
edgeWidth: { type: Number, default: 28 },
|
|
||||||
},
|
|
||||||
setup(props, { slots }) {
|
|
||||||
const runtime = useNativeRouter();
|
|
||||||
const root = ref<HTMLElement | null>(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<HTMLElement | null>(null);
|
|
||||||
let pointerId = -1;
|
|
||||||
let startX = 0;
|
|
||||||
let startY = 0;
|
|
||||||
let lastY = 0;
|
|
||||||
let lastTime = 0;
|
|
||||||
let captured = false;
|
|
||||||
let beginPromise: Promise<number | null> | 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) };
|
|
||||||
}
|
|
||||||
138
packages/core/src/components/NativeDismissGesture.vue
Executable file
138
packages/core/src/components/NativeDismissGesture.vue
Executable file
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, useAttrs } from "vue";
|
||||||
|
import { shouldIgnoreGesture } from "./gestures";
|
||||||
|
import { useNativeRouter } from "./lifecycle";
|
||||||
|
|
||||||
|
defineOptions({ name: "NativeDismissGesture", inheritAttrs: false });
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
as: { type: String, default: "div" },
|
||||||
|
});
|
||||||
|
const attrs = useAttrs();
|
||||||
|
const passthroughAttrs = computed(() => {
|
||||||
|
const result = { ...attrs };
|
||||||
|
delete result.onPointerdown;
|
||||||
|
delete result.onPointermove;
|
||||||
|
delete result.onPointerup;
|
||||||
|
delete result.onPointercancel;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const runtime = useNativeRouter();
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
let pointerId = -1;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
let lastTime = 0;
|
||||||
|
let captured = false;
|
||||||
|
let beginPromise: Promise<number | null> | null = null;
|
||||||
|
let ending = false;
|
||||||
|
let progress = 0;
|
||||||
|
let velocity = 0;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
pointerId = -1;
|
||||||
|
captured = false;
|
||||||
|
beginPromise = null;
|
||||||
|
ending = false;
|
||||||
|
progress = 0;
|
||||||
|
velocity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function up(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
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());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
ref="root"
|
||||||
|
v-bind="passthroughAttrs"
|
||||||
|
class="nvr-dismiss-gesture"
|
||||||
|
@pointerdown="down"
|
||||||
|
@pointermove="move"
|
||||||
|
@pointerup="up"
|
||||||
|
@pointercancel="cancel"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
104
packages/core/src/components/NativeGestureLink.vue
Executable file
104
packages/core/src/components/NativeGestureLink.vue
Executable file
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, useAttrs, type PropType } from "vue";
|
||||||
|
import type { RouteLocationRaw } from "vue-router";
|
||||||
|
import type { NativePresentationName } from "../types";
|
||||||
|
import { createPointerGesture, sourceRect } from "./gestures";
|
||||||
|
import { useNativeRouter } from "./lifecycle";
|
||||||
|
|
||||||
|
defineOptions({ name: "NativeGestureLink", inheritAttrs: false });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
to: {
|
||||||
|
type: [String, Object] as PropType<RouteLocationRaw>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
presentation: {
|
||||||
|
type: String as PropType<NativePresentationName>,
|
||||||
|
default: "reveal",
|
||||||
|
},
|
||||||
|
replace: Boolean,
|
||||||
|
direction: {
|
||||||
|
type: String as PropType<"left" | "right" | "any">,
|
||||||
|
default: "any",
|
||||||
|
},
|
||||||
|
as: { type: String, default: "div" },
|
||||||
|
});
|
||||||
|
const attrs = useAttrs();
|
||||||
|
const passthroughAttrs = computed(() => {
|
||||||
|
const result = { ...attrs };
|
||||||
|
delete result.onPointerdown;
|
||||||
|
delete result.onPointermove;
|
||||||
|
delete result.onPointerup;
|
||||||
|
delete result.onPointercancel;
|
||||||
|
delete result.onClick;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const runtime = useNativeRouter();
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
let dragDistance = 0;
|
||||||
|
let suppressClick = false;
|
||||||
|
|
||||||
|
const gesture = createPointerGesture(
|
||||||
|
() => root.value,
|
||||||
|
runtime,
|
||||||
|
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());
|
||||||
|
|
||||||
|
function pointerDown(event: PointerEvent) {
|
||||||
|
// Component-owned gestures outrank their containing navigator.
|
||||||
|
event.stopPropagation();
|
||||||
|
dragDistance = 0;
|
||||||
|
suppressClick = false;
|
||||||
|
gesture.down(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerMove(event: PointerEvent) {
|
||||||
|
if (event.buttons) {
|
||||||
|
dragDistance += Math.abs(event.movementX);
|
||||||
|
if (dragDistance > 8) suppressClick = true;
|
||||||
|
}
|
||||||
|
void gesture.move(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(event: MouseEvent) {
|
||||||
|
if (suppressClick) {
|
||||||
|
suppressClick = false;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runtime.push(props.to, {
|
||||||
|
presentation: props.presentation,
|
||||||
|
replace: props.replace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
ref="root"
|
||||||
|
v-bind="passthroughAttrs"
|
||||||
|
class="nvr-gesture-link"
|
||||||
|
@pointerdown="pointerDown"
|
||||||
|
@pointermove="pointerMove"
|
||||||
|
@pointerup="gesture.up"
|
||||||
|
@pointercancel="gesture.cancel"
|
||||||
|
@click="click"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
45
packages/core/src/components/NativeLink.vue
Executable file
45
packages/core/src/components/NativeLink.vue
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useAttrs, type PropType } from "vue";
|
||||||
|
import type { RouteLocationRaw } from "vue-router";
|
||||||
|
import type { NativePresentationName } from "../types";
|
||||||
|
import { useNativeRouter } from "./lifecycle";
|
||||||
|
|
||||||
|
defineOptions({ name: "NativeLink", inheritAttrs: false });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
to: {
|
||||||
|
type: [String, Object] as PropType<RouteLocationRaw>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
replace: Boolean,
|
||||||
|
presentation: String as PropType<NativePresentationName>,
|
||||||
|
});
|
||||||
|
const attrs = useAttrs();
|
||||||
|
const passthroughAttrs = computed(() => {
|
||||||
|
const result = { ...attrs };
|
||||||
|
delete result.onClick;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const runtime = useNativeRouter();
|
||||||
|
const href = computed(() => runtime.router.resolve(props.to).href);
|
||||||
|
|
||||||
|
function 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 }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a v-bind="passthroughAttrs" :href="href" @click="activate"><slot /></a>
|
||||||
|
</template>
|
||||||
105
packages/core/src/components/NativeNavigator.vue
Executable file
105
packages/core/src/components/NativeNavigator.vue
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, type PropType } from "vue";
|
||||||
|
import type { RouteLocationRaw } from "vue-router";
|
||||||
|
import { createPointerGesture, shouldIgnoreGesture } from "./gestures";
|
||||||
|
import { useNativeRouter } from "./lifecycle";
|
||||||
|
|
||||||
|
defineOptions({ name: "NativeNavigator" });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
siblings: {
|
||||||
|
type: Array as PropType<RouteLocationRaw[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
edgeWidth: { type: Number, default: 28 },
|
||||||
|
});
|
||||||
|
const runtime = useNativeRouter();
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
let candidate: "back" | "sibling" | null = null;
|
||||||
|
|
||||||
|
const gesture = createPointerGesture(
|
||||||
|
() => root.value,
|
||||||
|
runtime,
|
||||||
|
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;
|
||||||
|
|
||||||
|
function 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function down(event: PointerEvent) {
|
||||||
|
candidate =
|
||||||
|
props.siblings.length && gestureSettingAllowsNavigation()
|
||||||
|
? "sibling"
|
||||||
|
: null;
|
||||||
|
if (candidate) gesture.down(event);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="root"
|
||||||
|
class="nvr-navigator"
|
||||||
|
:data-native-can-go-back="String(runtime.canGoBack.value)"
|
||||||
|
@pointerdown.capture="captureDown"
|
||||||
|
@pointerdown="down"
|
||||||
|
@pointermove="gesture.move"
|
||||||
|
@pointerup="gesture.up"
|
||||||
|
@pointercancel="gesture.cancel"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
62
packages/core/src/components/NativeRouteScope.vue
Executable file
62
packages/core/src/components/NativeRouteScope.vue
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, provide, shallowReactive, watch, type PropType } from "vue";
|
||||||
|
import {
|
||||||
|
routeLocationKey,
|
||||||
|
type RouteLocationNormalizedLoaded,
|
||||||
|
} from "vue-router";
|
||||||
|
import type { NativeViewLifecycle, NativeViewRole } from "../types";
|
||||||
|
import { nativeViewLifecycleKey, useNativeRouter } from "./lifecycle";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
route: {
|
||||||
|
type: Object as PropType<RouteLocationNormalizedLoaded>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
entryKey: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
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<NativeViewRole>(() => {
|
||||||
|
const transaction = runtime.transaction.value;
|
||||||
|
if (!entry.value) return "inactive";
|
||||||
|
if (!transaction)
|
||||||
|
return entry.value.key === runtime.activeKey.value ? "active" : "inactive";
|
||||||
|
if (entry.value.key === transaction.fromKey) return "from";
|
||||||
|
if (entry.value.key === transaction.toKey) return "to";
|
||||||
|
return "inactive";
|
||||||
|
});
|
||||||
|
|
||||||
|
provide<NativeViewLifecycle>(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),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
97
packages/core/src/components/NativeRouterView.vue
Executable file
97
packages/core/src/components/NativeRouterView.vue
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useSlots } from "vue";
|
||||||
|
import { RouterView } from "vue-router";
|
||||||
|
import type {
|
||||||
|
NativeRouterRuntime,
|
||||||
|
NativeViewEntry,
|
||||||
|
NativeViewRole,
|
||||||
|
} from "../types";
|
||||||
|
import { useNativeRouter } from "./lifecycle";
|
||||||
|
import NativeRouteScope from "./NativeRouteScope.vue";
|
||||||
|
|
||||||
|
defineOptions({ name: "NativeRouterView" });
|
||||||
|
|
||||||
|
const runtime = useNativeRouter();
|
||||||
|
const slots = useSlots();
|
||||||
|
const mountedEntries = computed(() =>
|
||||||
|
runtime.entries.value.filter((entry) => entry.mounted),
|
||||||
|
);
|
||||||
|
const transaction = computed(() => runtime.transaction.value);
|
||||||
|
|
||||||
|
function roleFor(entry: NativeViewEntry) {
|
||||||
|
return interactiveRole(entry, runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerStyleFor(entry: NativeViewEntry) {
|
||||||
|
const currentTransaction = transaction.value;
|
||||||
|
if (!currentTransaction) return undefined;
|
||||||
|
const role = roleFor(entry);
|
||||||
|
if (role !== "from" && role !== "to") return undefined;
|
||||||
|
return runtime
|
||||||
|
.presentationFor(currentTransaction.presentation)
|
||||||
|
?.layerStyle?.({
|
||||||
|
progress: currentTransaction.progress,
|
||||||
|
role,
|
||||||
|
direction: currentTransaction.direction,
|
||||||
|
sourceRect: currentTransaction.sourceRect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function interactiveRole(
|
||||||
|
entry: NativeViewEntry,
|
||||||
|
nativeRuntime: NativeRouterRuntime,
|
||||||
|
): NativeViewRole {
|
||||||
|
const currentTransaction = nativeRuntime.transaction.value;
|
||||||
|
if (!currentTransaction)
|
||||||
|
return entry.key === nativeRuntime.activeKey.value ? "active" : "inactive";
|
||||||
|
if (entry.key === currentTransaction.fromKey) return "from";
|
||||||
|
if (entry.key === currentTransaction.toKey) return "to";
|
||||||
|
return "inactive";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="['nvr-router-view', transaction && 'nvr-router-view--interactive']"
|
||||||
|
:style="
|
||||||
|
transaction
|
||||||
|
? { '--native-progress': String(transaction.progress) }
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:data-native-presentation="transaction?.presentation"
|
||||||
|
:data-native-direction="transaction?.direction"
|
||||||
|
:data-native-transaction="transaction?.id"
|
||||||
|
:data-native-velocity="
|
||||||
|
transaction ? String(transaction.velocity) : undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
v-for="entry in mountedEntries"
|
||||||
|
:key="entry.key"
|
||||||
|
:class="['nvr-view', `nvr-view--${roleFor(entry)}`]"
|
||||||
|
:style="layerStyleFor(entry)"
|
||||||
|
:data-native-role="roleFor(entry)"
|
||||||
|
:data-native-route="entry.route.fullPath"
|
||||||
|
:data-native-presentation="transaction?.presentation"
|
||||||
|
:data-native-direction="transaction?.direction"
|
||||||
|
:inert="roleFor(entry) === 'inactive' ? true : undefined"
|
||||||
|
:aria-hidden="roleFor(entry) === 'inactive' ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<RouterView v-slot="{ Component, route }" :route="entry.route">
|
||||||
|
<slot
|
||||||
|
v-if="slots.default"
|
||||||
|
:Component="Component"
|
||||||
|
:route="route"
|
||||||
|
:entry="entry"
|
||||||
|
/>
|
||||||
|
<NativeRouteScope
|
||||||
|
v-else-if="Component"
|
||||||
|
:route="route"
|
||||||
|
:entry-key="entry.key"
|
||||||
|
>
|
||||||
|
<component :is="Component" />
|
||||||
|
</NativeRouteScope>
|
||||||
|
</RouterView>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
136
packages/core/src/components/gestures.ts
Executable file
136
packages/core/src/components/gestures.ts
Executable file
@@ -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<number | null>,
|
||||||
|
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<number | null> | 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) };
|
||||||
|
}
|
||||||
17
packages/core/src/components/index.ts
Executable file
17
packages/core/src/components/index.ts
Executable file
@@ -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";
|
||||||
110
packages/core/src/components/lifecycle.ts
Executable file
110
packages/core/src/components/lifecycle.ts
Executable file
@@ -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<NativeViewLifecycle> = Symbol(
|
||||||
|
"native-view-lifecycle",
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useNativeRouter() {
|
||||||
|
const runtime = inject<NativeRouterRuntime>(nativeRouterKey);
|
||||||
|
if (!runtime)
|
||||||
|
throw new Error(
|
||||||
|
"Native Vue Router is not installed. Call app.use(nativeRouter).",
|
||||||
|
);
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNativeViewLifecycle() {
|
||||||
|
const lifecycle = inject<NativeViewLifecycle>(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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user