akanjs 3.0.0-alpha.2 → 3.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/base/symbols.ts +0 -1
- package/client/cn.ts +7 -0
- package/client/csrTypes.ts +2 -0
- package/client/frameDebug.ts +3 -2
- package/constant/cascadePaths.ts +8 -79
- package/document/database.ts +1 -2
- package/document/databaseRegistry.ts +2 -2
- package/document/into.ts +6 -10
- package/package.json +1 -1
- package/server/SSR_MEMORY_DIAGNOSIS.md +2 -19
- package/server/cachePolicy.ts +12 -114
- package/server/di/diLifecycle.ts +10 -8
- package/server/processMetricsCollector.ts +0 -4
- package/server/resolver/database.resolver.ts +3 -23
- package/server/resolver/index.ts +0 -1
- package/server/resolver/service.resolver.ts +25 -9
- package/server/rscWorker.tsx +4 -32
- package/server/rscWorkerHost.ts +12 -61
- package/server/ssrFromRscRenderer.tsx +2 -2
- package/server/webRouter.ts +96 -9
- package/service/ipcTypes.ts +0 -25
- package/service/predefinedAdaptor/database.adaptor.ts +3 -3
- package/service/serve.ts +1 -5
- package/service/types.ts +0 -2
- package/types/base/symbols.d.ts +0 -1
- package/types/client/cn.d.ts +5 -0
- package/types/client/csrTypes.d.ts +2 -0
- package/types/constant/cascadePaths.d.ts +4 -22
- package/types/document/database.d.ts +1 -2
- package/types/document/databaseRegistry.d.ts +2 -2
- package/types/document/into.d.ts +5 -6
- package/types/server/cachePolicy.d.ts +2 -33
- package/types/server/resolver/database.resolver.d.ts +2 -6
- package/types/server/resolver/index.d.ts +0 -1
- package/types/server/resolver/service.resolver.d.ts +3 -3
- package/types/server/rscWorkerHost.d.ts +0 -13
- package/types/service/ipcTypes.d.ts +0 -24
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +2 -2
- package/types/service/types.d.ts +1 -2
- package/types/ui/Layout/BottomInset.d.ts +2 -1
- package/types/ui/Layout/index.d.ts +1 -1
- package/types/ui/Signal/style.d.ts +1 -1
- package/types/ui/UiOverride/context.d.ts +12 -2
- package/types/ui/recipe/badgeRecipe.d.ts +7 -3
- package/types/ui/recipe/buttonRecipe.d.ts +7 -3
- package/types/ui/recipe/inputRecipe.d.ts +5 -1
- package/types/webkit/useCsrValues.d.ts +3 -3
- package/types/webkit/useFrameRuntime.d.ts +6 -1
- package/ui/Badge.tsx +2 -2
- package/ui/Button.tsx +3 -1
- package/ui/Constant/Doc.tsx +1 -1
- package/ui/Data/ListContainer.tsx +1 -1
- package/ui/DatePicker.tsx +4 -3
- package/ui/DraggableList.tsx +3 -1
- package/ui/Dropdown.tsx +4 -2
- package/ui/Field.tsx +17 -9
- package/ui/Input.tsx +7 -5
- package/ui/Layout/BottomInset.tsx +6 -1
- package/ui/Loading/ProgressBar.tsx +8 -1
- package/ui/Menu.tsx +7 -8
- package/ui/Model/SureToRemove.tsx +2 -1
- package/ui/ObjectId.tsx +3 -4
- package/ui/Pagination.tsx +3 -4
- package/ui/Popconfirm.tsx +5 -7
- package/ui/Select.tsx +1 -1
- package/ui/System/CSR.tsx +6 -2
- package/ui/ToggleSelect.tsx +4 -3
- package/ui/UiOverride/context.ts +12 -2
- package/ui/recipe/badgeRecipe.ts +22 -3
- package/ui/recipe/buttonRecipe.ts +51 -2
- package/ui/recipe/factory.ts +2 -2
- package/ui/recipe/inputRecipe.ts +18 -4
- package/webkit/useCsrValues.ts +121 -4
- package/webkit/useFrameRuntime.ts +42 -12
- package/server/resolver/CascadeRunner.ts +0 -201
- package/types/server/resolver/CascadeRunner.d.ts +0 -13
package/webkit/useCsrValues.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type NavigationIntent,
|
|
14
14
|
normalizeDeepLinkHref,
|
|
15
15
|
type PageState,
|
|
16
|
+
type PageTransition,
|
|
16
17
|
type PathRoute,
|
|
17
18
|
type RouteGuide,
|
|
18
19
|
type RouteOptions,
|
|
@@ -23,13 +24,14 @@ import {
|
|
|
23
24
|
} from "akanjs/client";
|
|
24
25
|
import { loadCapacitorApp } from "akanjs/client/capacitor";
|
|
25
26
|
import { parseAkanI18nEnv, parseBasePaths } from "akanjs/common";
|
|
26
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
27
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
27
28
|
import {
|
|
28
29
|
createFrameSnapshot,
|
|
29
30
|
createTransitionPlan,
|
|
30
31
|
FRAME_Z_INDEX,
|
|
31
32
|
getFramePlatformProfile,
|
|
32
33
|
getFrameSlotsForSnapshot,
|
|
34
|
+
hasBottomAnchoredKeyboardSlot,
|
|
33
35
|
hasKeyboardStickySlot,
|
|
34
36
|
isPendingFrameReady,
|
|
35
37
|
PENDING_FRAME_READY_TIMEOUT_MS,
|
|
@@ -492,7 +494,6 @@ const useStackTrans = (routeState: RouteState): UseCsrTransition => {
|
|
|
492
494
|
gestureIntent.current = "pending";
|
|
493
495
|
stackBackConfigRef.current = null;
|
|
494
496
|
unlockPageScroll();
|
|
495
|
-
void Device.getDevice().hideKeyboard();
|
|
496
497
|
}
|
|
497
498
|
if (ix > initThreshold) {
|
|
498
499
|
gestureIntent.current = "scroll";
|
|
@@ -511,6 +512,7 @@ const useStackTrans = (routeState: RouteState): UseCsrTransition => {
|
|
|
511
512
|
if (absX < GESTURE_INTENT_THRESHOLD || absX <= absY * GESTURE_AXIS_LOCK_RATIO) return;
|
|
512
513
|
gestureIntent.current = "gesture";
|
|
513
514
|
lockPageScroll();
|
|
515
|
+
void Device.getDevice().hideKeyboard();
|
|
514
516
|
}
|
|
515
517
|
if (gestureIntent.current !== "gesture") {
|
|
516
518
|
cancel();
|
|
@@ -668,11 +670,11 @@ const useBottomUpTrans = (routeState: RouteState): UseCsrTransition => {
|
|
|
668
670
|
|
|
669
671
|
const pageBind = useDrag(
|
|
670
672
|
({ first, last, movement: [, my], initial: [, iy], cancel }) => {
|
|
671
|
-
if (first) void Device.getDevice().hideKeyboard();
|
|
672
673
|
if (iy > initThreshold) {
|
|
673
674
|
cancel();
|
|
674
675
|
return;
|
|
675
676
|
}
|
|
677
|
+
if (first) void Device.getDevice().hideKeyboard();
|
|
676
678
|
if (my < transUnitRange[1]) void transUnit.start(transUnitRange[1], { immediate: true });
|
|
677
679
|
else if (my > transUnitRange[0]) void transUnit.start(transUnitRange[0], { immediate: true });
|
|
678
680
|
else if (!last) void transUnit.start(my, { immediate: true });
|
|
@@ -864,6 +866,7 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
864
866
|
const resolvedPendingLocation = resolveLocationWithFrameState(pendingLocation, resolvedPathRouteMap);
|
|
865
867
|
const platformProfile = getFramePlatformProfile();
|
|
866
868
|
const accessoryHeight = resolveKeyboardAccessoryHeight(resolvedLocation.pathRoute.path, frameSlots);
|
|
869
|
+
const shouldAnchorContentBottom = hasBottomAnchoredKeyboardSlot(resolvedLocation.pathRoute.path, frameSlots);
|
|
867
870
|
const keyboardFrame = useKeyboardFrame({
|
|
868
871
|
bottomSafeArea: resolvedLocation.pathRoute.pageState.bottomSafeArea,
|
|
869
872
|
sticky: hasKeyboardStickySlot(resolvedLocation.pathRoute.path, frameSlots),
|
|
@@ -881,6 +884,103 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
881
884
|
}),
|
|
882
885
|
[viewport, keyboardFrame, accessoryHeight, resolvedLocation.pathRoute.pageState.bottomSafeArea],
|
|
883
886
|
);
|
|
887
|
+
const contentBottomAnchorRef = useRef<{
|
|
888
|
+
path: string;
|
|
889
|
+
contentViewportHeight: number;
|
|
890
|
+
bottomDistance: number;
|
|
891
|
+
} | null>(null);
|
|
892
|
+
const contentBottomAnchorRafRef = useRef<number | null>(null);
|
|
893
|
+
useLayoutEffect(() => {
|
|
894
|
+
const element = pageContentRef.current;
|
|
895
|
+
const path = resolvedLocation.pathRoute.path;
|
|
896
|
+
if (!element || !shouldAnchorContentBottom || !keyboardFrame.sticky) {
|
|
897
|
+
contentBottomAnchorRef.current = null;
|
|
898
|
+
if (contentBottomAnchorRafRef.current !== null) {
|
|
899
|
+
cancelAnimationFrame(contentBottomAnchorRafRef.current);
|
|
900
|
+
contentBottomAnchorRafRef.current = null;
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const previous = contentBottomAnchorRef.current;
|
|
906
|
+
const currentBottomDistance = Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight);
|
|
907
|
+
if (!previous || previous.path !== path) {
|
|
908
|
+
contentBottomAnchorRef.current = {
|
|
909
|
+
path,
|
|
910
|
+
contentViewportHeight: keyboardLayout.contentViewport.height,
|
|
911
|
+
bottomDistance: currentBottomDistance,
|
|
912
|
+
};
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const shouldRestoreBottom = previous.contentViewportHeight !== keyboardLayout.contentViewport.height;
|
|
917
|
+
const bottomDistance = previous.bottomDistance;
|
|
918
|
+
if (shouldRestoreBottom) {
|
|
919
|
+
const nextScrollTop = Math.max(0, element.scrollHeight - element.clientHeight - bottomDistance);
|
|
920
|
+
if (Math.abs(element.scrollTop - nextScrollTop) > 1) {
|
|
921
|
+
debugFrame("keyboard.contentAnchor", {
|
|
922
|
+
path,
|
|
923
|
+
from: element.scrollTop,
|
|
924
|
+
to: nextScrollTop,
|
|
925
|
+
bottomDistance,
|
|
926
|
+
contentViewportHeight: keyboardLayout.contentViewport.height,
|
|
927
|
+
});
|
|
928
|
+
element.scrollTop = nextScrollTop;
|
|
929
|
+
}
|
|
930
|
+
const startedAt = performance.now();
|
|
931
|
+
const duration = (keyboardFrame.animationDuration ?? 0) + 50;
|
|
932
|
+
const keepBottomAnchored = () => {
|
|
933
|
+
const scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - bottomDistance);
|
|
934
|
+
if (Math.abs(element.scrollTop - scrollTop) > 1) element.scrollTop = scrollTop;
|
|
935
|
+
if (performance.now() - startedAt < duration) {
|
|
936
|
+
contentBottomAnchorRafRef.current = requestAnimationFrame(keepBottomAnchored);
|
|
937
|
+
} else {
|
|
938
|
+
contentBottomAnchorRafRef.current = null;
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
if (contentBottomAnchorRafRef.current !== null) cancelAnimationFrame(contentBottomAnchorRafRef.current);
|
|
942
|
+
contentBottomAnchorRafRef.current = requestAnimationFrame(keepBottomAnchored);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
contentBottomAnchorRef.current = {
|
|
946
|
+
path,
|
|
947
|
+
contentViewportHeight: keyboardLayout.contentViewport.height,
|
|
948
|
+
bottomDistance: Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight),
|
|
949
|
+
};
|
|
950
|
+
return () => {
|
|
951
|
+
if (contentBottomAnchorRafRef.current !== null) {
|
|
952
|
+
cancelAnimationFrame(contentBottomAnchorRafRef.current);
|
|
953
|
+
contentBottomAnchorRafRef.current = null;
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
}, [
|
|
957
|
+
pageContentRef,
|
|
958
|
+
resolvedLocation.pathRoute.path,
|
|
959
|
+
shouldAnchorContentBottom,
|
|
960
|
+
keyboardFrame.sticky,
|
|
961
|
+
keyboardFrame.animationDuration,
|
|
962
|
+
keyboardLayout.contentViewport.height,
|
|
963
|
+
]);
|
|
964
|
+
useEffect(() => {
|
|
965
|
+
const element = pageContentRef.current;
|
|
966
|
+
const path = resolvedLocation.pathRoute.path;
|
|
967
|
+
if (!element || !shouldAnchorContentBottom || !keyboardFrame.sticky) return;
|
|
968
|
+
const updateBottomDistance = () => {
|
|
969
|
+
contentBottomAnchorRef.current = {
|
|
970
|
+
path,
|
|
971
|
+
contentViewportHeight: keyboardLayout.contentViewport.height,
|
|
972
|
+
bottomDistance: Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight),
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
element.addEventListener("scroll", updateBottomDistance, { passive: true });
|
|
976
|
+
return () => element.removeEventListener("scroll", updateBottomDistance);
|
|
977
|
+
}, [
|
|
978
|
+
pageContentRef,
|
|
979
|
+
resolvedLocation.pathRoute.path,
|
|
980
|
+
shouldAnchorContentBottom,
|
|
981
|
+
keyboardFrame.sticky,
|
|
982
|
+
keyboardLayout.contentViewport.height,
|
|
983
|
+
]);
|
|
884
984
|
useFrameRuntimeResync({ updateViewport });
|
|
885
985
|
const shouldPrepareFrameTransition = useCallback(
|
|
886
986
|
(nextHref?: string) => {
|
|
@@ -1245,6 +1345,7 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
1245
1345
|
keyboard: keyboardFrame,
|
|
1246
1346
|
contentViewport: keyboardLayout.contentViewport,
|
|
1247
1347
|
keyboardAccessory: keyboardLayout.keyboardAccessory,
|
|
1348
|
+
contentAnchor: shouldAnchorContentBottom ? "bottom" : undefined,
|
|
1248
1349
|
platformProfile,
|
|
1249
1350
|
zIndex: FRAME_Z_INDEX,
|
|
1250
1351
|
pageStateByPath: effectivePageStateByPath,
|
|
@@ -1262,6 +1363,21 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
1262
1363
|
bottomUp: useBottomUpTransition,
|
|
1263
1364
|
scaleOut: useScaleOutTransition,
|
|
1264
1365
|
};
|
|
1366
|
+
|
|
1367
|
+
const contentResizeStyle = useMemo(() => {
|
|
1368
|
+
if (!shouldAnchorContentBottom || !keyboardFrame.sticky) return null;
|
|
1369
|
+
const duration = keyboardFrame.animationDuration ?? 420;
|
|
1370
|
+
const easing = keyboardFrame.animationEasing ?? "cubic-bezier(0.16, 1, 0.3, 1)";
|
|
1371
|
+
return {
|
|
1372
|
+
transition: `height ${duration}ms ${easing}, padding-bottom ${duration}ms ${easing}`,
|
|
1373
|
+
willChange: "height, padding-bottom",
|
|
1374
|
+
};
|
|
1375
|
+
}, [shouldAnchorContentBottom, keyboardFrame.sticky, keyboardFrame.animationDuration, keyboardFrame.animationEasing]);
|
|
1376
|
+
const csrTransition = useCsrTransitionMap[resolvedLocation.pathRoute.pageState.transition];
|
|
1377
|
+
const page: PageTransition | null =
|
|
1378
|
+
contentResizeStyle && csrTransition.page
|
|
1379
|
+
? { ...csrTransition.page, contentStyle: { ...csrTransition.page.contentStyle, ...contentResizeStyle } }
|
|
1380
|
+
: csrTransition.page;
|
|
1265
1381
|
const nativeBackStateRef = useRef({
|
|
1266
1382
|
path: resolvedLocation.pathRoute.path,
|
|
1267
1383
|
keyboardHeight: keyboardFrame.height,
|
|
@@ -1409,6 +1525,7 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
|
|
|
1409
1525
|
|
|
1410
1526
|
return {
|
|
1411
1527
|
...routeState,
|
|
1412
|
-
...
|
|
1528
|
+
...csrTransition,
|
|
1529
|
+
page,
|
|
1413
1530
|
} satisfies CsrContextType;
|
|
1414
1531
|
};
|
|
@@ -25,7 +25,10 @@ export type FrameSlotBucket = "active" | "pending";
|
|
|
25
25
|
export type FrameSlotMapByBucket = Record<FrameSlotBucket, FrameSlotMap>;
|
|
26
26
|
export const PENDING_FRAME_READY_TIMEOUT_MS = 80;
|
|
27
27
|
export const PENDING_FRAME_READY_MAX_TIMEOUT_MS = 120;
|
|
28
|
-
export const
|
|
28
|
+
export const KEYBOARD_SHOW_ANIMATION_DURATION_MS = 420;
|
|
29
|
+
export const KEYBOARD_HIDE_ANIMATION_DURATION_MS = 90;
|
|
30
|
+
export const KEYBOARD_SHOW_ANIMATION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)";
|
|
31
|
+
export const KEYBOARD_HIDE_ANIMATION_EASING = "cubic-bezier(0.7, 0, 0.84, 0)";
|
|
29
32
|
|
|
30
33
|
export const FRAME_Z_INDEX = {
|
|
31
34
|
page: 10,
|
|
@@ -207,9 +210,14 @@ export function useFrameViewport() {
|
|
|
207
210
|
const updateViewport = useCallback((reason = "viewport.change") => {
|
|
208
211
|
const visualViewport = window.visualViewport;
|
|
209
212
|
const platform = getCurrentPlatform();
|
|
213
|
+
const usesAkanKeyboardResize = platform === "ios" || platform === "android";
|
|
214
|
+
const width = Math.round(visualViewport?.width ?? window.innerWidth);
|
|
215
|
+
const currentHeight = Math.round(window.innerHeight);
|
|
216
|
+
const stableHeight =
|
|
217
|
+
usesAkanKeyboardResize && width === viewport.width ? Math.max(viewport.height, currentHeight) : currentHeight;
|
|
210
218
|
const nextViewport = {
|
|
211
|
-
width
|
|
212
|
-
height:
|
|
219
|
+
width,
|
|
220
|
+
height: usesAkanKeyboardResize ? stableHeight : Math.round(visualViewport?.height ?? window.innerHeight),
|
|
213
221
|
visualWidth: Math.round(visualViewport?.width ?? window.innerWidth),
|
|
214
222
|
visualHeight: Math.round(visualViewport?.height ?? window.innerHeight),
|
|
215
223
|
visualOffsetTop: Math.round(visualViewport?.offsetTop ?? 0),
|
|
@@ -226,7 +234,7 @@ export function useFrameViewport() {
|
|
|
226
234
|
debugFrame(reason, { from: prev, to: nextViewport, platform });
|
|
227
235
|
return nextViewport;
|
|
228
236
|
});
|
|
229
|
-
}, []);
|
|
237
|
+
}, [viewport.height]);
|
|
230
238
|
|
|
231
239
|
useEffect(() => {
|
|
232
240
|
updateViewport("viewport.init");
|
|
@@ -259,6 +267,10 @@ export function hasKeyboardStickySlot(path: string, frameSlots: FrameSlotMap) {
|
|
|
259
267
|
return getKeyboardAccessorySlots(path, frameSlots).length > 0;
|
|
260
268
|
}
|
|
261
269
|
|
|
270
|
+
export function hasBottomAnchoredKeyboardSlot(path: string, frameSlots: FrameSlotMap) {
|
|
271
|
+
return getKeyboardAccessorySlots(path, frameSlots).some((slot) => slot.contentAnchor === "bottom");
|
|
272
|
+
}
|
|
273
|
+
|
|
262
274
|
export function resolveKeyboardFrame({
|
|
263
275
|
keyboardHeight,
|
|
264
276
|
bottomSafeArea,
|
|
@@ -274,22 +286,36 @@ export function resolveKeyboardFrame({
|
|
|
274
286
|
sticky: boolean;
|
|
275
287
|
freeze?: boolean;
|
|
276
288
|
}): KeyboardFrameState {
|
|
289
|
+
const useVisualViewportHeight = platformProfile === "android" && visualViewportKeyboardHeight > 0;
|
|
277
290
|
const visualFallbackHeight =
|
|
278
291
|
keyboardHeight <= 0 && visualViewportKeyboardHeight > 0 ? visualViewportKeyboardHeight : 0;
|
|
279
|
-
const effectiveKeyboardHeight =
|
|
280
|
-
|
|
292
|
+
const effectiveKeyboardHeight = useVisualViewportHeight
|
|
293
|
+
? visualViewportKeyboardHeight
|
|
294
|
+
: keyboardHeight > 0
|
|
295
|
+
? keyboardHeight
|
|
296
|
+
: visualFallbackHeight;
|
|
297
|
+
const source = useVisualViewportHeight
|
|
298
|
+
? "visualViewport"
|
|
299
|
+
: keyboardHeight > 0
|
|
300
|
+
? "native"
|
|
301
|
+
: visualFallbackHeight > 0
|
|
302
|
+
? "visualViewport"
|
|
303
|
+
: "fallback";
|
|
281
304
|
const visualCompensation =
|
|
282
|
-
platformProfile === "
|
|
305
|
+
platformProfile === "web" || platformProfile === "mobileWeb"
|
|
306
|
+
? Math.min(visualViewportKeyboardHeight, effectiveKeyboardHeight)
|
|
307
|
+
: 0;
|
|
283
308
|
const offset = sticky && !freeze ? Math.max(0, effectiveKeyboardHeight - visualCompensation) : 0;
|
|
309
|
+
const isShowing = effectiveKeyboardHeight > 0 && !freeze;
|
|
284
310
|
return {
|
|
285
311
|
height: effectiveKeyboardHeight,
|
|
286
312
|
offset,
|
|
287
|
-
visible:
|
|
313
|
+
visible: isShowing,
|
|
288
314
|
sticky,
|
|
289
315
|
frozen: freeze,
|
|
290
316
|
source,
|
|
291
|
-
animationDuration:
|
|
292
|
-
animationEasing:
|
|
317
|
+
animationDuration: isShowing ? KEYBOARD_SHOW_ANIMATION_DURATION_MS : KEYBOARD_HIDE_ANIMATION_DURATION_MS,
|
|
318
|
+
animationEasing: isShowing ? KEYBOARD_SHOW_ANIMATION_EASING : KEYBOARD_HIDE_ANIMATION_EASING,
|
|
293
319
|
};
|
|
294
320
|
}
|
|
295
321
|
|
|
@@ -344,14 +370,18 @@ export function useKeyboardFrame({
|
|
|
344
370
|
}: {
|
|
345
371
|
bottomSafeArea: number;
|
|
346
372
|
sticky: boolean;
|
|
347
|
-
viewport: { visualHeight: number; visualOffsetTop: number };
|
|
373
|
+
viewport: { height: number; visualHeight: number; visualOffsetTop: number };
|
|
348
374
|
platformProfile: FramePlatformProfile;
|
|
349
375
|
freeze?: boolean;
|
|
350
376
|
}) {
|
|
351
377
|
const keyboardHeight = st.use.keyboardHeight();
|
|
378
|
+
const androidViewportKeyboardHeight =
|
|
379
|
+
platformProfile === "android" ? Math.max(0, Math.round(viewport.height - window.innerHeight)) : 0;
|
|
352
380
|
const visualViewportKeyboardHeight = Math.max(
|
|
353
381
|
0,
|
|
354
|
-
|
|
382
|
+
androidViewportKeyboardHeight > 0
|
|
383
|
+
? androidViewportKeyboardHeight
|
|
384
|
+
: Math.round(viewport.height - viewport.visualHeight - viewport.visualOffsetTop),
|
|
355
385
|
);
|
|
356
386
|
return useMemo(
|
|
357
387
|
() =>
|
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
import { LIBS_REMOVE_HOOK } from "akanjs/base";
|
|
3
|
-
import { Logger } from "akanjs/common";
|
|
4
|
-
import { type CascadeWithPath, type ConstantModel, ConstantRegistry } from "akanjs/constant";
|
|
5
|
-
import { type DocumentSchema, documentQueryHelper } from "akanjs/document";
|
|
6
|
-
import type { DatabaseService, ServiceCls } from "akanjs/service";
|
|
7
|
-
|
|
8
|
-
/** A relation the removed document owns: the ids it holds name documents to remove. */
|
|
9
|
-
interface RefEdge {
|
|
10
|
-
readonly key: string;
|
|
11
|
-
readonly refName: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** A model that declared itself removable with this one: its rows name the removed document as their owner. */
|
|
15
|
-
interface WithEdge {
|
|
16
|
-
readonly refName: string;
|
|
17
|
-
readonly key: string;
|
|
18
|
-
readonly typeKey: string | null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface CascadeModule {
|
|
22
|
-
readonly constant: ConstantModel;
|
|
23
|
-
readonly schema: DocumentSchema;
|
|
24
|
-
readonly srvRef: ServiceCls;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface CascadePlan {
|
|
28
|
-
readonly refEdges: RefEdge[];
|
|
29
|
-
readonly withEdges: WithEdge[];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** One cascade in flight. Shared down the chain so a cycle is caught wherever it closes. */
|
|
33
|
-
interface CascadeContext {
|
|
34
|
-
readonly seen: Set<string>;
|
|
35
|
-
readonly depth: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** How deep one removal may cascade before the chain is treated as runaway and abandoned. */
|
|
39
|
-
const maxDepth = 16;
|
|
40
|
-
/** Ids taken per query while draining children one document at a time. */
|
|
41
|
-
const drainSize = 200;
|
|
42
|
-
|
|
43
|
-
export class CascadeRunner {
|
|
44
|
-
readonly #modules = new Map<string, CascadeModule>();
|
|
45
|
-
readonly #plans = new Map<string, CascadePlan>();
|
|
46
|
-
readonly #bulk = new Set<string>();
|
|
47
|
-
readonly #context = new AsyncLocalStorage<CascadeContext>();
|
|
48
|
-
readonly #logger = new Logger("Cascade");
|
|
49
|
-
#getService: ((refName: string) => DatabaseService) | null = null;
|
|
50
|
-
|
|
51
|
-
register(constant: ConstantModel, schema: DocumentSchema, srvRef: ServiceCls) {
|
|
52
|
-
this.#modules.set(constant.refName, { constant, schema, srvRef });
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Called once every service is live. A `_postRemove` and a `listenPost("remove")` registered during boot both
|
|
57
|
-
* count against bulk removal, so the strategy cannot be decided before the last service has initialized.
|
|
58
|
-
*/
|
|
59
|
-
seal(getService: (refName: string) => DatabaseService) {
|
|
60
|
-
this.#getService = getService;
|
|
61
|
-
for (const [refName, mod] of this.#modules) {
|
|
62
|
-
this.#plans.set(refName, { refEdges: this.#collectRefEdges(refName, mod), withEdges: [] });
|
|
63
|
-
}
|
|
64
|
-
for (const [refName, mod] of this.#modules) this.#collectWithEdges(refName, mod);
|
|
65
|
-
for (const refName of this.#modules.keys()) {
|
|
66
|
-
if (this.#hasRemoveSideEffect(refName)) continue;
|
|
67
|
-
this.#bulk.add(refName);
|
|
68
|
-
}
|
|
69
|
-
this.#report();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async run(refName: string, doc: Record<string, unknown>) {
|
|
73
|
-
const plan = this.#plans.get(refName);
|
|
74
|
-
if (!plan?.refEdges.length && !plan?.withEdges.length) return;
|
|
75
|
-
const parent = this.#context.getStore();
|
|
76
|
-
const seen = parent?.seen ?? new Set<string>();
|
|
77
|
-
const depth = (parent?.depth ?? 0) + 1;
|
|
78
|
-
const id = typeof doc.id === "string" ? doc.id : null;
|
|
79
|
-
if (id) seen.add(`${refName}:${id}`);
|
|
80
|
-
if (depth > maxDepth) {
|
|
81
|
-
this.#logger.error(`Cascade from ${refName} exceeded depth ${maxDepth} and was abandoned`);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
await this.#context.run({ seen, depth }, async () => {
|
|
85
|
-
for (const edge of plan.refEdges) await this.#removeRef(edge, doc, seen);
|
|
86
|
-
if (id) for (const edge of plan.withEdges) await this.#removeWith(edge, refName, id, seen);
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async #removeRef(edge: RefEdge, doc: Record<string, unknown>, seen: Set<string>) {
|
|
91
|
-
const value = doc[edge.key];
|
|
92
|
-
const ids = (Array.isArray(value) ? value : [value]).filter(
|
|
93
|
-
(item): item is string => typeof item === "string" && !seen.has(`${edge.refName}:${item}`),
|
|
94
|
-
);
|
|
95
|
-
if (!ids.length) return;
|
|
96
|
-
const service = this.#service(edge.refName);
|
|
97
|
-
if (this.#bulk.has(edge.refName)) {
|
|
98
|
-
await service.__removeMany({ id: documentQueryHelper.oneOf(ids) });
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
for (const id of ids) await service.__remove(id);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async #removeWith(edge: WithEdge, ownerRef: string, ownerId: string, seen: Set<string>) {
|
|
106
|
-
const service = this.#service(edge.refName);
|
|
107
|
-
const query = edge.typeKey ? { [edge.key]: ownerId, [edge.typeKey]: ownerRef } : { [edge.key]: ownerId };
|
|
108
|
-
if (this.#bulk.has(edge.refName)) {
|
|
109
|
-
await service.__removeMany(query);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
for (;;) {
|
|
114
|
-
const ids = await service.__listIds(query, { limit: drainSize });
|
|
115
|
-
if (!ids.length) return;
|
|
116
|
-
let removed = 0;
|
|
117
|
-
for (const id of ids) {
|
|
118
|
-
if (seen.has(`${edge.refName}:${id}`)) continue;
|
|
119
|
-
await service.__remove(id);
|
|
120
|
-
removed += 1;
|
|
121
|
-
}
|
|
122
|
-
if (!removed) return;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
#collectRefEdges(refName: string, mod: CascadeModule) {
|
|
127
|
-
return [...mod.constant.full.cascade.removeRef].map(([key, modelRef]) => {
|
|
128
|
-
const target = ConstantRegistry.getRefName(modelRef);
|
|
129
|
-
|
|
130
|
-
if (!this.#modules.has(target)) {
|
|
131
|
-
throw new Error(`Cascade field "${refName}.${key}" removes "${target}", which this app does not mount`);
|
|
132
|
-
}
|
|
133
|
-
return { key, refName: target };
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
#collectWithEdges(childRef: string, mod: CascadeModule) {
|
|
138
|
-
for (const [key, path] of mod.constant.full.cascade.removeWith) {
|
|
139
|
-
for (const owner of this.#resolveOwners(childRef, key, path)) {
|
|
140
|
-
this.#plans.get(owner)?.withEdges.push({ refName: childRef, key, typeKey: path.typeKey });
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
#resolveOwners(childRef: string, key: string, path: CascadeWithPath) {
|
|
146
|
-
if (path.typeValues.length) {
|
|
147
|
-
|
|
148
|
-
const mounted = path.typeValues.filter((owner) => this.#modules.has(owner));
|
|
149
|
-
for (const owner of path.typeValues) {
|
|
150
|
-
if (mounted.includes(owner)) continue;
|
|
151
|
-
this.#logger.warn(`Cascade field "${childRef}.${key}" names owner "${owner}", which this app does not mount`);
|
|
152
|
-
}
|
|
153
|
-
return mounted;
|
|
154
|
-
}
|
|
155
|
-
const owner = path.refName ?? ConstantRegistry.getRefName(path.modelRef as never);
|
|
156
|
-
if (!this.#modules.has(owner)) {
|
|
157
|
-
throw new Error(`Cascade field "${childRef}.${key}" is owned by "${owner}", which this app does not mount`);
|
|
158
|
-
}
|
|
159
|
-
return [owner];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Everything a bulk `removeMany` would skip. All of it absent means the two paths leave the same rows behind. */
|
|
163
|
-
#hasRemoveSideEffect(refName: string) {
|
|
164
|
-
const mod = this.#modules.get(refName);
|
|
165
|
-
if (!mod) return true;
|
|
166
|
-
if (mod.schema.preHooks.get("remove")?.length || mod.schema.postHooks.get("remove")?.length) return true;
|
|
167
|
-
if ((mod.srvRef as unknown as { [LIBS_REMOVE_HOOK]?: boolean })[LIBS_REMOVE_HOOK]) return true;
|
|
168
|
-
const proto = mod.srvRef.prototype as { _preRemove?: unknown; _postRemove?: unknown };
|
|
169
|
-
if (typeof proto._preRemove === "function" || typeof proto._postRemove === "function") return true;
|
|
170
|
-
const plan = this.#plans.get(refName);
|
|
171
|
-
return !!plan?.refEdges.length || !!plan?.withEdges.length;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Neither the strategy nor the edge list is visible from the source, and adding a `_postRemove` to a target
|
|
175
|
-
* silently flips it from one query back to one per document. A quiet cascade is the one nobody can explain. */
|
|
176
|
-
#report() {
|
|
177
|
-
const lines: string[] = [];
|
|
178
|
-
for (const [refName, plan] of this.#plans) {
|
|
179
|
-
for (const edge of plan.refEdges) {
|
|
180
|
-
lines.push(`${refName}.${edge.key} removeRef ${edge.refName} (${this.#strategy(edge.refName)})`);
|
|
181
|
-
}
|
|
182
|
-
for (const edge of plan.withEdges) {
|
|
183
|
-
const path = edge.typeKey ? `${edge.key}+${edge.typeKey}` : edge.key;
|
|
184
|
-
lines.push(`${refName} removeWith ${edge.refName}.${path} (${this.#strategy(edge.refName)})`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (!lines.length) return;
|
|
188
|
-
const bulk = lines.filter((line) => line.endsWith("(bulk)")).length;
|
|
189
|
-
this.#logger.info(`${lines.length} cascade edge(s), ${bulk} in one query`);
|
|
190
|
-
for (const line of lines) this.#logger.verbose(line);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
#strategy(refName: string) {
|
|
194
|
-
return this.#bulk.has(refName) ? "bulk" : "per document";
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
#service(refName: string) {
|
|
198
|
-
if (!this.#getService) throw new Error(`Cascade ran before the plan was sealed: ${refName}`);
|
|
199
|
-
return this.#getService(refName);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { type ConstantModel } from "akanjs/constant";
|
|
2
|
-
import { type DocumentSchema } from "akanjs/document";
|
|
3
|
-
import type { DatabaseService, ServiceCls } from "akanjs/service";
|
|
4
|
-
export declare class CascadeRunner {
|
|
5
|
-
#private;
|
|
6
|
-
register(constant: ConstantModel, schema: DocumentSchema, srvRef: ServiceCls): void;
|
|
7
|
-
/**
|
|
8
|
-
* Called once every service is live. A `_postRemove` and a `listenPost("remove")` registered during boot both
|
|
9
|
-
* count against bulk removal, so the strategy cannot be decided before the last service has initialized.
|
|
10
|
-
*/
|
|
11
|
-
seal(getService: (refName: string) => DatabaseService): void;
|
|
12
|
-
run(refName: string, doc: Record<string, unknown>): Promise<void>;
|
|
13
|
-
}
|